mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
feat(providers): add a search button to the provider page header
Provider search could only be opened with Cmd/Ctrl+F, so users with many providers did not know it existed. - Add a search button next to "Add provider" in the page header. It toggles the existing search panel; the panel closes when switching apps or leaving the provider page. - Match the API address as well as name, notes, and website. The address is read from every app's settingsConfig shape by a new shared extractProviderBaseUrl, which ProviderCard now also uses. - Only filter while the panel is open. Closing it with Esc used to leave the list silently filtered. - Portal the search panel to document.body. As a child of the list's space-y container it counted as a sibling, so opening it pushed every provider card down by 1rem. - Add a "Show provider search" setting (on by default, device-local). Hiding the button keeps the Cmd/Ctrl+F shortcut.
This commit is contained in:
1 parent
dc93c4962d
commit
c5efe0baaa
14 files changed
+319
-116
No files matched your search
@@ -394,6 +394,10 @@ pub struct AppSettings {
|
||||
/// Whether to show the project profile switcher on the main page header
|
||||
#[serde(default)]
|
||||
pub show_profile_switcher: bool,
|
||||
/// Show the provider search button in the provider page header (on by default;
|
||||
/// Cmd/Ctrl+F still opens the search when it is hidden)
|
||||
#[serde(default = "default_true")]
|
||||
pub show_provider_search: bool,
|
||||
/// Check installed CLI tools for new versions at startup (off by default:
|
||||
/// many users do not want to chase every release).
|
||||
#[serde(default)]
|
||||
@@ -554,6 +558,7 @@ impl Default for AppSettings {
|
||||
session_auto_sync_enabled: true,
|
||||
enable_failover_toggle: false,
|
||||
show_profile_switcher: false,
|
||||
show_provider_search: true,
|
||||
check_tool_updates_on_startup: false,
|
||||
preserve_codex_official_auth_on_switch: false,
|
||||
unify_codex_session_history: false,
|
||||
|
||||
+34
-2
@@ -10,7 +10,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, KeyRound, MoreHorizontal, Plus } from "lucide-react";
|
||||
import {
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import { KNOWN_APP_TYPES, type AppTypeFilter } from "@/types/usage";
|
||||
@@ -48,7 +54,7 @@ import {
|
||||
} from "@/utils/errorUtils";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
import { deepClone } from "@/utils/deepClone";
|
||||
import { isLinux, isWindows } from "@/lib/platform";
|
||||
import { isLinux, isMac, isWindows } from "@/lib/platform";
|
||||
import {
|
||||
APP_STORAGE_KEY,
|
||||
appPageBelongsTo,
|
||||
@@ -166,6 +172,11 @@ function App() {
|
||||
PROMPT_APP_IDS.includes(sharedFeatureApp) ? sharedFeatureApp : "claude",
|
||||
);
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
// 供应商搜索面板:页头按钮和 ⌘F 都能打开;换应用或离开供应商页就收起
|
||||
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
setProviderSearchOpen(false);
|
||||
}, [activeApp, currentView]);
|
||||
// 供应商页顶部正在看的那格(直连 / 路由 / 聚合),由 SwitchModePanel 报上来。打开新增、
|
||||
// 编辑时记下当时那格,表单按它选布局:在聚合那格打开就是聚合的简化表单。
|
||||
const [providerModeView, setProviderModeView] = useState<{
|
||||
@@ -1174,6 +1185,25 @@ function App() {
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "providers" &&
|
||||
(settingsData?.showProviderSearch ?? true) && (
|
||||
<HoverTip
|
||||
content={t("provider.searchButtonTip", {
|
||||
shortcut: isMac() ? "⌘F" : "Ctrl+F",
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="icon-compact"
|
||||
className="h-8 w-8"
|
||||
aria-label={t("provider.searchAriaLabel")}
|
||||
aria-pressed={providerSearchOpen}
|
||||
onClick={() => setProviderSearchOpen((open) => !open)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</HoverTip>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -1242,6 +1272,8 @@ function App() {
|
||||
onOpenWebsite: handleOpenWebsite,
|
||||
onOpenTerminal: activeApp === "claude" ? handleOpenTerminal : undefined,
|
||||
onCreate: () => openAddProvider(currentModeView),
|
||||
searchOpen: providerSearchOpen,
|
||||
onSearchOpenChange: setProviderSearchOpen,
|
||||
};
|
||||
|
||||
const renderProviderList = () => {
|
||||
|
||||
@@ -24,8 +24,8 @@ import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
|
||||
import XaiOauthQuotaFooter from "@/components/XaiOauthQuotaFooter";
|
||||
import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
extractCodexExperimentalBearerToken,
|
||||
extractProviderBaseUrl,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
import { resolveCodexOfficialIdentity } from "@/utils/providerCapabilities";
|
||||
@@ -155,43 +155,7 @@ const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
return provider.websiteUrl;
|
||||
}
|
||||
|
||||
const config = provider.settingsConfig;
|
||||
|
||||
if (config && typeof config === "object") {
|
||||
const object = config as Record<string, any>;
|
||||
const envBase =
|
||||
object?.env?.ANTHROPIC_BASE_URL || object?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||
if (typeof envBase === "string" && envBase.trim()) {
|
||||
return envBase;
|
||||
}
|
||||
|
||||
const directBaseUrl =
|
||||
object.baseUrl ||
|
||||
object.base_url ||
|
||||
object.options?.baseURL ||
|
||||
(Array.isArray(object.models)
|
||||
? object.models.find(
|
||||
(model: unknown) =>
|
||||
model &&
|
||||
typeof model === "object" &&
|
||||
typeof (model as Record<string, unknown>).baseUrl === "string",
|
||||
)?.baseUrl
|
||||
: undefined);
|
||||
if (typeof directBaseUrl === "string" && directBaseUrl.trim()) {
|
||||
return directBaseUrl;
|
||||
}
|
||||
|
||||
const baseUrl = object.config;
|
||||
|
||||
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
|
||||
const extractedBaseUrl = extractCodexBaseUrl(baseUrl);
|
||||
if (extractedBaseUrl) {
|
||||
return extractedBaseUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackText;
|
||||
return extractProviderBaseUrl(provider.settingsConfig) ?? fallbackText;
|
||||
};
|
||||
|
||||
export function ProviderCard({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -43,6 +44,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Notice } from "@/components/ui/notice";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
import { extractProviderBaseUrl } from "@/utils/providerConfigUtils";
|
||||
import { usePiCurrentState } from "@/lib/query/pi";
|
||||
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
|
||||
import {
|
||||
@@ -78,6 +80,9 @@ interface ProviderListProps {
|
||||
/** 切换式应用必传:按模式 tab 算卡片 */
|
||||
switchMode?: SwitchModeProps;
|
||||
isLoading?: boolean;
|
||||
/** 搜索面板开关由页头按钮控制时传入;不传则列表自己管(只能 ⌘F 打开) */
|
||||
searchOpen?: boolean;
|
||||
onSearchOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -98,6 +103,8 @@ export function ProviderList({
|
||||
onSetAsDefault,
|
||||
switchMode,
|
||||
isLoading = false,
|
||||
searchOpen,
|
||||
onSearchOpenChange,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||
@@ -216,7 +223,9 @@ export function ProviderList({
|
||||
});
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [innerSearchOpen, setInnerSearchOpen] = useState(false);
|
||||
const isSearchOpen = searchOpen ?? innerSearchOpen;
|
||||
const setIsSearchOpen = onSearchOpenChange ?? setInnerSearchOpen;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -240,7 +249,7 @@ export function ProviderList({
|
||||
|
||||
globalThis.addEventListener("keydown", handleKeyDown);
|
||||
return () => globalThis.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
}, [setIsSearchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchOpen) {
|
||||
@@ -252,16 +261,32 @@ export function ProviderList({
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
// 每家可被搜到的文字:名称、备注、官网、请求地址(请求地址要解析配置,只随列表变化重算)
|
||||
const searchIndex = useMemo(
|
||||
() =>
|
||||
sortedProviders.map((provider) => ({
|
||||
provider,
|
||||
text: [
|
||||
provider.name,
|
||||
provider.notes,
|
||||
provider.websiteUrl,
|
||||
extractProviderBaseUrl(provider.settingsConfig),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.toLowerCase(),
|
||||
})),
|
||||
[sortedProviders],
|
||||
);
|
||||
|
||||
// 关掉面板就不再过滤:搜索词留着,下次打开还在,但列表不会悄悄少几家
|
||||
const filteredProviders = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
const keyword = isSearchOpen ? searchTerm.trim().toLowerCase() : "";
|
||||
if (!keyword) return sortedProviders;
|
||||
return sortedProviders.filter((provider) => {
|
||||
const fields = [provider.name, provider.notes, provider.websiteUrl];
|
||||
return fields.some((field) =>
|
||||
field?.toString().toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [searchTerm, sortedProviders]);
|
||||
return searchIndex
|
||||
.filter((entry) => entry.text.includes(keyword))
|
||||
.map((entry) => entry.provider);
|
||||
}, [isSearchOpen, searchTerm, searchIndex, sortedProviders]);
|
||||
|
||||
const sections = useMemo<ProviderSection[]>(() => {
|
||||
if (switchMode) {
|
||||
@@ -427,69 +452,74 @@ export function ProviderList({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{piStateErrorNotice}
|
||||
<AnimatePresence>
|
||||
{isSearchOpen && (
|
||||
<motion.div
|
||||
key="provider-search"
|
||||
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="fixed end-6 top-[6.5rem] z-40 w-[min(90vw,26rem)]"
|
||||
>
|
||||
<div className="space-y-3 rounded-panel border border-border bg-surface p-4 shadow-v7-lg">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-3" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={t("provider.searchPlaceholder", {
|
||||
defaultValue: "Search name, notes, or URL...",
|
||||
})}
|
||||
aria-label={t("provider.searchAriaLabel", {
|
||||
defaultValue: "Search providers",
|
||||
})}
|
||||
className="pe-16 ps-9"
|
||||
/>
|
||||
{searchTerm && (
|
||||
{/* 面板挂到 body:留在 space-y 容器里会被算作兄弟元素,下面的列表多出 margin-top 往下挤 */}
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{isSearchOpen && (
|
||||
<motion.div
|
||||
key="provider-search"
|
||||
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="fixed end-6 top-[6.5rem] z-40 w-[min(90vw,26rem)]"
|
||||
>
|
||||
<div className="space-y-3 rounded-panel border border-border bg-surface p-4 shadow-v7-lg">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-3" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={t("provider.searchPlaceholder", {
|
||||
defaultValue: "Search name, notes, or URL...",
|
||||
})}
|
||||
aria-label={t("provider.searchAriaLabel", {
|
||||
defaultValue: "Search providers",
|
||||
})}
|
||||
className="pe-16 ps-9"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="compact"
|
||||
className="absolute end-11 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setSearchTerm("")}
|
||||
>
|
||||
{t("common.clear", { defaultValue: "Clear" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="compact"
|
||||
className="absolute end-11 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setSearchTerm("")}
|
||||
size="icon-compact"
|
||||
className="ms-auto"
|
||||
onClick={() => setIsSearchOpen(false)}
|
||||
aria-label={t("provider.searchCloseAriaLabel", {
|
||||
defaultValue: "Close provider search",
|
||||
})}
|
||||
>
|
||||
{t("common.clear", { defaultValue: "Clear" })}
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="icon-compact"
|
||||
className="ms-auto"
|
||||
onClick={() => setIsSearchOpen(false)}
|
||||
aria-label={t("provider.searchCloseAriaLabel", {
|
||||
defaultValue: "Close provider search",
|
||||
})}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-caption text-fg-3">
|
||||
<span>
|
||||
{t("provider.searchScopeHint", {
|
||||
defaultValue:
|
||||
"Matches provider name, notes, website, and API address.",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("provider.searchCloseHint", {
|
||||
defaultValue: "Press Esc to close",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-caption text-fg-3">
|
||||
<span>
|
||||
{t("provider.searchScopeHint", {
|
||||
defaultValue: "Matches provider name, notes, and URL.",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("provider.searchCloseHint", {
|
||||
defaultValue: "Press Esc to close",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="rounded-panel border border-dashed border-border px-6 py-8 text-center text-body text-fg-2">
|
||||
|
||||
@@ -48,6 +48,8 @@ type ListCallbacks = Pick<
|
||||
| "onOpenWebsite"
|
||||
| "onOpenTerminal"
|
||||
| "onCreate"
|
||||
| "searchOpen"
|
||||
| "onSearchOpenChange"
|
||||
>;
|
||||
|
||||
interface SwitchModePanelProps extends ListCallbacks {
|
||||
|
||||
@@ -125,6 +125,17 @@ export function GeneralSection({
|
||||
void onAutoSave({ showProfileSwitcher: value })
|
||||
}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={t("settings.appVisibility.showProviderSearch")}
|
||||
help={{
|
||||
title: t("settings.appVisibility.showProviderSearch"),
|
||||
body: t("settings.appVisibility.showProviderSearchDescription"),
|
||||
}}
|
||||
checked={settings.showProviderSearch ?? true}
|
||||
onCheckedChange={(value) =>
|
||||
void onAutoSave({ showProviderSearch: value })
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsBlock>
|
||||
|
||||
|
||||
@@ -158,7 +158,8 @@
|
||||
"dragHandle": "Drag to reorder",
|
||||
"searchPlaceholder": "Search name, notes, or URL...",
|
||||
"searchAriaLabel": "Search providers",
|
||||
"searchScopeHint": "Matches provider name, notes, and URL.",
|
||||
"searchScopeHint": "Matches provider name, notes, website, and API address.",
|
||||
"searchButtonTip": "Search providers ({{shortcut}})",
|
||||
"searchCloseHint": "Press Esc to close",
|
||||
"searchCloseAriaLabel": "Close provider search",
|
||||
"noSearchResults": "No providers match your search.",
|
||||
@@ -827,7 +828,9 @@
|
||||
"geminiDesc": "Google Gemini CLI",
|
||||
"opencodeDesc": "OpenCode CLI",
|
||||
"showProfileSwitcher": "Show project switcher",
|
||||
"showProfileSwitcherDescription": "Show the project switcher in the main page header"
|
||||
"showProfileSwitcherDescription": "Show the project switcher in the main page header",
|
||||
"showProviderSearch": "Show provider search",
|
||||
"showProviderSearchDescription": "Show a search button at the top of the provider page to find providers by name, notes, website, or API address. When hidden, ⌘F / Ctrl+F still opens search"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Preferred Terminal",
|
||||
|
||||
@@ -158,7 +158,8 @@
|
||||
"dragHandle": "ドラッグで並べ替え",
|
||||
"searchPlaceholder": "名前・メモ・URLで検索...",
|
||||
"searchAriaLabel": "プロバイダーを検索",
|
||||
"searchScopeHint": "名前・メモ・URL を対象に検索します。",
|
||||
"searchScopeHint": "名前・メモ・公式サイト・API アドレスを対象に検索します。",
|
||||
"searchButtonTip": "プロバイダーを検索({{shortcut}})",
|
||||
"searchCloseHint": "Esc で閉じる",
|
||||
"searchCloseAriaLabel": "検索を閉じる",
|
||||
"noSearchResults": "一致するプロバイダーがありません。",
|
||||
@@ -827,7 +828,9 @@
|
||||
"geminiDesc": "Google Gemini CLI",
|
||||
"opencodeDesc": "OpenCode CLI",
|
||||
"showProfileSwitcher": "プロジェクト切り替えを表示",
|
||||
"showProfileSwitcherDescription": "メイン画面上部にプロジェクト切り替えを表示します"
|
||||
"showProfileSwitcherDescription": "メイン画面上部にプロジェクト切り替えを表示します",
|
||||
"showProviderSearch": "プロバイダー検索を表示",
|
||||
"showProviderSearchDescription": "プロバイダー画面の上部に検索ボタンを表示し、名前・メモ・公式サイト・API アドレスで検索できます。非表示にしても ⌘F / Ctrl+F で検索を開けます"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "優先ターミナル",
|
||||
|
||||
@@ -158,7 +158,8 @@
|
||||
"dragHandle": "拖曳排序",
|
||||
"searchPlaceholder": "依名稱/備註/網址搜尋供應商...",
|
||||
"searchAriaLabel": "搜尋供應商",
|
||||
"searchScopeHint": "根據名稱、備註和官網連結比對結果。",
|
||||
"searchScopeHint": "根據名稱、備註、官網和請求位址比對結果。",
|
||||
"searchButtonTip": "搜尋供應商({{shortcut}})",
|
||||
"searchCloseHint": "按 Esc 關閉",
|
||||
"searchCloseAriaLabel": "關閉供應商搜尋",
|
||||
"noSearchResults": "沒有符合搜尋條件的供應商。",
|
||||
@@ -827,7 +828,9 @@
|
||||
"geminiDesc": "Google Gemini CLI",
|
||||
"opencodeDesc": "OpenCode CLI",
|
||||
"showProfileSwitcher": "顯示專案切換",
|
||||
"showProfileSwitcherDescription": "在主頁面頂部顯示專案切換入口"
|
||||
"showProfileSwitcherDescription": "在主頁面頂部顯示專案切換入口",
|
||||
"showProviderSearch": "顯示供應商搜尋",
|
||||
"showProviderSearchDescription": "在供應商頁頂部顯示搜尋按鈕,可依名稱、備註、官網和請求位址查找。關閉後仍可用 ⌘F / Ctrl+F 開啟搜尋"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "首選終端機",
|
||||
|
||||
@@ -158,7 +158,8 @@
|
||||
"dragHandle": "拖拽排序",
|
||||
"searchPlaceholder": "按名称/备注/网址搜索供应商...",
|
||||
"searchAriaLabel": "搜索供应商",
|
||||
"searchScopeHint": "根据名称、备注和官网链接匹配结果。",
|
||||
"searchScopeHint": "根据名称、备注、官网和请求地址匹配结果。",
|
||||
"searchButtonTip": "搜索供应商({{shortcut}})",
|
||||
"searchCloseHint": "按 Esc 关闭",
|
||||
"searchCloseAriaLabel": "关闭供应商搜索",
|
||||
"noSearchResults": "没有符合搜索条件的供应商。",
|
||||
@@ -827,7 +828,9 @@
|
||||
"geminiDesc": "Google Gemini CLI",
|
||||
"opencodeDesc": "OpenCode CLI",
|
||||
"showProfileSwitcher": "显示项目切换",
|
||||
"showProfileSwitcherDescription": "在主页面顶部显示项目切换入口"
|
||||
"showProfileSwitcherDescription": "在主页面顶部显示项目切换入口",
|
||||
"showProviderSearch": "显示供应商搜索",
|
||||
"showProviderSearchDescription": "在供应商页顶部显示搜索按钮,可按名称、备注、官网和请求地址查找。关掉后仍可用 ⌘F / Ctrl+F 打开搜索"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "首选终端",
|
||||
|
||||
@@ -401,6 +401,8 @@ export interface Settings {
|
||||
enableFailoverToggle?: boolean;
|
||||
// Whether to show the project profile switcher on the main page header
|
||||
showProfileSwitcher?: boolean;
|
||||
// 供应商页页头显示搜索按钮(默认开;关掉后 ⌘F 仍可用)
|
||||
showProviderSearch?: boolean;
|
||||
// 启动时检查已安装的命令行应用有没有新版本(默认关)
|
||||
checkToolUpdatesOnStartup?: boolean;
|
||||
// Preserve Codex ChatGPT login in auth.json when switching third-party providers
|
||||
|
||||
@@ -1057,6 +1057,43 @@ export const getCodexBaseUrl = (
|
||||
}
|
||||
};
|
||||
|
||||
// 从任一应用的 settingsConfig 中取出请求地址:各应用存法不同(env 变量 / baseUrl /
|
||||
// base_url / options.baseURL / models[].baseUrl / Codex 风格 TOML),按已知形态依次找
|
||||
export const extractProviderBaseUrl = (
|
||||
settingsConfig: unknown,
|
||||
): string | undefined => {
|
||||
if (!settingsConfig || typeof settingsConfig !== "object") return undefined;
|
||||
const object = settingsConfig as Record<string, any>;
|
||||
|
||||
const envBase =
|
||||
object.env?.ANTHROPIC_BASE_URL || object.env?.GOOGLE_GEMINI_BASE_URL;
|
||||
if (typeof envBase === "string" && envBase.trim()) {
|
||||
return envBase;
|
||||
}
|
||||
|
||||
const directBaseUrl =
|
||||
object.baseUrl ||
|
||||
object.base_url ||
|
||||
object.options?.baseURL ||
|
||||
(Array.isArray(object.models)
|
||||
? object.models.find(
|
||||
(model: unknown) =>
|
||||
model &&
|
||||
typeof model === "object" &&
|
||||
typeof (model as Record<string, unknown>).baseUrl === "string",
|
||||
)?.baseUrl
|
||||
: undefined);
|
||||
if (typeof directBaseUrl === "string" && directBaseUrl.trim()) {
|
||||
return directBaseUrl;
|
||||
}
|
||||
|
||||
if (typeof object.config === "string" && object.config.includes("base_url")) {
|
||||
return extractCodexBaseUrl(object.config) || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
|
||||
export const setCodexBaseUrl = (
|
||||
configText: string,
|
||||
|
||||
@@ -385,6 +385,61 @@ describe("ProviderList Component", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches the API address and stops filtering once the panel closes", () => {
|
||||
const relay = createProvider({
|
||||
id: "relay",
|
||||
name: "My Relay",
|
||||
settingsConfig: {
|
||||
env: { ANTHROPIC_BASE_URL: "https://api.relay-example.com" },
|
||||
},
|
||||
});
|
||||
const other = createProvider({ id: "other", name: "Other" });
|
||||
|
||||
useDragSortMock.mockReturnValue({
|
||||
sortedProviders: [relay, other],
|
||||
sensors: [],
|
||||
handleDragEnd: vi.fn(),
|
||||
});
|
||||
|
||||
const props = {
|
||||
providers: { relay, other },
|
||||
currentProviderId: "",
|
||||
appId: "claude" as const,
|
||||
onSwitch: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDuplicate: vi.fn(),
|
||||
onOpenWebsite: vi.fn(),
|
||||
};
|
||||
const onSearchOpenChange = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const ui = (searchOpen: boolean) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProviderList
|
||||
{...props}
|
||||
searchOpen={searchOpen}
|
||||
onSearchOpenChange={onSearchOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(ui(true));
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("Search name, notes, or URL..."),
|
||||
{ target: { value: "relay-example" } },
|
||||
);
|
||||
expect(screen.getByTestId("provider-card-relay")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("provider-card-other")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(onSearchOpenChange).toHaveBeenCalledWith(false);
|
||||
|
||||
rerender(ui(false));
|
||||
expect(screen.getByTestId("provider-card-other")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not manufacture a Pi selection summary card", async () => {
|
||||
server.use(
|
||||
http.post(`${TAURI_ENDPOINT}/get_pi_current_state`, () =>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractProviderBaseUrl } from "@/utils/providerConfigUtils";
|
||||
|
||||
describe("extractProviderBaseUrl", () => {
|
||||
it.each([
|
||||
[
|
||||
"claude / claude-desktop",
|
||||
{ env: { ANTHROPIC_BASE_URL: "https://claude.example.com" } },
|
||||
"https://claude.example.com",
|
||||
],
|
||||
[
|
||||
"gemini",
|
||||
{ env: { GOOGLE_GEMINI_BASE_URL: "https://gemini.example.com" } },
|
||||
"https://gemini.example.com",
|
||||
],
|
||||
[
|
||||
"codex / grokbuild",
|
||||
{
|
||||
auth: {},
|
||||
config: [
|
||||
'model_provider = "custom"',
|
||||
"",
|
||||
"[model_providers.custom]",
|
||||
'base_url = "https://codex.example.com/v1"',
|
||||
].join("\n"),
|
||||
},
|
||||
"https://codex.example.com/v1",
|
||||
],
|
||||
[
|
||||
"opencode / mcode",
|
||||
{ options: { baseURL: "https://opencode.example.com/v1" } },
|
||||
"https://opencode.example.com/v1",
|
||||
],
|
||||
[
|
||||
"openclaw / pi",
|
||||
{ baseUrl: "https://openclaw.example.com/v1" },
|
||||
"https://openclaw.example.com/v1",
|
||||
],
|
||||
[
|
||||
"hermes",
|
||||
{ base_url: "https://hermes.example.com/v1" },
|
||||
"https://hermes.example.com/v1",
|
||||
],
|
||||
])("reads the %s shape", (_app, settingsConfig, expected) => {
|
||||
expect(extractProviderBaseUrl(settingsConfig)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns undefined for official providers without an address", () => {
|
||||
expect(extractProviderBaseUrl({ env: {} })).toBeUndefined();
|
||||
expect(extractProviderBaseUrl({ auth: {}, config: "" })).toBeUndefined();
|
||||
expect(extractProviderBaseUrl(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user