mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
feat(opencode): add manual provider key input with duplicate check
- Add Provider Key input field for OpenCode providers (between icon and name) - User must manually enter a unique key instead of auto-generating from name - Real-time validation: format check and duplicate detection - Key is immutable after creation (disabled in edit mode) - Remove slugify auto-generation logic from mutations - Add beforeNameSlot prop to BasicFormFields for extensibility - Add i18n translations for zh/en/ja
This commit is contained in:
@@ -24,7 +24,7 @@ interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appId: AppId;
|
||||
onSubmit: (provider: Omit<Provider, "id">) => Promise<void> | void;
|
||||
onSubmit: (provider: Omit<Provider, "id"> & { providerKey?: string }) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function AddProviderDialog({
|
||||
@@ -85,7 +85,7 @@ export function AddProviderDialog({
|
||||
>;
|
||||
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> = {
|
||||
const providerData: Omit<Provider, "id"> & { 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;
|
||||
|
||||
@@ -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<ProviderFormData>;
|
||||
/** 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) {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Slot for additional fields between icon and name */}
|
||||
{beforeNameSlot}
|
||||
|
||||
{/* 基础信息 - 网格布局 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { ProviderCategory, ProviderMeta } from "@/types";
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
useGeminiConfigState,
|
||||
useGeminiCommonConfig,
|
||||
} from "./hooks";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
|
||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||
@@ -498,6 +500,23 @@ export function ProviderForm({
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
// OpenCode: query existing providers for duplicate key checking
|
||||
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||
const existingOpencodeKeys = useMemo(() => {
|
||||
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<string>(() => {
|
||||
if (appId !== "opencode") return "";
|
||||
// In edit mode, use the existing provider ID as the key
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
// OpenCode 配置状态
|
||||
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
|
||||
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({
|
||||
)}
|
||||
|
||||
{/* 基础字段 */}
|
||||
<BasicFormFields form={form} />
|
||||
<BasicFormFields
|
||||
form={form}
|
||||
beforeNameSlot={
|
||||
appId === "opencode" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="opencode-key">
|
||||
{t("opencode.providerKey")}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="opencode-key"
|
||||
value={opencodeProviderKey}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("opencode.providerKeyDuplicate")}
|
||||
</p>
|
||||
)}
|
||||
{opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("opencode.providerKeyInvalid")}
|
||||
</p>
|
||||
)}
|
||||
{!(existingOpencodeKeys.includes(opencodeProviderKey) && !isEditMode) &&
|
||||
(opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.providerKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : 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
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useProviderActions(activeApp: AppId) {
|
||||
|
||||
// 添加供应商
|
||||
const addProvider = useCallback(
|
||||
async (provider: Omit<Provider, "id">) => {
|
||||
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
},
|
||||
[addProviderMutation],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -475,7 +475,13 @@
|
||||
"addModel": "モデルを追加",
|
||||
"modelId": "モデル ID",
|
||||
"modelName": "表示名",
|
||||
"noModels": "モデルが設定されていません"
|
||||
"noModels": "モデルが設定されていません",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "プロバイダータイプ",
|
||||
|
||||
@@ -475,7 +475,13 @@
|
||||
"addModel": "添加模型",
|
||||
"modelId": "模型 ID",
|
||||
"modelName": "显示名称",
|
||||
"noModels": "暂无模型配置"
|
||||
"noModels": "暂无模型配置",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "预设供应商",
|
||||
|
||||
+18
-19
@@ -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<Provider, "id">) => {
|
||||
// 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<Provider, "id"> & { 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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user