feat: replace text inputs with dropdown selects for OpenClaw agent model config

- 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"
This commit is contained in:
Jason
2026-03-04 15:48:00 +08:00
parent 7d4ffa9872
commit 4e0f9d9552
5 changed files with 259 additions and 37 deletions

View File

@@ -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<OpenClawAgentsDefaults | null>(null);
const [fallbacks, setFallbacks] = useState("");
const [primaryModel, setPrimaryModel] = useState("");
const [fallbacks, setFallbacks] = useState<string[]>([]);
// 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<string>();
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 (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
@@ -123,31 +194,111 @@ const AgentsDefaultsPanel: React.FC = () => {
</h3>
<div className="space-y-4">
{/* Primary Model */}
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.primaryModel")}
</Label>
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted/50 font-mono text-xs text-muted-foreground">
{primaryModel || t("openclaw.agents.notSet")}
</div>
{noModels ? (
<p className="text-xs text-muted-foreground italic">
{t("openclaw.agents.noModels", {
defaultValue:
"No configured provider models. Please add an OpenClaw provider first.",
})}
</p>
) : (
<Select
value={primaryModel || UNSET_SENTINEL}
onValueChange={(v) =>
setPrimaryModel(v === UNSET_SENTINEL ? "" : v)
}
>
<SelectTrigger className="font-mono text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET_SENTINEL}>
{t("openclaw.agents.notSet")}
</SelectItem>
{primaryOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-xs text-muted-foreground mt-1">
{t("openclaw.agents.primaryModelHint")}
</p>
</div>
{/* Fallback Models */}
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.fallbackModels")}
</Label>
<Input
value={fallbacks}
onChange={(e) => setFallbacks(e.target.value)}
placeholder="provider/model-a, provider/model-b"
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("openclaw.agents.fallbackModelsHint")}
</p>
{fallbacks.length === 0 && !noModels && (
<p className="text-xs text-muted-foreground italic mb-2">
{t("openclaw.agents.fallbackModelsHint")}
</p>
)}
<div className="space-y-2">
{fallbacks.map((fb, index) => {
const opts = getFallbackOptions(index);
return (
<div key={index} className="flex items-center gap-2">
<Select
value={fb || UNSET_SENTINEL}
onValueChange={(v) =>
handleFallbackChange(
index,
v === UNSET_SENTINEL ? "" : v,
)
}
>
<SelectTrigger className="font-mono text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET_SENTINEL}>
{t("openclaw.agents.notSet")}
</SelectItem>
{opts.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveFallback(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
})}
</div>
{!noModels && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={handleAddFallback}
>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.agents.addFallback", {
defaultValue: "Add fallback model",
})}
</Button>
)}
</div>
</div>
</div>

View File

@@ -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<ModelOption[]>(() => {
const allProviders = providersData?.providers;
if (!allProviders) return [];
const dedupedOptions = new Map<string, string>();
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 };
}

View File

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

View File

@@ -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": "タイムアウト(秒)",

View File

@@ -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": "超时时间(秒)",