fix(openclaw): address code review findings across P0-P3 issues

- Add 25 missing i18n keys for OpenClawFormFields in all 3 locales (P0)
- Replace key={index} with stable crypto.randomUUID() keys in EnvPanel,
  ToolsPanel, and OpenClawFormFields to prevent list state bugs (P1)
- Exclude openclaw from ProxyToggle/FailoverToggle in App.tsx (P1)
- Add merge_additive_config() for openclaw/opencode deep link imports (P1)
- Normalize serde(flatten) field naming to `extra` + HashMap (P2)
- Add directory existence check in remove_openclaw_provider_from_live (P2)
- Remove dead code in import_default_config and openclaw API methods (P2)
- Add duplicate key validation in EnvPanel before save (P2)
- Add openclawConfigDir to Settings type (P2)
- Add staleTime to OpenClaw query hooks (P3)
- Fix type-unsafe delete via destructuring in mutations.ts (P3)
This commit is contained in:
Jason
2026-02-08 17:35:29 +08:00
parent 00b424628f
commit 5081206176
15 changed files with 241 additions and 102 deletions
+1 -1
View File
@@ -970,7 +970,7 @@ function App() {
)}
{currentView === "providers" && (
<>
{activeApp !== "opencode" && (
{activeApp !== "opencode" && activeApp !== "openclaw" && (
<>
<ProxyToggle activeApp={activeApp} />
<div
+13 -2
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import type { OpenClawEnvConfig } from "@/types";
interface EnvEntry {
id: string;
key: string;
value: string;
isNew?: boolean;
@@ -24,6 +25,7 @@ const EnvPanel: React.FC = () => {
useEffect(() => {
if (envData) {
const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({
id: crypto.randomUUID(),
key,
value: String(value ?? ""),
}));
@@ -34,9 +36,15 @@ const EnvPanel: React.FC = () => {
const handleSave = async () => {
try {
const env: OpenClawEnvConfig = {};
const seen = new Set<string>();
for (const entry of entries) {
const trimmedKey = entry.key.trim();
if (trimmedKey) {
if (seen.has(trimmedKey)) {
toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey }));
return;
}
seen.add(trimmedKey);
env[trimmedKey] = entry.value;
}
}
@@ -51,7 +59,10 @@ const EnvPanel: React.FC = () => {
};
const addEntry = () => {
setEntries((prev) => [...prev, { key: "", value: "", isNew: true }]);
setEntries((prev) => [
...prev,
{ id: crypto.randomUUID(), key: "", value: "", isNew: true },
]);
};
const removeEntry = (index: number) => {
@@ -103,7 +114,7 @@ const EnvPanel: React.FC = () => {
const visible = visibleKeys.has(visibilityId);
return (
<div key={index} className="flex items-center gap-2">
<div key={entry.id} className="flex items-center gap-2">
<div className="w-[200px] flex-shrink-0">
<Input
value={entry.key}
+47 -22
View File
@@ -16,6 +16,11 @@ import {
} from "@/components/ui/select";
import type { OpenClawToolsConfig } from "@/types";
interface ListItem {
id: string;
value: string;
}
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
const ToolsPanel: React.FC = () => {
@@ -23,14 +28,24 @@ const ToolsPanel: React.FC = () => {
const { data: toolsData, isLoading } = useOpenClawTools();
const saveToolsMutation = useSaveOpenClawTools();
const [config, setConfig] = useState<OpenClawToolsConfig>({});
const [allowList, setAllowList] = useState<string[]>([]);
const [denyList, setDenyList] = useState<string[]>([]);
const [allowList, setAllowList] = useState<ListItem[]>([]);
const [denyList, setDenyList] = useState<ListItem[]>([]);
useEffect(() => {
if (toolsData) {
setConfig(toolsData);
setAllowList(toolsData.allow ?? []);
setDenyList(toolsData.deny ?? []);
setAllowList(
(toolsData.allow ?? []).map((v) => ({
id: crypto.randomUUID(),
value: v,
})),
);
setDenyList(
(toolsData.deny ?? []).map((v) => ({
id: crypto.randomUUID(),
value: v,
})),
);
}
}, [toolsData]);
@@ -40,8 +55,8 @@ const ToolsPanel: React.FC = () => {
const newConfig: OpenClawToolsConfig = {
...other,
profile: config.profile,
allow: allowList.filter((s) => s.trim()),
deny: denyList.filter((s) => s.trim()),
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
};
await saveToolsMutation.mutateAsync(newConfig);
toast.success(t("openclaw.tools.saveSuccess"));
@@ -54,20 +69,20 @@ const ToolsPanel: React.FC = () => {
};
const updateListItem = (
list: string[],
setList: React.Dispatch<React.SetStateAction<string[]>>,
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
index: number,
value: string,
) => {
setList(list.map((item, i) => (i === index ? value : item)));
setList((prev) =>
prev.map((item, i) => (i === index ? { ...item, value } : item)),
);
};
const removeListItem = (
list: string[],
setList: React.Dispatch<React.SetStateAction<string[]>>,
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
index: number,
) => {
setList(list.filter((_, i) => i !== index));
setList((prev) => prev.filter((_, i) => i !== index));
};
if (isLoading) {
@@ -113,11 +128,11 @@ const ToolsPanel: React.FC = () => {
<Label className="mb-2 block">{t("openclaw.tools.allowList")}</Label>
<div className="space-y-2">
{allowList.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<div key={item.id} className="flex items-center gap-2">
<Input
value={item}
value={item.value}
onChange={(e) =>
updateListItem(allowList, setAllowList, index, e.target.value)
updateListItem(setAllowList, index, e.target.value)
}
placeholder={t("openclaw.tools.patternPlaceholder")}
className="font-mono text-xs"
@@ -126,7 +141,7 @@ const ToolsPanel: React.FC = () => {
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeListItem(allowList, setAllowList, index)}
onClick={() => removeListItem(setAllowList, index)}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -135,7 +150,12 @@ const ToolsPanel: React.FC = () => {
<Button
variant="outline"
size="sm"
onClick={() => setAllowList((prev) => [...prev, ""])}
onClick={() =>
setAllowList((prev) => [
...prev,
{ id: crypto.randomUUID(), value: "" },
])
}
>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.tools.addAllow")}
@@ -148,11 +168,11 @@ const ToolsPanel: React.FC = () => {
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
<div className="space-y-2">
{denyList.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<div key={item.id} className="flex items-center gap-2">
<Input
value={item}
value={item.value}
onChange={(e) =>
updateListItem(denyList, setDenyList, index, e.target.value)
updateListItem(setDenyList, index, e.target.value)
}
placeholder={t("openclaw.tools.patternPlaceholder")}
className="font-mono text-xs"
@@ -161,7 +181,7 @@ const ToolsPanel: React.FC = () => {
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeListItem(denyList, setDenyList, index)}
onClick={() => removeListItem(setDenyList, index)}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -170,7 +190,12 @@ const ToolsPanel: React.FC = () => {
<Button
variant="outline"
size="sm"
onClick={() => setDenyList((prev) => [...prev, ""])}
onClick={() =>
setDenyList((prev) => [
...prev,
{ id: crypto.randomUUID(), value: "" },
])
}
>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.tools.addDeny")}
@@ -309,6 +309,7 @@ export function AddProviderDialog({
</TabsContent>
</Tabs>
) : (
// OpenCode/OpenClaw: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useState, useRef, useCallback } from "react";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -64,6 +64,21 @@ export function OpenClawFormFields({
{},
);
// Stable key tracking for models list
const modelKeysRef = useRef<string[]>([]);
const getModelKeys = useCallback(() => {
// Grow keys array if models were added externally
while (modelKeysRef.current.length < models.length) {
modelKeysRef.current.push(crypto.randomUUID());
}
// Shrink if models were removed externally
if (modelKeysRef.current.length > models.length) {
modelKeysRef.current.length = models.length;
}
return modelKeysRef.current;
}, [models.length]);
const modelKeys = getModelKeys();
// Toggle advanced section for a model
const toggleModelAdvanced = (index: number) => {
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
@@ -71,6 +86,7 @@ export function OpenClawFormFields({
// Add a new model entry
const handleAddModel = () => {
modelKeysRef.current.push(crypto.randomUUID());
onModelsChange([
...models,
{
@@ -85,6 +101,7 @@ export function OpenClawFormFields({
// Remove a model entry
const handleRemoveModel = (index: number) => {
modelKeysRef.current.splice(index, 1);
const newModels = [...models];
newModels.splice(index, 1);
onModelsChange(newModels);
@@ -216,7 +233,7 @@ export function OpenClawFormFields({
<div className="space-y-4">
{models.map((model, index) => (
<div
key={index}
key={modelKeys[index]}
className="p-3 border border-border/50 rounded-lg space-y-3"
>
{/* Model ID and Name row */}
+3
View File
@@ -55,6 +55,7 @@ export function useOpenClawEnv() {
return useQuery({
queryKey: openclawKeys.env,
queryFn: () => openclawApi.getEnv(),
staleTime: 30_000,
});
}
@@ -65,6 +66,7 @@ export function useOpenClawTools() {
return useQuery({
queryKey: openclawKeys.tools,
queryFn: () => openclawApi.getTools(),
staleTime: 30_000,
});
}
@@ -75,6 +77,7 @@ export function useOpenClawAgentsDefaults() {
return useQuery({
queryKey: openclawKeys.agentsDefaults,
queryFn: () => openclawApi.getAgentsDefaults(),
staleTime: 30_000,
});
}
+27 -2
View File
@@ -744,7 +744,31 @@
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only."
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
"apiProtocol": "API Protocol",
"selectProtocol": "Select API Protocol",
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
"baseUrl": "API Endpoint",
"baseUrlHint": "The provider's API endpoint address.",
"models": "Models",
"addModel": "Add Model",
"noModels": "No models configured. Click Add Model to configure available models.",
"modelId": "Model ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "Display Name",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "Context Window",
"maxTokens": "Max Output Tokens",
"reasoning": "Reasoning Mode",
"reasoningOn": "Enabled",
"reasoningOff": "Disabled",
"inputCost": "Input Cost ($/M tokens)",
"outputCost": "Output Cost ($/M tokens)",
"advancedOptions": "Advanced Options",
"cacheReadCost": "Cache Read Cost ($/M tokens)",
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface."
},
"providerPreset": {
"label": "Provider Preset",
@@ -1143,7 +1167,8 @@
"add": "Add Variable",
"saveSuccess": "Environment variables saved",
"saveFailed": "Failed to save environment variables",
"loadFailed": "Failed to load environment variables"
"loadFailed": "Failed to load environment variables",
"duplicateKey": "Duplicate variable name detected: {{key}}"
},
"tools": {
"title": "Tool Permissions",
+27 -2
View File
@@ -744,7 +744,31 @@
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。"
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
"apiProtocol": "API プロトコル",
"selectProtocol": "API プロトコルを選択",
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
"baseUrl": "API エンドポイント",
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
"models": "モデル一覧",
"addModel": "モデルを追加",
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
"modelId": "モデル ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "表示名",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "コンテキストウィンドウ",
"maxTokens": "最大出力トークン数",
"reasoning": "推論モード",
"reasoningOn": "有効",
"reasoningOff": "無効",
"inputCost": "入力コスト ($/M トークン)",
"outputCost": "出力コスト ($/M トークン)",
"advancedOptions": "詳細オプション",
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。"
},
"providerPreset": {
"label": "プロバイダータイプ",
@@ -1143,7 +1167,8 @@
"add": "変数を追加",
"saveSuccess": "環境変数を保存しました",
"saveFailed": "環境変数の保存に失敗しました",
"loadFailed": "環境変数の読み込みに失敗しました"
"loadFailed": "環境変数の読み込みに失敗しました",
"duplicateKey": "重複する変数名が検出されました: {{key}}"
},
"tools": {
"title": "ツール権限",
+27 -2
View File
@@ -744,7 +744,31 @@
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符"
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
"apiProtocol": "API 协议",
"selectProtocol": "选择 API 协议",
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
"baseUrl": "API 端点",
"baseUrlHint": "供应商的 API 端点地址。",
"models": "模型列表",
"addModel": "添加模型",
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
"modelId": "模型 ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "显示名称",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "上下文窗口",
"maxTokens": "最大输出 Tokens",
"reasoning": "推理模式",
"reasoningOn": "启用",
"reasoningOff": "关闭",
"inputCost": "输入价格 ($/M tokens)",
"outputCost": "输出价格 ($/M tokens)",
"advancedOptions": "高级选项",
"cacheReadCost": "缓存读取价格 ($/M tokens)",
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。"
},
"providerPreset": {
"label": "预设供应商",
@@ -1143,7 +1167,8 @@
"add": "添加变量",
"saveSuccess": "环境变量已保存",
"saveFailed": "保存环境变量失败",
"loadFailed": "读取环境变量失败"
"loadFailed": "读取环境变量失败",
"duplicateKey": "检测到重复的变量名: {{key}}"
},
"tools": {
"title": "工具权限",
-18
View File
@@ -67,24 +67,6 @@ export const openclawApi = {
return await invoke("set_openclaw_agents_defaults", { defaults });
},
// ============================================================
// Provider Import
// ============================================================
/**
* Import providers from live config (openclaw.json) to database
*/
async importProvidersFromLive(): Promise<number> {
return await invoke("import_openclaw_providers_from_live");
},
/**
* Get provider IDs that exist in live config (openclaw.json)
*/
async getLiveProviderIds(): Promise<string[]> {
return await invoke("get_openclaw_live_provider_ids");
},
// ============================================================
// Env Configuration
// ============================================================
+3 -1
View File
@@ -30,8 +30,10 @@ export const useAddProviderMutation = (appId: AppId) => {
id = generateUUID();
}
const { providerKey: _providerKey, ...rest } = providerInput;
const newProvider: Provider = {
...providerInput,
...rest,
id,
createdAt: Date.now(),
};
+2
View File
@@ -224,6 +224,8 @@ export interface Settings {
geminiConfigDir?: string;
// 覆盖 OpenCode 配置目录(可选)
opencodeConfigDir?: string;
// 覆盖 OpenClaw 配置目录(可选)
openclawConfigDir?: string;
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current