import { useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { providersApi, settingsApi, type AppId } from "@/lib/api"; import { proxyApi } from "@/lib/api/proxy"; import type { Provider, UsageScript } from "@/types"; import { useAddProviderMutation, useUpdateProviderMutation, useDeleteProviderMutation, useSwitchProviderMutation, } from "@/lib/query"; import { extractErrorMessage } from "@/utils/errorUtils"; /** * 从 Codex TOML 配置字符串中提取 base_url */ function extractCodexBaseUrlFromToml(tomlConfig: string | null): string | null { if (!tomlConfig || typeof tomlConfig !== "string") return null; // 匹配 base_url = "xxx" 或 base_url = 'xxx' const match = tomlConfig.match(/base_url\s*=\s*(['"])([^'"]+)\1/); return match?.[2]?.trim() || null; } /** * 从 provider 配置中提取 base URL */ function extractBaseUrl(provider: Provider, appId: AppId): string | null { try { const config = provider.settingsConfig; if (!config) return null; if (appId === "claude") { // Claude: env.ANTHROPIC_BASE_URL const envUrl = config?.env?.ANTHROPIC_BASE_URL; return typeof envUrl === "string" ? envUrl.trim() : null; } if (appId === "codex") { // Codex: base_url 存储在 config 字段中(TOML 字符串) const tomlConfig = config?.config; if (typeof tomlConfig === "string") { return extractCodexBaseUrlFromToml(tomlConfig); } // 回退:尝试直接获取 base_url 字段 const baseUrl = config?.base_url; return typeof baseUrl === "string" ? baseUrl.trim() : null; } if (appId === "gemini") { // Gemini: env.GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_BASE const envUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; if (typeof envUrl === "string") return envUrl.trim(); const baseUrl = config?.GEMINI_API_BASE || config?.base_url; return typeof baseUrl === "string" ? baseUrl.trim() : null; } return null; } catch { return null; } } interface UseProviderActionsOptions { /** 代理服务是否正在运行 */ isProxyRunning?: boolean; /** 当前应用的代理接管是否激活 */ isTakeoverActive?: boolean; } /** * Hook for managing provider actions (add, update, delete, switch) * Extracts business logic from App.tsx */ export function useProviderActions( activeApp: AppId, options: UseProviderActionsOptions = {}, ) { const { isProxyRunning = false, isTakeoverActive = false } = options; const { t } = useTranslation(); const queryClient = useQueryClient(); const addProviderMutation = useAddProviderMutation(activeApp); const updateProviderMutation = useUpdateProviderMutation(activeApp); const deleteProviderMutation = useDeleteProviderMutation(activeApp); const switchProviderMutation = useSwitchProviderMutation(activeApp); // Claude 插件同步逻辑 const syncClaudePlugin = useCallback( async (provider: Provider) => { if (activeApp !== "claude") return; try { const settings = await settingsApi.get(); if (!settings?.enableClaudePluginIntegration) { return; } const isOfficial = provider.category === "official"; await settingsApi.applyClaudePluginConfig({ official: isOfficial }); // 静默执行,不显示成功通知 } catch (error) { const detail = extractErrorMessage(error) || t("notifications.syncClaudePluginFailed", { defaultValue: "同步 Claude 插件失败", }); toast.error(detail, { duration: 4200 }); } }, [activeApp, t], ); // 添加供应商 const addProvider = useCallback( async (provider: Omit & { providerKey?: string }) => { await addProviderMutation.mutateAsync(provider); }, [addProviderMutation], ); // 更新供应商 const updateProvider = useCallback( async (provider: Provider) => { await updateProviderMutation.mutateAsync(provider); // 更新托盘菜单(失败不影响主操作) try { await providersApi.updateTrayMenu(); } catch (trayError) { console.error( "Failed to update tray menu after updating provider", trayError, ); } }, [updateProviderMutation], ); // 切换供应商 const switchProvider = useCallback( async (provider: Provider) => { // 官方供应商不需要检查 if (provider.category === "official") { try { await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); toast.success( t("notifications.switchSuccess", { defaultValue: "切换成功!" }), { closeButton: true, }, ); } catch { // 错误提示由 mutation 处理 } return; } // 提取 base URL 和 API 格式 const baseUrl = extractBaseUrl(provider, activeApp); const apiFormat = provider.meta?.apiFormat; // 调用后端 API 检查是否需要代理(前后端使用相同逻辑) let proxyRequirement: string | null = null; if (baseUrl) { try { proxyRequirement = await proxyApi.checkProxyRequirement( activeApp, baseUrl, apiFormat, ); } catch (error) { console.error("Failed to check proxy requirement:", error); } } // 如果需要代理但代理未激活,阻止切换并提示 if (proxyRequirement && !(isProxyRunning && isTakeoverActive)) { let message: string; if (proxyRequirement === "openai_chat_format") { message = t("notifications.openAIChatFormatRequiresProxy", { defaultValue: "此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。", }); } else if (proxyRequirement === "full_url") { // 用户填了全链接(如 /v1/messages 结尾) message = t("notifications.fullUrlRequiresProxy", { defaultValue: "此供应商配置了完整 API 路径,直连模式下客户端可能会重复追加路径。请先开启代理并接管当前应用。", }); } else { // url_mismatch: 直连地址和代理地址不匹配 message = t("notifications.urlMismatchRequiresProxy", { defaultValue: "此供应商的请求地址配置与 API 格式不匹配,直连模式下无法正常工作。请先开启代理并接管当前应用。", }); } toast.warning(message, { duration: 6000, closeButton: true, }); return; // 阻止切换 } try { await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); // 普通供应商:显示切换成功 // OpenCode: show "added to config" message instead of "switched" const messageKey = activeApp === "opencode" ? "notifications.addToConfigSuccess" : "notifications.switchSuccess"; const defaultMessage = activeApp === "opencode" ? "已添加到配置" : "切换成功!"; toast.success(t(messageKey, { defaultValue: defaultMessage }), { closeButton: true, }); } catch { // 错误提示由 mutation 处理 } }, [ switchProviderMutation, syncClaudePlugin, activeApp, t, isProxyRunning, isTakeoverActive, ], ); // 删除供应商 const deleteProvider = useCallback( async (id: string) => { await deleteProviderMutation.mutateAsync(id); }, [deleteProviderMutation], ); // 保存用量脚本 const saveUsageScript = useCallback( async (provider: Provider, script: UsageScript) => { try { const updatedProvider: Provider = { ...provider, meta: { ...provider.meta, usage_script: script, }, }; await providersApi.update(updatedProvider, activeApp); await queryClient.invalidateQueries({ queryKey: ["providers", activeApp], }); // 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存 // 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存 await queryClient.invalidateQueries({ queryKey: ["usage", provider.id, activeApp], }); toast.success( t("provider.usageSaved", { defaultValue: "用量查询配置已保存", }), { closeButton: true }, ); } catch (error) { const detail = extractErrorMessage(error) || t("provider.usageSaveFailed", { defaultValue: "用量查询配置保存失败", }); toast.error(detail); } }, [activeApp, queryClient, t], ); return { addProvider, updateProvider, switchProvider, deleteProvider, saveUsageScript, isLoading: addProviderMutation.isPending || updateProviderMutation.isPending || deleteProviderMutation.isPending || switchProviderMutation.isPending, }; }