From 899133166e24d792c720271ddbf574da830db4d3 Mon Sep 17 00:00:00 2001 From: Emt-lin <41323133+Emt-lin@users.noreply.github.com> Date: Thu, 13 Feb 2025 05:11:09 +0800 Subject: [PATCH] feat: Support custom model displayNames and reorderable Model list. (#1225) --- package-lock.json | 56 ++ package.json | 3 + src/LLMProviders/chainRunner.ts | 2 +- src/aiParams.ts | 9 +- src/components/chat-components/ChatInput.tsx | 37 +- src/components/ui/form-field.tsx | 31 ++ src/components/ui/model-display.tsx | 101 ++++ src/constants.ts | 15 +- src/settings/v2/components/BasicSettings.tsx | 27 +- src/settings/v2/components/ModelAddDialog.tsx | 174 +++++-- .../v2/components/ModelEditDialog.tsx | 185 +++++-- src/settings/v2/components/ModelSettings.tsx | 19 +- src/settings/v2/components/ModelTable.tsx | 493 ++++++++++++++---- src/utils.ts | 33 +- tailwind.config.js | 4 + 15 files changed, 950 insertions(+), 239 deletions(-) create mode 100644 src/components/ui/form-field.tsx create mode 100644 src/components/ui/model-display.tsx diff --git a/package-lock.json b/package-lock.json index 11656a75..a58c48f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "2.8.4", "license": "AGPL-3.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@google/generative-ai": "^0.21.0", "@huggingface/inference": "^2.6.4", "@koa/cors": "^5.0.0", @@ -2116,6 +2119,59 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electron/get": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", diff --git a/package.json b/package.json index a3d16fb2..4f7a0248 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,9 @@ "web-streams-polyfill": "^3.3.2" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@google/generative-ai": "^0.21.0", "@huggingface/inference": "^2.6.4", "@koa/cors": "^5.0.0", diff --git a/src/LLMProviders/chainRunner.ts b/src/LLMProviders/chainRunner.ts index c8a683d1..0a1f70da 100644 --- a/src/LLMProviders/chainRunner.ts +++ b/src/LLMProviders/chainRunner.ts @@ -1,4 +1,3 @@ -import { ModelCapability } from "@/aiParams"; import { getStandaloneQuestion } from "@/chainUtils"; import { ABORT_REASON, @@ -6,6 +5,7 @@ import { EMPTY_INDEX_ERROR_MESSAGE, LOADING_MESSAGES, MAX_CHARS_FOR_LOCAL_SEARCH_CONTEXT, + ModelCapability, } from "@/constants"; import { BrevilabsClient } from "@/LLMProviders/brevilabsClient"; import { getSystemPrompt } from "@/settings/model"; diff --git a/src/aiParams.ts b/src/aiParams.ts index 89f1686a..fed1cd1d 100644 --- a/src/aiParams.ts +++ b/src/aiParams.ts @@ -4,6 +4,7 @@ import { ChatPromptTemplate } from "@langchain/core/prompts"; import { settingsAtom, settingsStore } from "@/settings/model"; import { atom, useAtom } from "jotai"; +import { ModelCapability } from "@/constants"; const userModelKeyAtom = atom(null); const modelKeyAtom = atom( @@ -65,12 +66,6 @@ export interface SetChainOptions { refreshIndex?: boolean; } -export enum ModelCapability { - REASONING = "reasoning", - VISION = "vision", - WEB_SEARCH = "websearch", -} - export interface CustomModel { name: string; provider: string; @@ -87,6 +82,8 @@ export interface CustomModel { context?: number; believerExclusive?: boolean; capabilities?: ModelCapability[]; + displayName?: string; + // Embedding models only (Jina at the moment) dimensions?: number; // OpenAI specific fields diff --git a/src/components/chat-components/ChatInput.tsx b/src/components/chat-components/ChatInput.tsx index 82f2bebd..8b5c5984 100644 --- a/src/components/chat-components/ChatInput.tsx +++ b/src/components/chat-components/ChatInput.tsx @@ -8,8 +8,7 @@ import { COPILOT_TOOL_NAMES } from "@/LLMProviders/intentAnalyzer"; import { Mention } from "@/mentions/Mention"; import { getModelKeyFromModel, useSettingsValue } from "@/settings/model"; import { getToolDescription } from "@/tools/toolManager"; -import { err2String, extractNoteTitles } from "@/utils"; -import { DisplayKeyProviders, ProviderSettingsKeyMap } from "@/constants"; +import { err2String, extractNoteTitles, checkModelApiKey } from "@/utils"; import { ArrowBigUp, ChevronDown, @@ -39,6 +38,7 @@ import { import { Button } from "@/components/ui/button"; import { useDropzone } from "react-dropzone"; import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal"; +import { ModelDisplay } from "@/components/ui/model-display"; import { Notice } from "obsidian"; interface ChatInputProps { @@ -488,10 +488,20 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( @@ -501,19 +511,14 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( {settings.activeModels .filter((model) => model.enabled) .map((model) => { - const providerKeyName = - ProviderSettingsKeyMap[model.provider as DisplayKeyProviders]; - const hasNoApiKey = !model.apiKey && !settings[providerKeyName]; + const { hasApiKey, errorNotice } = checkModelApiKey(model, settings); return ( { - if (hasNoApiKey) { + if (!hasApiKey && errorNotice) { event.preventDefault(); - const notice = - `Please configure API Key for ${model.name} in settings first.` + - "\nPath: Settings > copilot plugin > Basic Tab > Set Keys"; - new Notice(notice); + new Notice(errorNotice); return; } @@ -526,16 +531,16 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( new Notice(msg); // Restore to the last valid model const lastValidModel = settings.activeModels.find( - (m) => getModelKeyFromModel(m) === currentModelKey + (m) => m.enabled && getModelKeyFromModel(m) === currentModelKey ); if (lastValidModel) { setCurrentModelKey(getModelKeyFromModel(lastValidModel)); } } }} - className={hasNoApiKey ? "opacity-50 cursor-not-allowed" : ""} + className={!hasApiKey ? "opacity-50 cursor-not-allowed" : ""} > - {model.name} + ); })} diff --git a/src/components/ui/form-field.tsx b/src/components/ui/form-field.tsx new file mode 100644 index 00000000..25f130b7 --- /dev/null +++ b/src/components/ui/form-field.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Label } from "./label"; + +interface FormFieldProps { + label: string | React.ReactNode; + required?: boolean; + error?: boolean; + description?: string; + errorMessage?: string; + children: React.ReactNode; +} + +export const FormField: React.FC = ({ + label, + required = false, + error = false, + description, + errorMessage = "This field is required", + children, +}) => { + return ( +
+ + {children} + {error &&

{errorMessage}

} + {description &&

{description}

} +
+ ); +}; diff --git a/src/components/ui/model-display.tsx b/src/components/ui/model-display.tsx new file mode 100644 index 00000000..a3614a7e --- /dev/null +++ b/src/components/ui/model-display.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { CustomModel } from "@/aiParams"; +import { getProviderLabel } from "@/utils"; +import { Lightbulb, Eye, Globe } from "lucide-react"; +import { ModelCapability } from "@/constants"; + +interface ModelDisplayProps { + model: CustomModel; + iconSize?: number; +} + +interface ModelCapabilityIconsProps { + capabilities?: ModelCapability[]; + iconSize?: number; +} + +export const ModelCapabilityIcons: React.FC = ({ + capabilities = [], + iconSize = 16, +}) => { + return ( + <> + {capabilities.map((cap, index) => { + switch (cap) { + case ModelCapability.REASONING: + return ( + + ); + case ModelCapability.VISION: + return ( + + ); + case ModelCapability.WEB_SEARCH: + return ( + + ); + default: + return null; + } + })} + + ); +}; + +export const ModelDisplay: React.FC = ({ model, iconSize = 14 }) => { + const displayName = model.displayName || model.name; + const provider = `(${getProviderLabel(model.provider)})`; + + return ( +
+ + {displayName} + {provider} + + {model.capabilities && model.capabilities.length > 0 && ( +
+ +
+ )} +
+ ); +}; + +export const getModelDisplayText = (model: CustomModel): string => { + const displayName = model.displayName || model.name; + const provider = `(${getProviderLabel(model.provider)})`; + return `${displayName}${provider}`; +}; + +export const getModelDisplayWithIcons = (model: CustomModel): string => { + const displayName = model.displayName || model.name; + const provider = `(${getProviderLabel(model.provider, model)})`; + const icons = + model.capabilities + ?.map((cap) => { + switch (cap) { + case ModelCapability.REASONING: + return "Reasoning"; + case ModelCapability.VISION: + return "Vision"; + case ModelCapability.WEB_SEARCH: + return "Websearch"; + default: + return ""; + } + }) + .join("|") || ""; + return `${displayName}${provider} ${icons}`; +}; diff --git a/src/constants.ts b/src/constants.ts index dbb36a1c..6b16f96d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -import { CustomModel, ModelCapability } from "@/aiParams"; +import { CustomModel } from "@/aiParams"; import { type CopilotSettings } from "@/settings/model"; import { ChainType } from "./chainFactory"; @@ -72,6 +72,18 @@ export enum ChatModelProviders { MISTRAL = "mistralai", } +export enum ModelCapability { + REASONING = "reasoning", + VISION = "vision", + WEB_SEARCH = "websearch", +} + +export const MODEL_CAPABILITIES: Record = { + reasoning: "This model supports general reasoning tasks.", + vision: "This model supports image inputs.", + websearch: "This model can access the internet.", +}; + export const BUILTIN_CHAT_MODELS: CustomModel[] = [ { name: ChatModels.COPILOT_PLUS_FLASH, @@ -95,6 +107,7 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [ enabled: true, isBuiltIn: true, core: true, + capabilities: [ModelCapability.VISION], }, { name: ChatModels.O1_mini, diff --git a/src/settings/v2/components/BasicSettings.tsx b/src/settings/v2/components/BasicSettings.tsx index 4d4add51..7bdabfe3 100644 --- a/src/settings/v2/components/BasicSettings.tsx +++ b/src/settings/v2/components/BasicSettings.tsx @@ -9,13 +9,11 @@ import { COMMAND_NAMES, DEFAULT_OPEN_AREA, DISABLEABLE_COMMANDS, - DisplayKeyProviders, PLUS_UTM_MEDIUMS, - ProviderSettingsKeyMap, } from "@/constants"; import { useTab } from "@/contexts/TabContext"; import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/settings/model"; -import { formatDateTime, getProviderLabel } from "@/utils"; +import { formatDateTime, checkModelApiKey } from "@/utils"; import { HelpCircle, Key, Loader2 } from "lucide-react"; import { Notice } from "obsidian"; import React, { useState } from "react"; @@ -23,6 +21,8 @@ import ApiKeyDialog from "./ApiKeyDialog"; import { PlusSettings } from "@/settings/v2/components/PlusSettings"; import { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { createPlusPageUrl } from "@/plusUtils"; +import { getModelDisplayWithIcons } from "@/components/ui/model-display"; + const ChainType2Label: Record = { [ChainType.LLM_CHAIN]: "Chat", [ChainType.VAULT_QA_CHAIN]: "Vault QA (Basic)", @@ -163,24 +163,19 @@ const BasicSettings: React.FC = ({ indexVaultToVectorStore } const selectedModel = settings.activeModels.find( (m) => m.enabled && getModelKeyFromModel(m) === value ); - if (selectedModel) { - const providerKeyName = - ProviderSettingsKeyMap[selectedModel.provider as DisplayKeyProviders]; - const hasNoApiKey = !selectedModel.apiKey && !settings[providerKeyName]; - if (hasNoApiKey) { - const notice = - `Please configure API Key for ${selectedModel.name} in settings first.` + - "\nPath: Settings > copilot plugin > Basic Tab > Set Keys"; - new Notice(notice); - return; - } + if (!selectedModel) return; + + const { hasApiKey, errorNotice } = checkModelApiKey(selectedModel, settings); + if (!hasApiKey && errorNotice) { + new Notice(errorNotice); + return; } updateSetting("defaultModelKey", value); }} options={settings.activeModels .filter((m) => m.enabled) .map((model) => ({ - label: `${model.name} (${getProviderLabel(model.provider, model)})`, + label: getModelDisplayWithIcons(model), value: getModelKeyFromModel(model), }))} placeholder="Model" @@ -219,7 +214,7 @@ const BasicSettings: React.FC = ({ indexVaultToVectorStore } value={settings.embeddingModelKey} onChange={handleSetDefaultEmbeddingModel} options={settings.activeEmbeddingModels.map((model) => ({ - label: `${model.name} (${getProviderLabel(model.provider, model)})`, + label: getModelDisplayWithIcons(model), value: getModelKeyFromModel(model), }))} placeholder="Model" diff --git a/src/settings/v2/components/ModelAddDialog.tsx b/src/settings/v2/components/ModelAddDialog.tsx index ec9dfbd1..bed05c30 100644 --- a/src/settings/v2/components/ModelAddDialog.tsx +++ b/src/settings/v2/components/ModelAddDialog.tsx @@ -1,7 +1,24 @@ +import React, { useState } from "react"; +import { useTab } from "@/contexts/TabContext"; +import { getSettings } from "@/settings/model"; +import { + ChatModelProviders, + DisplayKeyProviders, + EmbeddingModelProviders, + MODEL_CAPABILITIES, + ModelCapability, + Provider, + ProviderMetadata, + ProviderSettingsKeyMap, +} from "@/constants"; import { CustomModel } from "@/aiParams"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; +import { err2String, getProviderInfo, getProviderLabel, omit } from "@/utils"; +import { Notice } from "obsidian"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { ChevronDown, Loader2, HelpCircle } from "lucide-react"; import { Dialog, DialogContent, @@ -9,9 +26,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; import { Select, SelectContent, @@ -19,49 +33,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - ChatModelProviders, - DisplayKeyProviders, - EmbeddingModelProviders, - Provider, - ProviderMetadata, - ProviderSettingsKeyMap, -} from "@/constants"; -import { useTab } from "@/contexts/TabContext"; -import { getSettings } from "@/settings/model"; -import { err2String, getProviderInfo, getProviderLabel, omit } from "@/utils"; -import { ChevronDown, Loader2 } from "lucide-react"; -import { Notice } from "obsidian"; -import React, { useState } from "react"; - -interface FormFieldProps { - label: string; - required?: boolean; - error?: boolean; - description?: string; - errorMessage?: string; - children: React.ReactNode; -} - -const FormField: React.FC = ({ - label, - required = false, - error = false, - description, - errorMessage = "This field is required", - children, -}) => { - return ( -
- - {children} - {error &&

{errorMessage}

} - {description &&

{description}

} -
- ); -}; +import { PasswordInput } from "@/components/ui/password-input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { FormField } from "@/components/ui/form-field"; interface FormErrors { name: boolean; @@ -69,6 +44,7 @@ interface FormErrors { deploymentName: boolean; embeddingDeploymentName: boolean; apiVersion: boolean; + displayName: boolean; } interface ModelAddDialogProps { @@ -101,6 +77,7 @@ export const ModelAddDialog: React.FC = ({ deploymentName: false, embeddingDeploymentName: false, apiVersion: false, + displayName: false, }); const setError = (field: keyof FormErrors, value: boolean) => { @@ -114,6 +91,7 @@ export const ModelAddDialog: React.FC = ({ deploymentName: false, embeddingDeploymentName: false, apiVersion: false, + displayName: false, }); }; @@ -160,6 +138,7 @@ export const ModelAddDialog: React.FC = ({ baseUrl: "", apiKey: getDefaultApiKey(provider), isEmbeddingModel, + capabilities: [], }; if (!isEmbeddingModel) { @@ -173,6 +152,23 @@ export const ModelAddDialog: React.FC = ({ }; const [model, setModel] = useState(getInitialModel()); + + // Clean up model data by trimming whitespace + const getCleanedModel = (modelData: CustomModel): CustomModel => { + return { + ...modelData, + name: modelData.name?.trim(), + baseUrl: modelData.baseUrl?.trim(), + apiKey: modelData.apiKey?.trim(), + openAIOrgId: modelData.openAIOrgId?.trim(), + azureOpenAIApiInstanceName: modelData.azureOpenAIApiInstanceName?.trim(), + azureOpenAIApiDeploymentName: modelData.azureOpenAIApiDeploymentName?.trim(), + azureOpenAIApiEmbeddingDeploymentName: + modelData.azureOpenAIApiEmbeddingDeploymentName?.trim(), + azureOpenAIApiVersion: modelData.azureOpenAIApiVersion?.trim(), + }; + }; + const [providerInfo, setProviderInfo] = useState( getProviderInfo(defaultProvider) ); @@ -193,7 +189,8 @@ export const ModelAddDialog: React.FC = ({ return; } - onAdd(model); + const cleanedModel = getCleanedModel(model); + onAdd(cleanedModel); onOpenChange(false); setModel(getInitialModel()); clearErrors(); @@ -233,7 +230,8 @@ export const ModelAddDialog: React.FC = ({ setIsVerifying(true); try { - await ping(model); + const cleanedModel = getCleanedModel(model); + await ping(cleanedModel); new Notice("Model verification successful!"); } catch (err) { console.error(err); @@ -381,6 +379,12 @@ export const ModelAddDialog: React.FC = ({ return `https://${instanceName}.openai.azure.com/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}`; }; + const capabilityOptions = Object.entries(MODEL_CAPABILITIES).map(([id, description]) => ({ + id, + label: id.charAt(0).toUpperCase() + id.slice(1), + description, + })) as Array<{ id: ModelCapability; label: string; description: string }>; + return ( = ({ Add a new model to your collection. -
+
= ({ /> + + Display Name + + + + + + +
+
Suggested format:
+
[Source]-[Payment]:[Pretty Model Name]
+
+ Example: +
  • Direct-Paid:Ds-r1
  • +
  • OpenRouter-Paid:Ds-r1
  • +
  • Perplexity-Paid:lg
  • +
    +
    +
    +
    +
    +
    + } + > + { + setModel({ ...model, displayName: e.target.value }); + }} + /> + + handleUpdate("name", e.target.value)} + placeholder="Enter model name" /> -
    + -
    -
    -
    - Context - + + Display Name + + + + + + +
    +
    Suggested format:
    +
    [Source]-[Payment]:[Pretty Model Name]
    +
    + Example: +
  • Direct-Paid:Ds-r1
  • +
  • OpenRouter-Paid:Ds-r1
  • +
  • Perplexity-Paid:lg
  • +
    +
    +
    +
    +
    -
    - handleUpdate("context", value)} - max={16000} - min={0} - step={100} + } + > + handleUpdate("displayName", e.target.value)} /> -
    + -
    -
    -
    Stream output
    - handleUpdate("stream", checked)} + +
    + {capabilityOptions.map(({ id, label, description }) => ( +
    + { + const newCapabilities = localModel.capabilities || []; + handleUpdate( + "capabilities", + checked + ? [...newCapabilities, id] + : newCapabilities.filter((cap) => cap !== id) + ); + }} + /> + +
    + ))} +
    +
    + + {/* + Temperature + + + + + + + Controls randomness: 0 is focused and deterministic, 2 is more creative + + + +
    + } + > + handleUpdate("temperature", value)} + max={2} + min={0} + step={0.1} /> + + + + Context + + + + + + + Maximum number of tokens to use for context + + + +
    + } + > + handleUpdate("context", value)} + max={16000} + min={0} + step={100} + /> + */} + +
    +
    + Stream output + + + + + + + Enable streaming responses from the model + + +
    + handleUpdate("stream", checked)} + />
    diff --git a/src/settings/v2/components/ModelSettings.tsx b/src/settings/v2/components/ModelSettings.tsx index 7850d92a..bc1b60fa 100644 --- a/src/settings/v2/components/ModelSettings.tsx +++ b/src/settings/v2/components/ModelSettings.tsx @@ -6,10 +6,11 @@ import ChatModelManager from "@/LLMProviders/chatModelManager"; import EmbeddingManager from "@/LLMProviders/embeddingManager"; import { ModelAddDialog } from "@/settings/v2/components/ModelAddDialog"; import { ModelTable } from "@/settings/v2/components/ModelTable"; +import { ModelEditDialog } from "@/settings/v2/components/ModelEditDialog"; const ModelSettings: React.FC = () => { const settings = useSettingsValue(); - // const [editingModel, setEditingModel] = useState(null); + const [editingModel, setEditingModel] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); const [showAddEmbeddingDialog, setShowAddEmbeddingDialog] = useState(false); @@ -40,6 +41,10 @@ const ModelSettings: React.FC = () => { updateSetting("activeModels", updatedModels); }; + const handleModelReorder = (newModels: CustomModel[]) => { + updateSetting("activeModels", newModels); + }; + const onDeleteEmbeddingModel = (modelKey: string) => { const [modelName, provider] = modelKey.split("|"); const updatedModels = settings.activeEmbeddingModels.filter( @@ -55,26 +60,31 @@ const ModelSettings: React.FC = () => { updateSetting("activeEmbeddingModels", updatedModels); }; + const handleEmbeddingModelReorder = (newModels: CustomModel[]) => { + updateSetting("activeEmbeddingModels", newModels); + }; + return (
    Chat Models
    setShowAddDialog(true)} onUpdateModel={handleModelUpdate} + onReorderModels={handleModelReorder} title="Chat Model" /> {/* model edit dialog*/} - {/* !open && setEditingModel(null)} model={editingModel} onUpdate={handleModelUpdate} - />*/} + /> {/* model add dialog */} { onDelete={onDeleteEmbeddingModel} onAdd={() => setShowAddEmbeddingDialog(true)} onUpdateModel={handleEmbeddingModelUpdate} + onReorderModels={handleEmbeddingModelReorder} title="Embedding Model" /> diff --git a/src/settings/v2/components/ModelTable.tsx b/src/settings/v2/components/ModelTable.tsx index 3f0c8437..91e83d9d 100644 --- a/src/settings/v2/components/ModelTable.tsx +++ b/src/settings/v2/components/ModelTable.tsx @@ -13,8 +13,87 @@ import { import { cn } from "@/lib/utils"; import { getModelKeyFromModel } from "@/settings/model"; import { getProviderLabel } from "@/utils"; -import { ChevronDown, ChevronRight, Pencil, Plus, Trash2 } from "lucide-react"; -import React from "react"; +import { + Move, + Pencil, + Plus, + Trash2, + Lightbulb, + Eye, + Globe, + ChevronDown, + ChevronRight, + LucideProps, +} from "lucide-react"; +import React, { ForwardRefExoticComponent, RefAttributes } from "react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { ModelCapabilityIcons } from "@/components/ui/model-display"; +import { MODEL_CAPABILITIES, ModelCapability } from "@/constants"; + +const CAPABILITY_ICONS: Record< + ModelCapability, + { + icon: ForwardRefExoticComponent & RefAttributes>; + color: string; + tooltip: string; + } +> = { + [ModelCapability.REASONING]: { + icon: Lightbulb, + color: "text-model-capabilities-blue", + tooltip: MODEL_CAPABILITIES.reasoning, + }, + [ModelCapability.VISION]: { + icon: Eye, + color: "text-model-capabilities-green", + tooltip: MODEL_CAPABILITIES.vision, + }, + [ModelCapability.WEB_SEARCH]: { + icon: Globe, + color: "text-model-capabilities-blue", + tooltip: MODEL_CAPABILITIES.websearch, + }, +} as const; + +const renderCapabilities = (model: CustomModel) => { + return ( + +
    + {model.capabilities?.map((capability) => { + const config = CAPABILITY_ICONS[capability]; + if (!config) return null; + + const Icon = config.icon; + return ( + + + + + {config.tooltip} + + ); + })} +
    +
    + ); +}; interface ModelTableProps { models: CustomModel[]; @@ -22,42 +101,88 @@ interface ModelTableProps { onDelete: (modelKey: string) => void; onAdd: () => void; onUpdateModel: (model: CustomModel) => void; + onReorderModels?: (newModels: CustomModel[]) => void; title: string; } -const ModelCard: React.FC<{ +interface ModelCardProps { model: CustomModel; onEdit?: () => void; onDelete?: () => void; onUpdateModel: (model: CustomModel) => void; -}> = ({ model, onEdit, onDelete, onUpdateModel }) => { + id: string; +} + +const ModelCard: React.FC = ({ model, onEdit, onDelete, onUpdateModel, id }) => { const [isExpanded, setIsExpanded] = React.useState(false); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled: model.core, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return ( - + -
    setIsExpanded(!isExpanded)} - > -
    -
    - {isExpanded ? ( - - ) : ( - - )} +
    + {!model.core && ( +
    +
    -
    -
    {model.name}
    -
    - - {getProviderLabel(model.provider, model)} - - {model.believerExclusive && (Believer)} + )} + +
    setIsExpanded(!isExpanded)}> +
    +
    + {isExpanded ? ( + + ) : ( + + )} +
    +
    +
    + {model.displayName || model.name} + {model.capabilities && model.capabilities.length > 0 && ( + + )} +
    +
    + + {getProviderLabel(model.provider, model)} + +
    +
    {onEdit && ( + )} + + {model.displayName || model.name} + {getProviderLabel(model.provider, model)} + {renderCapabilities(model)} + {!isEmbeddingModel && ( + + onUpdateModel({ ...model, enabled: checked })} + className="mx-auto" + /> + + )} + + onUpdateModel({ ...model, enableCors: checked })} + className="mx-auto" + /> + + +
    + {onEdit && ( + + )} + {!model.core && ( + + )} +
    +
    + + ); +}; + export const ModelTable: React.FC = ({ models, onEdit, onDelete, onAdd, onUpdateModel, + onReorderModels, title, }) => { - const isEmbeddingModel = models.length > 0 && models[0].isEmbeddingModel; + const isEmbeddingModel = !!(models.length > 0 && models[0].isEmbeddingModel); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // Find the index of the first non-core model + const firstDraggableIndex = models.findIndex((model) => !model.core); + + // Create unified modifier logic + const createDragModifier = (isMobile: boolean) => (args: any) => { + const { transform, active, activeNodeRect, over } = args; + if (!active || !activeNodeRect) return transform; + + // Get the index of current dragging item + const currentIndex = models.findIndex((model) => getModelKeyFromModel(model) === active.id); + + // Calculate the number of non-core items + const draggableItemsCount = models.filter((model) => !model.core).length; + + // Calculate row height + const rowHeight = activeNodeRect.height; + + // Calculate draggable range + const minY = (firstDraggableIndex - currentIndex) * rowHeight; + const maxY = (firstDraggableIndex + draggableItemsCount - 1 - currentIndex) * rowHeight; + + // For mobile view, check if hovering over a core model + if (isMobile && over) { + const overIndex = models.findIndex((model) => getModelKeyFromModel(model) === over.id); + const overModel = models[overIndex]; + + // If hovering over a core model, return to original position + if (overModel.core || overIndex < firstDraggableIndex) { + return { + ...transform, + x: 0, + y: currentIndex * rowHeight, + }; + } + } + + // Restrict within draggable range + return { + ...transform, + x: 0, + y: Math.min(Math.max(minY, transform.y), maxY), + }; + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = models.findIndex((model) => getModelKeyFromModel(model) === active.id); + const newIndex = models.findIndex((model) => getModelKeyFromModel(model) === over.id); + + // Get target model + const targetModel = models[newIndex]; + + // 1. Prevent moving to core model positions + if (newIndex < firstDraggableIndex) { + return; + } + + // 2. Prevent moving to other non-draggable model positions + if (targetModel.core) { + return; + } + + const newModels = arrayMove(models, oldIndex, newIndex); + onReorderModels?.(newModels); + } + }; + + // Mobile view rendering + const renderMobileView = () => ( +
    + + getModelKeyFromModel(model))} + strategy={verticalListSortingStrategy} + > +
    + {models.map((model) => ( + onEdit(model) : undefined} + onDelete={() => onDelete(getModelKeyFromModel(model))} + onUpdateModel={onUpdateModel} + /> + ))} +
    +
    +
    +
    + ); return (
    - {/* Desktop View */} + {/* Desktop view */}
    - - - - Model - Provider - {!isEmbeddingModel && Enable} - CORS - Actions - - - - {models.map((model) => ( - - {model.name} - {getProviderLabel(model.provider, model)} - {!isEmbeddingModel && ( - - - onUpdateModel({ ...model, enabled: checked }) - } - className="mx-auto" + +
    +
    + + + + Model + Provider + Capabilities + {!isEmbeddingModel && Enable} + CORS + Actions + + + + getModelKeyFromModel(model))} + strategy={verticalListSortingStrategy} + > + {models.map((model) => ( + onEdit(model) : undefined} + onDelete={onDelete} + onUpdateModel={onUpdateModel} + isEmbeddingModel={isEmbeddingModel} /> - - )} - - - onUpdateModel({ ...model, enableCors: checked }) - } - className="mx-auto" - /> - - -
    - {onEdit && ( - - )} - {!model.core && ( - - )} -
    -
    - - ))} -
    -
    + ))} + + + +
    +
    - {/* Mobile View */} -
    -
    - {models.map((model) => ( - onEdit(model) : undefined} - onDelete={() => onDelete(getModelKeyFromModel(model))} - onUpdateModel={onUpdateModel} - /> - ))} -
    -
    + {/* Mobile view */} + {renderMobileView()}