mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
refactor(openclaw): align provider model editor (#6201)
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
1 parent
076c2744ce
commit
95b95da6ab
12 files changed
+590
-328
No files matched your search
@@ -2,6 +2,7 @@ import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Edit,
|
||||
Loader2,
|
||||
@@ -14,9 +15,21 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
interface OpenClawDefaultModelOption {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ProviderActionsProps {
|
||||
appId?: AppId;
|
||||
isCurrent: boolean;
|
||||
@@ -41,7 +54,8 @@ interface ProviderActionsProps {
|
||||
isReadOnly?: boolean;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
defaultModelOptions?: OpenClawDefaultModelOption[];
|
||||
onSetAsDefault?: (modelId?: string) => void;
|
||||
}
|
||||
|
||||
// 主按钮的呈现状态。title 用于 disabled 态向用户解释为何不可点击;
|
||||
@@ -79,6 +93,7 @@ export function ProviderActions({
|
||||
isReadOnly = false,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel = false,
|
||||
defaultModelOptions = [],
|
||||
onSetAsDefault,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -240,18 +255,72 @@ export function ProviderActions({
|
||||
appId === "hermes"
|
||||
? t("provider.enable", { defaultValue: "启用" })
|
||||
: t("provider.setAsDefault", { defaultValue: "设为默认" });
|
||||
const defaultButtonClassName = cn(
|
||||
"w-fit px-2.5",
|
||||
isDefaultModel
|
||||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
||||
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
);
|
||||
|
||||
if (
|
||||
appId === "openclaw" &&
|
||||
!isDefaultModel &&
|
||||
defaultModelOptions.length > 1
|
||||
) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className={defaultButtonClassName}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
{inactiveLabel}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-72 min-w-64 overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("openclaw.selectDefaultModel", {
|
||||
defaultValue: "选择默认模型",
|
||||
})}
|
||||
</DropdownMenuLabel>
|
||||
{defaultModelOptions.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onSelect={() => onSetAsDefault(model.id)}
|
||||
className="flex min-w-0 flex-col items-start gap-0.5"
|
||||
>
|
||||
<span className="max-w-72 truncate">
|
||||
{model.name?.trim() || model.id}
|
||||
</span>
|
||||
{model.name?.trim() && model.name.trim() !== model.id && (
|
||||
<span className="max-w-72 truncate font-mono text-xs text-muted-foreground">
|
||||
{model.id}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isDefaultModel ? "secondary" : "default"}
|
||||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||||
disabled={isDefaultModel}
|
||||
className={cn(
|
||||
"w-fit px-2.5",
|
||||
onClick={
|
||||
isDefaultModel
|
||||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
||||
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
)}
|
||||
? undefined
|
||||
: () => onSetAsDefault(defaultModelOptions[0]?.id)
|
||||
}
|
||||
disabled={isDefaultModel}
|
||||
className={defaultButtonClassName}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
{isDefaultModel ? activeLabel : inactiveLabel}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
DraggableAttributes,
|
||||
DraggableSyntheticListeners,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Provider } from "@/types";
|
||||
import type { OpenClawProviderConfig, Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
@@ -66,7 +66,7 @@ interface ProviderCardProps {
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
onSetAsDefault?: (modelId?: string) => void;
|
||||
}
|
||||
|
||||
/** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */
|
||||
@@ -184,6 +184,15 @@ export function ProviderCard({
|
||||
return extractApiUrl(provider, fallbackUrlText);
|
||||
}, [provider, fallbackUrlText]);
|
||||
|
||||
const openclawDefaultModelOptions = useMemo(() => {
|
||||
if (appId !== "openclaw") return [];
|
||||
const config = provider.settingsConfig as OpenClawProviderConfig;
|
||||
if (!Array.isArray(config?.models)) return [];
|
||||
return config.models
|
||||
.filter((model) => typeof model.id === "string" && model.id.trim())
|
||||
.map((model) => ({ id: model.id, name: model.name }));
|
||||
}, [appId, provider.settingsConfig]);
|
||||
|
||||
const isClickableUrl = useMemo(() => {
|
||||
if (provider.notes?.trim()) {
|
||||
return false;
|
||||
@@ -602,6 +611,7 @@ export function ProviderCard({
|
||||
onToggleFailover={onToggleFailover}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isDefaultModel}
|
||||
defaultModelOptions={openclawDefaultModelOptions}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ interface ProviderListProps {
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
|
||||
onSetAsDefault?: (provider: Provider, modelId?: string) => void; // OpenClaw: set as default model
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -438,7 +438,9 @@ export function ProviderList({
|
||||
: isProviderDefaultModel(provider.id)
|
||||
}
|
||||
onSetAsDefault={
|
||||
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
|
||||
onSetAsDefault
|
||||
? (modelId) => onSetAsDefault(provider, modelId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -570,7 +572,7 @@ interface SortableProviderCardProps {
|
||||
activeProviderId?: string;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
onSetAsDefault?: (modelId?: string) => void;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
|
||||
@@ -11,20 +11,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Download,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Download, Plus, Trash2, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ApiKeySection, ModelDropdown } from "./shared";
|
||||
import {
|
||||
@@ -80,9 +68,7 @@ export function OpenClawFormFields({
|
||||
onUserAgentChange,
|
||||
}: OpenClawFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
@@ -102,8 +88,16 @@ export function OpenClawFormFields({
|
||||
const modelKeys = getModelKeys();
|
||||
|
||||
// Toggle advanced section for a model
|
||||
const toggleModelAdvanced = (index: number) => {
|
||||
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
const toggleModelAdvanced = (modelKey: string) => {
|
||||
setExpandedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(modelKey)) {
|
||||
next.delete(modelKey);
|
||||
} else {
|
||||
next.add(modelKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Add a new model entry
|
||||
@@ -152,16 +146,18 @@ export function OpenClawFormFields({
|
||||
|
||||
// Remove a model entry
|
||||
const handleRemoveModel = (index: number) => {
|
||||
const removedKey = modelKeysRef.current[index];
|
||||
modelKeysRef.current.splice(index, 1);
|
||||
const newModels = [...models];
|
||||
newModels.splice(index, 1);
|
||||
onModelsChange(newModels);
|
||||
// Clean up expanded state
|
||||
setExpandedModels((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[index];
|
||||
return updated;
|
||||
});
|
||||
if (removedKey) {
|
||||
setExpandedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(removedKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update model field
|
||||
@@ -274,10 +270,10 @@ export function OpenClawFormFields({
|
||||
</div>
|
||||
|
||||
{/* Models Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-3 border-l border-border-default pl-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FormLabel>
|
||||
{t("openclaw.models", { defaultValue: "模型列表" })}
|
||||
{t("openclaw.models", { defaultValue: "模型配置" })}
|
||||
</FormLabel>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
@@ -309,52 +305,59 @@ export function OpenClawFormFields({
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
<p role="status" className="py-2 text-sm text-muted-foreground">
|
||||
{t("openclaw.noModels", {
|
||||
defaultValue: "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
defaultValue: "暂无模型配置",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => (
|
||||
<div
|
||||
key={modelKeys[index]}
|
||||
className="p-3 border border-border/50 rounded-lg space-y-3"
|
||||
>
|
||||
{/* Role badge */}
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
|
||||
index === 0
|
||||
? "bg-blue-500/15 text-blue-600 dark:text-blue-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{index === 0
|
||||
? t("openclaw.primaryModel", {
|
||||
defaultValue: "默认模型",
|
||||
})
|
||||
: t("openclaw.fallbackModel", {
|
||||
defaultValue: "回退模型",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{/* Model ID and Name row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<span className="w-9" />
|
||||
<span className="flex-1">
|
||||
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("openclaw.modelName", { defaultValue: "显示名称" })}
|
||||
</span>
|
||||
<span className="w-9" />
|
||||
</div>
|
||||
{models.map((model, index) => {
|
||||
const modelKey = modelKeys[index];
|
||||
const isExpanded = expandedModels.has(modelKey);
|
||||
return (
|
||||
<div key={modelKey} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleModelAdvanced(modelKey)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t("openclaw.toggleModelDetails", {
|
||||
defaultValue: "展开或收起模型详情",
|
||||
})}
|
||||
className="h-9 w-9 shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform motion-reduce:transition-none ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-1 gap-1">
|
||||
<Input
|
||||
value={model.id}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "id", e.target.value)
|
||||
onChange={(event) =>
|
||||
handleModelChange(index, "id", event.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelIdPlaceholder", {
|
||||
defaultValue: "claude-3-sonnet",
|
||||
})}
|
||||
className="flex-1"
|
||||
aria-label={t("openclaw.modelId", {
|
||||
defaultValue: "模型 ID",
|
||||
})}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
@@ -363,95 +366,63 @@ export function OpenClawFormFields({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelName", { defaultValue: "显示名称" })}
|
||||
</label>
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "name", e.target.value)
|
||||
onChange={(event) =>
|
||||
handleModelChange(index, "name", event.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelNamePlaceholder", {
|
||||
defaultValue: "Claude 3 Sonnet",
|
||||
})}
|
||||
aria-label={t("openclaw.modelName", {
|
||||
defaultValue: "显示名称",
|
||||
})}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveModel(index)}
|
||||
className="h-9 w-9 mt-5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options (Collapsible) */}
|
||||
<Collapsible
|
||||
open={expandedModels[index] ?? false}
|
||||
onOpenChange={() => toggleModelAdvanced(index)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expandedModels[index] ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("openclaw.advancedOptions", {
|
||||
defaultValue: "高级选项",
|
||||
size="icon"
|
||||
onClick={() => handleRemoveModel(index)}
|
||||
aria-label={t("openclaw.removeModel", {
|
||||
defaultValue: "移除模型",
|
||||
})}
|
||||
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-2">
|
||||
{/* Reasoning, Input Types row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.reasoning", {
|
||||
defaultValue: "推理模式",
|
||||
})}
|
||||
</label>
|
||||
<div className="flex items-center h-9 gap-2">
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-9 grid gap-3 border-l-2 border-muted pl-4 sm:grid-cols-2 animate-in fade-in-0 slide-in-from-top-1 duration-200 motion-reduce:animate-none">
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-x-8 gap-y-2 sm:col-span-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FormLabel
|
||||
htmlFor={`openclaw-model-reasoning-${modelKey}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("openclaw.reasoning", {
|
||||
defaultValue: "支持扩展思考",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id={`openclaw-model-reasoning-${modelKey}`}
|
||||
checked={model.reasoning ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModelChange(index, "reasoning", checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.reasoning
|
||||
? t("openclaw.reasoningOn", {
|
||||
defaultValue: "启用",
|
||||
})
|
||||
: t("openclaw.reasoningOff", {
|
||||
defaultValue: "关闭",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.inputTypes", {
|
||||
defaultValue: "输入类型",
|
||||
})}
|
||||
</label>
|
||||
{/* "text" is checked by default but can be unchecked —
|
||||
some models genuinely don't support text input, and
|
||||
OpenClaw works fine with an empty or image-only array. */}
|
||||
<div className="flex items-center gap-4 h-9">
|
||||
<div className="flex items-center gap-3">
|
||||
<FormLabel>
|
||||
{t("openclaw.inputTypes", {
|
||||
defaultValue: "输入类型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{(["text", "image"] as const).map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-1.5 cursor-pointer select-none"
|
||||
className="flex cursor-pointer select-none items-center gap-1.5"
|
||||
>
|
||||
<Checkbox
|
||||
checked={(model.input ?? ["text"]).includes(
|
||||
@@ -461,7 +432,7 @@ export function OpenClawFormFields({
|
||||
const current = model.input ?? ["text"];
|
||||
const next = checked
|
||||
? [...new Set([...current, type])]
|
||||
: current.filter((v) => v !== type);
|
||||
: current.filter((value) => value !== type);
|
||||
handleModelChange(index, "input", next);
|
||||
}}
|
||||
/>
|
||||
@@ -470,152 +441,178 @@ export function OpenClawFormFields({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Context Window and Max Tokens row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`openclaw-model-context-${modelKey}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("openclaw.contextWindow", {
|
||||
defaultValue: "上下文窗口",
|
||||
defaultValue: "上下文长度",
|
||||
})}
|
||||
</label>
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`openclaw-model-context-${modelKey}`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={model.contextWindow ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange={(event) =>
|
||||
handleModelChange(
|
||||
index,
|
||||
"contextWindow",
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
event.target.value
|
||||
? parseInt(event.target.value)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="200000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`openclaw-model-max-tokens-${modelKey}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("openclaw.maxTokens", {
|
||||
defaultValue: "最大输出 Tokens",
|
||||
defaultValue: "最大输出 Token 数",
|
||||
})}
|
||||
</label>
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`openclaw-model-max-tokens-${modelKey}`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={model.maxTokens ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange={(event) =>
|
||||
handleModelChange(
|
||||
index,
|
||||
"maxTokens",
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
event.target.value
|
||||
? parseInt(event.target.value)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="32000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Cost row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.inputCost", {
|
||||
defaultValue: "输入价格 ($/M tokens)",
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("openclaw.modelCost", {
|
||||
defaultValue: "成本($/百万 Token)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.input ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "input", e.target.value)
|
||||
}
|
||||
placeholder="3"
|
||||
/>
|
||||
</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`openclaw-model-input-cost-${modelKey}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("openclaw.inputCost", {
|
||||
defaultValue: "输入",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`openclaw-model-input-cost-${modelKey}`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.001"
|
||||
value={model.cost?.input ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCostChange(
|
||||
index,
|
||||
"input",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="3"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`openclaw-model-output-cost-${modelKey}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("openclaw.outputCost", {
|
||||
defaultValue: "输出",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`openclaw-model-output-cost-${modelKey}`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.001"
|
||||
value={model.cost?.output ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCostChange(
|
||||
index,
|
||||
"output",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="15"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`openclaw-model-cache-read-${modelKey}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("openclaw.cacheReadCost", {
|
||||
defaultValue: "缓存读取",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`openclaw-model-cache-read-${modelKey}`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.001"
|
||||
value={model.cost?.cacheRead ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCostChange(
|
||||
index,
|
||||
"cacheRead",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="0.3"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<FormLabel
|
||||
htmlFor={`openclaw-model-cache-write-${modelKey}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("openclaw.cacheWriteCost", {
|
||||
defaultValue: "缓存写入",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`openclaw-model-cache-write-${modelKey}`}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.001"
|
||||
value={model.cost?.cacheWrite ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCostChange(
|
||||
index,
|
||||
"cacheWrite",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="3.75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.outputCost", {
|
||||
defaultValue: "输出价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.output ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "output", e.target.value)
|
||||
}
|
||||
placeholder="15"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Cache Cost row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheReadCost", {
|
||||
defaultValue: "缓存读取价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.cacheRead ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "cacheRead", e.target.value)
|
||||
}
|
||||
placeholder="0.3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheWriteCost", {
|
||||
defaultValue: "缓存写入价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.cacheWrite ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(
|
||||
index,
|
||||
"cacheWrite",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="3.75"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheCostHint", {
|
||||
defaultValue:
|
||||
"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
})}
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelsHint", {
|
||||
defaultValue:
|
||||
"配置该供应商支持的模型。第一个模型为默认模型(Primary),其余为回退模型(Fallback)。拖拽或调整顺序可更改默认模型。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -394,7 +394,7 @@ export function useProviderActions(
|
||||
|
||||
// Set provider as default model (OpenClaw only)
|
||||
const setAsDefaultModel = useCallback(
|
||||
async (provider: Provider) => {
|
||||
async (provider: Provider, modelId?: string) => {
|
||||
const config = provider.settingsConfig as OpenClawProviderConfig;
|
||||
if (!config.models || config.models.length === 0) {
|
||||
toast.error(
|
||||
@@ -405,12 +405,31 @@ export function useProviderActions(
|
||||
return;
|
||||
}
|
||||
|
||||
const model: OpenClawDefaultModel = {
|
||||
primary: `${provider.id}/${config.models[0].id}`,
|
||||
fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),
|
||||
};
|
||||
const selectedModel = modelId
|
||||
? config.models.find((model) => model.id === modelId)
|
||||
: config.models[0];
|
||||
if (!selectedModel) {
|
||||
toast.error(
|
||||
t("notifications.openclawModelNotFound", {
|
||||
defaultValue: "所选模型已不存在,请刷新后重试",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const primary = `${provider.id}/${selectedModel.id}`;
|
||||
const existingDefault = await openclawApi.getDefaultModel();
|
||||
const model: OpenClawDefaultModel = {
|
||||
...(existingDefault ?? {}),
|
||||
primary,
|
||||
};
|
||||
if (existingDefault?.fallbacks) {
|
||||
model.fallbacks = existingDefault.fallbacks.filter(
|
||||
(fallback) => fallback !== primary,
|
||||
);
|
||||
}
|
||||
|
||||
await openclawApi.setDefaultModel(model);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.defaultModel,
|
||||
|
||||
+14
-16
@@ -272,6 +272,7 @@
|
||||
"openclawDefaultModelSet": "Set as default model",
|
||||
"openclawDefaultModelSetFailed": "Failed to set default model",
|
||||
"openclawNoModels": "No models configured",
|
||||
"openclawModelNotFound": "The selected model no longer exists. Refresh and try again.",
|
||||
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider",
|
||||
"windowControlFailed": "Window control failed: {{error}}",
|
||||
"officialBlockedByProxy": "Cannot switch to official provider while local routing is active. Using routing with official APIs may cause account bans.",
|
||||
@@ -2080,26 +2081,25 @@
|
||||
"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",
|
||||
"models": "Model Configuration",
|
||||
"addModel": "Add Model",
|
||||
"noModels": "No models configured. Click Add Model to configure available models.",
|
||||
"selectDefaultModel": "Select Default Model",
|
||||
"noModels": "No models configured",
|
||||
"modelId": "Model ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "Display Name",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "Context Window",
|
||||
"toggleModelDetails": "Expand or collapse model details",
|
||||
"removeModel": "Remove model",
|
||||
"contextWindow": "Context Length",
|
||||
"maxTokens": "Max Output Tokens",
|
||||
"reasoning": "Reasoning Mode",
|
||||
"reasoningOn": "Enabled",
|
||||
"reasoningOff": "Disabled",
|
||||
"reasoning": "Supports Extended Thinking",
|
||||
"inputTypes": "Input Types",
|
||||
"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.",
|
||||
"modelCost": "Cost ($/million tokens)",
|
||||
"inputCost": "Input",
|
||||
"outputCost": "Output",
|
||||
"cacheReadCost": "Cache Read",
|
||||
"cacheWriteCost": "Cache Write",
|
||||
"userAgent": "Send User-Agent",
|
||||
"userAgentHint": "Some providers require a browser User-Agent header to work properly.",
|
||||
"env": {
|
||||
@@ -2168,9 +2168,7 @@
|
||||
"stringifiedEnvVars": "env.vars should be an object, but the current value looks stringified or malformed.",
|
||||
"stringifiedShellEnv": "env.shellEnv should be an object, but the current value looks stringified or malformed.",
|
||||
"parseFailed": "openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here."
|
||||
},
|
||||
"primaryModel": "Primary Model",
|
||||
"fallbackModel": "Fallback Model"
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"form": {
|
||||
|
||||
+14
-16
@@ -272,6 +272,7 @@
|
||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
|
||||
"openclawNoModels": "モデルが設定されていません",
|
||||
"openclawModelNotFound": "選択したモデルは存在しません。更新してからもう一度お試しください。",
|
||||
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました",
|
||||
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}",
|
||||
"officialBlockedByProxy": "ローカルルーティングモード中は公式プロバイダーに切り替えできません。ルーティング経由で公式 API にアクセスするとアカウントが停止される可能性があります。",
|
||||
@@ -2080,26 +2081,25 @@
|
||||
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
|
||||
"baseUrl": "API エンドポイント",
|
||||
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
|
||||
"models": "モデル一覧",
|
||||
"models": "モデル設定",
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
|
||||
"selectDefaultModel": "デフォルトモデルを選択",
|
||||
"noModels": "モデルが設定されていません",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "表示名",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"toggleModelDetails": "モデル詳細を展開または折りたたむ",
|
||||
"removeModel": "モデルを削除",
|
||||
"contextWindow": "コンテキスト長",
|
||||
"maxTokens": "最大出力トークン数",
|
||||
"reasoning": "推論モード",
|
||||
"reasoningOn": "有効",
|
||||
"reasoningOff": "無効",
|
||||
"reasoning": "拡張思考に対応",
|
||||
"inputTypes": "入力タイプ",
|
||||
"inputCost": "入力コスト ($/M トークン)",
|
||||
"outputCost": "出力コスト ($/M トークン)",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
|
||||
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
|
||||
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
|
||||
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。",
|
||||
"modelCost": "コスト($/100万トークン)",
|
||||
"inputCost": "入力",
|
||||
"outputCost": "出力",
|
||||
"cacheReadCost": "キャッシュ読み取り",
|
||||
"cacheWriteCost": "キャッシュ書き込み",
|
||||
"userAgent": "User-Agent を送信",
|
||||
"userAgentHint": "一部のプロバイダーはブラウザの User-Agent ヘッダーが必要です。",
|
||||
"env": {
|
||||
@@ -2168,9 +2168,7 @@
|
||||
"stringifiedEnvVars": "env.vars はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。",
|
||||
"stringifiedShellEnv": "env.shellEnv はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。",
|
||||
"parseFailed": "openclaw.json を有効な JSON5 として解析できませんでした。ここで編集する前にファイルを修正してください。"
|
||||
},
|
||||
"primaryModel": "プライマリモデル",
|
||||
"fallbackModel": "フォールバックモデル"
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"form": {
|
||||
|
||||
+15
-17
@@ -272,6 +272,7 @@
|
||||
"openclawDefaultModelSet": "已設為預設模型",
|
||||
"openclawDefaultModelSetFailed": "設定預設模型失敗",
|
||||
"openclawNoModels": "該供應商沒有設定模型",
|
||||
"openclawModelNotFound": "所選模型已不存在,請重新整理後再試",
|
||||
"backfillWarning": "切換成功,但舊供應商設定回填失敗,您手動修改的設定可能未儲存",
|
||||
"windowControlFailed": "視窗控制失敗:{{error}}",
|
||||
"officialBlockedByProxy": "本地路由模式下不能切換至官方供應商,使用路由存取官方 API 可能導致帳號被封鎖",
|
||||
@@ -2081,26 +2082,25 @@
|
||||
"apiProtocolHint": "選擇與供應商 API 相容的協定類型。大多數供應商使用 OpenAI Completions 格式。",
|
||||
"baseUrl": "API 端點",
|
||||
"baseUrlHint": "供應商的 API 端點位址。",
|
||||
"models": "模型清單",
|
||||
"models": "模型設定",
|
||||
"addModel": "新增模型",
|
||||
"noModels": "暫無模型設定。點擊新增模型來設定可用模型。",
|
||||
"selectDefaultModel": "選擇預設模型",
|
||||
"noModels": "暫無模型設定",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "顯示名稱",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "上下文視窗",
|
||||
"maxTokens": "最大輸出 Tokens",
|
||||
"reasoning": "推理模式",
|
||||
"reasoningOn": "啟用",
|
||||
"reasoningOff": "關閉",
|
||||
"toggleModelDetails": "展開或收合模型詳情",
|
||||
"removeModel": "移除模型",
|
||||
"contextWindow": "上下文長度",
|
||||
"maxTokens": "最大輸出 Token 數",
|
||||
"reasoning": "支援擴展思考",
|
||||
"inputTypes": "輸入類型",
|
||||
"inputCost": "輸入價格 ($/M tokens)",
|
||||
"outputCost": "輸出價格 ($/M tokens)",
|
||||
"advancedOptions": "進階選項",
|
||||
"cacheReadCost": "快取讀取價格 ($/M tokens)",
|
||||
"cacheWriteCost": "快取寫入價格 ($/M tokens)",
|
||||
"cacheCostHint": "快取價格用於計算 Prompt Caching 的成本。如不使用快取可留空。",
|
||||
"modelsHint": "設定該供應商支援的模型。模型 ID 用於 API 呼叫,顯示名稱用於介面展示。",
|
||||
"modelCost": "成本($/百萬 Token)",
|
||||
"inputCost": "輸入",
|
||||
"outputCost": "輸出",
|
||||
"cacheReadCost": "快取讀取",
|
||||
"cacheWriteCost": "快取寫入",
|
||||
"userAgent": "發送 User-Agent",
|
||||
"userAgentHint": "部分供應商需要瀏覽器 User-Agent 才能正常存取。",
|
||||
"env": {
|
||||
@@ -2169,9 +2169,7 @@
|
||||
"stringifiedEnvVars": "env.vars 應為物件,但目前值看起來像被字串化或已損壞。",
|
||||
"stringifiedShellEnv": "env.shellEnv 應為物件,但目前值看起來像被字串化或已損壞。",
|
||||
"parseFailed": "openclaw.json 不是合法 JSON5。請先修復檔案,再透過這裡編輯。"
|
||||
},
|
||||
"primaryModel": "預設模型",
|
||||
"fallbackModel": "備用模型"
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"form": {
|
||||
|
||||
+15
-17
@@ -272,6 +272,7 @@
|
||||
"openclawDefaultModelSet": "已设为默认模型",
|
||||
"openclawDefaultModelSetFailed": "设置默认模型失败",
|
||||
"openclawNoModels": "该供应商没有配置模型",
|
||||
"openclawModelNotFound": "所选模型已不存在,请刷新后重试",
|
||||
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
|
||||
"windowControlFailed": "窗口控制失败:{{error}}",
|
||||
"officialBlockedByProxy": "本地路由模式下不能切换到官方供应商,使用路由访问官方 API 可能导致账号被封禁",
|
||||
@@ -2080,26 +2081,25 @@
|
||||
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
"baseUrl": "API 端点",
|
||||
"baseUrlHint": "供应商的 API 端点地址。",
|
||||
"models": "模型列表",
|
||||
"models": "模型配置",
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
"selectDefaultModel": "选择默认模型",
|
||||
"noModels": "暂无模型配置",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "显示名称",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "上下文窗口",
|
||||
"maxTokens": "最大输出 Tokens",
|
||||
"reasoning": "推理模式",
|
||||
"reasoningOn": "启用",
|
||||
"reasoningOff": "关闭",
|
||||
"toggleModelDetails": "展开或收起模型详情",
|
||||
"removeModel": "移除模型",
|
||||
"contextWindow": "上下文长度",
|
||||
"maxTokens": "最大输出 Token 数",
|
||||
"reasoning": "支持扩展思考",
|
||||
"inputTypes": "输入类型",
|
||||
"inputCost": "输入价格 ($/M tokens)",
|
||||
"outputCost": "输出价格 ($/M tokens)",
|
||||
"advancedOptions": "高级选项",
|
||||
"cacheReadCost": "缓存读取价格 ($/M tokens)",
|
||||
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
|
||||
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
|
||||
"modelCost": "成本($/百万 Token)",
|
||||
"inputCost": "输入",
|
||||
"outputCost": "输出",
|
||||
"cacheReadCost": "缓存读取",
|
||||
"cacheWriteCost": "缓存写入",
|
||||
"userAgent": "发送 User-Agent",
|
||||
"userAgentHint": "部分供应商需要浏览器 User-Agent 才能正常访问。",
|
||||
"env": {
|
||||
@@ -2168,9 +2168,7 @@
|
||||
"stringifiedEnvVars": "env.vars 应为对象,但当前值看起来像被字符串化或已损坏。",
|
||||
"stringifiedShellEnv": "env.shellEnv 应为对象,但当前值看起来像被字符串化或已损坏。",
|
||||
"parseFailed": "openclaw.json 不是合法 JSON5。请先修复文件,再通过这里编辑。"
|
||||
},
|
||||
"primaryModel": "默认模型",
|
||||
"fallbackModel": "回退模型"
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"form": {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { OpenClawFormFields } from "@/components/providers/forms/OpenClawFormFields";
|
||||
|
||||
const models = [
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 15,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 3.125,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gpt-5.6-luna",
|
||||
name: "GPT-5.6 Luna",
|
||||
input: ["text"],
|
||||
},
|
||||
];
|
||||
|
||||
function renderForm() {
|
||||
const onModelsChange = vi.fn();
|
||||
function TestForm() {
|
||||
const form = useForm();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<OpenClawFormFields
|
||||
baseUrl="https://api.example.com/v1"
|
||||
onBaseUrlChange={vi.fn()}
|
||||
apiKey="test-key"
|
||||
onApiKeyChange={vi.fn()}
|
||||
shouldShowApiKeyLink={false}
|
||||
websiteUrl=""
|
||||
api="openai-responses"
|
||||
onApiChange={vi.fn()}
|
||||
models={models}
|
||||
onModelsChange={onModelsChange}
|
||||
userAgent={false}
|
||||
onUserAgentChange={vi.fn()}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
render(<TestForm />);
|
||||
return { onModelsChange };
|
||||
}
|
||||
|
||||
describe("OpenClawFormFields model editor", () => {
|
||||
it("uses the family model-row layout without inferred model roles", () => {
|
||||
renderForm();
|
||||
|
||||
expect(screen.getByText("模型配置")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("模型 ID")).toHaveLength(1);
|
||||
expect(screen.getAllByText("显示名称")).toHaveLength(1);
|
||||
expect(screen.queryByText("默认模型")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("回退模型")).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("gpt-5.6-sol")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("GPT-5.6 Luna")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals native OpenClaw model details from the row chevron", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderForm();
|
||||
|
||||
const toggles = screen.getAllByRole("button", {
|
||||
name: "展开或收起模型详情",
|
||||
});
|
||||
await user.click(toggles[0]);
|
||||
|
||||
expect(screen.getByText("支持扩展思考")).toBeInTheDocument();
|
||||
expect(screen.getByText("输入类型")).toBeInTheDocument();
|
||||
expect(screen.getByText("上下文长度")).toBeInTheDocument();
|
||||
expect(screen.getByText("最大输出 Token 数")).toBeInTheDocument();
|
||||
expect(screen.getByText("成本($/百万 Token)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
|
||||
describe("ProviderActions OpenClaw default model selection", () => {
|
||||
it("asks which model to use when a provider has multiple models", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSetAsDefault = vi.fn();
|
||||
|
||||
render(
|
||||
<ProviderActions
|
||||
appId="openclaw"
|
||||
isCurrent={false}
|
||||
isInConfig
|
||||
isDefaultModel={false}
|
||||
defaultModelOptions={[
|
||||
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
|
||||
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna" },
|
||||
]}
|
||||
onSwitch={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /设为默认/ }));
|
||||
await user.click(screen.getByText("GPT-5.6 Luna"));
|
||||
|
||||
expect(onSetAsDefault).toHaveBeenCalledWith("gpt-5.6-luna");
|
||||
});
|
||||
|
||||
it("uses the only configured model without opening a picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSetAsDefault = vi.fn();
|
||||
|
||||
render(
|
||||
<ProviderActions
|
||||
appId="openclaw"
|
||||
isCurrent={false}
|
||||
isInConfig
|
||||
isDefaultModel={false}
|
||||
defaultModelOptions={[{ id: "claude-opus-5", name: "Claude Opus 5" }]}
|
||||
onSwitch={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "设为默认" }));
|
||||
|
||||
expect(onSetAsDefault).toHaveBeenCalledWith("claude-opus-5");
|
||||
});
|
||||
});
|
||||
@@ -720,7 +720,7 @@ describe("useProviderActions", () => {
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show backup details when setting OpenClaw default model", async () => {
|
||||
it("sets the first OpenClaw model without inventing a fallback chain", async () => {
|
||||
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
|
||||
backupPath: "/tmp/openclaw-backup.json5",
|
||||
warnings: [],
|
||||
@@ -743,11 +743,42 @@ describe("useProviderActions", () => {
|
||||
|
||||
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
|
||||
primary: "provider-1/gpt-4.1",
|
||||
fallbacks: ["provider-1/gpt-4.1-mini"],
|
||||
});
|
||||
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: true });
|
||||
});
|
||||
|
||||
it("sets the explicitly selected OpenClaw model and preserves existing fallbacks", async () => {
|
||||
openclawApiGetDefaultModelMock.mockResolvedValueOnce({
|
||||
primary: "other/old-primary",
|
||||
fallbacks: ["provider-1/gpt-4.1-mini", "other/fallback"],
|
||||
customPolicy: "preserve-me",
|
||||
});
|
||||
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
settingsConfig: {
|
||||
models: [{ id: "gpt-4.1" }, { id: "gpt-4.1-mini" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProviderActions("openclaw"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setAsDefaultModel(provider, "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
|
||||
primary: "provider-1/gpt-4.1-mini",
|
||||
fallbacks: ["other/fallback"],
|
||||
customPolicy: "preserve-me",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("clears loading flag when all mutations idle", () => {
|
||||
addProviderMutation.isPending = false;
|
||||
|
||||
Reference in new issue
Block a user