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:
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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 (
|
||||
|
||||
@@ -25,13 +25,16 @@ export interface ClaudeDesktopRoutePreset {
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Desktop 3P fail-all 校验只接受 `claude-(sonnet|opus|haiku)-*` 形式的
|
||||
* routeId(1.6259.1+,实测 2026-05-13)。所有预设工厂、表单角色下拉、
|
||||
* 后端 `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。
|
||||
* Claude Desktop 3P fail-all 校验接受的角色名。Desktop 1.12603.1+ 起白名单
|
||||
* 纳入 fable(app.asar 内 ["sonnet","opus","haiku","fable","mythos"],实测
|
||||
* 2026-06-13);此前 1.6259.1 仅接受 sonnet/opus/haiku。mythos 官方未公开
|
||||
* 发布,暂不暴露给用户。所有预设工厂、表单角色下拉、后端
|
||||
* `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。
|
||||
*/
|
||||
export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = {
|
||||
sonnet: "claude-sonnet-4-6",
|
||||
opus: "claude-opus-4-8",
|
||||
fable: "claude-fable-5",
|
||||
haiku: "claude-haiku-4-5",
|
||||
} as const;
|
||||
|
||||
@@ -183,9 +186,9 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
mode: "proxy",
|
||||
@@ -240,6 +243,32 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "CCSub",
|
||||
websiteUrl: "https://www.ccsub.net",
|
||||
apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://www.ccsub.net",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(true),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.unity2.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(true),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
@@ -385,18 +414,22 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://api.moonshot.cn/anthropic",
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes("kimi-k2.6", "kimi-k2.6", "kimi-k2.6"),
|
||||
modelRoutes: brandedRoutes(
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.7-code",
|
||||
),
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
mode: "proxy",
|
||||
@@ -479,7 +512,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes("MiniMax-M2.7", "MiniMax-M2.7", "MiniMax-M2.7"),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -497,7 +529,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes("MiniMax-M2.7", "MiniMax-M2.7", "MiniMax-M2.7"),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -663,8 +694,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://sudocode.us", "https://sudocode.run"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sudocode",
|
||||
icon: "sudocode",
|
||||
},
|
||||
{
|
||||
@@ -944,20 +973,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
icon: "novita",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "LemonData",
|
||||
websiteUrl: "https://lemondata.cc",
|
||||
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.lemondata.cc",
|
||||
apiKeyField: "ANTHROPIC_API_KEY",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "lemondata",
|
||||
icon: "lemondata",
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
websiteUrl: "https://build.nvidia.com",
|
||||
|
||||
@@ -128,9 +128,9 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
@@ -193,6 +193,36 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "CCSub",
|
||||
websiteUrl: "https://www.ccsub.net",
|
||||
apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://www.ccsub.net",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.unity2.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
@@ -337,15 +367,15 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "kimi-k2.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.6",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.6",
|
||||
ANTHROPIC_MODEL: "kimi-k2.7-code",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.7-code",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.7-code",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.7-code",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -354,7 +384,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/",
|
||||
@@ -483,7 +513,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -509,7 +538,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -713,8 +741,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
endpointCandidates: ["https://sudocode.us", "https://sudocode.run"],
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sudocode",
|
||||
icon: "sudocode",
|
||||
},
|
||||
{
|
||||
@@ -1077,22 +1103,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "openai",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "LemonData",
|
||||
websiteUrl: "https://lemondata.cc",
|
||||
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
|
||||
apiKeyField: "ANTHROPIC_API_KEY",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.lemondata.cc",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "lemondata",
|
||||
icon: "lemondata",
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
websiteUrl: "https://build.nvidia.com",
|
||||
|
||||
@@ -136,9 +136,9 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"ark_agentplan",
|
||||
@@ -216,6 +216,38 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "CCSub",
|
||||
websiteUrl: "https://www.ccsub.net",
|
||||
apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"ccsub",
|
||||
"https://www.ccsub.net/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://www.ccsub.net/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"unity2",
|
||||
"https://api.unity2.ai",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.unity2.ai"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Azure OpenAI",
|
||||
websiteUrl:
|
||||
@@ -389,18 +421,52 @@ requires_openai_auth = true`,
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"kimi",
|
||||
"https://api.moonshot.cn/v1",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.7-code",
|
||||
),
|
||||
endpointCandidates: ["https://api.moonshot.cn/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "kimi-k2.6", displayName: "Kimi K2.6", contextWindow: 262144 },
|
||||
{
|
||||
model: "kimi-k2.7-code",
|
||||
displayName: "Kimi K2.7 Code",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
supportsEffort: false,
|
||||
thinkingParam: "thinking",
|
||||
effortParam: "none",
|
||||
outputFormat: "reasoning_content",
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
apiKeyUrl: "https://www.kimi.com/code/",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"kimi_coding",
|
||||
"https://api.kimi.com/coding/v1",
|
||||
"kimi-for-coding",
|
||||
),
|
||||
endpointCandidates: ["https://api.kimi.com/coding/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "kimi-for-coding",
|
||||
displayName: "Kimi For Coding",
|
||||
contextWindow: 262144,
|
||||
},
|
||||
]),
|
||||
codexChatReasoning: {
|
||||
supportsThinking: true,
|
||||
@@ -549,7 +615,6 @@ requires_openai_auth = true`,
|
||||
outputFormat: "reasoning_details",
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -585,7 +650,6 @@ requires_openai_auth = true`,
|
||||
outputFormat: "reasoning_details",
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -914,7 +978,11 @@ requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://api.atlascloud.ai/v1"],
|
||||
apiFormat: "openai_chat",
|
||||
modelCatalog: modelCatalog([
|
||||
{ model: "zai-org/glm-5.1", displayName: "GLM 5.1" },
|
||||
{
|
||||
model: "zai-org/glm-5.1",
|
||||
displayName: "GLM 5.1",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
]),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "atlascloud",
|
||||
@@ -940,8 +1008,6 @@ wire_api = "responses"
|
||||
requires_openai_auth = true`,
|
||||
endpointCandidates: ["https://sudocode.us/v1", "https://sudocode.run/v1"],
|
||||
apiFormat: "openai_responses",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sudocode",
|
||||
icon: "sudocode",
|
||||
},
|
||||
{
|
||||
@@ -1195,22 +1261,6 @@ model_auto_compact_token_limit = 9000000`,
|
||||
icon: "eflowcode",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "LemonData",
|
||||
websiteUrl: "https://lemondata.cc",
|
||||
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"lemondata",
|
||||
"https://api.lemondata.cc/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.lemondata.cc/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "lemondata",
|
||||
icon: "lemondata",
|
||||
},
|
||||
{
|
||||
name: "PIPELLM",
|
||||
websiteUrl: "https://code.pipellm.ai",
|
||||
|
||||
@@ -69,6 +69,24 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
partnerPromotionKey: "shengsuanyun",
|
||||
icon: "shengsuanyun",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.unity2.ai",
|
||||
GEMINI_MODEL: "gemini-3.1-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.unity2.ai",
|
||||
model: "gemini-3.1-pro",
|
||||
description: "Unity2.ai",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packyapi.com",
|
||||
@@ -146,8 +164,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
model: "gemini-3.1-flash-lite",
|
||||
description: "SudoCode",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sudocode",
|
||||
endpointCandidates: ["https://sudocode.us", "https://sudocode.run"],
|
||||
icon: "sudocode",
|
||||
},
|
||||
@@ -318,25 +334,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
icon: "eflowcode",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "LemonData",
|
||||
websiteUrl: "https://lemondata.cc",
|
||||
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.lemondata.cc",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.lemondata.cc",
|
||||
model: "gemini-3.5-flash",
|
||||
description: "LemonData",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "lemondata",
|
||||
endpointCandidates: ["https://api.lemondata.cc"],
|
||||
icon: "lemondata",
|
||||
},
|
||||
{
|
||||
name: "CherryIN",
|
||||
websiteUrl: "https://open.cherryin.ai",
|
||||
|
||||
@@ -151,9 +151,9 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
name: "ark_agentplan",
|
||||
base_url: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
@@ -238,6 +238,56 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CCSub",
|
||||
websiteUrl: "https://www.ccsub.net",
|
||||
apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA",
|
||||
settingsConfig: {
|
||||
name: "ccsub",
|
||||
base_url: "https://www.ccsub.net/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
context_length: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "ccsub" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
name: "unity2",
|
||||
base_url: "https://api.unity2.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
context_length: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "unity2" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
nameKey: "providerForm.presets.openrouter",
|
||||
@@ -465,24 +515,24 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
name: "kimi",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "kimi-k2.6", name: "Kimi K2.6" }],
|
||||
models: [{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }],
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
suggestedDefaults: {
|
||||
model: { default: "kimi-k2.6", provider: "kimi" },
|
||||
model: { default: "kimi-k2.7-code", provider: "kimi" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
name: "kimi_coding",
|
||||
base_url: "https://api.kimi.com/coding/",
|
||||
@@ -591,7 +641,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7" }],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: { backgroundColor: "#f64551", textColor: "#FFFFFF" },
|
||||
icon: "minimax",
|
||||
@@ -612,7 +661,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7" }],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: { backgroundColor: "#f64551", textColor: "#FFFFFF" },
|
||||
icon: "minimax",
|
||||
@@ -866,8 +914,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sudocode",
|
||||
icon: "sudocode",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "sudocode" },
|
||||
@@ -1191,25 +1237,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "claude-opus-4-8", provider: "eflowcode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LemonData",
|
||||
websiteUrl: "https://lemondata.cc",
|
||||
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
|
||||
settingsConfig: {
|
||||
name: "lemondata",
|
||||
base_url: "https://api.lemondata.cc/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "lemondata",
|
||||
icon: "lemondata",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "lemondata" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TheRouter",
|
||||
websiteUrl: "https://therouter.ai",
|
||||
|
||||
@@ -147,9 +147,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
apiKey: "",
|
||||
@@ -256,6 +256,80 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CCSub",
|
||||
websiteUrl: "https://www.ccsub.net",
|
||||
apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://www.ccsub.net/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
cost: { input: 5, output: 15 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "ccsub/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"ccsub/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.unity2.ai/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
cost: { input: 5, output: 15 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "unity2/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"unity2/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// ========== Chinese Officials ==========
|
||||
{
|
||||
name: "DeepSeek",
|
||||
@@ -416,8 +490,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi k2.6",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
name: "Kimi K2.7 Code",
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
@@ -425,9 +499,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6",
|
||||
contextWindow: 131072,
|
||||
id: "kimi-k2.7-code",
|
||||
name: "Kimi K2.7 Code",
|
||||
contextWindow: 262144,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
},
|
||||
],
|
||||
@@ -449,13 +523,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "kimi/kimi-k2.6" },
|
||||
modelCatalog: { "kimi/kimi-k2.6": { alias: "Kimi" } },
|
||||
model: { primary: "kimi/kimi-k2.7-code" },
|
||||
modelCatalog: { "kimi/kimi-k2.7-code": { alias: "Kimi" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.kimi.com/v1",
|
||||
@@ -599,7 +673,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -637,7 +710,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -1593,8 +1665,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sudocode",
|
||||
icon: "sudocode",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
@@ -2103,42 +2173,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LemonData",
|
||||
websiteUrl: "https://lemondata.cc",
|
||||
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.lemondata.cc/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "lemondata",
|
||||
icon: "lemondata",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "lemondata/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"lemondata/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// ========== Cloud Providers ==========
|
||||
{
|
||||
name: "AWS Bedrock",
|
||||
|
||||
@@ -311,9 +311,9 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "火山Agentplan",
|
||||
@@ -407,6 +407,62 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CCSub",
|
||||
websiteUrl: "https://www.ccsub.net",
|
||||
apiKeyUrl: "https://www.ccsub.net/register?ref=Y6Z8DXEA",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "CCSub",
|
||||
options: {
|
||||
baseURL: "https://www.ccsub.net/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Unity2.ai",
|
||||
options: {
|
||||
baseURL: "https://api.unity2.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
@@ -532,19 +588,19 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi k2.6",
|
||||
websiteUrl: "https://platform.moonshot.cn/console",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
name: "Kimi K2.7 Code",
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Kimi k2.6",
|
||||
name: "Kimi K2.7 Code",
|
||||
options: {
|
||||
baseURL: "https://api.moonshot.cn/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"kimi-k2.6": { name: "Kimi K2.6" },
|
||||
"kimi-k2.7-code": { name: "Kimi K2.7 Code" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -566,8 +622,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "Kimi For Coding",
|
||||
@@ -815,7 +871,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_cn",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -848,7 +903,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax_en",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
@@ -1318,8 +1372,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sudocode",
|
||||
icon: "sudocode",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
@@ -1658,34 +1710,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LemonData",
|
||||
websiteUrl: "https://lemondata.cc",
|
||||
apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "LemonData",
|
||||
options: {
|
||||
baseURL: "https://api.lemondata.cc/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "lemondata",
|
||||
icon: "lemondata",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AWS Bedrock",
|
||||
websiteUrl: "https://aws.amazon.com/bedrock/",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 自定义 User-Agent 预设。
|
||||
*
|
||||
* 取值来自 PR #3671 对 Kimi Coding Plan(api.kimi.com/coding)UA 白名单的 curl 实测:
|
||||
* `claude-cli/*`、`claude-code/*`、`Kilo-Code/*` 可通过;`codex-cli`、`kimi-cli` 会被 403。
|
||||
* 白名单只校验 UA 名称前缀、不看版本号,因此用静态值即可,版本不会因 Claude Code 升级而失效。
|
||||
*
|
||||
* 第一条是官方 Claude Code CLI 实际发送的完整格式(参见 `stream_check.rs` 里检测用的
|
||||
* `claude-cli/2.1.2 (external, cli)`),最贴近真实客户端、最稳过严格的 UA 校验;其余为简短变体。
|
||||
*
|
||||
* 这些预设主要用于"非白名单 Coding Agent(Codex/Gemini/Hermes/OpenClaw 等)想接入受 UA
|
||||
* 限制的上游"的场景——把转发请求伪装成已在白名单内的客户端。是否使用由用户显式选择。
|
||||
*/
|
||||
export const USER_AGENT_PRESETS: readonly string[] = [
|
||||
"claude-cli/2.1.161 (external, cli)",
|
||||
"claude-cli/2.1.161",
|
||||
"claude-code/1.0.0",
|
||||
"claude-code/0.1.0",
|
||||
"Kilo-Code/1.0",
|
||||
];
|
||||
@@ -6,14 +6,13 @@ import React, {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { UpdateInfo, UpdateHandle } from "../lib/updater";
|
||||
import type { UpdateInfo } from "../lib/updater";
|
||||
import { checkForUpdate } from "../lib/updater";
|
||||
|
||||
interface UpdateContextValue {
|
||||
// 更新状态
|
||||
hasUpdate: boolean;
|
||||
updateInfo: UpdateInfo | null;
|
||||
updateHandle: UpdateHandle | null;
|
||||
isChecking: boolean;
|
||||
error: string | null;
|
||||
|
||||
@@ -34,7 +33,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
@@ -72,7 +70,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||
if (result.status === "available") {
|
||||
setHasUpdate(true);
|
||||
setUpdateInfo(result.info);
|
||||
setUpdateHandle(result.update);
|
||||
|
||||
// 检查是否已经关闭过这个版本的提醒
|
||||
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
|
||||
@@ -89,7 +86,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||
} else {
|
||||
setHasUpdate(false);
|
||||
setUpdateInfo(null);
|
||||
setUpdateHandle(null);
|
||||
setIsDismissed(false);
|
||||
return false; // 已是最新
|
||||
}
|
||||
@@ -132,7 +128,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||
const value: UpdateContextValue = {
|
||||
hasUpdate,
|
||||
updateInfo,
|
||||
updateHandle,
|
||||
isChecking,
|
||||
error,
|
||||
isDismissed,
|
||||
|
||||
@@ -118,6 +118,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
|
||||
preserveCodexOfficialAuthOnSwitch:
|
||||
data.preserveCodexOfficialAuthOnSwitch ?? false,
|
||||
unifyCodexSessionHistory: data.unifyCodexSessionHistory ?? false,
|
||||
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
|
||||
@@ -143,6 +144,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
enableClaudePluginIntegration: false,
|
||||
skipClaudeOnboarding: false,
|
||||
preserveCodexOfficialAuthOnSwitch: false,
|
||||
unifyCodexSessionHistory: false,
|
||||
language: readPersistedLanguage(),
|
||||
} as SettingsFormState);
|
||||
|
||||
@@ -182,6 +184,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
|
||||
preserveCodexOfficialAuthOnSwitch:
|
||||
serverData.preserveCodexOfficialAuthOnSwitch ?? false,
|
||||
unifyCodexSessionHistory: serverData.unifyCodexSessionHistory ?? false,
|
||||
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
|
||||
|
||||
+27
-73
@@ -6,12 +6,17 @@ import {
|
||||
type StreamCheckResult,
|
||||
} from "@/lib/api/model-test";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useResetCircuitBreaker } from "@/lib/query/failover";
|
||||
|
||||
/**
|
||||
* 供应商连通性检查。
|
||||
*
|
||||
* 只探测 base_url 是否可达(任何 HTTP 响应都算可达),不发真实大模型请求。
|
||||
* 刻意 **不** 重置故障转移熔断器——可达 ≠ 配置正确,一个端口通但鉴权废的供应商
|
||||
* 不应被误判为"健康"而切回线上。熔断器只由真实转发流量驱动(见 proxy/forwarder.rs)。
|
||||
*/
|
||||
export function useStreamCheck(appId: AppId) {
|
||||
const { t } = useTranslation();
|
||||
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
||||
const resetCircuitBreaker = useResetCircuitBreaker();
|
||||
|
||||
const checkProvider = useCallback(
|
||||
async (
|
||||
@@ -25,89 +30,38 @@ export function useStreamCheck(appId: AppId) {
|
||||
|
||||
if (result.status === "operational") {
|
||||
toast.success(
|
||||
t("streamCheck.operational", {
|
||||
t("streamCheck.reachable", {
|
||||
providerName: providerName,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
defaultValue: `${providerName} 运行正常 (${result.responseTimeMs}ms)`,
|
||||
defaultValue: `${providerName} 连通正常 (${result.responseTimeMs}ms)`,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
|
||||
// 测试通过后重置熔断器状态
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else if (result.status === "degraded") {
|
||||
toast.warning(
|
||||
t("streamCheck.degraded", {
|
||||
t("streamCheck.reachableSlow", {
|
||||
providerName: providerName,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
||||
defaultValue: `${providerName} 连通但较慢 (${result.responseTimeMs}ms)`,
|
||||
}),
|
||||
);
|
||||
|
||||
// 降级状态也重置熔断器,因为至少能通信
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else if (result.errorCategory === "modelNotFound") {
|
||||
// 专门处理"模型不存在/已下架":指向配置入口,比通用 404 文案更有指导性
|
||||
toast.error(
|
||||
t("streamCheck.modelNotFound", {
|
||||
providerName: providerName,
|
||||
model: result.modelUsed,
|
||||
defaultValue: `${providerName} 测试模型 ${result.modelUsed} 不存在或已下架`,
|
||||
}),
|
||||
{
|
||||
description: t("streamCheck.modelNotFoundHint", {
|
||||
defaultValue: "",
|
||||
}),
|
||||
duration: 10000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else if (result.errorCategory === "quotaExceeded") {
|
||||
toast.warning(
|
||||
t("streamCheck.quotaExceeded", {
|
||||
providerName: providerName,
|
||||
defaultValue: `${providerName} Coding Plan quota has been exceeded`,
|
||||
}),
|
||||
{
|
||||
description: t("streamCheck.quotaExceededHint", {
|
||||
defaultValue: "",
|
||||
}),
|
||||
duration: 10000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const httpStatus = result.httpStatus;
|
||||
const hintKey = httpStatus
|
||||
? `streamCheck.httpHint.${httpStatus >= 500 ? "5xx" : httpStatus}`
|
||||
: null;
|
||||
const description =
|
||||
(hintKey ? t(hintKey, { defaultValue: "" }) : "") || undefined;
|
||||
|
||||
// 401/403/400 = 检查被拒(供应商可能正常);429/5xx = 临时问题
|
||||
const isProbeRejection =
|
||||
httpStatus != null &&
|
||||
([401, 403, 400, 429].includes(httpStatus) || httpStatus >= 500);
|
||||
|
||||
if (isProbeRejection) {
|
||||
toast.warning(
|
||||
t("streamCheck.rejected", {
|
||||
providerName: providerName,
|
||||
message: result.message,
|
||||
defaultValue: `${providerName} 检查被拒: ${result.message}`,
|
||||
// 仅当无法建立连接(DNS / 连接被拒 / TLS / 超时)才会到这里
|
||||
toast.error(
|
||||
t("streamCheck.unreachable", {
|
||||
providerName: providerName,
|
||||
message: result.message,
|
||||
defaultValue: `${providerName} 无法连通: ${result.message}`,
|
||||
}),
|
||||
{
|
||||
description: t("streamCheck.unreachableHint", {
|
||||
defaultValue:
|
||||
"无法建立连接(DNS / 连接 / TLS / 超时)。请检查 base_url 与网络。",
|
||||
}),
|
||||
{ description, duration: 8000, closeButton: true },
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("streamCheck.failed", {
|
||||
providerName: providerName,
|
||||
message: result.message,
|
||||
defaultValue: `${providerName} 检查失败: ${result.message}`,
|
||||
}),
|
||||
{ description, duration: 8000, closeButton: true },
|
||||
);
|
||||
}
|
||||
duration: 8000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -128,7 +82,7 @@ export function useStreamCheck(appId: AppId) {
|
||||
});
|
||||
}
|
||||
},
|
||||
[appId, t, resetCircuitBreaker],
|
||||
[appId, t],
|
||||
);
|
||||
|
||||
const isChecking = useCallback(
|
||||
|
||||
+53
-11
@@ -98,6 +98,7 @@
|
||||
"windowClose": "Close window"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "Connectivity check",
|
||||
"tabProvider": "Provider",
|
||||
"tabUniversal": "Universal",
|
||||
"noProviders": "No providers added yet",
|
||||
@@ -194,6 +195,7 @@
|
||||
"routeModelLabel": "Model role",
|
||||
"routeRoleSonnet": "Sonnet",
|
||||
"routeRoleOpus": "Opus",
|
||||
"routeRoleFable": "Fable",
|
||||
"routeRoleHaiku": "Haiku",
|
||||
"labelOverrideLabel": "Menu display name",
|
||||
"upstreamModelLabel": "Requested model",
|
||||
@@ -279,6 +281,18 @@
|
||||
"message": "Failover is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend configuring provider priorities in the failover queue first.",
|
||||
"confirm": "I understand, enable"
|
||||
},
|
||||
"unifyCodexHistory": {
|
||||
"title": "Unified Codex session history",
|
||||
"message": "When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.\n\nYou can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).",
|
||||
"migrateExisting": "Also migrate existing official session history",
|
||||
"confirm": "I understand, enable"
|
||||
},
|
||||
"unifyCodexHistoryOff": {
|
||||
"title": "Turn off unified session history",
|
||||
"message": "After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.",
|
||||
"restoreBackup": "Restore the official sessions migrated at enable time back to the official history (exact restore from backup)",
|
||||
"confirm": "Turn off"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Configure Usage Query",
|
||||
"message": "Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\n\nIf unsure how to configure, please consult your provider's documentation first.",
|
||||
@@ -670,6 +684,12 @@
|
||||
"codexAuth": "Codex App Enhancements",
|
||||
"preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers",
|
||||
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.",
|
||||
"unifyCodexSessionHistory": "Unified Codex session history",
|
||||
"unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.",
|
||||
"unifyCodexHistoryRestoreCompleted": "Official session history restored from backup ({{files}} session files, {{rows}} index rows)",
|
||||
"unifyCodexHistoryRestoreFailed": "Failed to restore official session history, please try again",
|
||||
"unifyCodexHistoryRestoreNothing": "No restorable migration backup for the current Codex directory",
|
||||
"unifyCodexHistoryRestoreSkippedToggleOn": "Unified session history was re-enabled; restore skipped",
|
||||
"appVisibility": {
|
||||
"title": "Homepage Display",
|
||||
"description": "Choose which apps to show on the homepage",
|
||||
@@ -979,7 +999,6 @@
|
||||
"apikeyfun": "APIKEY.FUN offers a special deal for CC Switch users. Register through the exclusive link to enjoy up to permanent 5% off top-ups.",
|
||||
"apinebula": "APINEBULA offers CC Switch users a special discount: register using the link and enter the \"ccswitch\" promo code during your first top-up to get 10% off.",
|
||||
"atlascloud": "Atlas Cloud is a full-modal AI inference platform that gives developers one API for video generation, image generation, and LLM access. Check out its new Coding Plan promotion for more budget-friendly API access.",
|
||||
"sudocode": "SudoCode is a global AI model aggregation service. One API key works across multiple models, with presets for Claude Code, Codex, Gemini CLI, OpenClaw, and more.",
|
||||
"patewayai": "PatewayAI offers special benefits for CC Switch users. Register via this link to receive $3 credit.",
|
||||
"claudeapi": "ClaudeAPI offers special benefits for CC Switch users. Register via this link to claim test credits.",
|
||||
"claudecn": "ClaudeCN is an enterprise-grade AI gateway operated by a registered company, supporting enterprise procurement processes with corporate payments, contracts, and compliance guarantees.",
|
||||
@@ -998,10 +1017,11 @@
|
||||
"micu": "Micu is an official partner of CC Switch",
|
||||
"ctok": "Join the CTok community on the official website and subscribe to a plan.",
|
||||
"shengsuanyun": "Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!",
|
||||
"lemondata": "Lemon Code offers a special promotion for CC Switch users. Sign up via this link to receive $1 in free credits.",
|
||||
"doubaoseed": "DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.",
|
||||
"volcengine_agentplan": "Volcengine Ark Agent Plan offers exclusive benefits for CC Switch users: subscribe via this link, starting from ¥40/month for new customers.",
|
||||
"byteplus": "Register via this link to get 500,000 tokens of free inference quota per model."
|
||||
"volcengine_agentplan": "Volcengine Ark Coding Plan offers exclusive benefits for CC Switch users: subscribe via this link and new customers get 75% off for the first two months, plus an extra 5% off with the exclusive invite code 6J6FV5N2 — as low as ¥9.4/month!",
|
||||
"byteplus": "Register via this link to get 500,000 tokens of free inference quota per model.",
|
||||
"ccsub": "CCSub is a stable, affordable AI API relay — a drop-in replacement for Claude.ai & OpenAI subscriptions, with one key for all models at ~30% of direct API cost.",
|
||||
"unity2": "Unity2.ai offers exclusive benefits for CC Switch users: register via this link to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
@@ -1042,6 +1062,10 @@
|
||||
"anthropicSmallFastModel": "Fast Model",
|
||||
"apiFormat": "API Format",
|
||||
"apiFormatHint": "Select the input format for the provider's API",
|
||||
"customUserAgent": "Custom User-Agent",
|
||||
"customUserAgentHint": "Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.",
|
||||
"customUserAgentInvalid": "User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.",
|
||||
"customUserAgentPresets": "Presets",
|
||||
"fullUrlLabel": "Full URL",
|
||||
"fullUrlEnabled": "Full URL Mode",
|
||||
"fullUrlDisabled": "Mark as Full URL",
|
||||
@@ -1071,6 +1095,7 @@
|
||||
"modelRoleLabel": "Model role",
|
||||
"modelRoleSonnet": "Sonnet",
|
||||
"modelRoleOpus": "Opus",
|
||||
"modelRoleFable": "Fable",
|
||||
"modelRoleHaiku": "Haiku",
|
||||
"modelDisplayNameLabel": "Display name",
|
||||
"modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro",
|
||||
@@ -1078,7 +1103,7 @@
|
||||
"modelOneMHeader": "Declare 1M",
|
||||
"modelOneMLabel": "1M",
|
||||
"fallbackModelLabel": "Default fallback model",
|
||||
"fallbackModelHint": "Used only when a Claude Code request does not clearly map to Sonnet, Opus, or Haiku. Usually safe to leave blank.",
|
||||
"fallbackModelHint": "A fallback for requests that don't clearly map to Sonnet, Opus, Fable, or Haiku. Recommended for third-party/relay endpoints—otherwise such requests (including Haiku background subtasks) are forwarded under their original Claude model name and may fail if the upstream doesn't host it. Safe to leave blank for official endpoints.",
|
||||
"quickSetModels": "Quick Set",
|
||||
"quickSetSuccess": "Model name applied to all roles",
|
||||
"advancedOptionsToggle": "Advanced Options",
|
||||
@@ -1256,8 +1281,9 @@
|
||||
"reasoningModeHint": "Enable when the upstream Chat Completions API supports toggling thinking on/off. Providers like Kimi, GLM and Qwen usually fall into this category.",
|
||||
"reasoningEffortToggle": "Supports Reasoning Effort",
|
||||
"reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.",
|
||||
"reasoningSectionToggle": "Reasoning Capability (Advanced · usually auto-detected)",
|
||||
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Expand to override manually only when auto-detection is wrong."
|
||||
"reasoningGroupTitle": "Reasoning Capability",
|
||||
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.",
|
||||
"advancedSectionHint": "Includes local routing, model mapping, reasoning overrides and custom User-Agent. Enable local routing here when your provider uses the Chat Completions protocol or non-GPT models."
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "Environment Variables (.env)",
|
||||
@@ -1320,7 +1346,14 @@
|
||||
"label": "Provider Preset",
|
||||
"custom": "Custom Configuration",
|
||||
"other": "Other",
|
||||
"hint": "You can continue to adjust the fields below after selecting a preset."
|
||||
"hint": "You can continue to adjust the fields below after selecting a preset.",
|
||||
"searchTooltip": "Search presets",
|
||||
"searchAriaLabel": "Search provider presets",
|
||||
"searchPlaceholder": "Search name, website, or category...",
|
||||
"sortAriaLabel": "Toggle preset sorting",
|
||||
"sortNameAscTooltip": "Sort by name A-Z",
|
||||
"sortOriginalTooltip": "Restore original order",
|
||||
"noSearchResults": "No provider presets match your search."
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage Statistics",
|
||||
@@ -1402,6 +1435,7 @@
|
||||
"modelPricingDesc": "Configure token costs for each model",
|
||||
"noPricingData": "No pricing data. Click \"Add\" to add model pricing configuration.",
|
||||
"model": "Model",
|
||||
"pricingModel": "Pricing Model",
|
||||
"displayName": "Display Name",
|
||||
"inputCost": "Input Cost",
|
||||
"outputCost": "Output Cost",
|
||||
@@ -1426,10 +1460,13 @@
|
||||
"modelIdPlaceholder": "e.g., claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "e.g., Claude 3.5 Sonnet",
|
||||
"appType": "App Type",
|
||||
"allApps": "All Apps",
|
||||
"statusCode": "Status Code",
|
||||
"searchProviderPlaceholder": "Search provider...",
|
||||
"searchModelPlaceholder": "Search model...",
|
||||
"allSources": "All Sources",
|
||||
"allModels": "All Models",
|
||||
"filterBySource": "Filter by source",
|
||||
"filterByModel": "Filter by model",
|
||||
"refreshInterval": "Auto-refresh interval",
|
||||
"refreshOff": "Off",
|
||||
"timeRange": "Time Range",
|
||||
"customRange": "Calendar Filter",
|
||||
"customRangeHint": "Supports both date and time",
|
||||
@@ -2479,6 +2516,11 @@
|
||||
"stopWithRestoreFailed": "Stop failed: {{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} is reachable ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} reachable but slow ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} unreachable: {{message}}",
|
||||
"unreachableHint": "Could not establish a connection (DNS / connect / TLS / timeout). Check the base_url and your network.",
|
||||
"connectivityNote": "Connectivity check only probes whether the provider address is reachable; it does not send a real model request. Any response counts as \"reachable\" — which does not guarantee that auth or model settings are correct.",
|
||||
"configSaved": "Health check config saved",
|
||||
"configSaveFailed": "Save failed",
|
||||
"testModels": "Test Models",
|
||||
|
||||
+53
-11
@@ -98,6 +98,7 @@
|
||||
"windowClose": "ウィンドウを閉じる"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "接続チェック",
|
||||
"tabProvider": "プロバイダー",
|
||||
"tabUniversal": "統一プロバイダー",
|
||||
"noProviders": "まだプロバイダーがありません",
|
||||
@@ -194,6 +195,7 @@
|
||||
"routeModelLabel": "モデル役割",
|
||||
"routeRoleSonnet": "Sonnet",
|
||||
"routeRoleOpus": "Opus",
|
||||
"routeRoleFable": "Fable",
|
||||
"routeRoleHaiku": "Haiku",
|
||||
"labelOverrideLabel": "メニュー表示名",
|
||||
"upstreamModelLabel": "リクエストモデル",
|
||||
@@ -279,6 +281,18 @@
|
||||
"message": "フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。",
|
||||
"confirm": "理解しました、有効にする"
|
||||
},
|
||||
"unifyCodexHistory": {
|
||||
"title": "Codex セッション履歴を統一",
|
||||
"message": "オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。\n\n既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。",
|
||||
"migrateExisting": "既存の公式セッション履歴も移行する",
|
||||
"confirm": "理解しました、オンにする"
|
||||
},
|
||||
"unifyCodexHistoryOff": {
|
||||
"title": "セッション履歴の統一をオフにする",
|
||||
"message": "オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。",
|
||||
"restoreBackup": "オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)",
|
||||
"confirm": "オフにする"
|
||||
},
|
||||
"usage": {
|
||||
"title": "使用量クエリの設定",
|
||||
"message": "使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\n\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。",
|
||||
@@ -670,6 +684,12 @@
|
||||
"codexAuth": "Codex アプリ拡張",
|
||||
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
|
||||
"preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。",
|
||||
"unifyCodexSessionHistory": "Codex セッション履歴を統一",
|
||||
"unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。",
|
||||
"unifyCodexHistoryRestoreCompleted": "バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)",
|
||||
"unifyCodexHistoryRestoreFailed": "公式セッション履歴の復元に失敗しました。もう一度お試しください",
|
||||
"unifyCodexHistoryRestoreNothing": "現在の Codex ディレクトリに復元可能な移行バックアップはありません",
|
||||
"unifyCodexHistoryRestoreSkippedToggleOn": "統一セッション履歴が再度有効化されたため、復元をスキップしました",
|
||||
"appVisibility": {
|
||||
"title": "ホームページ表示",
|
||||
"description": "ホームページに表示するアプリを選択",
|
||||
@@ -979,7 +999,6 @@
|
||||
"apikeyfun": "APIKEY.FUN は CC Switch ユーザー向けに特別優待を提供しています。専用リンクから登録すると、最大でチャージ永久 5% オフを受けられます。",
|
||||
"apinebula": "APINEBULA は CC Switch ユーザー向けに特別割引を提供しています。専用リンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると、さらに 10% OFF の割引が適用されます。",
|
||||
"atlascloud": "Atlas Cloud は、1 つの API で動画・画像生成や LLM を利用できる全モーダル対応の AI 推論プラットフォームです。より低コストで API を利用できる新しい「コーディングプラン」プロモーションをご確認ください。",
|
||||
"sudocode": "SudoCode は、世界水準の AI モデル集約サービスです。1 つの API Key で複数モデルを利用でき、Claude Code、Codex、Gemini CLI、OpenClaw などのツールに対応しています。",
|
||||
"patewayai": "PatewayAI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると $3 のクレジットがもらえます。",
|
||||
"claudeapi": "ClaudeAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録するとテストクレジットを受け取ることができます。",
|
||||
"claudecn": "ClaudeCN は登録企業が運営するエンタープライズグレードの AI ゲートウェイプラットフォームで、企業調達プロセスをサポートし、法人支払い、契約、コンプライアンス保証を提供します。",
|
||||
@@ -998,10 +1017,11 @@
|
||||
"micu": "Micu は CC Switch の公式パートナーです",
|
||||
"ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。",
|
||||
"shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!",
|
||||
"lemondata": "Lemon Code は CC Switch ユーザー向けの特別オファーを提供しています。このリンクから登録すると 1 ドル分の無料クレジットがもらえます。",
|
||||
"doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟AgentPlanを購読すると、新規のお客様は初月40元から利用できます。",
|
||||
"byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。"
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟 Coding Plan を購読すると、新規のお客様は最初の 2 か月が 75% オフ、さらに専用招待コード 6J6FV5N2 で 5% オフが加算され、月額 9.4 元から!",
|
||||
"byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。",
|
||||
"ccsub": "CCSub は安定・低価格の AI API リレーサービスです。Claude Code の公式サブスクリプションの代替として、1つのキーで全モデルを公式比約1/3のコストで利用できます。",
|
||||
"unity2": "Unity2.ai は CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると $2 分のクレジット、公式グループ参加でさらに $10、最大 $12 の無料クレジットがもらえます!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
@@ -1042,6 +1062,10 @@
|
||||
"anthropicSmallFastModel": "高速モデル",
|
||||
"apiFormat": "API フォーマット",
|
||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||
"customUserAgent": "カスタム User-Agent",
|
||||
"customUserAgentHint": "ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。",
|
||||
"customUserAgentInvalid": "User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。",
|
||||
"customUserAgentPresets": "プリセット",
|
||||
"fullUrlLabel": "フル URL",
|
||||
"fullUrlEnabled": "フル URL モード",
|
||||
"fullUrlDisabled": "フル URL として設定",
|
||||
@@ -1071,6 +1095,7 @@
|
||||
"modelRoleLabel": "モデル役割",
|
||||
"modelRoleSonnet": "Sonnet",
|
||||
"modelRoleOpus": "Opus",
|
||||
"modelRoleFable": "Fable",
|
||||
"modelRoleHaiku": "Haiku",
|
||||
"modelDisplayNameLabel": "表示名",
|
||||
"modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro",
|
||||
@@ -1078,7 +1103,7 @@
|
||||
"modelOneMHeader": "1M 対応を宣言",
|
||||
"modelOneMLabel": "1M",
|
||||
"fallbackModelLabel": "既定フォールバックモデル",
|
||||
"fallbackModelHint": "Claude Code のリクエストが Sonnet、Opus、Haiku のいずれにも明確に対応しない場合のみ使われます。通常は空欄で構いません。",
|
||||
"fallbackModelHint": "Sonnet・Opus・Fable・Haiku のいずれにも明確に対応しないリクエストのフォールバックです。サードパーティ/中継エンドポイントでは設定を推奨します。設定しないと、これらのリクエスト(Haiku のバックグラウンドサブタスクを含む)が元の Claude モデル名のまま上流へ転送され、上流に該当モデルが無い場合は失敗することがあります。公式エンドポイントでは空欄で構いません。",
|
||||
"quickSetModels": "一括設定",
|
||||
"quickSetSuccess": "モデル名をすべての役割に適用しました",
|
||||
"advancedOptionsToggle": "高級オプション",
|
||||
@@ -1256,8 +1281,9 @@
|
||||
"reasoningModeHint": "上流の Chat Completions API が思考(thinking)のオン/オフ切り替えに対応している場合に有効化します。Kimi、GLM、Qwen などが通常このタイプに該当します。",
|
||||
"reasoningEffortToggle": "思考レベルに対応",
|
||||
"reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。",
|
||||
"reasoningSectionToggle": "思考能力(詳細・通常は自動識別)",
|
||||
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ展開して手動で上書きしてください。"
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。",
|
||||
"advancedSectionHint": "ローカルルーティング、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。供給元が Chat Completions プロトコルまたは GPT 以外のモデルを使用する場合は、ここでローカルルーティングを有効にしてください。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境変数 (.env)",
|
||||
@@ -1320,7 +1346,14 @@
|
||||
"label": "プロバイダータイプ",
|
||||
"custom": "カスタム設定",
|
||||
"other": "その他",
|
||||
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
|
||||
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。",
|
||||
"searchTooltip": "プリセットを検索",
|
||||
"searchAriaLabel": "プロバイダープリセットを検索",
|
||||
"searchPlaceholder": "名前、サイト、カテゴリで検索...",
|
||||
"sortAriaLabel": "プリセットの並び順を切り替え",
|
||||
"sortNameAscTooltip": "名前順 A-Z で並び替え",
|
||||
"sortOriginalTooltip": "元の順序に戻す",
|
||||
"noSearchResults": "一致するプロバイダープリセットがありません。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "利用統計",
|
||||
@@ -1402,6 +1435,7 @@
|
||||
"modelPricingDesc": "各モデルのトークンコストを設定",
|
||||
"noPricingData": "料金データがありません。「追加」をクリックしてモデル料金を設定してください。",
|
||||
"model": "モデル",
|
||||
"pricingModel": "課金モデル",
|
||||
"displayName": "表示名",
|
||||
"inputCost": "入力コスト",
|
||||
"outputCost": "出力コスト",
|
||||
@@ -1426,10 +1460,13 @@
|
||||
"modelIdPlaceholder": "例: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例: Claude 3.5 Sonnet",
|
||||
"appType": "アプリ種別",
|
||||
"allApps": "すべてのアプリ",
|
||||
"statusCode": "ステータスコード",
|
||||
"searchProviderPlaceholder": "プロバイダーを検索...",
|
||||
"searchModelPlaceholder": "モデルを検索...",
|
||||
"allSources": "すべてのソース",
|
||||
"allModels": "すべてのモデル",
|
||||
"filterBySource": "ソースで絞り込む",
|
||||
"filterByModel": "モデルで絞り込む",
|
||||
"refreshInterval": "自動更新間隔",
|
||||
"refreshOff": "オフ",
|
||||
"timeRange": "期間",
|
||||
"customRange": "カレンダーフィルター",
|
||||
"customRangeHint": "日付と時刻の両方に対応",
|
||||
@@ -2479,6 +2516,11 @@
|
||||
"stopWithRestoreFailed": "停止に失敗しました: {{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} は接続可能 ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} は接続可能だが低速 ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} に接続できません: {{message}}",
|
||||
"unreachableHint": "接続を確立できませんでした(DNS / 接続 / TLS / タイムアウト)。base_url とネットワークを確認してください。",
|
||||
"connectivityNote": "接続チェックはプロバイダーのアドレスに到達できるかを確認するだけで、実際のモデルリクエストは送信しません。何らかの応答があれば「到達可能」とみなされますが、認証やモデル設定が正しいことを保証するものではありません。",
|
||||
"configSaved": "ヘルスチェック設定を保存しました",
|
||||
"configSaveFailed": "保存に失敗しました",
|
||||
"testModels": "テストモデル",
|
||||
|
||||
+78
-10
@@ -98,6 +98,7 @@
|
||||
"windowClose": "關閉視窗"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "檢測連通",
|
||||
"tabProvider": "供應商",
|
||||
"tabUniversal": "通用供應商",
|
||||
"noProviders": "尚未新增任何供應商",
|
||||
@@ -194,6 +195,7 @@
|
||||
"routeModelLabel": "模型角色",
|
||||
"routeRoleSonnet": "Sonnet",
|
||||
"routeRoleOpus": "Opus",
|
||||
"routeRoleFable": "Fable",
|
||||
"routeRoleHaiku": "Haiku",
|
||||
"labelOverrideLabel": "選單顯示名稱",
|
||||
"upstreamModelLabel": "實際請求模型",
|
||||
@@ -279,6 +281,18 @@
|
||||
"message": "故障轉移是一項進階功能,啟用前請確保您已了解其運作原理。\n\n建議先在故障轉移佇列中設定好供應商優先順序。",
|
||||
"confirm": "我已了解,繼續啟用"
|
||||
},
|
||||
"unifyCodexHistory": {
|
||||
"title": "統一 Codex 會話歷史",
|
||||
"message": "開啟後,官方訂閱與第三方將共用同一個會話歷史清單。注意:跨供應商繼續舊會話時,可能因對方後端無法解密 encrypted_content 推理內容而失敗。\n\n可選擇同時把現有官方會話歷史遷入共享清單(遷移前自動備份到 ~/.cc-switch/backups,關閉開關時可選擇恢復)。",
|
||||
"migrateExisting": "同時遷入現有官方會話歷史",
|
||||
"confirm": "我已了解,繼續開啟"
|
||||
},
|
||||
"unifyCodexHistoryOff": {
|
||||
"title": "關閉統一會話歷史",
|
||||
"message": "關閉後,官方訂閱與第三方將恢復各自獨立的會話歷史清單。開啟期間產生的會話因無法區分來源,將留在第三方歷史中,官方訂閱將看不到它們。",
|
||||
"restoreBackup": "把開啟時遷入的官方會話還原回官方歷史(按備份精確還原)",
|
||||
"confirm": "關閉"
|
||||
},
|
||||
"usage": {
|
||||
"title": "設定用量查詢",
|
||||
"message": "用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\n\n如不確定如何設定,請先查閱供應商文件。",
|
||||
@@ -670,6 +684,12 @@
|
||||
"codexAuth": "Codex 應用增強",
|
||||
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
|
||||
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能",
|
||||
"unifyCodexSessionHistory": "統一 Codex 會話歷史",
|
||||
"unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,官方與第三方會話出現在同一歷史清單中,並可選擇把現有官方會話一併遷入(遷移前自動備份)。關閉開關時可按備份恢復遷入的會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗",
|
||||
"unifyCodexHistoryRestoreCompleted": "已按備份還原官方會話歷史({{files}} 個會話檔案、{{rows}} 條索引記錄)",
|
||||
"unifyCodexHistoryRestoreFailed": "還原官方會話歷史失敗,請重試",
|
||||
"unifyCodexHistoryRestoreNothing": "目前 Codex 目錄沒有可恢復的遷移備份",
|
||||
"unifyCodexHistoryRestoreSkippedToggleOn": "統一會話歷史開關已重新開啟,已跳過還原",
|
||||
"appVisibility": {
|
||||
"title": "主頁面顯示",
|
||||
"description": "選擇在主頁面顯示的應用程式",
|
||||
@@ -950,7 +970,6 @@
|
||||
"apikeyfun": "APIKEY.FUN 為 CC Switch 的使用者提供了特別優惠,透過專屬連結註冊,可享受最高儲值永久 95 折優惠。",
|
||||
"apinebula": "APINEBULA 為 CC Switch 使用者提供特別優惠:使用專屬連結註冊並在儲值時填寫「ccswitch」優惠碼,可享 9 折優惠。",
|
||||
"atlascloud": "Atlas Cloud 是一個全模態 AI 推理平台,透過單一 API 為開發者提供影片生成、圖像生成及 LLM 接入。立即查看全新「編程計畫」優惠,取得更具性價比的 API 接入。",
|
||||
"sudocode": "SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。",
|
||||
"patewayai": "PatewayAI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以獲得 3 美元額度。",
|
||||
"claudeapi": "ClaudeAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以領取測試額度。",
|
||||
"claudecn": "ClaudeCN 是一家實體企業營運的企業級 AI 中繼平台,支援企業採購流程,可對公打款、簽約,服務合規有保障。",
|
||||
@@ -970,10 +989,11 @@
|
||||
"micu": "Micu 是 CC Switch 的官方合作夥伴",
|
||||
"ctok": "官網加入 CTok 社群,訂閱方案。",
|
||||
"shengsuanyun": "勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!",
|
||||
"lemondata": "Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。",
|
||||
"doubaoseed": "豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。",
|
||||
"byteplus": "透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。"
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 Coding Plan,新客戶首兩個月享 2.5 折優惠,再用專屬邀請碼 6J6FV5N2 領取獎勵疊加 9.5 折,低至 9.4 元/月!",
|
||||
"byteplus": "透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。",
|
||||
"ccsub": "CCSub 是穩定、實惠的 AI API 中轉平台,Claude Code 官方訂閱的超強平替,一個 Key 覆蓋全部模型,價格約為官方 1/3。",
|
||||
"unity2": "Unity2.ai 為 CC Switch 使用者提供專屬福利:透過此連結註冊可領取 $2 餘額,加入官方群再送 $10,最高可領 $12 免費額度!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "優雲智算",
|
||||
@@ -1014,6 +1034,10 @@
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "選擇供應商 API 的輸入格式",
|
||||
"customUserAgent": "自訂 User-Agent",
|
||||
"customUserAgentHint": "僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。",
|
||||
"customUserAgentInvalid": "User-Agent 不能包含控制字元(如換行字元),否則將被忽略。",
|
||||
"customUserAgentPresets": "預設",
|
||||
"fullUrlLabel": "完整 URL",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "標記為完整 URL",
|
||||
@@ -1043,6 +1067,7 @@
|
||||
"modelRoleLabel": "模型角色",
|
||||
"modelRoleSonnet": "Sonnet",
|
||||
"modelRoleOpus": "Opus",
|
||||
"modelRoleFable": "Fable",
|
||||
"modelRoleHaiku": "Haiku",
|
||||
"modelDisplayNameLabel": "顯示名稱",
|
||||
"modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro",
|
||||
@@ -1050,7 +1075,7 @@
|
||||
"modelOneMHeader": "聲明支援 1M",
|
||||
"modelOneMLabel": "1M",
|
||||
"fallbackModelLabel": "備用模型",
|
||||
"fallbackModelHint": "僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。",
|
||||
"fallbackModelHint": "用於未明確落到 Sonnet、Opus、Fable、Haiku 角色的請求。使用第三方/中轉端點時建議填寫:否則這些請求(含 Haiku 背景子任務)會以原始 Claude 模型名透傳給上游,可能因上游無此模型而報錯。官方端點可留空。",
|
||||
"quickSetModels": "一鍵設定",
|
||||
"quickSetSuccess": "已將模型名稱套用至所有角色",
|
||||
"advancedOptionsToggle": "進階選項",
|
||||
@@ -1203,7 +1228,34 @@
|
||||
"modelNamePlaceholder": "例如: gpt-5-codex",
|
||||
"contextWindow1M": "1M 上下文視窗",
|
||||
"autoCompactLimit": "壓縮閾值",
|
||||
"autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史"
|
||||
"autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史",
|
||||
"autoCompactLimitPlaceholder": "例如: 90000",
|
||||
"localRoutingToggle": "需要本地路由映射",
|
||||
"localRoutingOnHint": "Codex 目前僅原生支援 OpenAI Responses API 與 GPT 系列模型;如果您的供應商使用 Chat Completions 協定或非 GPT 模型(如 DeepSeek、Kimi),則需要打開本開關,並在使用過程中保持本地路由開啟。",
|
||||
"localRoutingOffHint": "如果您的供應商不是原生 OpenAI Responses API,或者模型名不是 Codex 預設的 GPT 系列,請打開此開關。",
|
||||
"modelMappingTitle": "模型映射",
|
||||
"modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。",
|
||||
"addCatalogModel": "新增模型",
|
||||
"catalogDisplayName": "顯示名稱",
|
||||
"catalogModelId": "模型 ID",
|
||||
"catalogColumnDisplay": "選單顯示名",
|
||||
"catalogColumnModel": "實際請求模型",
|
||||
"catalogColumnContext": "上下文視窗",
|
||||
"catalogDisplayNamePlaceholder": "例如: DeepSeek V4 Flash",
|
||||
"catalogModelPlaceholder": "例如: deepseek-v4-flash",
|
||||
"contextWindow": "上下文視窗",
|
||||
"contextWindowPlaceholder": "例如: 128000",
|
||||
"upstreamModelName": "上游模型名稱",
|
||||
"upstreamModelNameHint": "Chat 格式下這裡填寫真實上游模型;路由會把 Codex 的 Responses 請求轉換為 Chat Completions 並保持該模型。",
|
||||
"modelMetadataAdvanced": "模型中繼資料進階選項",
|
||||
"modelMetadataAdvancedHint": "用於未知模型的 Codex 中繼資料覆寫;清空欄位會從 config.toml 刪除對應設定。",
|
||||
"reasoningModeToggle": "支援思考模式",
|
||||
"reasoningModeHint": "上游 Chat Completions 介面支援開啟或關閉 thinking 時啟用。Kimi、GLM、Qwen 等通常屬於這一類。",
|
||||
"reasoningEffortToggle": "支援思考等級",
|
||||
"reasoningEffortHint": "上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。",
|
||||
"advancedSectionHint": "包含本地路由映射、模型映射、思考能力與自訂 User-Agent。供應商使用 Chat Completions 協定或非 GPT 模型時,需在此開啟本地路由映射。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境變數 (.env)",
|
||||
@@ -1266,7 +1318,14 @@
|
||||
"label": "預設供應商",
|
||||
"custom": "自訂設定",
|
||||
"other": "其他",
|
||||
"hint": "選擇預設後可繼續調整下方欄位。"
|
||||
"hint": "選擇預設後可繼續調整下方欄位。",
|
||||
"searchTooltip": "搜尋預設",
|
||||
"searchAriaLabel": "搜尋預設供應商",
|
||||
"searchPlaceholder": "搜尋名稱、官網或分類...",
|
||||
"sortAriaLabel": "切換預設排序",
|
||||
"sortNameAscTooltip": "按名稱 A-Z 排序",
|
||||
"sortOriginalTooltip": "恢復原始順序",
|
||||
"noSearchResults": "沒有相符的預設供應商。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "使用統計",
|
||||
@@ -1348,6 +1407,7 @@
|
||||
"modelPricingDesc": "設定各模型的 Token 成本",
|
||||
"noPricingData": "暫無定價資料。點擊「新增」加入模型定價設定。",
|
||||
"model": "模型",
|
||||
"pricingModel": "計價模型",
|
||||
"displayName": "顯示名稱",
|
||||
"inputCost": "輸入成本",
|
||||
"outputCost": "輸出成本",
|
||||
@@ -1372,10 +1432,13 @@
|
||||
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
|
||||
"appType": "應用程式類型",
|
||||
"allApps": "全部應用程式",
|
||||
"statusCode": "狀態碼",
|
||||
"searchProviderPlaceholder": "搜尋供應商...",
|
||||
"searchModelPlaceholder": "搜尋模型...",
|
||||
"allSources": "全部來源",
|
||||
"allModels": "全部模型",
|
||||
"filterBySource": "依來源篩選",
|
||||
"filterByModel": "依模型篩選",
|
||||
"refreshInterval": "自動重新整理間隔",
|
||||
"refreshOff": "關閉",
|
||||
"timeRange": "時間範圍",
|
||||
"customRange": "日曆篩選",
|
||||
"customRangeHint": "支援日期與時間",
|
||||
@@ -2425,6 +2488,11 @@
|
||||
"stopWithRestoreFailed": "停止失敗:{{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} 連通正常 ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} 連通但較慢 ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} 無法連通: {{message}}",
|
||||
"unreachableHint": "無法建立連線(DNS / 連線 / TLS / 逾時)。請檢查 base_url 與網路。",
|
||||
"connectivityNote": "連通檢測僅探測供應商位址是否可達,不發送真實模型請求。收到任意回應即視為「可達」——這不代表驗證或模型設定一定正確。",
|
||||
"configSaved": "健康檢測設定已儲存",
|
||||
"configSaveFailed": "儲存失敗",
|
||||
"testModels": "測試模型",
|
||||
|
||||
+53
-11
@@ -98,6 +98,7 @@
|
||||
"windowClose": "关闭窗口"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "检测连通",
|
||||
"tabProvider": "供应商",
|
||||
"tabUniversal": "统一供应商",
|
||||
"noProviders": "还没有添加任何供应商",
|
||||
@@ -194,6 +195,7 @@
|
||||
"routeModelLabel": "模型角色",
|
||||
"routeRoleSonnet": "Sonnet",
|
||||
"routeRoleOpus": "Opus",
|
||||
"routeRoleFable": "Fable",
|
||||
"routeRoleHaiku": "Haiku",
|
||||
"labelOverrideLabel": "菜单显示名",
|
||||
"upstreamModelLabel": "实际请求模型",
|
||||
@@ -279,6 +281,18 @@
|
||||
"message": "故障转移是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先在故障转移队列中配置好供应商优先级。",
|
||||
"confirm": "我已了解,继续启用"
|
||||
},
|
||||
"unifyCodexHistory": {
|
||||
"title": "统一 Codex 会话历史",
|
||||
"message": "开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。\n\n可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。",
|
||||
"migrateExisting": "同时迁入现有官方会话历史",
|
||||
"confirm": "我已了解,继续开启"
|
||||
},
|
||||
"unifyCodexHistoryOff": {
|
||||
"title": "关闭统一会话历史",
|
||||
"message": "关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。",
|
||||
"restoreBackup": "把开启时迁入的官方会话还原回官方历史(按备份精确还原)",
|
||||
"confirm": "关闭"
|
||||
},
|
||||
"usage": {
|
||||
"title": "配置用量查询",
|
||||
"message": "用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\n\n如不确定如何配置,请先查阅供应商文档。",
|
||||
@@ -670,6 +684,12 @@
|
||||
"codexAuth": "Codex 应用增强",
|
||||
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
|
||||
"preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能",
|
||||
"unifyCodexSessionHistory": "统一 Codex 会话历史",
|
||||
"unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败",
|
||||
"unifyCodexHistoryRestoreCompleted": "已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)",
|
||||
"unifyCodexHistoryRestoreFailed": "还原官方会话历史失败,请重试",
|
||||
"unifyCodexHistoryRestoreNothing": "当前 Codex 目录没有可恢复的迁移备份",
|
||||
"unifyCodexHistoryRestoreSkippedToggleOn": "统一会话历史开关已重新开启,已跳过还原",
|
||||
"appVisibility": {
|
||||
"title": "主页面显示",
|
||||
"description": "选择在主页面显示的应用",
|
||||
@@ -979,7 +999,6 @@
|
||||
"apikeyfun": "APIKEY.FUN 为 CC Switch 的用户提供了特别优惠,通过专属链接注册,可享受最高充值永久 95 折优惠。",
|
||||
"apinebula": "APINEBULA 为 CC Switch 用户提供特别优惠:使用专属链接注册并在充值时填写 \"ccswitch\" 优惠码,可享九折优惠。",
|
||||
"atlascloud": "Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。立即查看全新“编程计划”优惠,获取更具性价比的 API 接入。",
|
||||
"sudocode": "SudoCode 是全球一流 AI 模型聚合服务,一个 API Key 可通用多模型,并支持 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。",
|
||||
"patewayai": "PatewayAI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以获得3美元额度。",
|
||||
"claudeapi": "ClaudeAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以领取测试额度。",
|
||||
"claudecn": "ClaudeCN 是一家实体企业运营的企业级AI中转平台,支持企业采购流程,可对公打款、签约,服务合规有保障。",
|
||||
@@ -998,10 +1017,11 @@
|
||||
"micu": "Micu 是 CC Switch 的官方合作伙伴",
|
||||
"ctok": "官网加入CTok社群,订阅套餐。",
|
||||
"shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
|
||||
"lemondata": "Lemon Code 为 CC Switch 的用户提供了特别优惠。使用此链接注册可以获得1美元免费额度。",
|
||||
"doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟AgentPlan,新客户首月40元起。",
|
||||
"byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。"
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!",
|
||||
"byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。",
|
||||
"ccsub": "CCSub 是稳定、实惠的 AI API 中转平台,Claude Code 官方订阅的超强平替,一个 Key 覆盖全部模型,价格约为官方 1/3。",
|
||||
"unity2": "Unity2.ai 为 CC Switch 用户提供专属福利:通过此链接注册可领取 $2 余额,加入官方群再送 $10,最高可领 $12 免费额度!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "优云智算",
|
||||
@@ -1042,6 +1062,10 @@
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||
"customUserAgent": "自定义 User-Agent",
|
||||
"customUserAgentHint": "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||
"customUserAgentInvalid": "User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
|
||||
"customUserAgentPresets": "预设",
|
||||
"fullUrlLabel": "完整 URL",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "标记为完整 URL",
|
||||
@@ -1071,6 +1095,7 @@
|
||||
"modelRoleLabel": "模型角色",
|
||||
"modelRoleSonnet": "Sonnet",
|
||||
"modelRoleOpus": "Opus",
|
||||
"modelRoleFable": "Fable",
|
||||
"modelRoleHaiku": "Haiku",
|
||||
"modelDisplayNameLabel": "显示名称",
|
||||
"modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro",
|
||||
@@ -1078,7 +1103,7 @@
|
||||
"modelOneMHeader": "声明支持 1M",
|
||||
"modelOneMLabel": "1M",
|
||||
"fallbackModelLabel": "默认兜底模型",
|
||||
"fallbackModelHint": "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。",
|
||||
"fallbackModelHint": "用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。",
|
||||
"quickSetModels": "一键设置",
|
||||
"quickSetSuccess": "已将模型名称应用到所有角色",
|
||||
"advancedOptionsToggle": "高级选项",
|
||||
@@ -1256,8 +1281,9 @@
|
||||
"reasoningModeHint": "上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
|
||||
"reasoningEffortToggle": "支持思考等级",
|
||||
"reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
"reasoningSectionToggle": "思考能力(高级·通常自动识别)",
|
||||
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。"
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
|
||||
"advancedSectionHint": "包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "环境变量 (.env)",
|
||||
@@ -1320,7 +1346,14 @@
|
||||
"label": "预设供应商",
|
||||
"custom": "自定义配置",
|
||||
"other": "其他",
|
||||
"hint": "选择预设后可继续调整下方字段。"
|
||||
"hint": "选择预设后可继续调整下方字段。",
|
||||
"searchTooltip": "搜索预设",
|
||||
"searchAriaLabel": "搜索预设供应商",
|
||||
"searchPlaceholder": "搜索名称、官网或分类...",
|
||||
"sortAriaLabel": "切换预设排序",
|
||||
"sortNameAscTooltip": "按名称 A-Z 排序",
|
||||
"sortOriginalTooltip": "恢复原始顺序",
|
||||
"noSearchResults": "没有匹配的预设供应商。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "使用统计",
|
||||
@@ -1402,6 +1435,7 @@
|
||||
"modelPricingDesc": "配置各模型的 Token 成本",
|
||||
"noPricingData": "暂无定价数据。点击\"新增\"添加模型定价配置。",
|
||||
"model": "模型",
|
||||
"pricingModel": "计价模型",
|
||||
"displayName": "显示名称",
|
||||
"inputCost": "输入成本",
|
||||
"outputCost": "输出成本",
|
||||
@@ -1426,10 +1460,13 @@
|
||||
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
|
||||
"appType": "应用类型",
|
||||
"allApps": "全部应用",
|
||||
"statusCode": "状态码",
|
||||
"searchProviderPlaceholder": "搜索供应商...",
|
||||
"searchModelPlaceholder": "搜索模型...",
|
||||
"allSources": "全部来源",
|
||||
"allModels": "全部模型",
|
||||
"filterBySource": "按来源筛选",
|
||||
"filterByModel": "按模型筛选",
|
||||
"refreshInterval": "自动刷新间隔",
|
||||
"refreshOff": "关闭",
|
||||
"timeRange": "时间范围",
|
||||
"customRange": "日历筛选",
|
||||
"customRangeHint": "支持日期与时间",
|
||||
@@ -2479,6 +2516,11 @@
|
||||
"stopWithRestoreFailed": "停止失败: {{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} 连通正常 ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} 连通但较慢 ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} 无法连通: {{message}}",
|
||||
"unreachableHint": "无法建立连接(DNS / 连接 / TLS / 超时)。请检查 base_url 与网络。",
|
||||
"connectivityNote": "连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。",
|
||||
"configSaved": "健康检查配置已保存",
|
||||
"configSaveFailed": "保存失败",
|
||||
"testModels": "测试模型",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.1 MiB |
@@ -6,18 +6,19 @@ import _apinebula from "./apinebula_icon.png";
|
||||
import _atlascloud from "./atlascloud_icon.png";
|
||||
import _claudeapi from "./ClaudeApi.png";
|
||||
import _byteplus from "./byteplus.png";
|
||||
import _ccsub from "./ccsub.svg?url";
|
||||
import _claudecn from "./claudecn.png";
|
||||
import _cherryin from "./cherryin.png";
|
||||
import _eflowcode from "./eflowcode.png";
|
||||
import _hermes from "./hermes.png";
|
||||
import _huoshan from "./huoshan.png";
|
||||
import _lemondata from "./lemondata.png";
|
||||
import _pateway from "./pateway.jpg";
|
||||
import _pipellm from "./pipellm.png";
|
||||
import _relaxcode from "./relaxcode.png";
|
||||
import _runapi from "./runapi.jpg";
|
||||
import _shengsuanyun from "./shengsuanyun.svg?url";
|
||||
import _sudocode from "./sudocode.png";
|
||||
import _unity2 from "./unity2.png";
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
aicodemirror: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 1017.97 1056.47"><title>AICodeMirror</title><path fill="#E4906E" fill-rule="nonzero" d="M944.92 1014.53c-17.29,-9.23 -33.98,-19.28 -50.08,-30.16 -5.39,-3.65 -14.99,-11.7 -28.81,-24.16 -6.06,-5.47 -14.07,-13.51 -24.03,-24.13 -15.15,-16.17 -29.61,-29.9 -41.69,-40.4 -3.98,-3.46 -14.2,-11.02 -30.68,-22.69 -6.24,-4.42 -12.88,-12.15 -18.63,-19.09 -23.98,-29.04 -49.53,-58.44 -76.66,-88.19 -11.93,-13.1 -25.64,-26.11 -35.61,-36.5 -28.72,-29.92 -51.92,-51.96 -78.23,-79.66 -11.24,-11.83 -20.52,-21.3 -27.85,-28.41 -0.56,-0.53 -1.3,-0.84 -2.08,-0.84 -0.51,0 -1.02,0.14 -1.47,0.39 -9.76,5.54 -17.53,14.33 -24.91,23.31 -10.71,13.01 -21.86,26.65 -33.44,40.91 -4.37,5.38 -7.9,9.46 -10.56,12.24 -5.43,5.67 -9.83,10.88 -15.08,15.39 -9.57,8.23 -19.57,16.01 -29.98,23.31 -14.73,10.32 -29.07,20.29 -43.04,29.9 -5.22,3.61 -13.68,9.81 -20.14,15.41 -25.71,22.32 -53.59,46.12 -83.65,71.41 -9.46,7.95 -19.65,16.88 -34.02,29.22 -25.66,22.03 -52.94,40.06 -81.67,55.65 -7.71,4.19 -14.15,8.2 -19.32,12.01 -19.3,14.23 -33.84,25.65 -43.62,34.28 -25.99,22.91 -43.04,37.82 -51.16,44.73 -9.19,7.8 -18.6,17.05 -28.42,25.54 -2.71,2.35 -5.7,3.03 -8.96,2.01 -0.78,-0.24 -1.25,-1.02 -1.1,-1.82 0.36,-2 1.19,-4.1 2.47,-6.32 6.86,-11.81 14.46,-23.09 19.95,-36.03 3.48,-8.23 7.87,-16.52 13.18,-24.89 2.03,-3.19 4.73,-8.77 8.11,-16.74 2.98,-7.02 7.34,-15.05 13.07,-24.12 5.79,-9.14 16,-23.36 30.63,-42.67 7.66,-10.11 19.49,-23.6 35.49,-40.47 4.9,-5.16 12.21,-11.87 21.92,-20.14 12.12,-10.31 23.53,-21.19 34.23,-32.65 11.73,-12.54 16.99,-22.33 27.39,-40.8 2.37,-4.19 6.49,-9.43 12.37,-15.71 8.27,-8.82 17,-17.23 26.21,-25.23 30.11,-26.18 55.17,-47.43 75.17,-63.76 8.66,-7.08 26.42,-21.39 39.65,-30.77 17.11,-12.13 28.62,-20.44 34.53,-24.91 4.5,-3.4 8.93,-6.6 13.3,-10.56 26.03,-23.54 51.66,-45.71 77.28,-70.7 0.42,-0.41 0.51,-1.06 0.21,-1.58 -6.8,-11.78 -12.84,-21.8 -18.11,-30.06 -10.22,-15.99 -22.07,-29.65 -35.57,-40.99 -7.56,-6.36 -18.41,-13.85 -28.65,-20.43 -15.08,-9.66 -30.62,-21.97 -46.63,-36.9 -35.08,-32.73 -67.65,-71.22 -85.32,-115.42 -5.53,-13.85 -10.8,-29.31 -15.8,-46.37 -5.89,-20.13 -12.37,-35.63 -23.22,-51.27 -8.93,-12.9 -15.77,-21.94 -19.58,-35.93 -1.27,-4.67 -2.93,-12.75 -4.99,-24.23 -2.07,-11.54 -6.54,-22.62 -13.41,-33.25 -7.54,-11.68 -13.66,-21.04 -18.33,-28.08 -3.68,-5.53 -7.02,-12.39 -9.63,-18.53 -3.9,-9.18 -8.14,-15.7 -13.6,-23.37 -3.94,-5.53 -5.07,-12.75 0,-18.32 4.14,-4.57 17.49,-3.02 21.56,-1.13 3.86,1.81 8.1,5.13 12.71,9.94 16.16,16.88 26.41,27.77 30.74,32.66 4.69,5.31 11.21,13.79 16.69,19.94 20.19,22.63 36.17,39.74 47.36,59.71 10.46,18.66 16.41,30.42 29.84,44.67 9.32,9.92 17.94,19.33 25.85,28.23 9.01,10.15 19.25,22.95 30.72,38.39 7.54,10.17 13.89,20.11 19.05,29.84 6.39,12.05 10.8,30.19 15.13,41.41 4.88,12.67 12.52,23.25 22.92,31.75 0.58,0.47 6.79,5.44 18.62,14.89 13.54,10.82 23.74,23.47 30.61,37.96 4.55,9.58 7.82,16.16 9.8,19.74 6.62,11.85 14.64,22.05 24.07,30.59 8.99,8.14 17.47,13.2 31.06,22.64 4.28,2.96 6.68,5.98 10.65,2.54 7.08,-6.11 13.73,-10.71 17.96,-14.53 6.12,-5.54 11.71,-11.84 16.79,-18.92 3.5,-4.88 8.77,-10.16 15.19,-14.42 22.77,-15.02 38.17,-31.11 63.32,-55.15 22.13,-21.17 46.22,-47.56 69.25,-66.8 32.17,-26.89 54.99,-45.9 68.46,-57.01 17.15,-14.15 35.82,-30.97 56.02,-50.46 16.06,-15.5 29.25,-27.72 39.57,-36.66 9.78,-8.47 17.55,-14.8 23.31,-18.98 8.52,-6.2 18.55,-10.61 30.56,-15.03 12.34,-4.55 23.44,-11.06 35.67,-17.61 9.07,-4.85 19.76,-9.89 30.05,-8.84 0.5,0.06 0.97,0.32 1.3,0.72 0.85,1.07 1.01,2.48 0.48,4.23 -2.1,6.99 -5.15,13.55 -9.66,20.87 -6.42,10.42 -11.51,19.46 -15.29,27.11 -6.09,12.35 -9.66,19.49 -10.69,21.43 -7.78,14.65 -17.56,27.97 -29.34,39.97 -4.8,4.89 -12.93,12.92 -24.37,24.07 -14.23,13.87 -25.02,30.77 -37.12,50.78 -15.21,25.13 -29.56,48.47 -43.06,70.01 -5.21,8.29 -13.68,15.13 -21.58,21.47 -25.71,20.7 -46.75,41.48 -70.98,64.67 -1.97,1.88 -4.98,4.47 -9.03,7.76 -22.62,18.36 -45.88,35.93 -69.78,52.74 -5.96,4.2 -13.77,11.24 -23.42,21.11 -17.12,17.5 -25.93,26.56 -26.42,27.19 -1.22,1.54 -1.08,3.09 0.41,4.67 14.11,14.81 34.25,37.65 60.42,68.51 11.89,14.01 24.87,27.08 36.03,39.46 8.75,9.7 16.81,22.11 31.82,42.59 2.69,3.68 13.53,16.07 32.5,37.17 5.17,5.76 11.64,14.47 19.4,26.12 16.37,24.6 36.28,56.2 59.73,94.79 4.2,6.92 7.74,12.33 10.62,16.21 5.41,7.29 10.37,13.74 14.92,20.97 6.26,9.94 11.3,19.92 15.11,29.92 4.29,11.27 7.73,19.49 10.32,24.69 7.21,14.5 14.81,28.41 22.8,41.73 3.44,5.75 6.78,13.03 6.11,20.05 -0.07,0.76 -0.71,1.34 -1.48,1.34 -0.25,0 -0.49,-0.06 -0.71,-0.18l0 0.01z"/></svg>`,
|
||||
@@ -97,19 +98,20 @@ export const iconUrls: Record<string, string> = {
|
||||
apinebula: _apinebula,
|
||||
atlascloud: _atlascloud,
|
||||
byteplus: _byteplus,
|
||||
ccsub: _ccsub,
|
||||
claudeapi: _claudeapi,
|
||||
claudecn: _claudecn,
|
||||
cherryin: _cherryin,
|
||||
eflowcode: _eflowcode,
|
||||
hermes: _hermes,
|
||||
huoshan: _huoshan,
|
||||
lemondata: _lemondata,
|
||||
pateway: _pateway,
|
||||
pipellm: _pipellm,
|
||||
relaxcode: _relaxcode,
|
||||
runapi: _runapi,
|
||||
shengsuanyun: _shengsuanyun,
|
||||
sudocode: _sudocode,
|
||||
unity2: _unity2,
|
||||
};
|
||||
|
||||
export const iconList = [
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -133,6 +133,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["byteplus", "volcengine", "ark", "modelark"],
|
||||
defaultColor: "#3370FF",
|
||||
},
|
||||
ccsub: {
|
||||
name: "ccsub",
|
||||
displayName: "CCSub",
|
||||
category: "ai-provider",
|
||||
keywords: ["ccsub", "aggregator", "relay", "claude", "codex", "gateway"],
|
||||
defaultColor: "#1E88E5",
|
||||
},
|
||||
chatglm: {
|
||||
name: "chatglm",
|
||||
displayName: "chatglm",
|
||||
@@ -377,13 +384,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["hermes", "agent", "nous", "nousresearch"],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
lemondata: {
|
||||
name: "lemondata",
|
||||
displayName: "LemonData",
|
||||
category: "ai-provider",
|
||||
keywords: ["lemondata", "lemon", "lemoncode"],
|
||||
defaultColor: "#F5C518",
|
||||
},
|
||||
packycode: {
|
||||
name: "packycode",
|
||||
displayName: "PackyCode",
|
||||
@@ -432,6 +432,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["hunyuan"],
|
||||
defaultColor: "#00A4FF",
|
||||
},
|
||||
unity2: {
|
||||
name: "unity2",
|
||||
displayName: "Unity2.ai",
|
||||
category: "ai-provider",
|
||||
keywords: ["unity2", "aggregator", "relay", "claude", "codex", "gateway"],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
vercel: {
|
||||
name: "vercel",
|
||||
displayName: "vercel",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -18,12 +18,14 @@ export async function fetchModelsForConfig(
|
||||
apiKey: string,
|
||||
isFullUrl?: boolean,
|
||||
modelsUrl?: string,
|
||||
customUserAgent?: string,
|
||||
): Promise<FetchedModel[]> {
|
||||
return invoke("fetch_models_for_config", {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
isFullUrl,
|
||||
modelsUrl,
|
||||
customUserAgent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { AppId } from "./types";
|
||||
|
||||
// ===== 流式健康检查类型 =====
|
||||
// ===== 连通性检查类型 =====
|
||||
// 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移熔断器。
|
||||
|
||||
export type HealthStatus = "operational" | "degraded" | "failed";
|
||||
|
||||
export interface StreamCheckConfig {
|
||||
/** 单次探测超时(秒) */
|
||||
timeoutSecs: number;
|
||||
/** 超时类失败的最大重试次数 */
|
||||
maxRetries: number;
|
||||
/** 降级阈值(毫秒):可达但 TTFB 超过该值判定为"较慢" */
|
||||
degradedThresholdMs: number;
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
testPrompt: string;
|
||||
}
|
||||
|
||||
export interface StreamCheckResult {
|
||||
@@ -21,17 +21,14 @@ export interface StreamCheckResult {
|
||||
message: string;
|
||||
responseTimeMs?: number;
|
||||
httpStatus?: number;
|
||||
modelUsed: string;
|
||||
testedAt: number;
|
||||
retryCount: number;
|
||||
/** 细粒度错误分类,如 "modelNotFound" */
|
||||
errorCategory?: string;
|
||||
}
|
||||
|
||||
// ===== 流式健康检查 API =====
|
||||
// ===== 连通性检查 API =====
|
||||
|
||||
/**
|
||||
* 流式健康检查(单个供应商)
|
||||
* 连通性检查(单个供应商)
|
||||
*/
|
||||
export async function streamCheckProvider(
|
||||
appType: AppId,
|
||||
|
||||
@@ -19,6 +19,13 @@ export interface WebDavTestResult {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CodexUnifyHistoryRestoreResult {
|
||||
restoredJsonlFiles: number;
|
||||
restoredStateRows: number;
|
||||
/** 还原被跳过的原因(如当前目录没有账本);存在时不应报成功 */
|
||||
skippedReason?: string;
|
||||
}
|
||||
|
||||
export interface WebDavSyncResult {
|
||||
status: string;
|
||||
}
|
||||
@@ -32,10 +39,24 @@ export const settingsApi = {
|
||||
return await invoke("save_settings", { settings });
|
||||
},
|
||||
|
||||
/** 是否存在统一 Codex 会话历史的迁移备份(关闭弹窗据此显示"恢复备份"勾选) */
|
||||
async hasCodexUnifyHistoryBackup(): Promise<boolean> {
|
||||
return await invoke("has_codex_unify_history_backup");
|
||||
},
|
||||
|
||||
/** 按迁移备份账本把当时迁入共享桶的官方会话还原回 openai 桶(幂等) */
|
||||
async restoreCodexUnifiedHistory(): Promise<CodexUnifyHistoryRestoreResult> {
|
||||
return await invoke("restore_codex_unified_history");
|
||||
},
|
||||
|
||||
async restart(): Promise<boolean> {
|
||||
return await invoke("restart_app");
|
||||
},
|
||||
|
||||
async installUpdateAndRestart(): Promise<boolean> {
|
||||
return await invoke("install_update_and_restart");
|
||||
},
|
||||
|
||||
async checkUpdates(): Promise<void> {
|
||||
await invoke("check_for_updates");
|
||||
},
|
||||
|
||||
+44
-5
@@ -52,39 +52,78 @@ export const usageApi = {
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<UsageSummary> => {
|
||||
return invoke("get_usage_summary", { startDate, endDate, appType });
|
||||
return invoke("get_usage_summary", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getUsageSummaryByApp: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<UsageSummaryByApp[]> => {
|
||||
return invoke("get_usage_summary_by_app", { startDate, endDate });
|
||||
return invoke("get_usage_summary_by_app", {
|
||||
startDate,
|
||||
endDate,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getUsageTrends: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<DailyStats[]> => {
|
||||
return invoke("get_usage_trends", { startDate, endDate, appType });
|
||||
return invoke("get_usage_trends", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getProviderStats: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<ProviderStats[]> => {
|
||||
return invoke("get_provider_stats", { startDate, endDate, appType });
|
||||
return invoke("get_provider_stats", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getModelStats: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
providerName?: string,
|
||||
model?: string,
|
||||
): Promise<ModelStats[]> => {
|
||||
return invoke("get_model_stats", { startDate, endDate, appType });
|
||||
return invoke("get_model_stats", {
|
||||
startDate,
|
||||
endDate,
|
||||
appType,
|
||||
providerName,
|
||||
model,
|
||||
});
|
||||
},
|
||||
|
||||
getRequestLogs: async (
|
||||
|
||||
+126
-2
@@ -1,3 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
useQuery,
|
||||
type UseQueryResult,
|
||||
@@ -99,6 +100,112 @@ export interface UseUsageQueryOptions {
|
||||
autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用
|
||||
}
|
||||
|
||||
/** 最近一次成功的用量结果快照(keep-last-good 用)。 */
|
||||
export interface LastGoodUsage {
|
||||
data: UsageResult;
|
||||
at: number; // 该成功结果的获取时刻(ms)
|
||||
}
|
||||
|
||||
/** 在最近一次成功后多久内,失败仍继续展示该成功值。 */
|
||||
export const KEEP_LAST_GOOD_MS = 10 * 60 * 1000; // 10 分钟
|
||||
|
||||
/**
|
||||
* 判断一次用量查询失败是否属于"瞬时/网络类"(可被 keep-last-good 短暂掩盖)。
|
||||
*
|
||||
* 仅瞬时失败才允许继续展示上一次成功;**确定性失败**(鉴权失败、空 API Key、
|
||||
* 未知供应商、4xx、脚本/解析错误等)必须立即透出——用户改/删凭据后要马上看到,
|
||||
* 否则会一直显示过期额度直到窗口结束。
|
||||
*
|
||||
* 采用**白名单**:只认后端稳定的网络类错误前缀 + HTTP 5xx,失败安全——任何未识别
|
||||
* 的错误一律按"非瞬时"立即透出,绝不误掩盖确定性失败。需与后端错误文案保持同步:
|
||||
* - 原生 balance/coding_plan/subscription:reqwest send 失败/超时 → `"Network error: …"`;
|
||||
* 上游非 2xx → `"API error (HTTP <code>…)"`
|
||||
* - JS 脚本 usage_script:`request_failed` → "请求失败/Request failed"、
|
||||
* `read_response_failed` → "读取响应失败/Failed to read response"(仅 zh/en 两种本地化);
|
||||
* 上游非 2xx → `"HTTP <code> …"`
|
||||
*
|
||||
* HTTP 状态:**5xx**(服务端错误,通常瞬时)归为 transient;**4xx**(鉴权/客户端
|
||||
* 错误,如 401/403/404/429)保持确定性,立即透出。
|
||||
*/
|
||||
export function isTransientUsageError(result: UsageResult): boolean {
|
||||
if (result.success) return false;
|
||||
const e = result.error?.toLowerCase() ?? "";
|
||||
if (!e) return false;
|
||||
|
||||
// 网络类(send 失败/超时/读取响应失败)
|
||||
if (
|
||||
e.includes("network error") || // 原生路径
|
||||
e.includes("request failed") || // JS 脚本 (en)
|
||||
e.includes("请求失败") || // JS 脚本 (zh)
|
||||
e.includes("failed to read response") || // JS 脚本 (en)
|
||||
e.includes("读取响应失败") // JS 脚本 (zh)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTTP 状态码:5xx 视为瞬时,4xx 视为确定性。错误文案里第一处 "HTTP <code>" 即为
|
||||
// 上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。
|
||||
const httpMatch = e.match(/http\s+(\d{3})/);
|
||||
if (httpMatch) {
|
||||
const status = Number(httpMatch[1]);
|
||||
return status >= 500 && status <= 599;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep-last-good 的纯决策函数(无 ref、无时钟,`now` 注入以便测试)。
|
||||
*
|
||||
* 用量端点面向跨境/第三方,单次网络抖动会让后端返回 `Ok(success:false)` 当作"新数据"
|
||||
* 覆盖掉上一次成功,使卡片瞬间翻红——这是"一会成功一会失败"观感的主因。
|
||||
* 策略:当失败是**瞬时/网络类**(见 [`isTransientUsageError`])且最近一次成功在
|
||||
* `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该成功的时刻
|
||||
* (相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录时照常展示失败。
|
||||
*
|
||||
* **确定性失败**(鉴权/空 key/未知供应商/4xx 等)不仅立即透出,还会**清空 `lastGood`**:
|
||||
* 旧成功快照已不可信,否则随后一次网络抖动会把"配置/鉴权已失效"的旧额度重新复活。
|
||||
*
|
||||
* 注:会 reject 的传输层失败(Copilot/DB)react-query 本就保留上次 `data`,这里
|
||||
* 主要修的是 `Ok(success:false)` 这条覆盖路径。
|
||||
*/
|
||||
export function resolveDisplayUsage(
|
||||
raw: UsageResult | undefined,
|
||||
dataUpdatedAt: number,
|
||||
prevLastGood: LastGoodUsage | null,
|
||||
now: number,
|
||||
keepMs: number = KEEP_LAST_GOOD_MS,
|
||||
): {
|
||||
data: UsageResult | undefined;
|
||||
lastQueriedAt: number | null;
|
||||
lastGood: LastGoodUsage | null;
|
||||
} {
|
||||
let lastGood = prevLastGood;
|
||||
if (raw?.success) {
|
||||
// 成功:刷新快照
|
||||
lastGood = { data: raw, at: dataUpdatedAt || now };
|
||||
} else if (raw && !isTransientUsageError(raw)) {
|
||||
// 确定性失败(鉴权/空 key/未知供应商/4xx 等):旧成功快照已不可信,丢弃它,
|
||||
// 避免后续一次网络抖动把"配置/鉴权已失效"的旧额度重新复活。
|
||||
lastGood = null;
|
||||
}
|
||||
|
||||
let data = raw;
|
||||
let lastQueriedAt = dataUpdatedAt || null;
|
||||
if (
|
||||
raw &&
|
||||
!raw.success &&
|
||||
isTransientUsageError(raw) && // 仅瞬时/网络类失败才掩盖;确定性失败立即透出
|
||||
lastGood &&
|
||||
now - lastGood.at < keepMs
|
||||
) {
|
||||
data = lastGood.data;
|
||||
lastQueriedAt = lastGood.at;
|
||||
}
|
||||
|
||||
return { data, lastQueriedAt, lastGood };
|
||||
}
|
||||
|
||||
export const useUsageQuery = (
|
||||
providerId: string,
|
||||
appId: AppId,
|
||||
@@ -123,14 +230,31 @@ export const useUsageQuery = (
|
||||
: false,
|
||||
refetchIntervalInBackground: true, // 后台也继续定时查询
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
// 用量查询面向跨境/第三方端点,单次网络抖动或瞬时 5xx 不应直接判失败。
|
||||
// 重试一次以吸收瞬时故障(与 useSubscriptionQuota 的 retry:1 保持一致)。
|
||||
// 注意:原生 balance/coding_plan 路径把网络错误折叠成 Ok(success:false),
|
||||
// 这类不会触发 react-query 重试;本项主要覆盖会 reject 的传输层失败(Copilot/DB 等)。
|
||||
retry: 1,
|
||||
retryDelay: 1500,
|
||||
staleTime, // 使用动态计算的缓存时间
|
||||
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
|
||||
});
|
||||
|
||||
// Keep-last-good:失败时在 10 分钟窗口内继续展示上一次成功值(见 resolveDisplayUsage)。
|
||||
// 每个 hook 实例各持一份 ref(按卡片维度);ref 写入是幂等的(同份成功重复写无副作用)。
|
||||
const lastGoodRef = useRef<LastGoodUsage | null>(null);
|
||||
const { data, lastQueriedAt, lastGood } = resolveDisplayUsage(
|
||||
query.data,
|
||||
query.dataUpdatedAt,
|
||||
lastGoodRef.current,
|
||||
Date.now(),
|
||||
);
|
||||
lastGoodRef.current = lastGood;
|
||||
|
||||
return {
|
||||
...query,
|
||||
lastQueriedAt: query.dataUpdatedAt || null,
|
||||
data,
|
||||
lastQueriedAt,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+81
-26
@@ -1,7 +1,11 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { usageApi } from "@/lib/api/usage";
|
||||
import { resolveUsageRange } from "@/lib/usageRange";
|
||||
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
|
||||
import type {
|
||||
LogFilters,
|
||||
UsageRangeSelection,
|
||||
UsageScopeFilters,
|
||||
} from "@/types/usage";
|
||||
|
||||
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
|
||||
|
||||
@@ -35,7 +39,7 @@ export const usageKeys = {
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -43,12 +47,15 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
summaryByApp: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -56,12 +63,14 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
trends: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -69,13 +78,15 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
providerStats: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -83,13 +94,15 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
modelStats: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -97,7 +110,9 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
|
||||
[
|
||||
@@ -122,23 +137,38 @@ export const usageKeys = {
|
||||
[...usageKeys.all, providerId, appType] as const,
|
||||
};
|
||||
|
||||
/** 把 UI 侧的 "all" 哨兵归一成 undefined(后端语义:不过滤)。 */
|
||||
function normalizeScopeFilters(filters?: UsageScopeFilters): UsageScopeFilters {
|
||||
return {
|
||||
appType: filters?.appType === "all" ? undefined : filters?.appType,
|
||||
providerName: filters?.providerName,
|
||||
model: filters?.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summary(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getUsageSummary(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -147,6 +177,7 @@ export function useUsageSummary(
|
||||
|
||||
export function useUsageSummaryByApp(
|
||||
range: UsageRangeSelection,
|
||||
filters?: Pick<UsageScopeFilters, "providerName" | "model">,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
return useQuery({
|
||||
@@ -154,10 +185,16 @@ export function useUsageSummaryByApp(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
filters,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageSummaryByApp(startDate, endDate);
|
||||
return usageApi.getUsageSummaryByApp(
|
||||
startDate,
|
||||
endDate,
|
||||
filters?.providerName,
|
||||
filters?.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -166,20 +203,26 @@ export function useUsageSummaryByApp(
|
||||
|
||||
export function useUsageTrends(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.trends(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getUsageTrends(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -188,20 +231,26 @@ export function useUsageTrends(
|
||||
|
||||
export function useProviderStats(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.providerStats(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getProviderStats(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getProviderStats(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
@@ -210,20 +259,26 @@ export function useProviderStats(
|
||||
|
||||
export function useModelStats(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
filters?: UsageScopeFilters,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
const effective = normalizeScopeFilters(filters);
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
effective,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getModelStats(startDate, endDate, effectiveAppType);
|
||||
return usageApi.getModelStats(
|
||||
startDate,
|
||||
endDate,
|
||||
effective.appType,
|
||||
effective.providerName,
|
||||
effective.model,
|
||||
);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const settingsSchema = z.object({
|
||||
launchOnStartup: z.boolean().optional(),
|
||||
enableLocalProxy: z.boolean().optional(),
|
||||
preserveCodexOfficialAuthOnSwitch: z.boolean().optional(),
|
||||
unifyCodexSessionHistory: z.boolean().optional(),
|
||||
language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(),
|
||||
|
||||
// 设备级目录覆盖
|
||||
@@ -58,7 +59,7 @@ export const settingsSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// 本机自动迁移状态(后端维护,前端保存设置时应透传)
|
||||
// 本机自动迁移状态(后端维护且保存时后端忽略前端值,仅供读取展示)
|
||||
localMigrations: z
|
||||
.object({
|
||||
codexThirdPartyHistoryProviderBucketV1: z
|
||||
|
||||
+5
-83
@@ -1,22 +1,7 @@
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
|
||||
// 可选导入:在未注册插件或非 Tauri 环境下,调用时会抛错,外层需做兜底
|
||||
// 我们按需加载并在运行时捕获错误,避免构建期类型问题
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
|
||||
export type UpdateChannel = "stable" | "beta";
|
||||
|
||||
export type UpdaterPhase =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "downloading"
|
||||
| "installing"
|
||||
| "restarting"
|
||||
| "upToDate"
|
||||
| "error";
|
||||
|
||||
export interface UpdateInfo {
|
||||
currentVersion: string;
|
||||
availableVersion: string;
|
||||
@@ -24,64 +9,11 @@ export interface UpdateInfo {
|
||||
pubDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProgressEvent {
|
||||
event: "Started" | "Progress" | "Finished";
|
||||
total?: number;
|
||||
downloaded?: number;
|
||||
}
|
||||
|
||||
export interface UpdateHandle {
|
||||
version: string;
|
||||
notes?: string;
|
||||
date?: string;
|
||||
downloadAndInstall: (
|
||||
onProgress?: (e: UpdateProgressEvent) => void,
|
||||
) => Promise<void>;
|
||||
download?: () => Promise<void>;
|
||||
install?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
timeout?: number;
|
||||
channel?: UpdateChannel;
|
||||
}
|
||||
|
||||
function mapUpdateHandle(raw: Update): UpdateHandle {
|
||||
return {
|
||||
version: (raw as any).version ?? "",
|
||||
notes: (raw as any).notes,
|
||||
date: (raw as any).date,
|
||||
async downloadAndInstall(onProgress?: (e: UpdateProgressEvent) => void) {
|
||||
await (raw as any).downloadAndInstall((evt: any) => {
|
||||
if (!onProgress) return;
|
||||
const mapped: UpdateProgressEvent = {
|
||||
event: evt?.event,
|
||||
};
|
||||
if (evt?.event === "Started") {
|
||||
mapped.total = evt?.data?.contentLength ?? 0;
|
||||
mapped.downloaded = 0;
|
||||
} else if (evt?.event === "Progress") {
|
||||
mapped.downloaded = evt?.data?.chunkLength ?? 0; // 累积由调用方完成
|
||||
}
|
||||
onProgress(mapped);
|
||||
});
|
||||
},
|
||||
// 透传可选 API(若插件版本支持)
|
||||
download: (raw as any).download
|
||||
? async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await (raw as any).download();
|
||||
}
|
||||
: undefined,
|
||||
install: (raw as any).install
|
||||
? async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await (raw as any).install();
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentVersion(): Promise<string> {
|
||||
try {
|
||||
return await getVersion();
|
||||
@@ -93,8 +25,7 @@ export async function getCurrentVersion(): Promise<string> {
|
||||
export async function checkForUpdate(
|
||||
opts: CheckOptions = {},
|
||||
): Promise<
|
||||
| { status: "up-to-date" }
|
||||
| { status: "available"; info: UpdateInfo; update: UpdateHandle }
|
||||
{ status: "up-to-date" } | { status: "available"; info: UpdateInfo }
|
||||
> {
|
||||
// 动态引入,避免在未安装插件时导致打包期问题
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
@@ -106,21 +37,12 @@ export async function checkForUpdate(
|
||||
return { status: "up-to-date" };
|
||||
}
|
||||
|
||||
const mapped = mapUpdateHandle(update);
|
||||
const info: UpdateInfo = {
|
||||
currentVersion,
|
||||
availableVersion: mapped.version,
|
||||
notes: mapped.notes,
|
||||
pubDate: mapped.date,
|
||||
availableVersion: (update as any).version ?? "",
|
||||
notes: (update as any).notes,
|
||||
pubDate: (update as any).date,
|
||||
};
|
||||
|
||||
return { status: "available", info, update: mapped };
|
||||
return { status: "available", info };
|
||||
}
|
||||
|
||||
export async function relaunchApp(): Promise<void> {
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
await relaunch();
|
||||
}
|
||||
|
||||
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
|
||||
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 自定义 User-Agent 合法性校验。
|
||||
*
|
||||
* 与后端 `parse_custom_user_agent`(基于 `http::HeaderValue::from_str`)口径严格一致:
|
||||
* HeaderValue 按**字节**判定合法性,规则为 `b >= 32 && b != 127 || b == '\t'`。也就是说:
|
||||
* - 制表符(\t)、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符(UTF-8 字节均 ≥ 0x80)都合法;
|
||||
* - 仅控制字符非法:除 \t 外的 0x00–0x1F(含换行)与 0x7F(DEL)。
|
||||
*
|
||||
* 空串(trim 后为空)视为"未设置",合法。
|
||||
*/
|
||||
export function isValidUserAgentHeader(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return true;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return !/[\x00-\x08\x0a-\x1f\x7f]/.test(trimmed);
|
||||
}
|
||||
+8
-5
@@ -107,16 +107,12 @@ export interface UsageResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 供应商单独的模型测试配置
|
||||
// 供应商单独的连通检测配置(覆盖全局配置)
|
||||
export interface ProviderTestConfig {
|
||||
// 是否启用单独配置(false 时使用全局配置)
|
||||
enabled: boolean;
|
||||
// 测试用的模型名称(覆盖全局配置)
|
||||
testModel?: string;
|
||||
// 超时时间(秒)
|
||||
timeoutSecs?: number;
|
||||
// 测试提示词
|
||||
testPrompt?: string;
|
||||
// 降级阈值(毫秒)
|
||||
degradedThresholdMs?: number;
|
||||
// 最大重试次数
|
||||
@@ -219,6 +215,8 @@ export interface ProviderMeta {
|
||||
codexFastMode?: boolean;
|
||||
// Codex Responses -> Chat Completions reasoning capability metadata
|
||||
codexChatReasoning?: CodexChatReasoning;
|
||||
// Custom User-Agent for local proxy routing. Only applied by the local proxy.
|
||||
customUserAgent?: string;
|
||||
// 供应商类型(用于识别 Copilot 等特殊供应商)
|
||||
providerType?: string;
|
||||
// GitHub Copilot 关联账号 ID(旧字段,保留兼容读取)
|
||||
@@ -349,6 +347,11 @@ export interface Settings {
|
||||
enableFailoverToggle?: boolean;
|
||||
// Preserve Codex ChatGPT login in auth.json when switching third-party providers
|
||||
preserveCodexOfficialAuthOnSwitch?: boolean;
|
||||
// Run official Codex under the shared "custom" provider id so future
|
||||
// sessions share one resume-history bucket with third-party providers
|
||||
unifyCodexSessionHistory?: boolean;
|
||||
// User opted in (enable dialog checkbox) to migrate existing official sessions
|
||||
unifyCodexMigrateExisting?: boolean;
|
||||
// User has confirmed the failover toggle first-run notice
|
||||
failoverConfirmed?: boolean;
|
||||
// User has confirmed the first-run welcome notice
|
||||
|
||||
+28
-5
@@ -14,6 +14,8 @@ export interface RequestLog {
|
||||
appType: string;
|
||||
model: string;
|
||||
requestModel?: string;
|
||||
/** 写入时实际用于计价的模型名;路由接管 + request 计价模式下可能与 model 不同 */
|
||||
pricingModel?: string;
|
||||
costMultiplier: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -120,6 +122,20 @@ export interface LogFilters {
|
||||
endDate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard 顶栏的全局筛选维度,作用于 Hero / 趋势图 / 三个统计 Tab。
|
||||
*
|
||||
* - `providerName` 按展示名精确匹配(与 Provider 统计列表同口径,含
|
||||
* "Claude (Session)" 等会话占位名);
|
||||
* - `model` 按「有效计价模型」匹配(pricing_model 优先、回落 model,
|
||||
* 与模型统计的分组口径一致)。
|
||||
*/
|
||||
export interface UsageScopeFilters {
|
||||
appType?: string;
|
||||
providerName?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface ProviderLimitStatus {
|
||||
providerId: string;
|
||||
dailyUsage: string;
|
||||
@@ -139,12 +155,19 @@ export interface UsageRangeSelection {
|
||||
}
|
||||
|
||||
/**
|
||||
* App types whose token usage is reliably collected by the proxy.
|
||||
* App types surfaced as dashboard filter buttons.
|
||||
*
|
||||
* The proxy has handlers for `claude-desktop` too, but in practice those
|
||||
* requests overwhelmingly fail (500/503) and contribute near-zero tokens, so
|
||||
* it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have
|
||||
* no proxy handler at all — they appear only as managed apps elsewhere.
|
||||
* `claude-desktop` is intentionally NOT listed: the Desktop gateway's proxy
|
||||
* traffic is still recorded under its own `app_type` (preserving route-takeover
|
||||
* billing audit — the request detail panel shows the real value), but the
|
||||
* dashboard folds it into `claude` for display. It is the embedded Claude Code
|
||||
* runtime running inside the Desktop shell, and Desktop *chat* usage never
|
||||
* passes through this app at all, so a separate "Claude Desktop" bucket would
|
||||
* only ever show a partial number and mislead users into reading it as the
|
||||
* Desktop's full usage. The backend collapses `claude-desktop → claude` in
|
||||
* every dashboard query (see `folded_app_type_sql`).
|
||||
* `opencode` / `openclaw` / `hermes` have no proxy handler at all — they
|
||||
* appear only as managed apps elsewhere.
|
||||
*/
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user