From 00b424628fdaf791b99b771f1b141dca630a0764 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Feb 2026 15:35:09 +0800 Subject: [PATCH] refactor(openclaw): migrate config panels to TanStack Query hooks Centralize query keys and extract reusable hooks (useOpenClaw.ts), replacing manual useState/useEffect load/save patterns in Env, Tools, and AgentsDefaults panels for consistency with MCP/Skills modules. --- src/App.tsx | 3 +- .../openclaw/AgentsDefaultsPanel.tsx | 78 +++++------ src/components/openclaw/EnvPanel.tsx | 53 +++----- src/components/openclaw/ToolsPanel.tsx | 56 ++++---- src/components/providers/ProviderList.tsx | 22 +-- src/hooks/useOpenClaw.ts | 128 ++++++++++++++++++ src/hooks/useProviderActions.ts | 3 +- src/lib/query/mutations.ts | 5 +- 8 files changed, 228 insertions(+), 120 deletions(-) create mode 100644 src/hooks/useOpenClaw.ts diff --git a/src/App.tsx b/src/App.tsx index 9985431ec..384eaa661 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import { } from "@/lib/api"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; +import { openclawKeys } from "@/hooks/useOpenClaw"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; @@ -458,7 +459,7 @@ function App() { }); } else if (activeApp === "openclaw") { await queryClient.invalidateQueries({ - queryKey: ["openclawLiveProviderIds"], + queryKey: openclawKeys.liveProviderIds, }); } toast.success( diff --git a/src/components/openclaw/AgentsDefaultsPanel.tsx b/src/components/openclaw/AgentsDefaultsPanel.tsx index 481b91b0d..03d5af3f9 100644 --- a/src/components/openclaw/AgentsDefaultsPanel.tsx +++ b/src/components/openclaw/AgentsDefaultsPanel.tsx @@ -1,8 +1,12 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Save } from "lucide-react"; import { toast } from "sonner"; -import { openclawApi } from "@/lib/api/openclaw"; +import { + useOpenClawAgentsDefaults, + useSaveOpenClawAgentsDefaults, +} from "@/hooks/useOpenClaw"; +import { extractErrorMessage } from "@/utils/errorUtils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -10,11 +14,11 @@ import type { OpenClawAgentsDefaults } from "@/types"; const AgentsDefaultsPanel: React.FC = () => { const { t } = useTranslation(); + const { data: agentsData, isLoading } = useOpenClawAgentsDefaults(); + const saveAgentsMutation = useSaveOpenClawAgentsDefaults(); const [defaults, setDefaults] = useState(null); const [primaryModel, setPrimaryModel] = useState(""); const [fallbacks, setFallbacks] = useState(""); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); // Extra known fields from agents.defaults const [workspace, setWorkspace] = useState(""); @@ -22,38 +26,25 @@ const AgentsDefaultsPanel: React.FC = () => { const [contextTokens, setContextTokens] = useState(""); const [maxConcurrent, setMaxConcurrent] = useState(""); - const loadDefaults = useCallback(async () => { - try { - setLoading(true); - const data = await openclawApi.getAgentsDefaults(); - setDefaults(data); - - if (data) { - setPrimaryModel(data.model?.primary ?? ""); - setFallbacks((data.model?.fallbacks ?? []).join(", ")); - - // Extract known extra fields - setWorkspace(String(data.workspace ?? "")); - setTimeout_(String(data.timeout ?? "")); - setContextTokens(String(data.contextTokens ?? "")); - setMaxConcurrent(String(data.maxConcurrent ?? "")); - } - } catch (err) { - toast.error(t("openclaw.agents.loadFailed")); - console.error("Failed to load agents defaults:", err); - } finally { - setLoading(false); - } - }, [t]); - useEffect(() => { - void loadDefaults(); - }, [loadDefaults]); + // agentsData is undefined while loading, null when config section is absent + if (agentsData === undefined) return; + setDefaults(agentsData); + + if (agentsData) { + setPrimaryModel(agentsData.model?.primary ?? ""); + setFallbacks((agentsData.model?.fallbacks ?? []).join(", ")); + + // Extract known extra fields + setWorkspace(String(agentsData.workspace ?? "")); + setTimeout_(String(agentsData.timeout ?? "")); + setContextTokens(String(agentsData.contextTokens ?? "")); + setMaxConcurrent(String(agentsData.maxConcurrent ?? "")); + } + }, [agentsData]); const handleSave = async () => { try { - setSaving(true); - // Preserve all unknown fields from original data const updated: OpenClawAgentsDefaults = { ...defaults }; @@ -94,18 +85,17 @@ const AgentsDefaultsPanel: React.FC = () => { if (concNum !== undefined) updated.maxConcurrent = concNum; else delete updated.maxConcurrent; - await openclawApi.setAgentsDefaults(updated); + await saveAgentsMutation.mutateAsync(updated); toast.success(t("openclaw.agents.saveSuccess")); - await loadDefaults(); - } catch (err) { - toast.error(t("openclaw.agents.saveFailed")); - console.error("Failed to save agents defaults:", err); - } finally { - setSaving(false); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.agents.saveFailed"), { + description: detail || undefined, + }); } }; - if (loading) { + if (isLoading) { return (
@@ -222,9 +212,13 @@ const AgentsDefaultsPanel: React.FC = () => { {/* Save button */}
-
diff --git a/src/components/openclaw/EnvPanel.tsx b/src/components/openclaw/EnvPanel.tsx index 537845064..baba04fae 100644 --- a/src/components/openclaw/EnvPanel.tsx +++ b/src/components/openclaw/EnvPanel.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react"; import { toast } from "sonner"; -import { openclawApi } from "@/lib/api/openclaw"; +import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw"; +import { extractErrorMessage } from "@/utils/errorUtils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { OpenClawEnvConfig } from "@/types"; @@ -15,35 +16,23 @@ interface EnvEntry { const EnvPanel: React.FC = () => { const { t } = useTranslation(); + const { data: envData, isLoading } = useOpenClawEnv(); + const saveEnvMutation = useSaveOpenClawEnv(); const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const [visibleKeys, setVisibleKeys] = useState>(new Set()); - const loadEnv = useCallback(async () => { - try { - setLoading(true); - const env = await openclawApi.getEnv(); - const items: EnvEntry[] = Object.entries(env).map(([key, value]) => ({ + useEffect(() => { + if (envData) { + const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({ key, value: String(value ?? ""), })); setEntries(items.length > 0 ? items : []); - } catch (err) { - toast.error(t("openclaw.env.loadFailed")); - console.error("Failed to load env config:", err); - } finally { - setLoading(false); } - }, [t]); - - useEffect(() => { - void loadEnv(); - }, [loadEnv]); + }, [envData]); const handleSave = async () => { try { - setSaving(true); const env: OpenClawEnvConfig = {}; for (const entry of entries) { const trimmedKey = entry.key.trim(); @@ -51,15 +40,13 @@ const EnvPanel: React.FC = () => { env[trimmedKey] = entry.value; } } - await openclawApi.setEnv(env); + await saveEnvMutation.mutateAsync(env); toast.success(t("openclaw.env.saveSuccess")); - // Reload to normalize - await loadEnv(); - } catch (err) { - toast.error(t("openclaw.env.saveFailed")); - console.error("Failed to save env config:", err); - } finally { - setSaving(false); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.env.saveFailed"), { + description: detail || undefined, + }); } }; @@ -93,7 +80,7 @@ const EnvPanel: React.FC = () => { const isApiKey = (key: string) => /key|token|secret|password/i.test(key); - if (loading) { + if (isLoading) { return (
@@ -168,9 +155,13 @@ const EnvPanel: React.FC = () => { {t("openclaw.env.add")}
-
diff --git a/src/components/openclaw/ToolsPanel.tsx b/src/components/openclaw/ToolsPanel.tsx index 58830296f..b2ee74cd2 100644 --- a/src/components/openclaw/ToolsPanel.tsx +++ b/src/components/openclaw/ToolsPanel.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2, Save } from "lucide-react"; import { toast } from "sonner"; -import { openclawApi } from "@/lib/api/openclaw"; +import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw"; +import { extractErrorMessage } from "@/utils/errorUtils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -19,34 +20,22 @@ const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"]; const ToolsPanel: React.FC = () => { const { t } = useTranslation(); + const { data: toolsData, isLoading } = useOpenClawTools(); + const saveToolsMutation = useSaveOpenClawTools(); const [config, setConfig] = useState({}); const [allowList, setAllowList] = useState([]); const [denyList, setDenyList] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - const loadTools = useCallback(async () => { - try { - setLoading(true); - const tools = await openclawApi.getTools(); - setConfig(tools); - setAllowList(tools.allow ?? []); - setDenyList(tools.deny ?? []); - } catch (err) { - toast.error(t("openclaw.tools.loadFailed")); - console.error("Failed to load tools config:", err); - } finally { - setLoading(false); - } - }, [t]); useEffect(() => { - void loadTools(); - }, [loadTools]); + if (toolsData) { + setConfig(toolsData); + setAllowList(toolsData.allow ?? []); + setDenyList(toolsData.deny ?? []); + } + }, [toolsData]); const handleSave = async () => { try { - setSaving(true); const { profile, allow, deny, ...other } = config; const newConfig: OpenClawToolsConfig = { ...other, @@ -54,14 +43,13 @@ const ToolsPanel: React.FC = () => { allow: allowList.filter((s) => s.trim()), deny: denyList.filter((s) => s.trim()), }; - await openclawApi.setTools(newConfig); + await saveToolsMutation.mutateAsync(newConfig); toast.success(t("openclaw.tools.saveSuccess")); - await loadTools(); - } catch (err) { - toast.error(t("openclaw.tools.saveFailed")); - console.error("Failed to save tools config:", err); - } finally { - setSaving(false); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.tools.saveFailed"), { + description: detail || undefined, + }); } }; @@ -82,7 +70,7 @@ const ToolsPanel: React.FC = () => { setList(list.filter((_, i) => i !== index)); }; - if (loading) { + if (isLoading) { return (
@@ -192,9 +180,13 @@ const ToolsPanel: React.FC = () => { {/* Save button */}
-
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 8c4f635cc..58111cba1 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -19,8 +19,12 @@ import { useQuery } from "@tanstack/react-query"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { providersApi } from "@/lib/api/providers"; -import { openclawApi } from "@/lib/api/openclaw"; import { useDragSort } from "@/hooks/useDragSort"; +import { + useOpenClawLiveProviderIds, + useOpenClawDefaultModel, +} from "@/hooks/useOpenClaw"; +// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏 import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; import { @@ -88,11 +92,9 @@ export function ProviderList({ }); // OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig - const { data: openclawLiveIds } = useQuery({ - queryKey: ["openclawLiveProviderIds"], - queryFn: () => providersApi.getOpenClawLiveProviderIds(), - enabled: appId === "openclaw", - }); + const { data: openclawLiveIds } = useOpenClawLiveProviderIds( + appId === "openclaw", + ); // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw) const isProviderInConfig = useCallback( @@ -109,11 +111,9 @@ export function ProviderList({ ); // OpenClaw: query default model to determine which provider is default - const { data: openclawDefaultModel } = useQuery({ - queryKey: ["openclawDefaultModel"], - queryFn: () => openclawApi.getDefaultModel(), - enabled: appId === "openclaw", - }); + const { data: openclawDefaultModel } = useOpenClawDefaultModel( + appId === "openclaw", + ); const isProviderDefaultModel = useCallback( (providerId: string): boolean => { diff --git a/src/hooks/useOpenClaw.ts b/src/hooks/useOpenClaw.ts new file mode 100644 index 000000000..384bb0399 --- /dev/null +++ b/src/hooks/useOpenClaw.ts @@ -0,0 +1,128 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { openclawApi } from "@/lib/api/openclaw"; +import { providersApi } from "@/lib/api/providers"; +import type { + OpenClawEnvConfig, + OpenClawToolsConfig, + OpenClawAgentsDefaults, +} from "@/types"; + +/** + * Centralized query keys for all OpenClaw-related queries. + * Import this from any file that needs to invalidate OpenClaw caches. + */ +export const openclawKeys = { + all: ["openclaw"] as const, + liveProviderIds: ["openclaw", "liveProviderIds"] as const, + defaultModel: ["openclaw", "defaultModel"] as const, + env: ["openclaw", "env"] as const, + tools: ["openclaw", "tools"] as const, + agentsDefaults: ["openclaw", "agentsDefaults"] as const, +}; + +// ============================================================ +// Query hooks +// ============================================================ + +/** + * Query live provider IDs from openclaw.json config. + * Used by ProviderList to show "In Config" badge. + */ +export function useOpenClawLiveProviderIds(enabled: boolean) { + return useQuery({ + queryKey: openclawKeys.liveProviderIds, + queryFn: () => providersApi.getOpenClawLiveProviderIds(), + enabled, + }); +} + +/** + * Query the default model from agents.defaults.model. + * Used by ProviderList to show which provider is the default. + */ +export function useOpenClawDefaultModel(enabled: boolean) { + return useQuery({ + queryKey: openclawKeys.defaultModel, + queryFn: () => openclawApi.getDefaultModel(), + enabled, + }); +} + +/** + * Query env section of openclaw.json. + */ +export function useOpenClawEnv() { + return useQuery({ + queryKey: openclawKeys.env, + queryFn: () => openclawApi.getEnv(), + }); +} + +/** + * Query tools section of openclaw.json. + */ +export function useOpenClawTools() { + return useQuery({ + queryKey: openclawKeys.tools, + queryFn: () => openclawApi.getTools(), + }); +} + +/** + * Query agents.defaults section of openclaw.json. + */ +export function useOpenClawAgentsDefaults() { + return useQuery({ + queryKey: openclawKeys.agentsDefaults, + queryFn: () => openclawApi.getAgentsDefaults(), + }); +} + +// ============================================================ +// Mutation hooks +// ============================================================ + +/** + * Save env config. Invalidates env query on success. + * Toast notifications are handled by the component. + */ +export function useSaveOpenClawEnv() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: openclawKeys.env }); + }, + }); +} + +/** + * Save tools config. Invalidates tools query on success. + * Toast notifications are handled by the component. + */ +export function useSaveOpenClawTools() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: openclawKeys.tools }); + }, + }); +} + +/** + * Save agents.defaults config. Invalidates both agentsDefaults and defaultModel + * queries on success (since changing agents.defaults may affect the default model). + * Toast notifications are handled by the component. + */ +export function useSaveOpenClawAgentsDefaults() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (defaults: OpenClawAgentsDefaults) => + openclawApi.setAgentsDefaults(defaults), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults }); + queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel }); + }, + }); +} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 2f2083df8..42a5c4120 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -17,6 +17,7 @@ import { useSwitchProviderMutation, } from "@/lib/query"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { openclawKeys } from "@/hooks/useOpenClaw"; /** * Hook for managing provider actions (add, update, delete, switch) @@ -244,7 +245,7 @@ export function useProviderActions(activeApp: AppId) { try { await openclawApi.setDefaultModel(model); await queryClient.invalidateQueries({ - queryKey: ["openclawDefaultModel"], + queryKey: openclawKeys.defaultModel, }); toast.success( t("notifications.openclawDefaultModelSet", { diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index da83c204f..28efdd570 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -5,6 +5,7 @@ import { providersApi, settingsApi, type AppId } from "@/lib/api"; import type { Provider, Settings } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; +import { openclawKeys } from "@/hooks/useOpenClaw"; export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); @@ -185,10 +186,10 @@ export const useSwitchProviderMutation = (appId: AppId) => { } if (appId === "openclaw") { await queryClient.invalidateQueries({ - queryKey: ["openclawLiveProviderIds"], + queryKey: openclawKeys.liveProviderIds, }); await queryClient.invalidateQueries({ - queryKey: ["openclawDefaultModel"], + queryKey: openclawKeys.defaultModel, }); }