diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 7eae340ea..c3ff9e578 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -24,7 +24,7 @@ interface AddProviderDialogProps { open: boolean; onOpenChange: (open: boolean) => void; appId: AppId; - onSubmit: (provider: Omit) => Promise | void; + onSubmit: (provider: Omit & { providerKey?: string }) => Promise | void; } export function AddProviderDialog({ @@ -85,7 +85,7 @@ export function AddProviderDialog({ >; // 构造基础提交数据 - const providerData: Omit = { + const providerData: Omit & { providerKey?: string } = { name: values.name.trim(), notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, @@ -96,6 +96,11 @@ export function AddProviderDialog({ ...(values.meta ? { meta: values.meta } : {}), }; + // OpenCode: pass providerKey for ID generation + if (appId === "opencode" && values.providerKey) { + providerData.providerKey = values.providerKey; + } + const hasCustomEndpoints = providerData.meta?.custom_endpoints && Object.keys(providerData.meta.custom_endpoints).length > 0; diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index 239c47e03..0bdaf5b18 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { useState } from "react"; +import type { ReactNode } from "react"; import { FormControl, FormField, @@ -24,9 +25,11 @@ import type { ProviderFormData } from "@/lib/schemas/provider"; interface BasicFormFieldsProps { form: UseFormReturn; + /** Slot to render content between icon and name fields */ + beforeNameSlot?: ReactNode; } -export function BasicFormFields({ form }: BasicFormFieldsProps) { +export function BasicFormFields({ form, beforeNameSlot }: BasicFormFieldsProps) { const { t } = useTranslation(); const [iconDialogOpen, setIconDialogOpen] = useState(false); @@ -112,6 +115,9 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) { + {/* Slot for additional fields between icon and name */} + {beforeNameSlot} + {/* 基础信息 - 网格布局 */}
{ + if (!opencodeProvidersData?.providers) return []; + // Exclude current provider ID when in edit mode + return Object.keys(opencodeProvidersData.providers).filter( + (k) => k !== providerId + ); + }, [opencodeProvidersData?.providers, providerId]); + + // OpenCode Provider Key state + const [opencodeProviderKey, setOpencodeProviderKey] = useState(() => { + if (appId !== "opencode") return ""; + // In edit mode, use the existing provider ID as the key + return providerId || ""; + }); + // OpenCode 配置状态 const [opencodeNpm, setOpencodeNpm] = useState(() => { if (appId !== "opencode") return "@ai-sdk/openai-compatible"; @@ -625,6 +644,23 @@ export function ProviderForm({ return; } + // OpenCode: validate provider key + if (appId === "opencode") { + const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + if (!opencodeProviderKey.trim()) { + toast.error(t("opencode.providerKeyRequired")); + return; + } + if (!keyPattern.test(opencodeProviderKey)) { + toast.error(t("opencode.providerKeyInvalid")); + return; + } + if (!isEditMode && existingOpencodeKeys.includes(opencodeProviderKey)) { + toast.error(t("opencode.providerKeyDuplicate")); + return; + } + } + // 非官方供应商必填校验:端点和 API Key if (category !== "official") { if (appId === "claude") { @@ -722,6 +758,11 @@ export function ProviderForm({ settingsConfig, }; + // OpenCode: pass provider key for ID generation + if (appId === "opencode") { + payload.providerKey = opencodeProviderKey; + } + if (activePreset) { payload.presetId = activePreset.id; if (activePreset.category) { @@ -879,6 +920,7 @@ export function ProviderForm({ } // OpenCode 自定义模式:重置为空配置 if (appId === "opencode") { + setOpencodeProviderKey(""); setOpencodeNpm("@ai-sdk/openai-compatible"); setOpencodeBaseUrl(""); setOpencodeApiKey(""); @@ -942,6 +984,9 @@ export function ProviderForm({ const preset = entry.preset as OpenCodeProviderPreset; const config = preset.settingsConfig; + // Clear provider key (user must enter their own unique key) + setOpencodeProviderKey(""); + // Update OpenCode-specific states setOpencodeNpm(config.npm || "@ai-sdk/openai-compatible"); setOpencodeBaseUrl(config.options?.baseURL || ""); @@ -996,7 +1041,48 @@ export function ProviderForm({ )} {/* 基础字段 */} - + + + setOpencodeProviderKey(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} + placeholder={t("opencode.providerKeyPlaceholder")} + disabled={isEditMode} + className={ + (existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) || + (opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) + ? "border-destructive" + : "" + } + /> + {existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode && ( +

+ {t("opencode.providerKeyDuplicate")} +

+ )} + {opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && ( +

+ {t("opencode.providerKeyInvalid")} +

+ )} + {!(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) && + (opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && ( +

+ {t("opencode.providerKeyHint")} +

+ )} +
+ ) : undefined + } + /> {/* Claude 专属字段 */} {appId === "claude" && ( @@ -1252,4 +1338,5 @@ export type ProviderFormValues = ProviderFormData & { presetCategory?: ProviderCategory; isPartner?: boolean; meta?: ProviderMeta; + providerKey?: string; // OpenCode: user-defined provider key }; diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index a8322b4ce..cc46cca89 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -54,7 +54,7 @@ export function useProviderActions(activeApp: AppId) { // 添加供应商 const addProvider = useCallback( - async (provider: Omit) => { + async (provider: Omit & { providerKey?: string }) => { await addProviderMutation.mutateAsync(provider); }, [addProviderMutation], diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2b0d987f1..4df6f3dad 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -475,7 +475,13 @@ "addModel": "Add Model", "modelId": "Model ID", "modelName": "Display Name", - "noModels": "No models configured" + "noModels": "No models configured", + "providerKey": "Provider Key", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", + "providerKeyRequired": "Provider key is required", + "providerKeyDuplicate": "This key is already in use", + "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only." }, "providerPreset": { "label": "Provider Preset", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2360ae195..03c83036b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -475,7 +475,13 @@ "addModel": "モデルを追加", "modelId": "モデル ID", "modelName": "表示名", - "noModels": "モデルが設定されていません" + "noModels": "モデルが設定されていません", + "providerKey": "プロバイダーキー", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。", + "providerKeyRequired": "プロバイダーキーを入力してください", + "providerKeyDuplicate": "このキーは既に使用されています", + "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。" }, "providerPreset": { "label": "プロバイダータイプ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2f4181bae..30c27457e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -475,7 +475,13 @@ "addModel": "添加模型", "modelId": "模型 ID", "modelName": "显示名称", - "noModels": "暂无模型配置" + "noModels": "暂无模型配置", + "providerKey": "供应商标识", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", + "providerKeyRequired": "请填写供应商标识", + "providerKeyDuplicate": "此标识已被使用,请更换", + "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符" }, "providerPreset": { "label": "预设供应商", diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 9ab4db1f8..73d75c854 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -6,36 +6,35 @@ import type { Provider, Settings } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; -/** - * Convert a name to a URL-safe slug for use as provider ID - * Used for OpenCode's additive mode where ID becomes the config key - */ -function slugify(name: string): string { - return name - .toLowerCase() - .trim() - .replace(/[\s_]+/g, "-") // spaces and underscores → hyphens - .replace(/[^a-z0-9-]/g, "") // remove special characters - .replace(/-+/g, "-") // collapse multiple hyphens - .replace(/^-|-$/g, ""); // trim leading/trailing hyphens -} - export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); const { t } = useTranslation(); return useMutation({ - mutationFn: async (providerInput: Omit) => { - // OpenCode: use slugified name as ID (for readable config keys) - // Other apps: use random UUID - const id = - appId === "opencode" ? slugify(providerInput.name) : generateUUID(); + mutationFn: async ( + providerInput: Omit & { providerKey?: string } + ) => { + let id: string; + + if (appId === "opencode") { + // OpenCode: use user-provided providerKey as ID + if (!providerInput.providerKey) { + throw new Error("Provider key is required for OpenCode"); + } + id = providerInput.providerKey; + } else { + // Other apps: use random UUID + id = generateUUID(); + } const newProvider: Provider = { ...providerInput, id, createdAt: Date.now(), }; + // Remove providerKey from the provider object before saving + delete (newProvider as any).providerKey; + await providersApi.add(newProvider, appId); return newProvider; },