diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4727cf37b..e36f1c8df 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index db24f6e4b..cde4ba9fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { )} + {currentView === "providers" && + (settingsData?.showProviderSearch ?? true) && ( + + + + )} {currentView === "providers" && ( + )} - )} - + +
+ + {t("provider.searchScopeHint", { + defaultValue: + "Matches provider name, notes, website, and API address.", + })} + + + {t("provider.searchCloseHint", { + defaultValue: "Press Esc to close", + })} + +
-
- - {t("provider.searchScopeHint", { - defaultValue: "Matches provider name, notes, and URL.", - })} - - - {t("provider.searchCloseHint", { - defaultValue: "Press Esc to close", - })} - -
- - - )} - + + )} + , + document.body, + )} {filteredProviders.length === 0 ? (
diff --git a/src/components/providers/mode/SwitchModePanel.tsx b/src/components/providers/mode/SwitchModePanel.tsx index b8ce8ddeb..c0b19d52c 100644 --- a/src/components/providers/mode/SwitchModePanel.tsx +++ b/src/components/providers/mode/SwitchModePanel.tsx @@ -48,6 +48,8 @@ type ListCallbacks = Pick< | "onOpenWebsite" | "onOpenTerminal" | "onCreate" + | "searchOpen" + | "onSearchOpenChange" >; interface SwitchModePanelProps extends ListCallbacks { diff --git a/src/components/settings/sections/GeneralSection.tsx b/src/components/settings/sections/GeneralSection.tsx index f2fe275c6..6c1d9523b 100644 --- a/src/components/settings/sections/GeneralSection.tsx +++ b/src/components/settings/sections/GeneralSection.tsx @@ -125,6 +125,17 @@ export function GeneralSection({ void onAutoSave({ showProfileSwitcher: value }) } /> + + void onAutoSave({ showProviderSearch: value }) + } + /> diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a84d09d4f..e506feb0d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d50130bf5..5d8277b27 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "優先ターミナル", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 7a42468bd..2b31a4102 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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": "首選終端機", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e87727ba4..e527305d3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "首选终端", diff --git a/src/types.ts b/src/types.ts index 52d46a2f1..4bdb543b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 4dfd21907..4651a04b0 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -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; + + 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).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, diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index 7ccd50bb8..e927ae76d 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -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) => ( + + + + ); + 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`, () => diff --git a/tests/utils/providerConfigUtils.baseUrl.test.ts b/tests/utils/providerConfigUtils.baseUrl.test.ts new file mode 100644 index 000000000..ce3463346 --- /dev/null +++ b/tests/utils/providerConfigUtils.baseUrl.test.ts @@ -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(); + }); +});