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.
This commit is contained in:
Jason
2026-02-08 15:35:09 +08:00
parent bc87f9d9eb
commit 00b424628f
8 changed files with 228 additions and 120 deletions

View File

@@ -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(

View File

@@ -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<OpenClawAgentsDefaults | null>(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 (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
@@ -222,9 +212,13 @@ const AgentsDefaultsPanel: React.FC = () => {
{/* Save button */}
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={saving}>
<Button
size="sm"
onClick={handleSave}
disabled={saveAgentsMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saving ? t("common.saving") : t("common.save")}
{saveAgentsMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>

View File

@@ -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<EnvEntry[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(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 (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
@@ -168,9 +155,13 @@ const EnvPanel: React.FC = () => {
{t("openclaw.env.add")}
</Button>
<div className="flex-1" />
<Button size="sm" onClick={handleSave} disabled={saving}>
<Button
size="sm"
onClick={handleSave}
disabled={saveEnvMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saving ? t("common.saving") : t("common.save")}
{saveEnvMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>

View File

@@ -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<OpenClawToolsConfig>({});
const [allowList, setAllowList] = useState<string[]>([]);
const [denyList, setDenyList] = useState<string[]>([]);
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 (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
@@ -192,9 +180,13 @@ const ToolsPanel: React.FC = () => {
{/* Save button */}
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={saving}>
<Button
size="sm"
onClick={handleSave}
disabled={saveToolsMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saving ? t("common.saving") : t("common.save")}
{saveToolsMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>

View File

@@ -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 => {

128
src/hooks/useOpenClaw.ts Normal file
View File

@@ -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 });
},
});
}

View File

@@ -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", {

View File

@@ -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,
});
}