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

# Conflicts:
#	src/components/providers/forms/ProviderForm.tsx
This commit is contained in:
SaladDay
2026-06-23 15:54:43 +00:00
91 changed files with 4342 additions and 626 deletions
+29 -22
View File
@@ -15,7 +15,6 @@ import {
Book,
Brain,
Wrench,
RefreshCw,
History,
BarChart2,
Download,
@@ -71,7 +70,11 @@ import { FailoverToggle } from "@/components/proxy/FailoverToggle";
import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import {
SkillsPage,
getSkillsPageHeaderActions,
type SkillsPageSource,
} from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
@@ -169,6 +172,8 @@ function App() {
const sharedFeatureApp: AppId =
activeApp === "claude-desktop" ? "claude" : activeApp;
const [currentView, setCurrentView] = useState<View>(getInitialView);
const [skillsDiscoverySource, setSkillsDiscoverySource] =
useState<SkillsPageSource>("repos");
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
@@ -857,6 +862,11 @@ function App() {
}
};
const handleOpenSkillsDiscovery = () => {
setSkillsDiscoverySource("repos");
setCurrentView("skillsDiscovery");
};
const renderContent = () => {
const content = (() => {
switch (currentView) {
@@ -884,7 +894,7 @@ function App() {
return (
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
onOpenDiscovery={handleOpenSkillsDiscovery}
currentApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
@@ -897,6 +907,7 @@ function App() {
initialApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
onSourceChange={setSkillsDiscoverySource}
/>
);
case "mcp":
@@ -1312,7 +1323,7 @@ function App() {
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
onClick={handleOpenSkillsDiscovery}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
@@ -1322,24 +1333,20 @@ function App() {
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="w-4 h-4 mr-2" />
{t("skills.repoManager")}
</Button>
{getSkillsPageHeaderActions(skillsDiscoverySource).map(
({ key, labelKey, Icon, execute }) => (
<Button
key={key}
variant="ghost"
size="sm"
onClick={() => execute(skillsPageRef.current)}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Icon className="w-4 h-4 mr-2" />
{t(labelKey)}
</Button>
),
)}
</>
)}
{currentView === "providers" && (
@@ -33,6 +33,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
gemini_flash_lite: "subscription.geminiFlashLite",
// Token Planfive_hour 已在上方官方映射中)
weekly_limit: "subscription.sevenDay",
// 火山方舟 Agent Plan / Coding Plan 的月窗口
monthly: "subscription.monthly",
// GitHub Copilot
premium: "subscription.copilotPremium",
};
+93 -3
View File
@@ -8,6 +8,7 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api";
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
import { useSettingsQuery } from "@/lib/query";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useDarkMode } from "@/hooks/useDarkMode";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
@@ -196,6 +197,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const queryClient = useQueryClient();
const { data: settingsData } = useSettingsQuery();
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
const isDarkMode = useDarkMode();
// 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t);
@@ -538,8 +540,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
// Coding Plan 模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
// ZenMux 使用用户在脚本配置中手动填入的 API Key 和 Base URL
// ZenMux 手填 baseUrl/apiKey;火山是 native 供应商,baseUrl 走推理配置,
// 另用账号 AK/SK 签名查询控制面用量。
const isZenMux = script.codingPlanProvider === "zenmux";
const isVolcengine = script.codingPlanProvider === "volcengine";
const baseUrl = isZenMux
? (script.baseUrl ?? "")
: (providerCredentials.baseUrl ?? "");
@@ -547,7 +551,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
? (script.apiKey ?? "")
: (providerCredentials.apiKey ?? "");
const { subscriptionApi } = await import("@/lib/api/subscription");
const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey);
const quota = await subscriptionApi.getCodingPlanQuota(
baseUrl,
apiKey,
isVolcengine ? script.accessKeyId : undefined,
isVolcengine ? script.secretAccessKey : undefined,
);
if (quota.success && quota.tiers.length > 0) {
const summary = quota.tiers
.map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`)
@@ -726,8 +735,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
providerCredentials.baseUrl,
);
const provider = script.codingPlanProvider || autoDetected || "kimi";
// ZenMux 允许手动填写 API Key 和 Base URL,不清除
// ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;其余清除
const isZenMux = provider === "zenmux";
const isVolcengine = provider === "volcengine";
setScript({
...script,
code: "",
@@ -735,6 +745,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
baseUrl: isZenMux ? script.baseUrl : undefined,
accessToken: undefined,
userId: undefined,
accessKeyId: isVolcengine ? script.accessKeyId : undefined,
secretAccessKey: isVolcengine ? script.secretAccessKey : undefined,
codingPlanProvider: provider,
});
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
@@ -1246,6 +1258,83 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
)}
{/* 火山方舟:控制面用量查询需账号 AK/SK(与推理 Key 是两套凭据) */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "volcengine" && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("usageScript.volcengineAkSkHint")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="usage-volcengine-ak">
{t("usageScript.accessKeyId")}
</Label>
<Input
id="usage-volcengine-ak"
type="text"
value={script.accessKeyId || ""}
onChange={(e) =>
setScript({
...script,
accessKeyId: e.target.value,
})
}
placeholder="AKLT..."
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-volcengine-sk">
{t("usageScript.secretAccessKey")}
</Label>
<div className="relative">
<Input
id="usage-volcengine-sk"
type={showApiKey ? "text" : "password"}
value={script.secretAccessKey || ""}
onChange={(e) =>
setScript({
...script,
secretAccessKey: e.target.value,
})
}
placeholder="••••••••"
autoComplete="off"
className="border-white/10"
/>
{script.secretAccessKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* 通用配置(始终显示) */}
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
{/* 超时时间 */}
@@ -1333,6 +1422,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
height={480}
language="javascript"
showMinimap={false}
darkMode={isDarkMode}
/>
</div>
)}
+10 -1
View File
@@ -10,6 +10,7 @@ import {
DRAG_REGION_STYLE,
} from "@/lib/platform";
import { isTextEditableTarget } from "@/utils/domUtils";
import { cn } from "@/lib/utils";
interface FullScreenPanelProps {
isOpen: boolean;
@@ -17,6 +18,11 @@ interface FullScreenPanelProps {
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
/**
* 覆盖内容区滚动容器的内边距/间距类。默认 `px-6 py-6 space-y-6`。
* 通过 `cn`(twMerge) 合并,传入如 `pt-3` 只覆盖顶部内边距,其余保持默认。
*/
contentClassName?: string;
}
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
@@ -33,6 +39,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
onClose,
children,
footer,
contentClassName,
}) => {
React.useEffect(() => {
if (isOpen) {
@@ -136,7 +143,9 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="px-6 py-6 space-y-6 w-full">{children}</div>
<div className={cn("px-6 py-6 space-y-6 w-full", contentClassName)}>
{children}
</div>
</div>
{/* Footer */}
@@ -297,6 +297,9 @@ export function AddProviderDialog({
const footer =
!showUniversalTab || activeTab === "app-specific" ? (
<>
<span className="mr-auto min-w-0 text-xs text-muted-foreground truncate">
{t("provider.addFooterHint")}
</span>
<Button
variant="outline"
onClick={closeDialog}
@@ -339,6 +342,7 @@ export function AddProviderDialog({
title={t("provider.addNewProvider")}
onClose={handlePanelClose}
footer={footer}
contentClassName="pt-3"
>
{showUniversalTab ? (
<Tabs
+5 -5
View File
@@ -320,7 +320,7 @@ export function ProviderCard({
)}
/>
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<button
type="button"
className={cn(
@@ -335,7 +335,7 @@ export function ProviderCard({
<GripVertical className="h-4 w-4" />
</button>
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
name={provider.name}
@@ -344,7 +344,7 @@ export function ProviderCard({
/>
</div>
<div className="space-y-1">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2 min-h-7">
<h3 className="text-base font-semibold leading-none">
{provider.name}
@@ -451,7 +451,7 @@ export function ProviderCard({
type="button"
onClick={handleOpenWebsite}
className={cn(
"inline-flex items-center text-sm max-w-[280px]",
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
isClickableUrl
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
: "text-muted-foreground cursor-default",
@@ -459,7 +459,7 @@ export function ProviderCard({
title={displayUrl}
disabled={!isClickableUrl}
>
<span className="truncate">{displayUrl}</span>
<span className="min-w-0 truncate">{displayUrl}</span>
</button>
)}
</div>
+8 -2
View File
@@ -45,6 +45,7 @@ import {
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { isTextEditableTarget } from "@/utils/domUtils";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -245,8 +246,13 @@ export function ProviderList({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const key = event.key.toLowerCase();
if ((event.metaKey || event.ctrlKey) && key === "f") {
// 正在输入框/可编辑区域中时不抢占 Ctrl+F(例如添加供应商表单里
// ProviderPresetSelector 的搜索框),避免与其同名快捷键冲突。
if (isTextEditableTarget(document.activeElement)) return;
event.preventDefault();
setIsSearchOpen(true);
return;
@@ -257,8 +263,8 @@ export function ProviderList({
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
@@ -10,7 +10,6 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
@@ -471,6 +470,19 @@ export function OmoFormFields({
const firstIsUnavailable =
Boolean(currentVariant) &&
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
const defaultVariantLabel = t("omo.defaultWrapped", {
defaultValue: "(Default)",
});
const getVariantLabel = (variant: string, index: number) =>
firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant;
const selectedVariantLabel = currentVariant
? getVariantLabel(currentVariant, 0)
: defaultVariantLabel;
return (
<Select
@@ -479,25 +491,21 @@ export function OmoFormFields({
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
}
>
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
<SelectValue
placeholder={t("omo.variantPlaceholder", {
defaultValue: "variant",
})}
/>
<SelectTrigger
className="w-28 min-w-0 h-8 overflow-hidden text-xs shrink-0"
title={selectedVariantLabel}
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedVariantLabel}
</span>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_VARIANT_VALUE}>
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
{defaultVariantLabel}
</SelectItem>
{variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
{getVariantLabel(variant, index)}
</SelectItem>
))}
</SelectContent>
@@ -14,6 +14,7 @@ import {
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
import { useDarkMode } from "@/hooks/useDarkMode";
import type {
ProviderCategory,
ProviderMeta,
@@ -281,6 +282,7 @@ function ProviderFormFull({
const { data: settingsData } = useSettingsQuery();
const showCommonConfigNotice =
settingsData != null && settingsData.commonConfigConfirmed !== true;
const isDarkMode = useDarkMode();
const handleCommonConfigConfirm = async () => {
try {
@@ -2298,6 +2300,7 @@ function ProviderFormFull({
rows={14}
showValidation={false}
language="json"
darkMode={isDarkMode}
/>
</div>
) : appId === "opencode" &&
@@ -2322,6 +2325,7 @@ function ProviderFormFull({
rows={14}
showValidation={true}
language="json"
darkMode={isDarkMode}
/>
</div>
{settingsConfigErrorField}
@@ -2352,6 +2356,7 @@ function ProviderFormFull({
rows={14}
showValidation={true}
language="json"
darkMode={isDarkMode}
/>
</div>
<FormField
@@ -4,7 +4,15 @@ 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 { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
import {
ArrowUpAZ,
Search,
Zap,
Star,
Heart,
Layers,
Settings2,
} from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
@@ -80,7 +88,21 @@ export function sortPresetEntries(
t: PresetTranslator,
): PresetEntry[] {
if (sortMode === PresetSortMode.Original) {
return [...entries];
// 置顶优先级:官方分类 > 尊享合作伙伴(Kimi)> 其余原顺序。
// 用分区拼接而非排序,确保每组内部各自的相对顺序都不变;
// 排他条件保证「既是官方又是 prime」的预设只归入官方组、不被重复。
const official = entries.filter(
(entry) => entry.preset.category === "official",
);
const prime = entries.filter(
(entry) =>
entry.preset.category !== "official" && entry.preset.primePartner,
);
const rest = entries.filter(
(entry) =>
entry.preset.category !== "official" && !entry.preset.primePartner,
);
return [...official, ...prime, ...rest];
}
return [...entries].sort((a, b) =>
@@ -123,7 +145,7 @@ export function ProviderPresetSelector({
onUniversalPresetSelect,
onManageUniversalProviders,
category,
}: ProviderPresetSelectorProps) {
}: Readonly<ProviderPresetSelectorProps>) {
const { t } = useTranslation();
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -131,6 +153,7 @@ export function ProviderPresetSelector({
PresetSortMode.Original,
);
const searchContainerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为
useEffect(() => {
@@ -150,6 +173,25 @@ export function ProviderPresetSelector({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [searchOpen]);
// 键盘快捷键: Ctrl/Cmd+F 打开搜索并聚焦输入框。
// 使用捕获阶段并阻止冒泡,避免背后 ProviderList 的同名快捷键被意外触发。
// 首次打开靠 Input 的 autoFocus 聚焦;若搜索已打开(例如点击 preset 后焦点
// 停在按钮上),setSearchOpen(true) 同值不会重渲染、autoFocus 不重触发,
// 这里用 rAF 命令式地把焦点移回搜索框(不 select,避免吞掉随后输入的首字符)。
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") {
event.preventDefault();
event.stopPropagation();
setSearchOpen(true);
requestAnimationFrame(() => searchInputRef.current?.focus());
}
};
globalThis.addEventListener("keydown", handleKeyDown, true);
return () => globalThis.removeEventListener("keydown", handleKeyDown, true);
}, []);
const visiblePresetEntries = useMemo(
() =>
getVisiblePresetEntries(presetEntries, {
@@ -258,12 +300,13 @@ export function ProviderPresetSelector({
};
return (
<div className="space-y-3">
<div ref={searchContainerRef} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div ref={searchContainerRef} className="flex items-center gap-2">
<div className="flex items-center gap-2">
{searchOpen && (
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
@@ -278,7 +321,7 @@ export function ProviderPresetSelector({
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className="w-48 h-8"
className="w-60 h-8"
autoFocus
/>
)}
@@ -359,6 +402,7 @@ export function ProviderPresetSelector({
{visiblePresetEntries.map((entry) => {
const isSelected = selectedPresetId === entry.id;
const isPartner = entry.preset.isPartner;
const isPrimePartner = entry.preset.primePartner;
const presetCategory = entry.preset.category ?? "others";
return (
<button
@@ -376,10 +420,18 @@ export function ProviderPresetSelector({
<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" />
</span>
{isPrimePartner ? (
<Heart
className="absolute -top-1 -right-1 h-5 w-5 fill-amber-500 text-amber-500 drop-shadow-sm"
strokeWidth={0}
aria-hidden
/>
) : (
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" />
</span>
)
)}
</button>
);
@@ -387,50 +439,47 @@ export function ProviderPresetSelector({
</div>
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<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 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",
<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 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}
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>
</button>
))}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
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 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
>
<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>
</button>
))}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
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 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</span>
</button>
)}
</div>
</>
</span>
</button>
)}
</div>
)}
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
@@ -13,6 +13,7 @@ import {
MessageSquare,
Clock,
FolderOpen,
FileText,
X,
CheckSquare,
} from "lucide-react";
@@ -897,6 +898,38 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</TooltipContent>
</Tooltip>
)}
{selectedSession.sourcePath && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
void handleCopy(
selectedSession.sourcePath!,
t("sessionManager.sourcePathCopied"),
)
}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
<FileText className="size-3 shrink-0" />
<span className="font-mono truncate max-w-[200px]">
{getBaseName(selectedSession.sourcePath)}
</span>
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-xs"
>
<p className="font-mono text-xs break-all">
{selectedSession.sourcePath}
</p>
<p className="text-muted-foreground mt-1">
{t("sessionManager.clickToCopyPath")}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
+19 -2
View File
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { motion } from "framer-motion";
import {
Loader2,
@@ -102,6 +109,7 @@ export function SettingsPage({
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
const tabScrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
@@ -116,6 +124,12 @@ export function SettingsPage({
}
}, [requiresRestart]);
useLayoutEffect(() => {
if (tabScrollContainerRef.current) {
tabScrollContainerRef.current.scrollTop = 0;
}
}, [activeTab]);
const closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
@@ -226,7 +240,10 @@ export function SettingsPage({
</TabsList>
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<div
ref={tabScrollContainerRef}
className="flex-1 overflow-y-auto overflow-x-hidden pr-2"
>
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<motion.div
+55 -10
View File
@@ -15,7 +15,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, Search, Loader2 } from "lucide-react";
import {
RefreshCw,
Search,
Loader2,
Settings,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
@@ -36,8 +42,11 @@ import type {
} from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
export type SkillsPageSource = "repos" | "skillssh";
interface SkillsPageProps {
initialApp?: AppId;
onSourceChange?: (source: SkillsPageSource) => void;
}
export interface SkillsPageHandle {
@@ -45,7 +54,35 @@ export interface SkillsPageHandle {
openRepoManager: () => void;
}
type SearchSource = "repos" | "skillssh";
type SkillsPageHeaderAction = {
key: string;
sources: readonly SkillsPageSource[];
labelKey: string;
Icon: LucideIcon;
execute: (page: SkillsPageHandle | null) => void;
};
const SKILLS_PAGE_HEADER_ACTIONS: readonly SkillsPageHeaderAction[] = [
{
key: "refresh-repos",
sources: ["repos"],
labelKey: "skills.refresh",
Icon: RefreshCw,
execute: (page) => page?.refresh(),
},
{
key: "manage-repos",
sources: ["repos", "skillssh"],
labelKey: "skills.repoManager",
Icon: Settings,
execute: (page) => page?.openRepoManager(),
},
];
export const getSkillsPageHeaderActions = (source: SkillsPageSource) =>
SKILLS_PAGE_HEADER_ACTIONS.filter((action) =>
action.sources.includes(source),
);
const SKILLSSH_PAGE_SIZE = 20;
@@ -54,7 +91,7 @@ const SKILLSSH_PAGE_SIZE = 20;
* skills.sh Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ initialApp = "claude" }, ref) => {
({ initialApp = "claude", onSourceChange }, ref) => {
const { t } = useTranslation();
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -64,7 +101,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
>("all");
// skills.sh 搜索状态
const [searchSource, setSearchSource] = useState<SearchSource>("repos");
const [searchSource, setSearchSource] = useState<SkillsPageSource>("repos");
const [skillsShInput, setSkillsShInput] = useState("");
const [skillsShQuery, setSkillsShQuery] = useState("");
const [skillsShOffset, setSkillsShOffset] = useState(0);
@@ -90,23 +127,25 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
data: skillsShResult,
isLoading: loadingSkillsSh,
isFetching: fetchingSkillsSh,
isPlaceholderData: placeholderSkillsSh,
} = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset);
// 当搜索结果返回时累积
useEffect(() => {
if (skillsShResult) {
if (skillsShResult && !placeholderSkillsSh) {
if (skillsShOffset === 0) {
setAccumulatedResults(skillsShResult.skills);
} else {
setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]);
}
}
}, [skillsShResult, skillsShOffset]);
}, [skillsShResult, skillsShOffset, placeholderSkillsSh]);
// 手动提交搜索
const handleSkillsShSearch = () => {
const trimmed = skillsShInput.trim();
if (trimmed.length < 2) return;
if (trimmed === skillsShQuery && skillsShOffset === 0) return;
setSkillsShOffset(0);
setAccumulatedResults([]);
setSkillsShQuery(trimmed);
@@ -314,13 +353,19 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
// 是否有更多 skills.sh 结果
const hasMoreSkillsSh =
skillsShResult && accumulatedResults.length < skillsShResult.totalCount;
const searchingSkillsSh =
(loadingSkillsSh || fetchingSkillsSh) && accumulatedResults.length === 0;
// 无仓库时默认切换到 skills.sh
// 无仓库配置时默认切换到 skills.sh;仓库发现结果为空时仍保留仓库视图,方便手动刷新重试。
const effectiveSource =
searchSource === "repos" && skills.length === 0 && !loading
searchSource === "repos" && repos.length === 0 && !loading
? "skillssh"
: searchSource;
useEffect(() => {
onSourceChange?.(effectiveSource);
}, [effectiveSource, onSourceChange]);
return (
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
@@ -528,7 +573,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
) : (
/* ===== skills.sh 模式 ===== */
<>
{loadingSkillsSh && accumulatedResults.length === 0 ? (
{searchingSkillsSh ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-sm text-muted-foreground">
@@ -542,7 +587,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
{t("skills.skillssh.searchPlaceholder")}
</p>
</div>
) : accumulatedResults.length === 0 && !loadingSkillsSh ? (
) : accumulatedResults.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.skillssh.noResults", {
+6 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
@@ -87,6 +87,11 @@ const SelectItem = React.forwardRef<
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -34,6 +35,7 @@ export function UniversalProviderFormModal({
editingProvider,
initialPreset,
}: UniversalProviderFormModalProps) {
const isDarkMode = useDarkMode();
const { t } = useTranslation();
const isEditMode = !!editingProvider;
@@ -658,6 +660,7 @@ requires_openai_auth = true`;
value={JSON.stringify(claudeConfigJson, null, 2)}
onChange={() => {}}
height={180}
darkMode={isDarkMode}
/>
</div>
)}
@@ -673,6 +676,7 @@ requires_openai_auth = true`;
value={JSON.stringify(codexConfigJson, null, 2)}
onChange={() => {}}
height={280}
darkMode={isDarkMode}
/>
</div>
)}
@@ -688,6 +692,7 @@ requires_openai_auth = true`;
value={JSON.stringify(geminiConfigJson, null, 2)}
onChange={() => {}}
height={140}
darkMode={isDarkMode}
/>
</div>
)}
@@ -0,0 +1,452 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, Loader2, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUpdateModelPricing } from "@/lib/query/usage";
import { isTextEditableTarget } from "@/utils/domUtils";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
const DEFAULT_VISIBLE_ROWS = 50;
const MAX_VISIBLE_ROWS = 200;
interface ModelsDevCost {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
}
interface ModelsDevModel {
id?: string;
name?: string;
release_date?: string;
cost?: ModelsDevCost;
}
interface ModelsDevProvider {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
}
type ModelsDevResponse = Record<string, ModelsDevProvider>;
interface ModelsDevEntry {
/** providerId/modelId,同一模型可能出现在多个供应商下 */
key: string;
providerId: string;
providerName: string;
modelId: string;
/** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */
normalizedId: string;
modelName: string;
/** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */
releaseDate: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
/**
* clean_model_id_for_pricingusage_stats.rs
* '/' ':' '@' '-' [1m]
* ID
*/
export function normalizeModelIdForPricing(modelId: string): string {
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
const beforeColon = afterSlash.split(":")[0] ?? "";
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
if (normalized.endsWith("[1m]")) {
normalized = normalized.slice(0, -"[1m]".length).trim();
}
return normalized;
}
/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */
export function formatPrice(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
// toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 0 处理
if (value >= 1e12) return "0";
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
return trimmed || "0";
}
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
const entries: ModelsDevEntry[] = [];
for (const [providerId, provider] of Object.entries(data)) {
if (!provider || typeof provider !== "object") continue;
const providerName = provider.name || providerId;
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
const cost = model?.cost;
const input = typeof cost?.input === "number" ? cost.input : null;
const output = typeof cost?.output === "number" ? cost.output : null;
if (input === null && output === null) continue;
const normalizedId = normalizeModelIdForPricing(modelId);
if (!normalizedId) continue;
entries.push({
key: `${providerId}/${modelId}`,
providerId,
providerName,
modelId,
normalizedId,
modelName: model?.name || modelId,
releaseDate:
typeof model?.release_date === "string" ? model.release_date : "",
input: input ?? 0,
output: output ?? 0,
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
cacheWrite:
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
});
}
}
// 最新发布的排在前面
entries.sort(
(a, b) =>
b.releaseDate.localeCompare(a.releaseDate) ||
a.modelName.localeCompare(b.modelName),
);
return entries;
}
interface ModelsDevPickerDialogProps {
open: boolean;
onClose: () => void;
/** 导入成功后调用(此时定价列表已刷新) */
onImported: () => void;
}
export function ModelsDevPickerDialog({
open,
onClose,
onImported,
}: ModelsDevPickerDialogProps) {
const { t } = useTranslation();
const updatePricing = useUpdateModelPricing();
const [search, setSearch] = useState("");
const [providerFilter, setProviderFilter] = useState("all");
const [selected, setSelected] = useState<ModelsDevEntry | null>(null);
// 每次打开时重置选择与过滤条件
useEffect(() => {
if (open) {
setSearch("");
setProviderFilter("all");
setSelected(null);
}
}, [open]);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["models-dev-pricing"],
queryFn: async (): Promise<ModelsDevResponse> => {
const res = await fetch(MODELS_DEV_API_URL);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
enabled: open,
staleTime: 60 * 60 * 1000,
retry: 1,
});
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
const providers = useMemo(() => {
const map = new Map<string, string>();
for (const entry of entries) {
if (!map.has(entry.providerId)) {
map.set(entry.providerId, entry.providerName);
}
}
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name),
);
}, [entries]);
const isFiltering = search.trim() !== "" || providerFilter !== "all";
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return entries.filter(
(entry) =>
(providerFilter === "all" || entry.providerId === providerFilter) &&
(!query ||
entry.modelId.toLowerCase().includes(query) ||
entry.normalizedId.includes(query) ||
entry.modelName.toLowerCase().includes(query) ||
entry.providerName.toLowerCase().includes(query)),
);
}, [entries, search, providerFilter]);
// 默认只展示最新发布的一批,搜索/筛选时展示全量匹配(设上限防卡顿)
const visible = useMemo(
() =>
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
[filtered, isFiltering],
);
// 单选:点击未选中的行替换选择,点击已选中的行取消选择。
// 限制单选是为了避免批量导入时每条都触发一次全量零成本回填扫描(见 update_model_pricing)。
const toggleEntry = (entry: ModelsDevEntry) => {
setSelected((prev) => (prev?.key === entry.key ? null : entry));
};
const handleImport = async () => {
if (!selected) return;
try {
await updatePricing.mutateAsync({
modelId: selected.normalizedId,
displayName: selected.modelName,
inputCost: formatPrice(selected.input),
outputCost: formatPrice(selected.output),
cacheReadCost: formatPrice(selected.cacheRead),
cacheCreationCost: formatPrice(selected.cacheWrite),
});
toast.success(
t("usage.modelsDevImported", {
name: selected.modelName,
defaultValue: "已导入 {{name}} 的定价",
}),
{ closeButton: true },
);
onImported();
} catch (error) {
toast.error(String(error));
}
};
const priceColumns = (entry: ModelsDevEntry) =>
[
{ label: t("usage.inputCost", "输入成本"), value: entry.input },
{ label: t("usage.outputCost", "输出成本"), value: entry.output },
{ label: t("usage.cacheReadCost", "缓存命中"), value: entry.cacheRead },
{
label: t("usage.cacheWriteCost", "缓存创建"),
value: entry.cacheWrite,
},
] as const;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !updatePricing.isPending) {
onClose();
}
}}
>
<DialogContent
zIndex="top"
className="max-w-3xl h-[80vh]"
onEscapeKeyDown={(e) => {
// 在搜索框里按 ESC 不应关闭弹窗丢掉已选模型(与 FullScreenPanel 的约定一致)
if (isTextEditableTarget(e.target)) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>
{t("usage.modelsDevPickerTitle", "从 models.dev 导入定价")}
</DialogTitle>
<DialogDescription>
{t(
"usage.modelsDevPickerDesc",
"选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个",
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{t("usage.modelsDevLoadError", "加载 models.dev 数据失败")}:{" "}
{error instanceof Error ? error.message : String(error)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="shrink-0"
>
{t("usage.modelsDevRetry", "重试")}
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex items-center gap-2">
<Select
value={providerFilter}
onValueChange={setProviderFilter}
>
<SelectTrigger className="w-44 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[120] max-h-[min(24rem,var(--radix-select-content-available-height))]">
<SelectItem value="all">
{t("usage.modelsDevAllProviders", "全部供应商")}
</SelectItem>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t(
"usage.modelsDevSearchPlaceholder",
"搜索模型或供应商(全量搜索)...",
)}
className="pl-8"
/>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border border-border/50">
{filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-8 text-sm text-muted-foreground">
{t("usage.modelsDevNoResults", "没有匹配的模型")}
</div>
) : (
<div className="divide-y divide-border/30">
{visible.map((entry) => (
<div
key={entry.key}
role="button"
aria-pressed={selected?.key === entry.key}
onClick={() => toggleEntry(entry)}
className={`flex cursor-pointer items-center gap-3 px-3 py-2 ${
selected?.key === entry.key
? "bg-accent/50"
: "hover:bg-muted/40"
}`}
>
<Check
className={`h-4 w-4 shrink-0 text-primary ${
selected?.key === entry.key
? "visible"
: "invisible"
}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{entry.modelName}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.providerName}
</span>
{entry.releaseDate && (
<span className="shrink-0 text-[10px] text-muted-foreground/70">
{entry.releaseDate}
</span>
)}
</div>
<div
className="truncate font-mono text-xs text-muted-foreground"
title={entry.modelId}
>
{entry.normalizedId}
</div>
</div>
<div className="flex shrink-0 gap-3 text-right">
{priceColumns(entry).map((column) => (
<div key={column.label} className="w-16">
<div className="text-[10px] text-muted-foreground">
{column.label}
</div>
<div className="font-mono text-xs">
${formatPrice(column.value)}
</div>
</div>
))}
</div>
</div>
))}
{filtered.length > visible.length && (
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
{isFiltering
? t("usage.modelsDevTruncated", {
shown: visible.length,
total: filtered.length,
defaultValue:
"仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
})
: t("usage.modelsDevDefaultHint", {
shown: visible.length,
total: filtered.length,
defaultValue:
"默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
})}
</div>
)}
</div>
)}
</div>
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={updatePricing.isPending}
>
{t("common.cancel", "取消")}
</Button>
<Button
onClick={handleImport}
disabled={!selected || updatePricing.isPending}
>
{updatePricing.isPending ? (
<>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
{t("usage.modelsDevImporting", "导入中...")}
</>
) : (
t("usage.modelsDevImportButton", "导入")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+35 -1
View File
@@ -1,13 +1,14 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save, Plus } from "lucide-react";
import { Save, Plus, Globe } from "lucide-react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useUpdateModelPricing } from "@/lib/query/usage";
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
import { ModelsDevPickerDialog } from "./ModelsDevPickerDialog";
interface PricingEditModalProps {
open: boolean;
@@ -26,6 +27,7 @@ export function PricingEditModal({
}: PricingEditModalProps) {
const { t } = useTranslation();
const updatePricing = useUpdateModelPricing();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [formData, setFormData] = useState({
modelId: model.modelId,
@@ -111,6 +113,27 @@ export function PricingEditModal({
</Button>
}
>
{isNew && (
<div className="mb-6 flex items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 px-3 py-2.5">
<p className="text-xs text-muted-foreground">
{t(
"usage.modelsDevHint",
"无需手动填写,可从 models.dev 选择模型定价",
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsPickerOpen(true)}
className="shrink-0"
>
<Globe className="mr-1.5 h-4 w-4" />
{t("usage.importFromModelsDev", "从 models.dev 导入")}
</Button>
</div>
)}
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
{isNew && (
<div className="space-y-2">
@@ -220,6 +243,17 @@ export function PricingEditModal({
/>
</div>
</form>
{isNew && isPickerOpen && (
<ModelsDevPickerDialog
open={isPickerOpen}
onClose={() => setIsPickerOpen(false)}
onImported={() => {
setIsPickerOpen(false);
onClose();
}}
/>
)}
</FullScreenPanel>
);
}
+12 -3
View File
@@ -108,9 +108,18 @@ export function UsageDashboard() {
return getUsageRangePresetLabel(range.preset, t);
}
return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date(
resolvedRange.endDate * 1000,
).toLocaleString(locale)}`;
const startStr = new Date(resolvedRange.startDate * 1000).toLocaleString(
locale,
);
if (range.liveEndTime) {
return `${startStr}${t("usage.liveEndTimeNow", "现在")}`;
}
const endStr = new Date(resolvedRange.endDate * 1000).toLocaleString(
locale,
);
return `${startStr} - ${endStr}`;
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
// 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响),
+70 -9
View File
@@ -7,6 +7,7 @@ import {
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Popover,
@@ -117,6 +118,9 @@ export function UsageDateRangePicker({
);
const [draftStart, setDraftStart] = useState(resolvedRange.startDate);
const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate);
const [draftLiveEnd, setDraftLiveEnd] = useState(
selection.preset === "custom" ? (selection.liveEndTime ?? false) : false,
);
const [displayMonth, setDisplayMonth] = useState(
() =>
new Date(
@@ -136,6 +140,9 @@ export function UsageDateRangePicker({
const r = resolveUsageRange(selection);
setDraftStart(r.startDate);
setDraftEnd(r.endDate);
setDraftLiveEnd(
selection.preset === "custom" ? (selection.liveEndTime ?? false) : false,
);
setDisplayMonth(
new Date(
fromTs(r.startDate).getFullYear(),
@@ -147,6 +154,15 @@ export function UsageDateRangePicker({
setError(null);
}, [open, selection]);
// Keep draftEnd ticking when live mode is active and popover is open
useEffect(() => {
if (!open || !draftLiveEnd) return;
const tick = () => setDraftEnd(Math.floor(Date.now() / 1000));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [open, draftLiveEnd]);
const calendarDays = useMemo(
() => getCalendarDays(displayMonth),
[displayMonth],
@@ -169,6 +185,14 @@ export function UsageDateRangePicker({
/* Pick a date from the calendar */
const handleDatePick = (day: Date) => {
setError(null);
// When live end time is active, calendar only controls start date
if (draftLiveEnd) {
const nextTs = setDateKeepTime(draftStart, day);
setDraftStart(nextTs);
return;
}
const nextTs = setDateKeepTime(
activeField === "start" ? draftStart : draftEnd,
day,
@@ -211,6 +235,7 @@ export function UsageDateRangePicker({
preset: "custom",
customStartDate: draftStart,
customEndDate: draftEnd,
liveEndTime: draftLiveEnd,
});
setOpen(false);
};
@@ -222,6 +247,7 @@ export function UsageDateRangePicker({
/* ── Field card (start / end) ── */
const renderField = (field: DraftField) => {
const isActive = activeField === field;
const isEndLive = field === "end" && draftLiveEnd;
const ts = field === "start" ? draftStart : draftEnd;
const setTs = field === "start" ? setDraftStart : setDraftEnd;
const label =
@@ -232,12 +258,16 @@ export function UsageDateRangePicker({
return (
<div
className={cn(
"rounded-lg border px-3 py-2 cursor-pointer transition-all",
isActive
? "border-primary ring-1 ring-primary/30 bg-primary/5"
: "border-border/50 hover:border-border",
"rounded-lg border px-3 py-2 transition-all",
isEndLive
? "border-border/30 bg-muted/30 cursor-not-allowed opacity-50"
: isActive
? "border-primary ring-1 ring-primary/30 bg-primary/5 cursor-pointer"
: "border-border/50 hover:border-border cursor-pointer",
)}
onClick={() => setActiveField(field)}
onClick={() => {
if (!isEndLive) setActiveField(field);
}}
>
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
@@ -245,27 +275,41 @@ export function UsageDateRangePicker({
<div className="flex items-center gap-1.5">
<Input
type="date"
className="h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
className={cn(
"h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0",
isEndLive && "pointer-events-none",
)}
value={fmtDate(ts)}
onChange={(e) => {
if (isEndLive) return;
const next = parseDateInput(ts, e.target.value);
setTs(next);
const d = fromTs(next);
setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1));
setError(null);
}}
onFocus={() => setActiveField(field)}
onFocus={() => {
if (!isEndLive) setActiveField(field);
}}
readOnly={isEndLive}
/>
<Input
type="time"
step={60}
className="h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
className={cn(
"h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0",
isEndLive && "pointer-events-none",
)}
value={fmtTime(ts)}
onChange={(e) => {
if (isEndLive) return;
setTs(parseTimeInput(ts, e.target.value));
setError(null);
}}
onFocus={() => setActiveField(field)}
onFocus={() => {
if (!isEndLive) setActiveField(field);
}}
readOnly={isEndLive}
/>
</div>
</div>
@@ -318,6 +362,23 @@ export function UsageDateRangePicker({
{renderField("start")}
{renderField("end")}
<label className="flex items-center gap-2 cursor-pointer select-none">
<Checkbox
checked={draftLiveEnd}
onCheckedChange={(checked) => {
const live = checked === true;
setDraftLiveEnd(live);
if (live) {
setDraftEnd(Math.floor(Date.now() / 1000));
setActiveField("start");
}
}}
/>
<span className="text-xs text-muted-foreground">
{t("usage.liveEndTime", "结束时间跟随当前时刻")}
</span>
</label>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2 pt-1">
+9 -6
View File
@@ -47,6 +47,7 @@ export interface ClaudeDesktopProviderPreset {
apiKeyUrl?: string;
category?: ProviderCategory;
isPartner?: boolean;
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
partnerPromotionKey?: string;
baseUrl: string;
@@ -414,6 +415,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
},
{
name: "Kimi",
primePartner: true,
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
category: "cn_official",
baseUrl: "https://api.moonshot.cn/anthropic",
@@ -429,6 +431,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
},
{
name: "Kimi For Coding",
primePartner: true,
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
category: "cn_official",
baseUrl: "https://api.kimi.com/coding/",
@@ -897,17 +900,17 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
iconColor: "#000000",
},
{
name: "CTok.ai",
websiteUrl: "https://ctok.ai",
apiKeyUrl: "https://ctok.ai",
name: "ETok.ai",
websiteUrl: "https://etok.ai",
apiKeyUrl: "https://etok.ai",
category: "third_party",
baseUrl: "https://api.ctok.ai",
baseUrl: "https://api.etok.ai",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
isPartner: true,
partnerPromotionKey: "ctok",
icon: "ctok",
partnerPromotionKey: "etok",
icon: "etok",
iconColor: "#000000",
},
{
+18 -6
View File
@@ -31,6 +31,7 @@ export interface ProviderPreset {
settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设
isPartner?: boolean; // 标识是否为商业合作伙伴
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
category?: ProviderCategory; // 新增:分类
// 新增:指定该预设所使用的 API Key 字段名(默认 ANTHROPIC_AUTH_TOKEN
@@ -367,6 +368,7 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "Kimi",
primePartner: true,
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
settingsConfig: {
env: {
@@ -384,11 +386,21 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "Kimi For Coding",
primePartner: true,
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/",
ANTHROPIC_AUTH_TOKEN: "",
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "${CLAUDE_CODE_AUTO_COMPACT_WINDOW}",
},
},
templateValues: {
CLAUDE_CODE_AUTO_COMPACT_WINDOW: {
label: "Auto Compact Window",
placeholder: "262144",
defaultValue: "262144",
editorValue: "262144",
},
},
category: "cn_official",
@@ -971,19 +983,19 @@ export const providerPresets: ProviderPreset[] = [
iconColor: "#000000",
},
{
name: "CTok.ai",
websiteUrl: "https://ctok.ai",
apiKeyUrl: "https://ctok.ai",
name: "ETok.ai",
websiteUrl: "https://etok.ai",
apiKeyUrl: "https://etok.ai",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.ctok.ai",
ANTHROPIC_BASE_URL: "https://api.etok.ai",
ANTHROPIC_AUTH_TOKEN: "",
},
},
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "ctok", // 促销信息 i18n key
icon: "ctok",
partnerPromotionKey: "etok", // 促销信息 i18n key
icon: "etok",
iconColor: "#000000",
},
{
+11 -8
View File
@@ -19,6 +19,7 @@ export interface CodexProviderPreset {
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设
isPartner?: boolean; // 标识是否为商业合作伙伴
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
category?: ProviderCategory; // 新增:分类
isCustomTemplate?: boolean; // 标识是否为自定义模板
@@ -421,6 +422,7 @@ requires_openai_auth = true`,
},
{
name: "Kimi",
primePartner: true,
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
auth: generateThirdPartyAuth(""),
@@ -451,6 +453,7 @@ requires_openai_auth = true`,
},
{
name: "Kimi For Coding",
primePartner: true,
websiteUrl: "https://www.kimi.com/code/docs/",
apiKeyUrl: "https://www.kimi.com/code/",
auth: generateThirdPartyAuth(""),
@@ -1220,20 +1223,20 @@ requires_openai_auth = true`,
iconColor: "#000000",
},
{
name: "CTok.ai",
websiteUrl: "https://ctok.ai",
apiKeyUrl: "https://ctok.ai",
name: "ETok.ai",
websiteUrl: "https://etok.ai",
apiKeyUrl: "https://etok.ai",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"ctok",
"https://api.ctok.ai/v1",
"etok",
"https://api.etok.ai/v1",
"gpt-5.5",
),
endpointCandidates: ["https://api.ctok.ai/v1"],
endpointCandidates: ["https://api.etok.ai/v1"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "ctok", // 促销信息 i18n key
icon: "ctok",
partnerPromotionKey: "etok", // 促销信息 i18n key
icon: "etok",
iconColor: "#000000",
},
{
+9 -1
View File
@@ -11,7 +11,7 @@ import { TEMPLATE_TYPES } from "@/config/constants";
export interface CodingPlanProviderEntry {
/** 与后端 QuotaTier 的 `codingPlanProvider` 取值对齐 */
id: "kimi" | "zhipu" | "minimax" | "zenmux";
id: "kimi" | "zhipu" | "minimax" | "zenmux" | "volcengine";
/** UsageScriptModal 下拉显示用 */
label: string;
/** base_url 匹配规则 */
@@ -35,6 +35,14 @@ export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [
label: "ZenMux",
pattern: /zenmux\./i,
},
{
// 火山方舟 Agent Plan / Coding Plan。base_url 形如
// ark.cn-beijing.volces.com/api/coding[/v3];与后端 detect_provider 的
// `volces.com/api/coding` 子串判断同效。
id: "volcengine",
label: "火山方舟 (Volcengine)",
pattern: /volces\.com\/api\/coding/i,
},
] as const;
/** 根据 Base URL 自动检测 Coding Plan 供应商;未命中返回 null */
+10 -9
View File
@@ -23,6 +23,7 @@ export interface GeminiProviderPreset {
description?: string;
category?: ProviderCategory;
isPartner?: boolean;
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
partnerPromotionKey?: string;
endpointCandidates?: string[];
theme?: GeminiPresetTheme;
@@ -280,23 +281,23 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
iconColor: "#000000",
},
{
name: "CTok.ai",
websiteUrl: "https://ctok.ai",
apiKeyUrl: "https://ctok.ai",
name: "ETok.ai",
websiteUrl: "https://etok.ai",
apiKeyUrl: "https://etok.ai",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.ctok.ai/v1beta",
GOOGLE_GEMINI_BASE_URL: "https://api.etok.ai/v1beta",
GEMINI_MODEL: "gemini-3.5-flash",
},
},
baseURL: "https://api.ctok.ai/v1beta",
baseURL: "https://api.etok.ai/v1beta",
model: "gemini-3.5-flash",
description: "CTok",
description: "ETok",
category: "third_party",
isPartner: true,
partnerPromotionKey: "ctok",
endpointCandidates: ["https://api.ctok.ai/v1beta"],
icon: "ctok",
partnerPromotionKey: "etok",
endpointCandidates: ["https://api.etok.ai/v1beta"],
icon: "etok",
iconColor: "#000000",
},
{
+11 -8
View File
@@ -104,6 +104,7 @@ export interface HermesProviderPreset {
settingsConfig: HermesProviderSettingsConfig;
isOfficial?: boolean;
isPartner?: boolean;
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
partnerPromotionKey?: string;
category?: ProviderCategory;
templateValues?: Record<string, TemplateValueConfig>;
@@ -515,6 +516,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
{
name: "Kimi",
primePartner: true,
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
settingsConfig: {
name: "kimi",
@@ -532,6 +534,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
{
name: "Kimi For Coding",
primePartner: true,
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
settingsConfig: {
name: "kimi_coding",
@@ -1192,12 +1195,12 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
},
{
name: "CTok.ai",
websiteUrl: "https://ctok.ai",
apiKeyUrl: "https://ctok.ai",
name: "ETok.ai",
websiteUrl: "https://etok.ai",
apiKeyUrl: "https://etok.ai",
settingsConfig: {
name: "ctok",
base_url: "https://api.ctok.ai",
name: "etok",
base_url: "https://api.etok.ai",
api_key: "",
api_mode: "anthropic_messages",
models: [
@@ -1208,11 +1211,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "ctok",
icon: "ctok",
partnerPromotionKey: "etok",
icon: "etok",
iconColor: "#000000",
suggestedDefaults: {
model: { default: "claude-opus-4-8", provider: "ctok" },
model: { default: "claude-opus-4-8", provider: "etok" },
},
},
{
+12 -9
View File
@@ -26,6 +26,7 @@ export interface OpenClawProviderPreset {
settingsConfig: OpenClawProviderConfig;
isOfficial?: boolean;
isPartner?: boolean;
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
partnerPromotionKey?: string;
category?: ProviderCategory;
/** Template variable definitions */
@@ -490,7 +491,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
{
name: "Kimi K2.7 Code",
name: "Kimi",
primePartner: true,
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: {
@@ -529,6 +531,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
{
name: "Kimi For Coding",
primePartner: true,
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: {
@@ -2074,11 +2077,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
{
name: "CTok.ai",
websiteUrl: "https://ctok.ai",
apiKeyUrl: "https://ctok.ai",
name: "ETok.ai",
websiteUrl: "https://etok.ai",
apiKeyUrl: "https://etok.ai",
settingsConfig: {
baseUrl: "https://api.ctok.ai",
baseUrl: "https://api.etok.ai",
apiKey: "",
api: "anthropic-messages",
models: [
@@ -2092,8 +2095,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "ctok",
icon: "ctok",
partnerPromotionKey: "etok",
icon: "etok",
iconColor: "#000000",
templateValues: {
apiKey: {
@@ -2104,10 +2107,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "ctok/claude-opus-4-8",
primary: "etok/claude-opus-4-8",
},
modelCatalog: {
"ctok/claude-opus-4-8": { alias: "Opus" },
"etok/claude-opus-4-8": { alias: "Opus" },
},
},
},
+12 -9
View File
@@ -9,6 +9,7 @@ export interface OpenCodeProviderPreset {
settingsConfig: OpenCodeProviderConfig;
isOfficial?: boolean;
isPartner?: boolean;
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
partnerPromotionKey?: string;
category?: ProviderCategory;
templateValues?: Record<string, TemplateValueConfig>;
@@ -588,12 +589,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
{
name: "Kimi K2.7 Code",
name: "Kimi",
primePartner: true,
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.7 Code",
name: "Kimi",
options: {
baseURL: "https://api.moonshot.cn/v1",
apiKey: "",
@@ -622,6 +624,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
{
name: "Kimi For Coding",
primePartner: true,
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
settingsConfig: {
@@ -1651,14 +1654,14 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
{
name: "CTok.ai",
websiteUrl: "https://ctok.ai",
apiKeyUrl: "https://ctok.ai",
name: "ETok.ai",
websiteUrl: "https://etok.ai",
apiKeyUrl: "https://etok.ai",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "CTok",
name: "ETok",
options: {
baseURL: "https://api.ctok.ai/v1",
baseURL: "https://api.etok.ai/v1",
apiKey: "",
setCacheKey: true,
},
@@ -1669,8 +1672,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "ctok",
icon: "ctok",
partnerPromotionKey: "etok",
icon: "etok",
iconColor: "#000000",
templateValues: {
apiKey: {
+23 -2
View File
@@ -115,6 +115,7 @@
"editProviderHint": "Configuration will be applied to the current provider immediately after update.",
"deleteProvider": "Delete Provider",
"addNewProvider": "Add New Provider",
"addFooterHint": "💡 After choosing a preset, fill in the fields below (e.g. API Key)",
"addClaudeProvider": "Add Claude Code Provider",
"addCodexProvider": "Add Codex Provider",
"addGeminiProvider": "Add Gemini Provider",
@@ -753,7 +754,7 @@
"appConfigDirDescription": "Customize the storage location for CC Switch configuration (point to cloud sync folder to enable config sync)",
"browsePlaceholderApp": "e.g., C:\\Users\\Administrator\\.cc-switch",
"claudeConfigDir": "Claude Code Configuration Directory",
"claudeConfigDirDescription": "Override Claude configuration directory (settings.json) and keep claude.json (MCP) alongside it.",
"claudeConfigDirDescription": "Override Claude configuration directory (settings.json). Custom directories store .claude.json (MCP) inside the directory; the default ~/.claude directory keeps Claude Code's split ~/.claude.json path.",
"codexConfigDir": "Codex Configuration Directory",
"codexConfigDirDescription": "Override Codex configuration directory.",
"geminiConfigDir": "Gemini Configuration Directory",
@@ -1015,7 +1016,7 @@
"siliconflow": "SiliconFlow is an official partner of CC Switch",
"ucloud": "Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!",
"micu": "Micu is an official partner of CC Switch",
"ctok": "Join the CTok community on the official website and subscribe to a plan.",
"etok": "Join the ETok 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!",
"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 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!",
@@ -1473,6 +1474,8 @@
"customRangeHint": "Supports both date and time",
"startTime": "Start Time",
"endTime": "End Time",
"liveEndTime": "End time follows current time",
"liveEndTimeNow": "Now",
"input": "Input",
"output": "Output",
"cacheWrite": "Creation",
@@ -1510,6 +1513,20 @@
"editPricing": "Edit Pricing",
"pricingAdded": "Pricing added",
"pricingUpdated": "Pricing updated",
"importFromModelsDev": "Import from models.dev",
"modelsDevHint": "Skip manual entry — pick model pricing from models.dev",
"modelsDevPickerTitle": "Import Pricing from models.dev",
"modelsDevPickerDesc": "Select a model to import (prices in USD per million tokens). One model per import.",
"modelsDevSearchPlaceholder": "Search models or providers (full search)...",
"modelsDevAllProviders": "All providers",
"modelsDevLoadError": "Failed to load models.dev data",
"modelsDevRetry": "Retry",
"modelsDevImportButton": "Import",
"modelsDevImporting": "Importing...",
"modelsDevImported": "Imported pricing for {{name}}",
"modelsDevNoResults": "No matching models",
"modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search",
"modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all",
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
},
@@ -1543,6 +1560,9 @@
"accessTokenPlaceholder": "Generate in 'Security Settings'",
"userId": "User ID",
"userIdPlaceholder": "e.g., 114514",
"accessKeyId": "AccessKey ID",
"secretAccessKey": "SecretAccessKey",
"volcengineAkSkHint": "Volcengine usage query needs an account-level AccessKey ID / Secret (different from the inference API key). Create them in the Volcengine console via the top-right account menu → 'API Access Keys'.",
"defaultPlan": "Default Plan",
"queryFailedMessage": "Query failed",
"queryScript": "Query script (JavaScript)",
@@ -2842,6 +2862,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "Weekly",
"monthly": "Monthly",
"copilotPremium": "Premium",
"utilization": "{{value}}%",
"resetsIn": "Resets in {{time}}",
+23 -2
View File
@@ -115,6 +115,7 @@
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
"deleteProvider": "プロバイダーを削除",
"addNewProvider": "新しいプロバイダーを追加",
"addFooterHint": "💡 プリセットを選んだら、下のフィールド(API Key など)を入力してください",
"addClaudeProvider": "Claude Code プロバイダーを追加",
"addCodexProvider": "Codex プロバイダーを追加",
"addGeminiProvider": "Gemini プロバイダーを追加",
@@ -753,7 +754,7 @@
"appConfigDirDescription": "CC Switch の保存場所をカスタマイズします(クラウド同期フォルダを指定すると設定を同期できます)",
"browsePlaceholderApp": "例: C:\\\\Users\\\\Administrator\\\\.cc-switch",
"claudeConfigDir": "Claude Code 設定ディレクトリ",
"claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きし、claude.jsonMCP)も同じ場所に置きます。",
"claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きします。カスタムディレクトリでは .claude.json(MCP)をその中に保存し、既定の ~/.claude では Claude Code の既定どおり ~/.claude.json を使います。",
"codexConfigDir": "Codex 設定ディレクトリ",
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
"geminiConfigDir": "Gemini 設定ディレクトリ",
@@ -1015,7 +1016,7 @@
"siliconflow": "SiliconFlow は CC Switch の公式パートナーです",
"ucloud": "Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!",
"micu": "Micu は CC Switch の公式パートナーです",
"ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。",
"etok": "公式サイトで ETok コミュニティに参加し、プランを購読してください。",
"shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!",
"doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。",
"volcengine_agentplan": "火山方舟 Coding Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟 Coding Plan を購読すると、新規のお客様は最初の 2 か月が 75% オフ、さらに専用招待コード 6J6FV5N2 で 5% オフが加算され、月額 9.4 元から!",
@@ -1473,6 +1474,8 @@
"customRangeHint": "日付と時刻の両方に対応",
"startTime": "開始時刻",
"endTime": "終了時刻",
"liveEndTime": "終了時刻を現在時刻に追従",
"liveEndTimeNow": "現在",
"input": "Input",
"output": "Output",
"cacheWrite": "作成",
@@ -1510,6 +1513,20 @@
"editPricing": "価格設定を編集",
"pricingAdded": "価格設定が追加されました",
"pricingUpdated": "価格設定が更新されました",
"importFromModelsDev": "models.dev からインポート",
"modelsDevHint": "手動入力の代わりに、models.dev からモデル価格を選択できます",
"modelsDevPickerTitle": "models.dev から価格をインポート",
"modelsDevPickerDesc": "インポートするモデルを選択してください(価格単位:USD / 100万トークン)。1回につき1モデルです。",
"modelsDevSearchPlaceholder": "モデルまたはプロバイダーを検索(全件検索)...",
"modelsDevAllProviders": "すべてのプロバイダー",
"modelsDevLoadError": "models.dev データの読み込みに失敗しました",
"modelsDevRetry": "再試行",
"modelsDevImportButton": "インポート",
"modelsDevImporting": "インポート中...",
"modelsDevImported": "{{name}} の価格をインポートしました",
"modelsDevNoResults": "一致するモデルがありません",
"modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください",
"modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます",
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
},
@@ -1543,6 +1560,9 @@
"accessTokenPlaceholder": "「Security Settings」で生成",
"userId": "ユーザー ID",
"userIdPlaceholder": "例: 114514",
"accessKeyId": "AccessKey ID",
"secretAccessKey": "SecretAccessKey",
"volcengineAkSkHint": "Volcengine の使用量照会にはアカウントレベルの AccessKey ID / Secret が必要です(推論 API キーとは別物)。Volcengine コンソール右上のアカウントメニュー →「API アクセスキー」で作成してください。",
"defaultPlan": "デフォルトプラン",
"queryFailedMessage": "照会に失敗しました",
"queryScript": "照会スクリプト (JavaScript)",
@@ -2842,6 +2862,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "週間",
"monthly": "月間",
"copilotPremium": "プレミアム",
"utilization": "{{value}}%",
"resetsIn": "{{time}}後にリセット",
+23 -2
View File
@@ -115,6 +115,7 @@
"editProviderHint": "更新設定後將立即套用至目前供應商。",
"deleteProvider": "刪除供應商",
"addNewProvider": "新增供應商",
"addFooterHint": "💡 選擇預設後,請在下方填寫 API Key 等欄位",
"addClaudeProvider": "新增 Claude Code 供應商",
"addCodexProvider": "新增 Codex 供應商",
"addGeminiProvider": "新增 Gemini 供應商",
@@ -753,7 +754,7 @@
"appConfigDirDescription": "自訂 CC Switch 的設定儲存位置(指定至雲端同步資料夾即可雲端同步設定)",
"browsePlaceholderApp": "例如:C:\\Users\\Administrator\\.cc-switch",
"claudeConfigDir": "Claude Code 設定目錄",
"claudeConfigDirDescription": "覆寫 Claude 設定目錄 (settings.json),同時會在同級存放 Claude MCP 的 claude.json。",
"claudeConfigDirDescription": "覆寫 Claude 設定目錄 (settings.json)。真正自訂目錄會在目錄內存放 Claude MCP 的 .claude.json;預設 ~/.claude 目錄仍使用 Claude 預設的 ~/.claude.json。",
"codexConfigDir": "Codex 設定目錄",
"codexConfigDirDescription": "覆寫 Codex 設定目錄。",
"geminiConfigDir": "Gemini 設定目錄",
@@ -987,7 +988,7 @@
"siliconflow": "矽基流動是 CC Switch 的官方合作夥伴",
"ucloud": "優雲智算為 CC Switch 的使用者提供了特殊優惠,透過此連結註冊,可以獲得 5 元平台體驗金!",
"micu": "Micu 是 CC Switch 的官方合作夥伴",
"ctok": "官網加入 CTok 社群,訂閱方案。",
"etok": "官網加入 ETok 社群,訂閱方案。",
"shengsuanyun": "勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!",
"doubaoseed": "豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度",
"volcengine_agentplan": "火山方舟 Coding Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 Coding Plan,新客戶首兩個月享 2.5 折優惠,再用專屬邀請碼 6J6FV5N2 領取獎勵疊加 9.5 折,低至 9.4 元/月!",
@@ -1445,6 +1446,8 @@
"customRangeHint": "支援日期與時間",
"startTime": "開始時間",
"endTime": "結束時間",
"liveEndTime": "結束時間跟隨當前時刻",
"liveEndTimeNow": "現在",
"input": "Input",
"output": "Output",
"cacheWrite": "建立",
@@ -1482,6 +1485,20 @@
"editPricing": "編輯定價",
"pricingAdded": "定價已新增",
"pricingUpdated": "定價已更新",
"importFromModelsDev": "從 models.dev 匯入",
"modelsDevHint": "無需手動填寫,可從 models.dev 選擇模型定價",
"modelsDevPickerTitle": "從 models.dev 匯入定價",
"modelsDevPickerDesc": "選擇要匯入的模型(價格單位:USD / 百萬 tokens),每次匯入一個",
"modelsDevSearchPlaceholder": "搜尋模型或供應商(全量搜尋)...",
"modelsDevAllProviders": "全部供應商",
"modelsDevLoadError": "載入 models.dev 資料失敗",
"modelsDevRetry": "重試",
"modelsDevImportButton": "匯入",
"modelsDevImporting": "匯入中...",
"modelsDevImported": "已匯入 {{name}} 的定價",
"modelsDevNoResults": "沒有符合的模型",
"modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍",
"modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋",
"cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)",
"cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)"
},
@@ -1515,6 +1532,9 @@
"accessTokenPlaceholder": "在「安全設定」裡產生",
"userId": "使用者 ID",
"userIdPlaceholder": "例如:114514",
"accessKeyId": "AccessKey ID",
"secretAccessKey": "SecretAccessKey",
"volcengineAkSkHint": "火山用量查詢需帳號級 AccessKey ID / Secret(與推理 API Key 不同)。請在火山引擎主控台右上角帳號選單 →「API存取金鑰」中建立。",
"defaultPlan": "預設方案",
"queryFailedMessage": "查詢失敗",
"queryScript": "查詢腳本(JavaScript",
@@ -2814,6 +2834,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "每週",
"monthly": "每月",
"copilotPremium": "進階請求",
"utilization": "{{value}}%",
"resetsIn": "{{time}} 後重設",
+23 -2
View File
@@ -115,6 +115,7 @@
"editProviderHint": "更新配置后将立即应用到当前供应商。",
"deleteProvider": "删除供应商",
"addNewProvider": "添加新供应商",
"addFooterHint": "💡 选择预设后,请在下方填写 API Key 等字段",
"addClaudeProvider": "添加 Claude Code 供应商",
"addCodexProvider": "添加 Codex 供应商",
"addGeminiProvider": "添加 Gemini 供应商",
@@ -753,7 +754,7 @@
"appConfigDirDescription": "自定义 CC Switch 的配置存储位置(指定到云同步文件夹即可云同步配置)",
"browsePlaceholderApp": "例如:C:\\Users\\Administrator\\.cc-switch",
"claudeConfigDir": "Claude Code 配置目录",
"claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json),同时会在同级存放 Claude MCP 的 claude.json。",
"claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json)。真正自定义目录会在目录内存放 Claude MCP 的 .claude.json;默认 ~/.claude 目录仍使用 Claude 默认的 ~/.claude.json。",
"codexConfigDir": "Codex 配置目录",
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
"geminiConfigDir": "Gemini 配置目录",
@@ -1015,7 +1016,7 @@
"siliconflow": "硅基流动是 CC Switch 的官方合作伙伴",
"ucloud": "优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!",
"micu": "Micu 是 CC Switch 的官方合作伙伴",
"ctok": "官网加入CTok社群,订阅套餐。",
"etok": "官网加入ETok社群,订阅套餐。",
"shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
"doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度",
"volcengine_agentplan": "火山方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!",
@@ -1473,6 +1474,8 @@
"customRangeHint": "支持日期与时间",
"startTime": "开始时间",
"endTime": "结束时间",
"liveEndTime": "结束时间跟随当前时刻",
"liveEndTimeNow": "现在",
"input": "Input",
"output": "Output",
"cacheWrite": "创建",
@@ -1510,6 +1513,20 @@
"editPricing": "编辑定价",
"pricingAdded": "定价已添加",
"pricingUpdated": "定价已更新",
"importFromModelsDev": "从 models.dev 导入",
"modelsDevHint": "无需手动填写,可从 models.dev 选择模型定价",
"modelsDevPickerTitle": "从 models.dev 导入定价",
"modelsDevPickerDesc": "选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个",
"modelsDevSearchPlaceholder": "搜索模型或供应商(全量搜索)...",
"modelsDevAllProviders": "全部供应商",
"modelsDevLoadError": "加载 models.dev 数据失败",
"modelsDevRetry": "重试",
"modelsDevImportButton": "导入",
"modelsDevImporting": "导入中...",
"modelsDevImported": "已导入 {{name}} 的定价",
"modelsDevNoResults": "没有匹配的模型",
"modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
"modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
},
@@ -1543,6 +1560,9 @@
"accessTokenPlaceholder": "在'安全设置'里生成",
"userId": "用户 ID",
"userIdPlaceholder": "例如:114514",
"accessKeyId": "AccessKey ID",
"secretAccessKey": "SecretAccessKey",
"volcengineAkSkHint": "火山用量查询需账号级 AccessKey ID / Secret(与推理 API Key 不同)。请在火山引擎控制台右上角账号菜单 →「API访问密钥」中创建。",
"defaultPlan": "默认套餐",
"queryFailedMessage": "查询失败",
"queryScript": "查询脚本(JavaScript",
@@ -2842,6 +2862,7 @@
"geminiFlash": "Flash",
"geminiFlashLite": "Flash Lite",
"weeklyLimit": "每周",
"monthly": "每月",
"copilotPremium": "高级请求",
"utilization": "{{value}}%",
"resetsIn": "{{time}}后重置",
-21
View File
@@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<!-- 圆角方形背景 -->
<rect x="0" y="0" width="200" height="200" rx="40" ry="40" fill="#3B82F6"/>
<!-- 中心白色圆环 -->
<circle cx="100" cy="100" r="45" fill="white"/>
<circle cx="100" cy="100" r="25" fill="#3B82F6"/>
<!-- 装饰小圆点 -->
<circle cx="50" cy="50" r="6" fill="white" opacity="0.6"/>
<circle cx="165" cy="70" r="5" fill="white" opacity="0.5"/>
<circle cx="170" cy="140" r="7" fill="white" opacity="0.4"/>
<!-- 右下角深蓝色装饰 -->
<defs>
<clipPath id="roundedCorner">
<rect x="0" y="0" width="200" height="200" rx="40" ry="40"/>
</clipPath>
</defs>
<path d="M 200 150 Q 175 175 150 200 L 200 200 Z" fill="#2563EB" opacity="0.6" clip-path="url(#roundedCorner)"/>
</svg>

Before

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -202,12 +202,12 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: [],
defaultColor: "currentColor",
},
ctok: {
name: "ctok",
displayName: "CTok",
etok: {
name: "etok",
displayName: "ETok",
category: "ai-provider",
keywords: ["ctok", "ai", "programming"],
defaultColor: "#3B82F6",
keywords: ["etok", "ai", "programming"],
defaultColor: "#F97316",
},
cubence: {
name: "cubence",
@@ -305,7 +305,7 @@ export const iconMetadata: Record<string, IconMetadata> = {
displayName: "Kimi",
category: "ai-provider",
keywords: ["moonshot"],
defaultColor: "#6366F1",
defaultColor: "#1783FF",
},
meta: {
name: "meta",
+9 -1
View File
@@ -9,8 +9,16 @@ export const subscriptionApi = {
getCodingPlanQuota: (
baseUrl: string,
apiKey: string,
// 火山方舟用账号 AK/SK 签名查询用量;其他供应商不传。
accessKeyId?: string,
secretAccessKey?: string,
): Promise<SubscriptionQuota> =>
invoke("get_coding_plan_quota", { baseUrl, apiKey }),
invoke("get_coding_plan_quota", {
baseUrl,
apiKey,
accessKeyId,
secretAccessKey,
}),
getBalance: (
baseUrl: string,
apiKey: string,
+19 -1
View File
@@ -26,6 +26,7 @@ type RequestLogsKey = {
preset: UsageRangeSelection["preset"];
customStartDate?: number;
customEndDate?: number;
liveEndTime?: boolean;
appType?: string;
providerName?: string;
model?: string;
@@ -40,6 +41,7 @@ export const usageKeys = {
customStartDate: number | undefined,
customEndDate: number | undefined,
filters?: UsageScopeFilters,
liveEndTime?: boolean,
) =>
[
...usageKeys.all,
@@ -47,6 +49,7 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
liveEndTime ?? false,
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
@@ -55,7 +58,8 @@ export const usageKeys = {
preset: UsageRangeSelection["preset"],
customStartDate: number | undefined,
customEndDate: number | undefined,
filters?: UsageScopeFilters,
filters?: Pick<UsageScopeFilters, "providerName" | "model">,
liveEndTime?: boolean,
) =>
[
...usageKeys.all,
@@ -63,6 +67,7 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
liveEndTime ?? false,
filters?.providerName ?? null,
filters?.model ?? null,
] as const,
@@ -71,6 +76,7 @@ export const usageKeys = {
customStartDate: number | undefined,
customEndDate: number | undefined,
filters?: UsageScopeFilters,
liveEndTime?: boolean,
) =>
[
...usageKeys.all,
@@ -78,6 +84,7 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
liveEndTime ?? false,
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
@@ -87,6 +94,7 @@ export const usageKeys = {
customStartDate: number | undefined,
customEndDate: number | undefined,
filters?: UsageScopeFilters,
liveEndTime?: boolean,
) =>
[
...usageKeys.all,
@@ -94,6 +102,7 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
liveEndTime ?? false,
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
@@ -103,6 +112,7 @@ export const usageKeys = {
customStartDate: number | undefined,
customEndDate: number | undefined,
filters?: UsageScopeFilters,
liveEndTime?: boolean,
) =>
[
...usageKeys.all,
@@ -110,6 +120,7 @@ export const usageKeys = {
preset,
customStartDate ?? 0,
customEndDate ?? 0,
liveEndTime ?? false,
filters?.appType ?? null,
filters?.providerName ?? null,
filters?.model ?? null,
@@ -121,6 +132,7 @@ export const usageKeys = {
key.preset,
key.customStartDate ?? 0,
key.customEndDate ?? 0,
key.liveEndTime ?? false,
key.appType ?? "",
key.providerName ?? "",
key.model ?? "",
@@ -159,6 +171,7 @@ export function useUsageSummary(
range.customStartDate,
range.customEndDate,
effective,
range.liveEndTime,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
@@ -186,6 +199,7 @@ export function useUsageSummaryByApp(
range.customStartDate,
range.customEndDate,
filters,
range.liveEndTime,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
@@ -213,6 +227,7 @@ export function useUsageTrends(
range.customStartDate,
range.customEndDate,
effective,
range.liveEndTime,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
@@ -241,6 +256,7 @@ export function useProviderStats(
range.customStartDate,
range.customEndDate,
effective,
range.liveEndTime,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
@@ -269,6 +285,7 @@ export function useModelStats(
range.customStartDate,
range.customEndDate,
effective,
range.liveEndTime,
),
queryFn: () => {
const { startDate, endDate } = resolveUsageRange(range);
@@ -296,6 +313,7 @@ export function useRequestLogs({
preset: range.preset,
customStartDate: range.customStartDate,
customEndDate: range.customEndDate,
liveEndTime: range.liveEndTime,
appType: filters.appType,
providerName: filters.providerName,
model: filters.model,
+3 -1
View File
@@ -49,7 +49,9 @@ export function resolveUsageRange(
};
case "custom": {
const startDate = selection.customStartDate ?? endDate - DAY_SECONDS;
const customEndDate = selection.customEndDate ?? endDate;
const customEndDate = selection.liveEndTime
? endDate
: (selection.customEndDate ?? endDate);
return {
startDate,
endDate: customEndDate,
+2
View File
@@ -62,6 +62,8 @@ export interface UsageScript {
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
accessToken?: string; // 访问令牌(NewAPI 模板使用)
userId?: string; // 用户IDNewAPI 模板使用)
accessKeyId?: string; // 火山方舟 AccessKey ID(用量查询签名用,与推理 Key 分离)
secretAccessKey?: string; // 火山方舟 SecretAccessKey
codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax"
autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用)
autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段
+3
View File
@@ -152,6 +152,9 @@ export interface UsageRangeSelection {
preset: UsageRangePreset;
customStartDate?: number;
customEndDate?: number;
/** When true (custom mode only), endDate resolves to "now" instead of the
* fixed customEndDate snapshot, and the end-time field becomes read-only. */
liveEndTime?: boolean;
}
/**
+50 -18
View File
@@ -400,7 +400,7 @@ export const hasTomlCommonConfigSnippet = (
const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
const TOML_BASE_URL_PATTERN =
/^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
/^\s*base_url\s*=\s*(?:"((?:\\.|[^"\\\r\n])*)"|'([^'\r\n]*)')\s*(?:#.*)?$/;
const TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN =
/^\s*experimental_bearer_token\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN =
@@ -553,11 +553,12 @@ const findTomlAssignmentInRange = (
): TomlAssignmentMatch | undefined => {
for (let index = startIndex; index < endIndex; index += 1) {
const match = lines[index].match(pattern);
if (match?.[2]) {
const value = match?.[2] ?? match?.[1];
if (value) {
return {
index,
sectionName,
value: match[2],
value,
};
}
}
@@ -565,6 +566,26 @@ const findTomlAssignmentInRange = (
return undefined;
};
const findTomlAssignmentsInRange = (
lines: string[],
pattern: RegExp,
startIndex: number,
endIndex: number,
sectionName?: string,
): TomlAssignmentMatch[] => {
const matches: TomlAssignmentMatch[] = [];
for (let index = startIndex; index < endIndex; index += 1) {
const match = lines[index].match(pattern);
const value = match?.[2] ?? match?.[1];
if (value) {
matches.push({ index, sectionName, value });
}
}
return matches;
};
const findTomlLineInRange = (
lines: string[],
pattern: RegExp,
@@ -595,14 +616,15 @@ const findTomlAssignments = (
}
const match = line.match(pattern);
if (!match?.[2]) {
const value = match?.[2] ?? match?.[1];
if (!value) {
return;
}
assignments.push({
index,
sectionName: currentSectionName,
value: match[2],
value,
});
});
@@ -1228,18 +1250,20 @@ export const setCodexBaseUrl = (
if (targetSectionName) {
const sectionRange = getTomlSectionRange(lines, targetSectionName);
const targetMatch = sectionRange
? findTomlAssignmentInRange(
const targetMatches = sectionRange
? findTomlAssignmentsInRange(
lines,
TOML_BASE_URL_PATTERN,
sectionRange.bodyStartIndex,
sectionRange.bodyEndIndex,
targetSectionName,
)
: undefined;
: [];
if (targetMatch) {
lines.splice(targetMatch.index, 1);
if (targetMatches.length > 0) {
for (const match of [...targetMatches].reverse()) {
lines.splice(match.index, 1);
}
return finalizeTomlText(lines);
}
}
@@ -1257,18 +1281,22 @@ export const setCodexBaseUrl = (
if (targetSectionName) {
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
const targetMatch = targetSectionRange
? findTomlAssignmentInRange(
const targetMatches = targetSectionRange
? findTomlAssignmentsInRange(
lines,
TOML_BASE_URL_PATTERN,
targetSectionRange.bodyStartIndex,
targetSectionRange.bodyEndIndex,
targetSectionName,
)
: undefined;
: [];
if (targetMatch) {
lines[targetMatch.index] = replacementLine;
if (targetMatches.length > 0) {
const [firstMatch, ...duplicateMatches] = targetMatches;
lines[firstMatch.index] = replacementLine;
for (const match of [...duplicateMatches].reverse()) {
lines.splice(match.index, 1);
}
return finalizeTomlText(lines);
}
@@ -1291,14 +1319,18 @@ export const setCodexBaseUrl = (
}
const topLevelEndIndex = getTopLevelEndIndex(lines);
const topLevelMatch = findTomlAssignmentInRange(
const topLevelMatches = findTomlAssignmentsInRange(
lines,
TOML_BASE_URL_PATTERN,
0,
topLevelEndIndex,
);
if (topLevelMatch) {
lines[topLevelMatch.index] = replacementLine;
if (topLevelMatches.length > 0) {
const [firstMatch, ...duplicateMatches] = topLevelMatches;
lines[firstMatch.index] = replacementLine;
for (const match of [...duplicateMatches].reverse()) {
lines.splice(match.index, 1);
}
return finalizeTomlText(lines);
}