From 4e0f9d955216c095d0b336345289b2ecebaa6dd2 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 4 Mar 2026 15:48:00 +0800 Subject: [PATCH] feat: replace text inputs with dropdown selects for OpenClaw agent model config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useOpenClawModelOptions hook to aggregate models from all configured OpenClaw providers - Replace read-only primary model display with a searchable Select dropdown - Replace comma-separated fallback text input with add/remove Select rows - Filter out already-selected models from fallback options - Show "(not configured)" marker for values whose provider has been deleted - Unify terminology: rename "主模型/Primary Model" to "默认模型/Default Model" --- .../openclaw/AgentsDefaultsPanel.tsx | 207 +++++++++++++++--- .../openclaw/hooks/useOpenClawModelOptions.ts | 62 ++++++ src/i18n/locales/en.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/zh.json | 9 +- 5 files changed, 259 insertions(+), 37 deletions(-) create mode 100644 src/components/openclaw/hooks/useOpenClawModelOptions.ts diff --git a/src/components/openclaw/AgentsDefaultsPanel.tsx b/src/components/openclaw/AgentsDefaultsPanel.tsx index c4a9abf18..e8611f078 100644 --- a/src/components/openclaw/AgentsDefaultsPanel.tsx +++ b/src/components/openclaw/AgentsDefaultsPanel.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Save } from "lucide-react"; +import { Save, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { useOpenClawAgentsDefaults, @@ -10,14 +10,28 @@ import { extractErrorMessage } from "@/utils/errorUtils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import type { OpenClawAgentsDefaults } from "@/types"; +import { useOpenClawModelOptions } from "./hooks/useOpenClawModelOptions"; + +const UNSET_SENTINEL = "__unset__"; const AgentsDefaultsPanel: React.FC = () => { const { t } = useTranslation(); const { data: agentsData, isLoading } = useOpenClawAgentsDefaults(); const saveAgentsMutation = useSaveOpenClawAgentsDefaults(); + const { options: modelOptions, isLoading: modelsLoading } = + useOpenClawModelOptions(); + const [defaults, setDefaults] = useState(null); - const [fallbacks, setFallbacks] = useState(""); + const [primaryModel, setPrimaryModel] = useState(""); + const [fallbacks, setFallbacks] = useState([]); // Extra known fields from agents.defaults const [workspace, setWorkspace] = useState(""); @@ -25,16 +39,14 @@ const AgentsDefaultsPanel: React.FC = () => { const [contextTokens, setContextTokens] = useState(""); const [maxConcurrent, setMaxConcurrent] = useState(""); - // Primary model is read-only — set via the "Set as default model" button on provider cards - const primaryModel = agentsData?.model?.primary ?? ""; - useEffect(() => { // agentsData is undefined while loading, null when config section is absent if (agentsData === undefined) return; setDefaults(agentsData); if (agentsData) { - setFallbacks((agentsData.model?.fallbacks ?? []).join(", ")); + setPrimaryModel(agentsData.model?.primary ?? ""); + setFallbacks(agentsData.model?.fallbacks ?? []); // Extract known extra fields setWorkspace(String(agentsData.workspace ?? "")); @@ -44,25 +56,82 @@ const AgentsDefaultsPanel: React.FC = () => { } }, [agentsData]); + // Build primary options, including a "not in list" entry if current value is missing + const primaryOptions = useMemo(() => { + const result = [...modelOptions]; + if ( + primaryModel && + !modelOptions.some((opt) => opt.value === primaryModel) + ) { + result.unshift({ + value: primaryModel, + label: t("openclaw.agents.notInList", { + value: primaryModel, + defaultValue: "{{value}} (not configured)", + }), + }); + } + return result; + }, [modelOptions, primaryModel, t]); + + // For each fallback row, compute available options (exclude primary + other fallbacks) + const getFallbackOptions = (currentIndex: number) => { + const usedValues = new Set(); + if (primaryModel) usedValues.add(primaryModel); + fallbacks.forEach((fb, idx) => { + if (idx !== currentIndex && fb) usedValues.add(fb); + }); + + const filtered = modelOptions.filter((opt) => !usedValues.has(opt.value)); + + // If current fallback value is not in modelOptions, add a "not in list" entry + const currentValue = fallbacks[currentIndex]; + if ( + currentValue && + !modelOptions.some((opt) => opt.value === currentValue) + ) { + filtered.unshift({ + value: currentValue, + label: t("openclaw.agents.notInList", { + value: currentValue, + defaultValue: "{{value}} (not configured)", + }), + }); + } + + return filtered; + }; + + const handleAddFallback = () => { + setFallbacks((prev) => [...prev, ""]); + }; + + const handleRemoveFallback = (index: number) => { + setFallbacks((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleFallbackChange = (index: number, value: string) => { + setFallbacks((prev) => { + const next = [...prev]; + next[index] = value; + return next; + }); + }; + const handleSave = async () => { try { // Preserve all unknown fields from original data const updated: OpenClawAgentsDefaults = { ...defaults }; - // Model configuration — primary is read-only, preserve original value - const fallbackList = fallbacks - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + // Model configuration + const fallbackList = fallbacks.filter(Boolean); - const origPrimary = defaults?.model?.primary; - if (origPrimary) { + if (primaryModel) { updated.model = { - primary: origPrimary, + primary: primaryModel, ...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}), }; } else if (fallbackList.length > 0) { - // No primary set but user provided fallbacks — keep fallbacks only updated.model = { primary: "", fallbacks: fallbackList }; } @@ -110,6 +179,8 @@ const AgentsDefaultsPanel: React.FC = () => { ); } + const noModels = modelOptions.length === 0 && !modelsLoading; + return (

@@ -123,31 +194,111 @@ const AgentsDefaultsPanel: React.FC = () => {

+ {/* Primary Model */}
-
- {primaryModel || t("openclaw.agents.notSet")} -
+ {noModels ? ( +

+ {t("openclaw.agents.noModels", { + defaultValue: + "No configured provider models. Please add an OpenClaw provider first.", + })} +

+ ) : ( + + )}

{t("openclaw.agents.primaryModelHint")}

+ {/* Fallback Models */}
- setFallbacks(e.target.value)} - placeholder="provider/model-a, provider/model-b" - className="font-mono text-xs" - /> -

- {t("openclaw.agents.fallbackModelsHint")} -

+ + {fallbacks.length === 0 && !noModels && ( +

+ {t("openclaw.agents.fallbackModelsHint")} +

+ )} + +
+ {fallbacks.map((fb, index) => { + const opts = getFallbackOptions(index); + return ( +
+ + +
+ ); + })} +
+ + {!noModels && ( + + )}
diff --git a/src/components/openclaw/hooks/useOpenClawModelOptions.ts b/src/components/openclaw/hooks/useOpenClawModelOptions.ts new file mode 100644 index 000000000..974162b64 --- /dev/null +++ b/src/components/openclaw/hooks/useOpenClawModelOptions.ts @@ -0,0 +1,62 @@ +import { useMemo } from "react"; +import { useProvidersQuery } from "@/lib/query/queries"; +import type { OpenClawProviderConfig } from "@/types"; + +export interface ModelOption { + value: string; // "providerId/modelId" + label: string; // "Provider Name / Model Name" +} + +export function useOpenClawModelOptions(): { + options: ModelOption[]; + isLoading: boolean; +} { + const { data: providersData, isLoading } = useProvidersQuery("openclaw"); + + const options = useMemo(() => { + const allProviders = providersData?.providers; + if (!allProviders) return []; + + const dedupedOptions = new Map(); + + for (const [providerKey, provider] of Object.entries(allProviders)) { + let config: OpenClawProviderConfig; + try { + config = + typeof provider.settingsConfig === "string" + ? (JSON.parse(provider.settingsConfig) as OpenClawProviderConfig) + : (provider.settingsConfig as OpenClawProviderConfig); + } catch { + continue; + } + + const models = config.models; + if (!Array.isArray(models)) continue; + + const providerDisplayName = + typeof provider.name === "string" && provider.name.trim() + ? provider.name + : providerKey; + + for (const model of models) { + if (!model.id) continue; + const value = `${providerKey}/${model.id}`; + const modelDisplayName = + typeof model.name === "string" && model.name.trim() + ? model.name + : model.id; + const label = `${providerDisplayName} / ${modelDisplayName}`; + + if (!dedupedOptions.has(value)) { + dedupedOptions.set(value, label); + } + } + } + + return Array.from(dedupedOptions.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")); + }, [providersData?.providers]); + + return { options, isLoading }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7f2564b46..f725f2cdf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1292,11 +1292,14 @@ "title": "Agents Config", "description": "Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)", "modelSection": "Model Configuration", - "primaryModel": "Primary Model", - "primaryModelHint": "Set via the \"Set as Default Model\" button in the provider list", + "primaryModel": "Default Model", + "primaryModelHint": "Select from models of configured providers", "notSet": "Not set", "fallbackModels": "Fallback Models", - "fallbackModelsHint": "Comma-separated, ordered by priority", + "fallbackModelsHint": "When the primary model is unavailable, fallbacks are tried in order", + "addFallback": "Add fallback model", + "noModels": "No configured provider models. Please add an OpenClaw provider first.", + "notInList": "{{value}} (not configured)", "runtimeSection": "Runtime Parameters", "workspace": "Workspace Path", "timeout": "Timeout (seconds)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9935ccc86..0440fb4aa 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1292,11 +1292,14 @@ "title": "Agents 設定", "description": "openclaw.json の agents.defaults 設定を管理(デフォルトモデル、ランタイムパラメータなど)", "modelSection": "モデル設定", - "primaryModel": "プライマリモデル", - "primaryModelHint": "プロバイダ一覧の「デフォルトモデルに設定」ボタンで設定します", + "primaryModel": "デフォルトモデル", + "primaryModelHint": "設定済みプロバイダのモデルから選択してください", "notSet": "未設定", "fallbackModels": "フォールバックモデル", - "fallbackModelsHint": "カンマ区切り、優先度順", + "fallbackModelsHint": "プライマリモデルが利用不可の場合、優先度順にフォールバックが試行されます", + "addFallback": "フォールバックモデルを追加", + "noModels": "設定済みのプロバイダモデルがありません。先に OpenClaw プロバイダを追加してください。", + "notInList": "{{value}} (未設定)", "runtimeSection": "ランタイムパラメータ", "workspace": "ワークスペースパス", "timeout": "タイムアウト(秒)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 72453e770..ad99f1dba 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1292,11 +1292,14 @@ "title": "Agents 配置", "description": "管理 openclaw.json 中的 agents.defaults 配置(默认模型、运行参数等)", "modelSection": "模型配置", - "primaryModel": "主模型", - "primaryModelHint": "在供应商列表中通过「设为默认模型」按钮设置", + "primaryModel": "默认模型", + "primaryModelHint": "从已配置供应商的模型中选择默认模型", "notSet": "未设置", "fallbackModels": "回退模型", - "fallbackModelsHint": "逗号分隔,按优先级排列", + "fallbackModelsHint": "当主模型不可用时,按优先级依次尝试以下回退模型", + "addFallback": "添加回退模型", + "noModels": "暂无已配置的供应商模型。请先添加 OpenClaw 供应商。", + "notInList": "{{value}} (供应商未配置)", "runtimeSection": "运行参数", "workspace": "工作区路径", "timeout": "超时时间(秒)",