Merge branch 'main' into feat/smart-url-path-detection

# Conflicts:
#	src/App.tsx
#	src/hooks/useProviderActions.ts
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
This commit is contained in:
YoVinchen
2026-02-15 13:48:28 +08:00
112 changed files with 11858 additions and 585 deletions
+28 -4
View File
@@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps {
@@ -24,7 +25,10 @@ interface AddProviderDialogProps {
onOpenChange: (open: boolean) => void;
appId: AppId;
onSubmit: (
provider: Omit<Provider, "id"> & { providerKey?: string },
provider: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
},
) => Promise<void> | void;
}
@@ -35,7 +39,8 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
const showUniversalTab = appId !== "opencode";
// OpenCode and OpenClaw don't support universal providers
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
@@ -82,7 +87,11 @@ export function AddProviderDialog({
unknown
>;
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
// 构造基础提交数据
const providerData: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
} = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -93,7 +102,11 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
if (appId === "opencode" && values.providerKey) {
// OpenCode/OpenClaw: pass providerKey for ID generation
if (
(appId === "opencode" || appId === "openclaw") &&
values.providerKey
) {
providerData.providerKey = values.providerKey;
}
@@ -185,6 +198,11 @@ export function AddProviderDialog({
if (options?.baseURL) {
addUrl(options.baseURL);
}
} else if (appId === "openclaw") {
// OpenClaw uses baseUrl directly
if (parsedConfig.baseUrl) {
addUrl(parsedConfig.baseUrl as string);
}
}
const urls = Array.from(urlSet);
@@ -206,6 +224,11 @@ export function AddProviderDialog({
}
}
// OpenClaw: pass suggestedDefaults for model registration
if (appId === "openclaw" && values.suggestedDefaults) {
providerData.suggestedDefaults = values.suggestedDefaults;
}
await onSubmit(providerData);
onOpenChange(false);
},
@@ -286,6 +309,7 @@ export function AddProviderDialog({
</TabsContent>
</Tabs>
) : (
// OpenCode/OpenClaw: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
+41 -7
View File
@@ -10,6 +10,7 @@ import {
Terminal,
TestTube2,
Trash2,
Zap,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -36,6 +37,9 @@ interface ProviderActionsProps {
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
export function ProviderActions({
@@ -58,14 +62,20 @@ export function ProviderActions({
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
// OpenClaw: default model
isDefaultModel = false,
onSetAsDefault,
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
const isOpenCodeMode = appId === "opencode" && !isOmo;
// 累加模式应用(OpenCode 非 OMO 和 OpenClaw
const isAdditiveMode =
(appId === "opencode" && !isOmo) || appId === "openclaw";
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
const isFailoverMode =
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
const handleMainButtonClick = () => {
if (isOmo) {
@@ -74,7 +84,8 @@ export function ProviderActions({
} else {
onSwitch();
}
} else if (isOpenCodeMode) {
} else if (isAdditiveMode) {
// 累加模式:切换配置状态(添加/移除)
if (isInConfig) {
if (onRemoveFromConfig) {
onRemoveFromConfig();
@@ -112,13 +123,16 @@ export function ProviderActions({
};
}
if (isOpenCodeMode) {
// 累加模式(OpenCode 非 OMO / OpenClaw
if (isAdditiveMode) {
if (isInConfig) {
return {
disabled: false,
disabled: isDefaultModel === true,
variant: "secondary" as const,
className:
className: cn(
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
isDefaultModel && "opacity-40 cursor-not-allowed",
),
icon: <Minus className="h-4 w-4" />,
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
};
@@ -180,12 +194,32 @@ export function ProviderActions({
const canDelete = isOmo
? !(isLastOmo && isCurrent)
: isOpenCodeMode
: isAdditiveMode
? true
: !isCurrent;
return (
<div className="flex items-center gap-1.5">
{appId === "openclaw" && isInConfig && onSetAsDefault && (
<Button
size="sm"
variant={isDefaultModel ? "secondary" : "default"}
onClick={isDefaultModel ? undefined : onSetAsDefault}
disabled={isDefaultModel}
className={cn(
"w-[4.5rem] 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",
)}
>
<Zap className="h-4 w-4" />
{isDefaultModel
? t("provider.isDefault", { defaultValue: "默认" })
: t("provider.setAsDefault", { defaultValue: "启用" })}
</Button>
)}
<Button
size="sm"
variant={buttonState.variant}
+19 -2
View File
@@ -48,6 +48,9 @@ interface ProviderCardProps {
isInFailoverQueue?: boolean; // 是否在故障转移队列中
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
@@ -108,6 +111,9 @@ export function ProviderCard({
isInFailoverQueue = false,
onToggleFailover,
activeProviderId,
// OpenClaw: default model
isDefaultModel,
onSetAsDefault,
}: ProviderCardProps) {
const { t } = useTranslation();
@@ -133,7 +139,10 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
const shouldAutoQuery =
appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
@@ -176,9 +185,14 @@ export function ProviderCard({
onOpenWebsite(displayUrl);
};
// 判断是否是"当前使用中"的供应商
// - OMO 供应商:使用 isCurrent
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 普通模式:isCurrent
const isActiveProvider = isOmo
? isCurrent
: appId === "opencode"
: appId === "opencode" || appId === "openclaw"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
@@ -378,6 +392,9 @@ export function ProviderCard({
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
// OpenClaw: default model
isDefaultModel={isDefaultModel}
onSetAsDefault={onSetAsDefault}
/>
</div>
</div>
+51 -6
View File
@@ -20,6 +20,11 @@ import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
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 {
@@ -51,6 +56,7 @@ interface ProviderListProps {
isProxyRunning?: boolean; // 代理服务运行状态
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
}
export function ProviderList({
@@ -71,6 +77,7 @@ export function ProviderList({
isProxyRunning = false,
isProxyTakeover = false,
activeProviderId,
onSetAsDefault,
}: ProviderListProps) {
const { t } = useTranslation();
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
@@ -84,14 +91,39 @@ export function ProviderList({
enabled: appId === "opencode",
});
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
return opencodeLiveIds?.includes(providerId) ?? false;
},
[appId, opencodeLiveIds],
// OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: openclawLiveIds } = useOpenClawLiveProviderIds(
appId === "openclaw",
);
// 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId === "opencode") {
return opencodeLiveIds?.includes(providerId) ?? false;
}
if (appId === "openclaw") {
return openclawLiveIds?.includes(providerId) ?? false;
}
return true; // 其他应用始终返回 true
},
[appId, opencodeLiveIds, openclawLiveIds],
);
// OpenClaw: query default model to determine which provider is default
const { data: openclawDefaultModel } = useOpenClawDefaultModel(
appId === "openclaw",
);
const isProviderDefaultModel = useCallback(
(providerId: string): boolean => {
if (appId !== "openclaw" || !openclawDefaultModel?.primary) return false;
return openclawDefaultModel.primary.startsWith(providerId + "/");
},
[appId, openclawDefaultModel],
);
// 故障转移相关
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
@@ -240,6 +272,11 @@ export function ProviderList({
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
// OpenClaw: default model
isDefaultModel={isProviderDefaultModel(provider.id)}
onSetAsDefault={
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
}
/>
);
})}
@@ -352,6 +389,9 @@ interface SortableProviderCardProps {
isInFailoverQueue: boolean;
onToggleFailover: (enabled: boolean) => void;
activeProviderId?: string;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
function SortableProviderCard({
@@ -379,6 +419,8 @@ function SortableProviderCard({
isInFailoverQueue,
onToggleFailover,
activeProviderId,
isDefaultModel,
onSetAsDefault,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -428,6 +470,9 @@ function SortableProviderCard({
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
activeProviderId={activeProviderId}
// OpenClaw: default model
isDefaultModel={isDefaultModel}
onSetAsDefault={onSetAsDefault}
/>
</div>
);
@@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
claude: 8,
gemini: 8,
opencode: 8,
openclaw: 8,
};
interface TestResult {
+267 -89
View File
@@ -12,6 +12,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Plus,
Trash2,
@@ -21,6 +34,10 @@ import {
Settings,
FolderInput,
Loader2,
HelpCircle,
Check,
ChevronsUpDown,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@@ -43,6 +60,13 @@ const ADVANCED_PLACEHOLDER = `{
interface OmoFormFieldsProps {
modelOptions: Array<{ value: string; label: string }>;
modelVariantsMap?: Record<string, string[]>;
presetMetaMap?: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>;
agents: Record<string, Record<string, unknown>>;
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
categories: Record<string, Record<string, unknown>>;
@@ -53,19 +77,149 @@ interface OmoFormFieldsProps {
onOtherFieldsStrChange: (value: string) => void;
}
type CustomModelItem = { key: string; model: string };
export type CustomModelItem = {
key: string;
model: string;
sourceKey?: string;
};
type BuiltinModelDef = Pick<
OmoAgentDef | OmoCategoryDef,
"key" | "display" | "descZh" | "descEn" | "recommended"
"key" | "display" | "descKey" | "recommended" | "tooltipKey"
>;
type ModelOption = { value: string; label: string };
function DeferredKeyInput({
value,
onCommit,
placeholder,
className,
}: {
value: string;
onCommit: (value: string) => void;
placeholder?: string;
className?: string;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
return (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== value) {
onCommit(draft);
}
}}
placeholder={placeholder}
className={className}
/>
);
}
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__";
const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__";
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__";
function ModelCombobox({
value,
options,
recommended,
onChange,
}: {
value: string;
options: ModelOption[];
recommended?: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => o.value === value)?.label;
const selectModelText = t("omo.selectModel", {
defaultValue: "Select configured model",
});
const placeholderText = recommended
? `${selectModelText} (${t("omo.recommendedHint", { model: recommended, defaultValue: "Recommended: {{model}}" })})`
: selectModelText;
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
className="flex flex-1 h-8 items-center justify-between whitespace-nowrap rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus:outline-none focus-visible:outline-none focus:border-border-default focus-visible:border-border-default focus:ring-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{selectedLabel || placeholderText}
</span>
<span className="flex items-center shrink-0 ml-1 gap-0.5">
{value && (
<X
className="h-3.5 w-3.5 opacity-50 hover:opacity-100 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
/>
)}
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50" />
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
sideOffset={6}
avoidCollisions={true}
collisionPadding={8}
className="z-[1000] w-[var(--radix-popover-trigger-width)] p-0 border-border-default"
>
<Command>
<CommandInput
placeholder={t("omo.searchModel", {
defaultValue: "Search model...",
})}
/>
<CommandList>
<CommandEmpty>
{t("omo.noEnabledModels", {
defaultValue: "No configured models",
})}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label]}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function getAdvancedStr(config: Record<string, unknown> | undefined): string {
if (!config) return "";
@@ -86,24 +240,58 @@ function collectCustomModels(
customs.push({
key: k,
model: ((v as Record<string, unknown>).model as string) || "",
sourceKey: k,
});
}
}
return customs;
}
function mergeCustomModelsIntoStore(
export function mergeCustomModelsIntoStore(
store: Record<string, Record<string, unknown>>,
builtinKeys: Set<string>,
customs: CustomModelItem[],
modelVariantsMap: Record<string, string[]>,
): Record<string, Record<string, unknown>> {
const updated = { ...store };
for (const key of Object.keys(updated)) {
if (!builtinKeys.has(key)) delete updated[key];
const updated: Record<string, Record<string, unknown>> = {};
for (const [key, value] of Object.entries(store)) {
if (builtinKeys.has(key)) {
updated[key] = { ...value };
}
}
for (const custom of customs) {
if (custom.key.trim()) {
updated[custom.key] = { ...updated[custom.key], model: custom.model };
const targetKey = custom.key.trim();
if (!targetKey) continue;
const sourceKey = (custom.sourceKey || targetKey).trim();
const sourceEntry = store[sourceKey] ?? store[targetKey];
const nextEntry = {
...(updated[targetKey] || {}),
...(sourceEntry || {}),
};
if (custom.model.trim()) {
nextEntry.model = custom.model;
const currentVariant =
typeof nextEntry.variant === "string" ? nextEntry.variant : "";
if (currentVariant) {
const validVariants = modelVariantsMap[custom.model] || [];
if (!validVariants.includes(currentVariant)) {
delete nextEntry.variant;
}
}
updated[targetKey] = nextEntry;
continue;
}
delete nextEntry.model;
delete nextEntry.variant;
if (Object.keys(nextEntry).length > 0) {
updated[targetKey] = nextEntry;
} else {
delete updated[targetKey];
}
}
return updated;
@@ -112,6 +300,7 @@ function mergeCustomModelsIntoStore(
export function OmoFormFields({
modelOptions,
modelVariantsMap = {},
presetMetaMap: _presetMetaMap = {},
agents,
onAgentsChange,
categories,
@@ -119,8 +308,7 @@ export function OmoFormFields({
otherFieldsStr,
onOtherFieldsStrChange,
}: OmoFormFieldsProps) {
const { t, i18n } = useTranslation();
const isZh = i18n.language?.startsWith("zh");
const { t } = useTranslation();
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
@@ -159,19 +347,29 @@ export function OmoFormFields({
const syncCustomAgents = useCallback(
(customs: CustomModelItem[]) => {
onAgentsChange(
mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs),
mergeCustomModelsIntoStore(
agents,
BUILTIN_AGENT_KEYS,
customs,
modelVariantsMap,
),
);
},
[agents, onAgentsChange],
[agents, onAgentsChange, modelVariantsMap],
);
const syncCustomCategories = useCallback(
(customs: CustomModelItem[]) => {
onCategoriesChange(
mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs),
mergeCustomModelsIntoStore(
categories,
BUILTIN_CATEGORY_KEYS,
customs,
modelVariantsMap,
),
);
},
[categories, onCategoriesChange],
[categories, onCategoriesChange, modelVariantsMap],
);
const buildEffectiveModelOptions = useCallback(
@@ -212,43 +410,16 @@ export function OmoFormFields({
const renderModelSelect = (
currentModel: string,
onChange: (value: string) => void,
placeholder?: string,
recommended?: string,
) => {
const options = buildEffectiveModelOptions(currentModel);
return (
<Select
value={currentModel || EMPTY_MODEL_VALUE}
onValueChange={(value) =>
onChange(value === EMPTY_MODEL_VALUE ? "" : value)
}
>
<SelectTrigger className="flex-1 h-8 text-sm">
<SelectValue
placeholder={
placeholder ||
t("omo.selectEnabledModel", {
defaultValue: "Select enabled model",
})
}
/>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_MODEL_VALUE}>
{t("omo.clearWrapped", { defaultValue: "(Clear)" })}
</SelectItem>
{options.length === 0 ? (
<SelectItem value={UNAVAILABLE_MODEL_VALUE} disabled>
{t("omo.noEnabledModels", { defaultValue: "No enabled models" })}
</SelectItem>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
<ModelCombobox
value={currentModel}
options={options}
recommended={recommended}
onChange={onChange}
/>
);
};
@@ -268,11 +439,21 @@ export function OmoFormFields({
currentVariant: string,
onChange: (value: string) => void,
) => {
const hasModel = Boolean(currentModel);
const modelVariantKeys = hasModel
? modelVariantsMap[currentModel] || []
: [];
const hasVariants = modelVariantKeys.length > 0;
const shouldShow = hasModel && (hasVariants || Boolean(currentVariant));
if (!shouldShow) {
return null;
}
const variantOptions = buildEffectiveVariantOptions(
currentModel,
currentVariant,
);
const hasModel = Boolean(currentModel);
const firstIsUnavailable =
Boolean(currentVariant) &&
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
@@ -283,9 +464,8 @@ export function OmoFormFields({
onValueChange={(value) =>
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
}
disabled={!hasModel}
>
<SelectTrigger className="w-32 h-8 text-xs shrink-0">
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
<SelectValue
placeholder={t("omo.variantPlaceholder", {
defaultValue: "variant",
@@ -296,30 +476,16 @@ export function OmoFormFields({
<SelectItem value={EMPTY_VARIANT_VALUE}>
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
</SelectItem>
{!hasModel ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.selectModelFirst", {
defaultValue: "Select model first",
})}
{variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
</SelectItem>
) : variantOptions.length === 0 ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.noVariantsForModel", {
defaultValue: "No variants for model",
})}
</SelectItem>
) : (
variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
</SelectItem>
))
)}
))}
</SelectContent>
</Select>
);
@@ -536,7 +702,7 @@ export function OmoFormFields({
toast.warning(
t("omo.noEnabledModelsWarning", {
defaultValue:
"No enabled models available. Configure and enable OpenCode models first.",
"No configured models available. Configure OpenCode models first.",
}),
);
return;
@@ -641,9 +807,17 @@ export function OmoFormFields({
<div key={key} className="border-b border-border/30 last:border-b-0">
<div className="flex items-center gap-2 py-1.5">
<div className="w-32 shrink-0">
<div className="text-sm font-medium">{def.display}</div>
<div className="flex items-center gap-1 text-sm font-medium">
{def.display}
<span className="relative inline-flex group/tip">
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground cursor-help shrink-0" />
<span className="invisible opacity-0 group-hover/tip:visible group-hover/tip:opacity-100 transition-opacity duration-150 absolute left-0 top-full mt-1 z-50 w-[260px] rounded-md bg-popover text-popover-foreground border border-border shadow-md px-3 py-2 text-xs leading-relaxed font-normal pointer-events-none">
{t(def.tooltipKey)}
</span>
</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{isZh ? def.descZh : def.descEn}
{t(def.descKey)}
</div>
</div>
{renderModelSelect(
@@ -727,16 +901,14 @@ export function OmoFormFields({
className="border-b border-border/30 last:border-b-0"
>
<div className="flex items-center gap-2 py-1.5">
<Input
<DeferredKeyInput
value={item.key}
onChange={(e) => updateCustom({ key: e.target.value })}
onCommit={(value) => updateCustom({ key: value })}
placeholder={keyPlaceholder}
className="w-32 shrink-0 h-8 text-sm text-primary"
/>
{renderModelSelect(
item.model,
(value) => updateCustom({ model: value }),
t("omo.modelNamePlaceholder", { defaultValue: "model-name" }),
{renderModelSelect(item.model, (value) =>
updateCustom({ model: value }),
)}
{renderVariantSelect(item.model, currentVariant, (value) => {
if (!item.key) return;
@@ -877,11 +1049,17 @@ export function OmoFormFields({
const addCustomModel = (scope: AdvancedScope) => {
if (scope === "agent") {
setCustomAgents((prev) => [...prev, { key: "", model: "" }]);
setCustomAgents((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setSubAgentsOpen(true);
return;
}
setCustomCategories((prev) => [...prev, { key: "", model: "" }]);
setCustomCategories((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setCategoriesOpen(true);
};
@@ -931,7 +1109,7 @@ export function OmoFormFields({
·{" "}
{t("omo.enabledModelsCount", {
count: modelOptions.length,
defaultValue: "{{count}} enabled models available",
defaultValue: "{{count}} configured models available",
})}
</span>
{localFilePath && (
@@ -0,0 +1,472 @@
import { useTranslation } from "react-i18next";
import { useState, useRef, useCallback } from "react";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { ApiKeySection } from "./shared";
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
import type { ProviderCategory, OpenClawModel } from "@/types";
interface OpenClawFormFieldsProps {
// Base URL
baseUrl: string;
onBaseUrlChange: (value: string) => void;
// API Key
apiKey: string;
onApiKeyChange: (value: string) => void;
category?: ProviderCategory;
shouldShowApiKeyLink: boolean;
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
// API Protocol
api: string;
onApiChange: (value: string) => void;
// Models
models: OpenClawModel[];
onModelsChange: (models: OpenClawModel[]) => void;
}
export function OpenClawFormFields({
baseUrl,
onBaseUrlChange,
apiKey,
onApiKeyChange,
category,
shouldShowApiKeyLink,
websiteUrl,
isPartner,
partnerPromotionKey,
api,
onApiChange,
models,
onModelsChange,
}: OpenClawFormFieldsProps) {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
{},
);
// 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] }));
};
// Add a new model entry
const handleAddModel = () => {
modelKeysRef.current.push(crypto.randomUUID());
onModelsChange([
...models,
{
id: "",
name: "",
contextWindow: undefined,
maxTokens: undefined,
cost: undefined,
},
]);
};
// Remove a model entry
const handleRemoveModel = (index: number) => {
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;
});
};
// Update model field
const handleModelChange = (
index: number,
field: keyof OpenClawModel,
value: unknown,
) => {
const newModels = [...models];
newModels[index] = { ...newModels[index], [field]: value };
onModelsChange(newModels);
};
// Update model cost
const handleCostChange = (
index: number,
costField: "input" | "output" | "cacheRead" | "cacheWrite",
value: string,
) => {
const newModels = [...models];
const numValue = parseFloat(value);
const currentCost = newModels[index].cost || { input: 0, output: 0 };
newModels[index] = {
...newModels[index],
cost: {
...currentCost,
[costField]: isNaN(numValue) ? undefined : numValue,
},
};
onModelsChange(newModels);
};
return (
<>
{/* API Protocol Selector */}
<div className="space-y-2">
<FormLabel htmlFor="openclaw-api">
{t("openclaw.apiProtocol", {
defaultValue: "API 协议",
})}
</FormLabel>
<Select value={api} onValueChange={onApiChange}>
<SelectTrigger id="openclaw-api">
<SelectValue
placeholder={t("openclaw.selectProtocol", {
defaultValue: "选择 API 协议",
})}
/>
</SelectTrigger>
<SelectContent>
{openclawApiProtocols.map((protocol) => (
<SelectItem key={protocol.value} value={protocol.value}>
{protocol.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("openclaw.apiProtocolHint", {
defaultValue:
"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
})}
</p>
</div>
{/* Base URL */}
<div className="space-y-2">
<FormLabel htmlFor="openclaw-baseurl">
{t("openclaw.baseUrl", { defaultValue: "API 端点" })}
</FormLabel>
<Input
id="openclaw-baseurl"
value={baseUrl}
onChange={(e) => onBaseUrlChange(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground">
{t("openclaw.baseUrlHint", {
defaultValue: "供应商的 API 端点地址。",
})}
</p>
</div>
{/* API Key */}
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
/>
{/* Models Editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>
{t("openclaw.models", { defaultValue: "模型列表" })}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("openclaw.addModel", { defaultValue: "添加模型" })}
</Button>
</div>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("openclaw.noModels", {
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"
>
{/* 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>
<Input
value={model.id}
onChange={(e) =>
handleModelChange(index, "id", e.target.value)
}
placeholder={t("openclaw.modelIdPlaceholder", {
defaultValue: "claude-3-sonnet",
})}
/>
</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)
}
placeholder={t("openclaw.modelNamePlaceholder", {
defaultValue: "Claude 3 Sonnet",
})}
/>
</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: "高级选项",
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{/* Context Window, Max Tokens and Reasoning row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.contextWindow", {
defaultValue: "上下文窗口",
})}
</label>
<Input
type="number"
value={model.contextWindow ?? ""}
onChange={(e) =>
handleModelChange(
index,
"contextWindow",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="200000"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.maxTokens", {
defaultValue: "最大输出 Tokens",
})}
</label>
<Input
type="number"
value={model.maxTokens ?? ""}
onChange={(e) =>
handleModelChange(
index,
"maxTokens",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="32000"
/>
</div>
<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">
<Switch
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>
{/* 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)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.input ?? ""}
onChange={(e) =>
handleCostChange(index, "input", e.target.value)
}
placeholder="3"
/>
</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>
)}
<p className="text-xs text-muted-foreground">
{t("openclaw.modelsHint", {
defaultValue:
"配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
})}
</p>
</div>
</>
);
}
+560 -75
View File
@@ -16,6 +16,7 @@ import type {
ClaudeApiFormat,
OpenCodeModel,
OpenCodeProviderConfig,
OpenClawModel,
} from "@/types";
import {
providerPresets,
@@ -31,9 +32,16 @@ import {
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
OPENCODE_PRESET_MODEL_VARIANTS,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import {
openclawProviderPresets,
type OpenClawProviderPreset,
type OpenClawSuggestedDefaults,
} from "@/config/openclawProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import {
applyTemplateValues,
@@ -56,7 +64,7 @@ import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
import * as configApi from "@/lib/api/config";
import type { OmoGlobalConfig } from "@/types/omo";
import { mergeOmoConfigPreview } from "@/types/omo";
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
@@ -115,21 +123,25 @@ const isKnownOpencodeOptionKey = (key: string) =>
function parseOpencodeConfig(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const normalize = (
parsed: Partial<OpenCodeProviderConfig>,
): OpenCodeProviderConfig => ({
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
});
try {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
return normalize(parsed);
} catch {
return {
npm: OPENCODE_DEFAULT_NPM,
@@ -139,6 +151,25 @@ function parseOpencodeConfig(
}
}
function parseOpencodeConfigStrict(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
}
function toOpencodeExtraOptions(
options: OpenCodeProviderConfig["options"],
): Record<string, string> {
@@ -151,13 +182,25 @@ function toOpencodeExtraOptions(
return extra;
}
const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
{
baseUrl: "",
apiKey: "",
api: "openai-completions",
models: [],
},
null,
2,
);
type PresetEntry = {
id: string;
preset:
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
| OpenCodeProviderPreset;
| OpenCodeProviderPreset
| OpenClawProviderPreset;
};
interface ProviderFormProps {
@@ -198,8 +241,10 @@ function buildOmoProfilePreview(
}
if (otherFieldsStr.trim()) {
try {
const other = JSON.parse(otherFieldsStr);
Object.assign(profileOnly, other);
const other = parseOmoOtherFieldsObject(otherFieldsStr);
if (other) {
Object.assign(profileOnly, other);
}
} catch {}
}
return profileOnly;
@@ -236,6 +281,7 @@ export function ProviderForm({
category?: ProviderCategory;
isPartner?: boolean;
partnerPromotionKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
} | null>(null);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
@@ -314,7 +360,9 @@ export function ProviderForm({
? GEMINI_DEFAULT_CONFIG
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
: appId === "openclaw"
? OPENCLAW_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
@@ -444,6 +492,11 @@ export function ProviderForm({
id: `opencode-${index}`,
preset,
}));
} else if (appId === "openclaw") {
return openclawProviderPresets.map<PresetEntry>((preset, index) => ({
id: `openclaw-${index}`,
preset,
}));
}
return providerPresets.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`,
@@ -586,19 +639,24 @@ export function ProviderForm({
);
}, [opencodeProvidersData?.providers, providerId]);
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
string[]
>([]);
string[] | null
>(null);
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
const lastOmoModelSourceWarningRef = useRef<string>("");
useEffect(() => {
let active = true;
if (!isOmoCategory) {
setEnabledOpencodeProviderIds([]);
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
return () => {
active = false;
};
}
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
(async () => {
try {
const ids = await providersApi.getOpenCodeLiveProviderIds();
@@ -606,9 +664,13 @@ export function ProviderForm({
setEnabledOpencodeProviderIds(ids);
}
} catch (error) {
console.error("Failed to load OpenCode live provider ids:", error);
console.warn(
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
error,
);
if (active) {
setEnabledOpencodeProviderIds([]);
setOmoLiveIdsLoadFailed(true);
setEnabledOpencodeProviderIds(null);
}
}
})();
@@ -618,23 +680,71 @@ export function ProviderForm({
};
}, [isOmoCategory]);
const omoModelOptions = useMemo(() => {
if (!isOmoCategory) return [];
const omoModelBuild = useMemo(() => {
const empty = {
options: [] as Array<{ value: string; label: string }>,
variantsMap: {} as Record<string, string[]>,
presetMetaMap: {} as Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>,
parseFailedProviders: [] as string[],
usedFallbackSource: false,
};
if (!isOmoCategory) {
return empty;
}
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) return [];
if (!allProviders) {
return empty;
}
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) return [];
const shouldFilterByLive = !omoLiveIdsLoadFailed;
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
return empty;
}
const liveSet =
shouldFilterByLive && enabledOpencodeProviderIds
? new Set(enabledOpencodeProviderIds)
: null;
const dedupedOptions = new Map<string, string>();
const variantsMap: Record<string, string[]> = {};
const presetMetaMap: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
> = {};
const parseFailedProviders: string[] = [];
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
if (provider.category === "omo") {
continue;
}
if (liveSet && !liveSet.has(providerKey)) {
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
let parsedConfig: OpenCodeProviderConfig;
try {
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
} catch (error) {
parseFailedProviders.push(providerKey);
console.warn(
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
{
providerKey,
error,
},
);
continue;
}
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
@@ -651,63 +761,107 @@ export function ProviderForm({
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"));
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
]);
const omoModelVariantsMap = useMemo(() => {
const variantsMap: Record<string, string[]> = {};
if (!isOmoCategory) {
return variantsMap;
}
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) {
return variantsMap;
}
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) {
return variantsMap;
}
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
const rawVariants = model.variants;
if (
!rawVariants ||
typeof rawVariants !== "object" ||
Array.isArray(rawVariants)
rawVariants &&
typeof rawVariants === "object" &&
!Array.isArray(rawVariants)
) {
continue;
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length > 0) {
variantsMap[value] = variantKeys;
}
}
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length === 0) {
continue;
}
// Preset fallback: for models without config-defined variants,
// check if the npm package has preset variant definitions.
// Also collect preset metadata (options, limit) for enrichment.
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
if (presetModels) {
for (const modelId of Object.keys(parsedConfig.models || {})) {
const fullKey = `${providerKey}/${modelId}`;
const preset = presetModels.find((p) => p.id === modelId);
if (!preset) continue;
// Variant fallback
if (!variantsMap[fullKey] && preset.variants) {
const presetKeys = Object.keys(preset.variants).filter(Boolean);
if (presetKeys.length > 0) {
variantsMap[fullKey] = presetKeys;
}
}
// Collect preset metadata for model enrichment
const meta: (typeof presetMetaMap)[string] = {};
if (preset.options) meta.options = preset.options;
if (preset.contextLimit || preset.outputLimit) {
meta.limit = {};
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
}
if (Object.keys(meta).length > 0) {
presetMetaMap[fullKey] = meta;
}
}
variantsMap[`${providerKey}/${modelId}`] = variantKeys;
}
}
return variantsMap;
return {
options: Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
variantsMap,
presetMetaMap,
parseFailedProviders,
usedFallbackSource: omoLiveIdsLoadFailed,
};
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
omoLiveIdsLoadFailed,
]);
const omoModelOptions = omoModelBuild.options;
const omoModelVariantsMap = omoModelBuild.variantsMap;
const omoPresetMetaMap = omoModelBuild.presetMetaMap;
useEffect(() => {
if (!isOmoCategory) return;
const failed = omoModelBuild.parseFailedProviders;
const fallback = omoModelBuild.usedFallbackSource;
if (failed.length === 0 && !fallback) return;
const signature = `${fallback ? "fallback:" : ""}${failed
.slice()
.sort()
.join(",")}`;
if (lastOmoModelSourceWarningRef.current === signature) return;
lastOmoModelSourceWarningRef.current = signature;
if (failed.length > 0) {
toast.warning(
t("omo.modelSourcePartialWarning", {
count: failed.length,
defaultValue:
"Some provider model configs are invalid and were skipped.",
}),
);
}
if (fallback) {
toast.warning(
t("omo.modelSourceFallbackWarning", {
defaultValue:
"Failed to load live provider state. Falling back to configured providers.",
}),
);
}
}, [
isOmoCategory,
omoModelBuild.parseFailedProviders,
omoModelBuild.usedFallbackSource,
t,
]);
const initialOmoSettings =
@@ -725,6 +879,24 @@ export function ProviderForm({
return providerId || "";
});
// OpenClaw: query existing providers for duplicate key checking
const { data: openclawProvidersData } = useProvidersQuery("openclaw");
const existingOpenclawKeys = useMemo(() => {
if (!openclawProvidersData?.providers) return [];
// Exclude current provider ID when in edit mode
return Object.keys(openclawProvidersData.providers).filter(
(k) => k !== providerId,
);
}, [openclawProvidersData?.providers, providerId]);
// OpenClaw Provider Key state
const [openclawProviderKey, setOpenclawProviderKey] = useState<string>(() => {
if (appId !== "openclaw") return "";
// In edit mode, use the existing provider ID as the key
return providerId || "";
});
// OpenCode 配置状态
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
if (appId !== "opencode") return OPENCODE_DEFAULT_NPM;
return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;
@@ -866,6 +1038,128 @@ export function ProviderForm({
setUseOmoCommonConfig(useCommonConfig);
}, []);
// OpenClaw 配置状态
const [openclawBaseUrl, setOpenclawBaseUrl] = useState<string>(() => {
if (appId !== "openclaw") return "";
try {
const config = JSON.parse(
initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig)
: OPENCLAW_DEFAULT_CONFIG,
);
return config.baseUrl || "";
} catch {
return "";
}
});
const [openclawApiKey, setOpenclawApiKey] = useState<string>(() => {
if (appId !== "openclaw") return "";
try {
const config = JSON.parse(
initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig)
: OPENCLAW_DEFAULT_CONFIG,
);
return config.apiKey || "";
} catch {
return "";
}
});
const [openclawApi, setOpenclawApi] = useState<string>(() => {
if (appId !== "openclaw") return "openai-completions";
try {
const config = JSON.parse(
initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig)
: OPENCLAW_DEFAULT_CONFIG,
);
return config.api || "openai-completions";
} catch {
return "openai-completions";
}
});
const [openclawModels, setOpenclawModels] = useState<OpenClawModel[]>(() => {
if (appId !== "openclaw") return [];
try {
const config = JSON.parse(
initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig)
: OPENCLAW_DEFAULT_CONFIG,
);
return config.models || [];
} catch {
return [];
}
});
// OpenClaw handlers - sync state to form
const handleOpenclawBaseUrlChange = useCallback(
(baseUrl: string) => {
setOpenclawBaseUrl(baseUrl);
try {
const config = JSON.parse(
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
);
config.baseUrl = baseUrl.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpenclawApiKeyChange = useCallback(
(apiKey: string) => {
setOpenclawApiKey(apiKey);
try {
const config = JSON.parse(
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
);
config.apiKey = apiKey;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpenclawApiChange = useCallback(
(api: string) => {
setOpenclawApi(api);
try {
const config = JSON.parse(
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
);
config.api = api;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const handleOpenclawModelsChange = useCallback(
(models: OpenClawModel[]) => {
setOpenclawModels(models);
try {
const config = JSON.parse(
form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG,
);
config.models = models;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[form],
);
const updateOpencodeSettings = useCallback(
(updater: (config: Record<string, any>) => void) => {
try {
@@ -993,6 +1287,24 @@ export function ProviderForm({
}
}
// OpenClaw: validate provider key
if (appId === "openclaw") {
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!openclawProviderKey.trim()) {
toast.error(t("openclaw.providerKeyRequired"));
return;
}
if (!keyPattern.test(openclawProviderKey)) {
toast.error(t("openclaw.providerKeyInvalid"));
return;
}
if (!isEditMode && existingOpenclawKeys.includes(openclawProviderKey)) {
toast.error(t("openclaw.providerKeyDuplicate"));
return;
}
}
// 非官方供应商必填校验:端点和 API Key
if (category !== "official") {
if (appId === "claude") {
if (!baseUrl.trim()) {
@@ -1084,7 +1396,19 @@ export function ProviderForm({
}
if (omoOtherFieldsStr.trim()) {
try {
omoConfig.otherFields = JSON.parse(omoOtherFieldsStr);
const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr);
if (!otherFields) {
toast.error(
t("omo.jsonMustBeObject", {
field: t("omo.otherFields", {
defaultValue: "Other Config",
}),
defaultValue: "{{field}} must be a JSON object",
}),
);
return;
}
omoConfig.otherFields = otherFields;
} catch {
toast.error(
t("omo.invalidJson", {
@@ -1114,6 +1438,8 @@ export function ProviderForm({
} else {
payload.providerKey = opencodeProviderKey;
}
} else if (appId === "openclaw") {
payload.providerKey = openclawProviderKey;
}
if (category === "omo" && !payload.presetCategory) {
@@ -1128,6 +1454,10 @@ export function ProviderForm({
if (activePreset.isPartner) {
payload.isPartner = activePreset.isPartner;
}
// OpenClaw: 传递预设的 suggestedDefaults 到提交数据
if (activePreset.suggestedDefaults) {
payload.suggestedDefaults = activePreset.suggestedDefaults;
}
}
if (!isEditMode && draftCustomEndpoints.length > 0) {
@@ -1268,6 +1598,21 @@ export function ProviderForm({
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用 API Key 链接 hook (OpenClaw)
const {
shouldShowApiKeyLink: shouldShowOpenclawApiKeyLink,
websiteUrl: openclawWebsiteUrl,
isPartner: isOpenclawPartner,
partnerPromotionKey: openclawPartnerPromotionKey,
} = useApiKeyLink({
appId: "openclaw",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用端点测速候选 hook
const speedTestEndpoints = useSpeedTestEndpoints({
appId,
selectedPresetId,
@@ -1299,6 +1644,14 @@ export function ProviderForm({
setOpencodeExtraOptions({});
resetOmoDraftState();
}
// OpenClaw 自定义模式:重置为空配置
if (appId === "openclaw") {
setOpenclawProviderKey("");
setOpenclawBaseUrl("");
setOpenclawApiKey("");
setOpenclawApi("openai-completions");
setOpenclawModels([]);
}
return;
}
@@ -1382,6 +1735,40 @@ export function ProviderForm({
return;
}
// OpenClaw preset handling
if (appId === "openclaw") {
const preset = entry.preset as OpenClawProviderPreset;
const config = preset.settingsConfig;
// Update activePreset with suggestedDefaults for OpenClaw
setActivePreset({
id: value,
category: preset.category,
isPartner: preset.isPartner,
partnerPromotionKey: preset.partnerPromotionKey,
suggestedDefaults: preset.suggestedDefaults,
});
// Clear provider key (user must enter their own unique key)
setOpenclawProviderKey("");
// Update OpenClaw-specific states
setOpenclawBaseUrl(config.baseUrl || "");
setOpenclawApiKey(config.apiKey || "");
setOpenclawApi(config.api || "openai-completions");
setOpenclawModels(config.models || []);
// Update form fields
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
const preset = entry.preset as ProviderPreset;
const config = applyTemplateValues(
preset.settingsConfig,
@@ -1486,6 +1873,54 @@ export function ProviderForm({
</p>
)}
</div>
) : appId === "openclaw" ? (
<div className="space-y-2">
<Label htmlFor="openclaw-key">
{t("openclaw.providerKey")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="openclaw-key"
value={openclawProviderKey}
onChange={(e) =>
setOpenclawProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("openclaw.providerKeyPlaceholder")}
disabled={isEditMode}
className={
(existingOpenclawKeys.includes(openclawProviderKey) &&
!isEditMode) ||
(openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey))
? "border-destructive"
: ""
}
/>
{existingOpenclawKeys.includes(openclawProviderKey) &&
!isEditMode && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyDuplicate")}
</p>
)}
{openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey) && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyInvalid")}
</p>
)}
{!(
existingOpenclawKeys.includes(openclawProviderKey) &&
!isEditMode
) &&
(openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey)) && (
<p className="text-xs text-muted-foreground">
{t("openclaw.providerKeyHint")}
</p>
)}
</div>
) : undefined
}
/>
@@ -1611,6 +2046,7 @@ export function ProviderForm({
<OmoFormFields
modelOptions={omoModelOptions}
modelVariantsMap={omoModelVariantsMap}
presetMetaMap={omoPresetMetaMap}
agents={omoAgents}
onAgentsChange={setOmoAgents}
categories={omoCategories}
@@ -1620,6 +2056,26 @@ export function ProviderForm({
/>
)}
{/* OpenClaw 专属字段 */}
{appId === "openclaw" && (
<OpenClawFormFields
baseUrl={openclawBaseUrl}
onBaseUrlChange={handleOpenclawBaseUrlChange}
apiKey={openclawApiKey}
onApiKeyChange={handleOpenclawApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowOpenclawApiKeyLink}
websiteUrl={openclawWebsiteUrl}
isPartner={isOpenclawPartner}
partnerPromotionKey={openclawPartnerPromotionKey}
api={openclawApi}
onApiChange={handleOpenclawApiChange}
models={openclawModels}
onModelsChange={handleOpenclawModelsChange}
/>
)}
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
<CodexConfigEditor
@@ -1696,6 +2152,34 @@ export function ProviderForm({
</div>
{settingsConfigErrorField}
</>
) : appId === "openclaw" ? (
<>
<div className="space-y-2">
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
placeholder={`{
"baseUrl": "https://api.example.com/v1",
"apiKey": "your-api-key-here",
"api": "openai-completions",
"models": []
}`}
rows={14}
showValidation={true}
language="json"
/>
</div>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<CommonConfigEditor
@@ -1745,5 +2229,6 @@ export type ProviderFormValues = ProviderFormData & {
presetCategory?: ProviderCategory;
isPartner?: boolean;
meta?: ProviderMeta;
providerKey?: string;
providerKey?: string; // OpenCode/OpenClaw: user-defined provider key
suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration
};
@@ -4,9 +4,10 @@ import {
setCodexBaseUrl as setCodexBaseUrlInConfig,
} from "@/utils/providerConfigUtils";
import type { ProviderCategory } from "@/types";
import type { AppId } from "@/lib/api";
interface UseBaseUrlStateProps {
appType: "claude" | "codex" | "gemini" | "opencode";
appType: AppId;
category: ProviderCategory | undefined;
settingsConfig: string;
codexConfig?: string;