diff --git a/package.json b/package.json index 147441196..62c1c7177 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@lobehub/icons-static-svg": "^1.73.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d82cfdc38..6c791d5e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 32c17f5dd..71974e7c0 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -35,7 +35,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", ); @@ -185,6 +186,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); diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx new file mode 100644 index 000000000..7db6ef7df --- /dev/null +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -0,0 +1,452 @@ +import { useTranslation } from "react-i18next"; +import { useState } 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>( + {} + ); + + // Toggle advanced section for a model + const toggleModelAdvanced = (index: number) => { + setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] })); + }; + + // Add a new model entry + const handleAddModel = () => { + onModelsChange([ + ...models, + { + id: "", + name: "", + contextWindow: undefined, + maxTokens: undefined, + cost: undefined, + }, + ]); + }; + + // Remove a model entry + const handleRemoveModel = (index: number) => { + 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 */} +
+ + {t("openclaw.apiProtocol", { + defaultValue: "API 协议", + })} + + +

+ {t("openclaw.apiProtocolHint", { + defaultValue: + "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。", + })} +

+
+ + {/* Base URL */} +
+ + {t("openclaw.baseUrl", { defaultValue: "API 端点" })} + + onBaseUrlChange(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ {t("openclaw.baseUrlHint", { + defaultValue: "供应商的 API 端点地址。", + })} +

+
+ + {/* API Key */} + + + {/* Models Editor */} +
+
+ + {t("openclaw.models", { defaultValue: "模型列表" })} + + +
+ + {models.length === 0 ? ( +

+ {t("openclaw.noModels", { + defaultValue: "暂无模型配置。点击添加模型来配置可用模型。", + })} +

+ ) : ( +
+ {models.map((model, index) => ( +
+ {/* Model ID and Name row */} +
+
+ + + handleModelChange(index, "id", e.target.value) + } + placeholder={t("openclaw.modelIdPlaceholder", { + defaultValue: "claude-3-sonnet", + })} + /> +
+
+ + + handleModelChange(index, "name", e.target.value) + } + placeholder={t("openclaw.modelNamePlaceholder", { + defaultValue: "Claude 3 Sonnet", + })} + /> +
+ +
+ + {/* Context Window, Max Tokens and Reasoning row */} +
+
+ + + handleModelChange( + index, + "contextWindow", + e.target.value ? parseInt(e.target.value) : undefined + ) + } + placeholder="200000" + /> +
+
+ + + handleModelChange( + index, + "maxTokens", + e.target.value ? parseInt(e.target.value) : undefined + ) + } + placeholder="32000" + /> +
+
+ +
+ + handleModelChange(index, "reasoning", checked) + } + /> + + {model.reasoning + ? t("openclaw.reasoningOn", { defaultValue: "启用" }) + : t("openclaw.reasoningOff", { defaultValue: "关闭" })} + +
+
+ {/* Spacer for alignment with delete button */} +
+
+ + {/* Basic Cost row */} +
+
+ + + handleCostChange(index, "input", e.target.value) + } + placeholder="3" + /> +
+
+ + + handleCostChange(index, "output", e.target.value) + } + placeholder="15" + /> +
+ {/* Spacer for alignment */} +
+
+
+ + {/* Advanced Options (Collapsible) */} + toggleModelAdvanced(index)} + > + + + + +
+
+ + + handleCostChange(index, "cacheRead", e.target.value) + } + placeholder="0.3" + /> +
+
+ + + handleCostChange( + index, + "cacheWrite", + e.target.value + ) + } + placeholder="3.75" + /> +
+ {/* Spacer for alignment */} +
+
+
+

+ {t("openclaw.cacheCostHint", { + defaultValue: + "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。", + })} +

+ + +
+ ))} +
+ )} + +

+ {t("openclaw.modelsHint", { + defaultValue: + "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。", + })} +

+
+ + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 381a7d68c..347492506 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -34,7 +34,13 @@ import { OPENCODE_PRESET_MODEL_VARIANTS, type OpenCodeProviderPreset, } from "@/config/opencodeProviderPresets"; +import { + openclawProviderPresets, + type OpenClawProviderPreset, +} from "@/config/openclawProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; +import { OpenClawFormFields } from "./OpenClawFormFields"; +import type { OpenCodeModel, OpenClawModel } from "@/types"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; @@ -172,13 +178,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 { @@ -337,7 +355,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 ?? "", }), @@ -464,6 +484,11 @@ export function ProviderForm({ id: `opencode-${index}`, preset, })); + } else if (appId === "openclaw") { + return openclawProviderPresets.map((preset, index) => ({ + id: `openclaw-${index}`, + preset, + })); } return providerPresets.map((preset, index) => ({ id: `claude-${index}`, @@ -987,6 +1012,128 @@ export function ProviderForm({ setUseOmoCommonConfig(useCommonConfig); }, []); + // OpenClaw 配置状态 + const [openclawBaseUrl, setOpenclawBaseUrl] = useState(() => { + 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(() => { + 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(() => { + 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(() => { + 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) => void) => { try { @@ -1399,6 +1546,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, @@ -1430,6 +1592,13 @@ export function ProviderForm({ setOpencodeExtraOptions({}); resetOmoDraftState(); } + // OpenClaw 自定义模式:重置为空配置 + if (appId === "openclaw") { + setOpenclawBaseUrl(""); + setOpenclawApiKey(""); + setOpenclawApi("openai-completions"); + setOpenclawModels([]); + } return; } @@ -1513,6 +1682,28 @@ export function ProviderForm({ return; } + // OpenClaw preset handling + if (appId === "openclaw") { + const preset = entry.preset as OpenClawProviderPreset; + const config = preset.settingsConfig; + + // 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, @@ -1752,6 +1943,26 @@ export function ProviderForm({ /> )} + {/* OpenClaw 专属字段 */} + {appId === "openclaw" && ( + + )} + + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} {appId === "codex" ? ( <> {settingsConfigErrorField} + ) : appId === "openclaw" ? ( + <> +
+ + 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" + /> +
+ ( + + + + )} + /> + ) : ( <>