From 167ceb4297e779e5c8047daadcb958a121e81541 Mon Sep 17 00:00:00 2001 From: wyh Date: Fri, 20 Dec 2024 16:12:16 +0800 Subject: [PATCH] feat: New Setting UI. --- src/LLMProviders/chatModelManager.ts | 35 +- src/LLMProviders/embeddingManager.ts | 28 +- src/aiParams.ts | 11 + src/components/chat-components/ChatInput.tsx | 15 +- src/components/ui/button.tsx | 50 ++ src/components/ui/checkbox.tsx | 27 + src/components/ui/collapsible.tsx | 9 + src/components/ui/dialog.tsx | 105 ++++ src/components/ui/input.tsx | 24 + src/components/ui/label.tsx | 19 + src/components/ui/password-input.tsx | 65 +++ src/components/ui/popover.tsx | 34 ++ src/components/ui/scroll-area.tsx | 44 ++ src/components/ui/select.tsx | 157 ++++++ src/components/ui/setting-item.tsx | 241 +++++++++ src/components/ui/setting-slider.tsx | 38 ++ src/components/ui/setting-switch.tsx | 59 +++ src/components/ui/setting-tabs.tsx | 103 ++++ src/components/ui/slider.tsx | 23 + src/components/ui/table.tsx | 94 ++++ src/components/ui/textarea.tsx | 22 + src/constants.ts | 89 ++++ src/contexts/TabContext.tsx | 36 ++ src/main.ts | 4 +- src/settings/SettingsPage.tsx | 7 +- src/settings/components/QASettings.tsx | 4 +- src/settings/components/SettingBlocks.tsx | 3 +- src/settings/model.ts | 13 +- src/settings/v2/SettingsMainV2.tsx | 103 ++++ .../v2/components/AdvancedSettings.tsx | 39 ++ src/settings/v2/components/BasicSettings.tsx | 329 ++++++++++++ src/settings/v2/components/ModelAddDialog.tsx | 478 ++++++++++++++++++ .../v2/components/ModelEditDialog.tsx | 100 ++++ src/settings/v2/components/ModelSettings.tsx | 381 ++++++++++++++ src/settings/v2/components/ModelTable.tsx | 100 ++++ src/utils.ts | 30 +- 36 files changed, 2876 insertions(+), 43 deletions(-) create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/password-input.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/setting-item.tsx create mode 100644 src/components/ui/setting-slider.tsx create mode 100644 src/components/ui/setting-switch.tsx create mode 100644 src/components/ui/setting-tabs.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/contexts/TabContext.tsx create mode 100644 src/settings/v2/SettingsMainV2.tsx create mode 100644 src/settings/v2/components/AdvancedSettings.tsx create mode 100644 src/settings/v2/components/BasicSettings.tsx create mode 100644 src/settings/v2/components/ModelAddDialog.tsx create mode 100644 src/settings/v2/components/ModelEditDialog.tsx create mode 100644 src/settings/v2/components/ModelSettings.tsx create mode 100644 src/settings/v2/components/ModelTable.tsx diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index 686bebef..7bdb8b86 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -1,8 +1,8 @@ import { CustomModel, getModelKey, ModelConfig, setModelKey } from "@/aiParams"; import { BUILTIN_CHAT_MODELS, ChatModelProviders } from "@/constants"; import { getDecryptedKey } from "@/encryptionService"; -import { getSettings, subscribeToSettingsChange } from "@/settings/model"; -import { safeFetch } from "@/utils"; +import { getModelKeyFromModel, getSettings, subscribeToSettingsChange } from "@/settings/model"; +import { err2String, safeFetch } from "@/utils"; import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai"; import { ChatAnthropic } from "@langchain/anthropic"; import { ChatCohere } from "@langchain/cohere"; @@ -78,8 +78,8 @@ export default class ChatModelManager { const isO1Model = modelName.startsWith("o1"); const baseConfig: ModelConfig = { modelName: modelName, - temperature: settings.temperature, - streaming: true, + temperature: customModel.temperature ?? settings.temperature, + streaming: customModel.stream ?? true, maxRetries: 3, maxConcurrency: 3, enableCors: customModel.enableCors, @@ -96,7 +96,7 @@ export default class ChatModelManager { fetch: customModel.enableCors ? safeFetch : undefined, }, // @ts-ignore - openAIOrgId: getDecryptedKey(settings.openAIOrgId), + openAIOrgId: getDecryptedKey(customModel.openAIOrgId || settings.openAIOrgId), ...this.handleOpenAIExtraArgs(isO1Model, settings.maxTokens, settings.temperature), }, [ChatModelProviders.ANTHROPIC]: { @@ -111,9 +111,11 @@ export default class ChatModelManager { }, [ChatModelProviders.AZURE_OPENAI]: { azureOpenAIApiKey: getDecryptedKey(customModel.apiKey || settings.azureOpenAIApiKey), - azureOpenAIApiInstanceName: settings.azureOpenAIApiInstanceName, - azureOpenAIApiDeploymentName: settings.azureOpenAIApiDeploymentName, - azureOpenAIApiVersion: settings.azureOpenAIApiVersion, + azureOpenAIApiInstanceName: + customModel.azureOpenAIApiInstanceName || settings.azureOpenAIApiInstanceName, + azureOpenAIApiDeploymentName: + customModel.azureOpenAIApiDeploymentName || settings.azureOpenAIApiDeploymentName, + azureOpenAIApiVersion: customModel.azureOpenAIApiVersion || settings.azureOpenAIApiVersion, configuration: { baseURL: customModel.baseUrl, fetch: customModel.enableCors ? safeFetch : undefined, @@ -224,7 +226,7 @@ export default class ChatModelManager { const getDefaultApiKey = this.providerApiKeyMap[model.provider as ChatModelProviders]; const apiKey = model.apiKey || getDefaultApiKey(); - const modelKey = `${model.name}|${model.provider}`; + const modelKey = getModelKeyFromModel(model); modelMap[modelKey] = { hasApiKey: Boolean(model.apiKey || apiKey), AIConstructor: constructor, @@ -252,7 +254,7 @@ export default class ChatModelManager { } setChatModel(model: CustomModel): void { - const modelKey = `${model.name}|${model.provider}`; + const modelKey = getModelKeyFromModel(model); if (!ChatModelManager.modelMap.hasOwnProperty(modelKey)) { throw new Error(`No model found for: ${modelKey}`); } @@ -268,7 +270,7 @@ export default class ChatModelManager { const modelConfig = this.getModelConfig(model); - setModelKey(`${model.name}|${model.provider}`); + setModelKey(modelKey); try { const newModelInstance = new selectedModel.AIConstructor({ ...modelConfig, @@ -327,7 +329,7 @@ export default class ChatModelManager { // First try without CORS await tryPing(false); return true; - } catch (error) { + } catch (firstError) { console.log("First ping attempt failed, trying with CORS..."); try { // Second try with CORS @@ -337,8 +339,13 @@ export default class ChatModelManager { ); return true; } catch (error) { - console.error("Chat model ping failed:", error); - throw error; + const msg = + "\nwithout CORS Error: " + + err2String(firstError) + + "\nwith CORS Error: " + + err2String(error); + // console.error("Chat model ping failed:", error); + throw new Error(msg); } } } diff --git a/src/LLMProviders/embeddingManager.ts b/src/LLMProviders/embeddingManager.ts index 9421b317..a1f8ce8b 100644 --- a/src/LLMProviders/embeddingManager.ts +++ b/src/LLMProviders/embeddingManager.ts @@ -3,8 +3,8 @@ import { CustomModel } from "@/aiParams"; import { EmbeddingModelProviders } from "@/constants"; import { getDecryptedKey } from "@/encryptionService"; import { CustomError } from "@/error"; -import { getSettings, subscribeToSettingsChange } from "@/settings/model"; -import { safeFetch } from "@/utils"; +import { err2String, safeFetch } from "@/utils"; +import { getSettings, subscribeToSettingsChange, getModelKeyFromModel } from "@/settings/model"; import { CohereEmbeddings } from "@langchain/cohere"; import { Embeddings } from "@langchain/core/embeddings"; import { GoogleGenerativeAIEmbeddings } from "@langchain/google-genai"; @@ -95,7 +95,7 @@ export default class EmbeddingManager { const apiKey = model.apiKey || this.providerApiKeyMap[model.provider as EmbeddingModelProviders](); - const modelKey = `${model.name}|${model.provider}`; + const modelKey = getModelKeyFromModel(model); modelMap[modelKey] = { hasApiKey: Boolean(apiKey), EmbeddingConstructor: constructor, @@ -121,7 +121,7 @@ export default class EmbeddingManager { // Get the custom model that matches the name and provider from the model key private getCustomModel(modelKey: string): CustomModel { return this.activeEmbeddingModels.filter((model) => { - const key = `${model.name}|${model.provider}`; + const key = getModelKeyFromModel(model); return modelKey === key; })[0]; } @@ -186,9 +186,12 @@ export default class EmbeddingManager { }, [EmbeddingModelProviders.AZURE_OPENAI]: { azureOpenAIApiKey: getDecryptedKey(customModel.apiKey || settings.azureOpenAIApiKey), - azureOpenAIApiInstanceName: settings.azureOpenAIApiInstanceName, - azureOpenAIApiDeploymentName: settings.azureOpenAIApiEmbeddingDeploymentName, - azureOpenAIApiVersion: settings.azureOpenAIApiVersion, + azureOpenAIApiInstanceName: + customModel.azureOpenAIApiInstanceName || settings.azureOpenAIApiInstanceName, + azureOpenAIApiDeploymentName: + customModel.azureOpenAIApiEmbeddingDeploymentName || + settings.azureOpenAIApiEmbeddingDeploymentName, + azureOpenAIApiVersion: customModel.azureOpenAIApiVersion || settings.azureOpenAIApiVersion, configuration: { baseURL: customModel.baseUrl, fetch: customModel.enableCors ? safeFetch : undefined, @@ -236,7 +239,7 @@ export default class EmbeddingManager { // First try without CORS await tryPing(false); return true; - } catch (error) { + } catch (firstError) { console.log("First ping attempt failed, trying with CORS..."); try { // Second try with CORS @@ -246,8 +249,13 @@ export default class EmbeddingManager { ); return true; } catch (error) { - console.error("Embedding model ping failed:", error); - throw error; + const msg = + "\nwithout CORS Error: " + + err2String(firstError) + + "\nwith CORS Error: " + + err2String(error); + // console.error("Embedding model ping failed:", error); + throw new Error(msg); } } } diff --git a/src/aiParams.ts b/src/aiParams.ts index a87650bd..df86d6c0 100644 --- a/src/aiParams.ts +++ b/src/aiParams.ts @@ -73,6 +73,17 @@ export interface CustomModel { isBuiltIn?: boolean; enableCors?: boolean; core?: boolean; + stream?: boolean; + temperature?: number; + context?: number; + // OpenAI specific fields + openAIOrgId?: string; + + // Azure OpenAI specific fields + azureOpenAIApiInstanceName?: string; + azureOpenAIApiDeploymentName?: string; + azureOpenAIApiVersion?: string; + azureOpenAIApiEmbeddingDeploymentName?: string; } export function setModelKey(modelKey: string) { diff --git a/src/components/chat-components/ChatInput.tsx b/src/components/chat-components/ChatInput.tsx index 791ffa2a..be369f71 100644 --- a/src/components/chat-components/ChatInput.tsx +++ b/src/components/chat-components/ChatInput.tsx @@ -1,4 +1,4 @@ -import { CustomModel, useChainType, useModelKey } from "@/aiParams"; +import { useChainType, useModelKey } from "@/aiParams"; import { ChainType } from "@/chainFactory"; import { AddImageModal } from "@/components/modals/AddImageModal"; import { ListPromptModal } from "@/components/modals/ListPromptModal"; @@ -7,7 +7,7 @@ import { ContextProcessor } from "@/contextProcessor"; import { CustomPromptProcessor } from "@/customPromptProcessor"; import { COPILOT_TOOL_NAMES } from "@/LLMProviders/intentAnalyzer"; import { Mention } from "@/mentions/Mention"; -import { useSettingsValue } from "@/settings/model"; +import { getModelKeyFromModel, useSettingsValue } from "@/settings/model"; import { ChatMessage } from "@/sharedState"; import { getToolDescription } from "@/tools/toolManager"; import { extractNoteTitles } from "@/utils"; @@ -40,8 +40,6 @@ interface ChatInputProps { chatHistory: ChatMessage[]; } -const getModelKey = (model: CustomModel) => `${model.name}|${model.provider}`; - const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( ( { @@ -447,8 +445,9 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
- {settings.activeModels.find((model) => getModelKey(model) === currentModelKey) - ?.name || "Select Model"} + {settings.activeModels.find( + (model) => getModelKeyFromModel(model) === currentModelKey + )?.name || "Select Model"} @@ -458,8 +457,8 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( .filter((model) => model.enabled) .map((model) => ( setCurrentModelKey(getModelKey(model))} + key={getModelKeyFromModel(model)} + onSelect={() => setCurrentModelKey(getModelKeyFromModel(model))} > {model.name} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 00000000..fc544ec3 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..4e0fceba --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..5c28cbcc --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..68fca17b --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + container?: HTMLElement | null; + } +>(({ className, children, container, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 00000000..f9415078 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 00000000..dea58222 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/src/components/ui/password-input.tsx b/src/components/ui/password-input.tsx new file mode 100644 index 00000000..841699ff --- /dev/null +++ b/src/components/ui/password-input.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { Eye, EyeOff } from "lucide-react"; +import { getDecryptedKey } from "@/encryptionService"; + +export function PasswordInput({ + value, + onChange, + placeholder, + disabled, + className, +}: { + value?: string | number; + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +}) { + const [showPassword, setShowPassword] = useState(false); + + // 获取要显示的值 + const displayValue = typeof value === "string" && value ? getDecryptedKey(value) : value; + + return ( +
+ onChange?.(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className={cn("![padding-right:1.75rem] w-full")} + /> +
!disabled && setShowPassword(!showPassword)} + className={cn( + "absolute right-2 top-1/2 -translate-y-1/2", + "cursor-pointer", + disabled && "opacity-50 cursor-not-allowed" + )} + role="button" + aria-label={showPassword ? "Hide password" : "Show password"} + > + {showPassword ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000..d431f927 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + usePortal?: boolean; + } +>(({ className, align = "center", sideOffset = 4, usePortal = false, ...props }, ref) => { + const content = ( + + ); + return usePortal ? {content} : content; +}); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..e4ddd5a3 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 00000000..760b561b --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,157 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + "hover:data-[state=closed]:bg-accent hover:data-[state=closed]:text-accent-foreground", + "disabled:cursor-not-allowed disabled:opacity-50", + // "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + // "hover:data-[state=closed]:bg-accent hover:data-[state=closed]:text-accent-foreground", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + container?: HTMLElement | null; + } +>(({ className, children, position = "popper", container, ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/src/components/ui/setting-item.tsx b/src/components/ui/setting-item.tsx new file mode 100644 index 00000000..f9cb5485 --- /dev/null +++ b/src/components/ui/setting-item.tsx @@ -0,0 +1,241 @@ +import React from "react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTab } from "@/contexts/TabContext"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Textarea } from "@/components/ui/textarea"; +import { SettingSwitch } from "@/components/ui/setting-switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { SettingSlider } from "@/components/ui/setting-slider"; + +// 定义输入控件的类型 +type InputType = + | "text" + | "password" + | "number" + | "textarea" + | "switch" + | "select" + | "custom" + | "slider" + | "dialog"; + +// Select选项的类型 +interface SelectOption { + label: string; + value: string | number; +} + +// 基础Props +interface BaseSettingItemProps { + type: InputType; + title: string; + description?: string | React.ReactNode; + className?: string; + disabled?: boolean; +} + +// 不同类型输入控件的Props +interface TextSettingItemProps extends BaseSettingItemProps { + type: "text" | "password" | "number"; + value?: string | number; + onChange?: (value: string) => void; + placeholder?: string; +} + +interface TextareaSettingItemProps extends BaseSettingItemProps { + type: "textarea"; + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + rows?: number; +} + +interface SwitchSettingItemProps extends BaseSettingItemProps { + type: "switch"; + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +} + +interface SelectSettingItemProps extends BaseSettingItemProps { + type: "select"; + value?: string | number; + onChange?: (value: string) => void; + options: SelectOption[]; + placeholder?: string; +} + +interface CustomSettingItemProps extends BaseSettingItemProps { + type: "custom"; + children: React.ReactNode; +} + +// 添加 Slider 类型的 Props +interface SliderSettingItemProps extends BaseSettingItemProps { + type: "slider"; + value?: number; + onChange?: (value: number) => void; + min: number; + max: number; + step: number; +} + +// 添加 Dialog 类型的 Props +interface DialogSettingItemProps extends BaseSettingItemProps { + type: "dialog"; + dialogTitle?: string; + dialogDescription?: string; + trigger: React.ReactNode; + children: React.ReactNode; +} + +// 联合类型 +type SettingItemProps = + | TextSettingItemProps + | TextareaSettingItemProps + | SwitchSettingItemProps + | SelectSettingItemProps + | CustomSettingItemProps + | SliderSettingItemProps + | DialogSettingItemProps; + +export function SettingItem(props: SettingItemProps) { + const { title, description, className, disabled } = props; + const { modalContainer } = useTab(); + + const renderControl = () => { + switch (props.type) { + case "text": + case "number": + return ( + props.onChange?.(e.target.value)} + placeholder={props.placeholder} + disabled={disabled} + className="w-full sm:w-[200px]" + /> + ); + + case "password": + return ( + + ); + + case "textarea": + return ( +