feat: Support custom model displayNames and reorderable Model list. (#1225)

This commit is contained in:
Emt-lin 2025-02-13 05:11:09 +08:00 committed by GitHub
parent 45838c4754
commit 899133166e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 950 additions and 239 deletions

56
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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";

View file

@ -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<string | null>(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

View file

@ -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>(
<Button variant="ghost2" size="fit">
{modelError ? (
<span className="text-error">Model Load Failed</span>
) : (
settings.activeModels.find(
) : settings.activeModels.find(
(model) => model.enabled && getModelKeyFromModel(model) === currentModelKey
)?.name || "Select Model"
) ? (
<ModelDisplay
model={
settings.activeModels.find(
(model) =>
model.enabled && getModelKeyFromModel(model) === currentModelKey
)!
}
iconSize={8}
/>
) : (
"Select Model"
)}
<ChevronDown className="size-5 mt-0.5" />
</Button>
@ -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 (
<DropdownMenuItem
key={getModelKeyFromModel(model)}
onSelect={async (event) => {
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}
<ModelDisplay model={model} iconSize={12} />
</DropdownMenuItem>
);
})}

View file

@ -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<FormFieldProps> = ({
label,
required = false,
error = false,
description,
errorMessage = "This field is required",
children,
}) => {
return (
<div className="space-y-2">
<Label className={error ? "text-error" : ""}>
{label} {required && <span className="text-error">*</span>}
</Label>
{children}
{error && <p className="text-xs text-error">{errorMessage}</p>}
{description && <p className="text-sm text-muted">{description}</p>}
</div>
);
};

View file

@ -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<ModelCapabilityIconsProps> = ({
capabilities = [],
iconSize = 16,
}) => {
return (
<>
{capabilities.map((cap, index) => {
switch (cap) {
case ModelCapability.REASONING:
return (
<Lightbulb
key={index}
className="text-model-capabilities-blue"
style={{ width: iconSize, height: iconSize }}
/>
);
case ModelCapability.VISION:
return (
<Eye
key={index}
className="text-model-capabilities-green"
style={{ width: iconSize, height: iconSize }}
/>
);
case ModelCapability.WEB_SEARCH:
return (
<Globe
key={index}
className="text-model-capabilities-blue"
style={{ width: iconSize, height: iconSize }}
/>
);
default:
return null;
}
})}
</>
);
};
export const ModelDisplay: React.FC<ModelDisplayProps> = ({ model, iconSize = 14 }) => {
const displayName = model.displayName || model.name;
const provider = `(${getProviderLabel(model.provider)})`;
return (
<div className="flex items-center gap-1">
<span>
{displayName}
<span className="text-[10px]">{provider}</span>
</span>
{model.capabilities && model.capabilities.length > 0 && (
<div className="flex gap-0.5 items-center">
<ModelCapabilityIcons capabilities={model.capabilities} iconSize={iconSize} />
</div>
)}
</div>
);
};
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}`;
};

View file

@ -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<ModelCapability, string> = {
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,

View file

@ -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, string> = {
[ChainType.LLM_CHAIN]: "Chat",
[ChainType.VAULT_QA_CHAIN]: "Vault QA (Basic)",
@ -163,24 +163,19 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ 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<BasicSettingsProps> = ({ 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"

View file

@ -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<FormFieldProps> = ({
label,
required = false,
error = false,
description,
errorMessage = "This field is required",
children,
}) => {
return (
<div className="space-y-2">
<Label className={error ? "text-error" : ""}>
{label} {required && <span className="text-error">*</span>}
</Label>
{children}
{error && <p className="text-xs text-error">{errorMessage}</p>}
{description && <p className="text-sm text-muted">{description}</p>}
</div>
);
};
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<ModelAddDialogProps> = ({
deploymentName: false,
embeddingDeploymentName: false,
apiVersion: false,
displayName: false,
});
const setError = (field: keyof FormErrors, value: boolean) => {
@ -114,6 +91,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
deploymentName: false,
embeddingDeploymentName: false,
apiVersion: false,
displayName: false,
});
};
@ -160,6 +138,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
baseUrl: "",
apiKey: getDefaultApiKey(provider),
isEmbeddingModel,
capabilities: [],
};
if (!isEmbeddingModel) {
@ -173,6 +152,23 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
};
const [model, setModel] = useState<CustomModel>(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<ProviderMetadata>(
getProviderInfo(defaultProvider)
);
@ -193,7 +189,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
return;
}
onAdd(model);
const cleanedModel = getCleanedModel(model);
onAdd(cleanedModel);
onOpenChange(false);
setModel(getInitialModel());
clearErrors();
@ -233,7 +230,8 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
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<ModelAddDialogProps> = ({
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
@ -393,7 +397,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
<DialogDescription>Add a new model to your collection.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-3">
<FormField
label="Model Name"
required
@ -413,6 +417,42 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
/>
</FormField>
<FormField
label={
<div className="flex items-center gap-1.5">
<span className="leading-none">Display Name</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent align="start" className="max-w-96" side="bottom">
<div className="text-sm text-muted flex flex-col gap-0.5">
<div className="text-[12px] font-bold">Suggested format:</div>
<div className="text-accent">[Source]-[Payment]:[Pretty Model Name]</div>
<div className="text-[12px]">
Example:
<li>Direct-Paid:Ds-r1</li>
<li>OpenRouter-Paid:Ds-r1</li>
<li>Perplexity-Paid:lg</li>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<Input
type="text"
placeholder="Custom display name (optional)"
value={model.displayName || ""}
onChange={(e) => {
setModel({ ...model, displayName: e.target.value });
}}
/>
</FormField>
<FormField label="Provider">
<Select value={model.provider} onValueChange={handleProviderChange}>
<SelectTrigger>
@ -456,6 +496,38 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
)}
</FormField>
<FormField label="Model Capabilities">
<div className="flex gap-4 items-center">
{capabilityOptions.map(({ id, label, description }) => (
<div key={id} className="flex items-center gap-2">
<Checkbox
id={id}
checked={model.capabilities?.includes(id)}
onCheckedChange={(checked) => {
const newCapabilities = model.capabilities || [];
setModel({
...model,
capabilities: checked
? [...newCapabilities, id]
: newCapabilities.filter((cap) => cap !== id),
});
}}
/>
<Label htmlFor={id} className="text-sm">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span>{label}</span>
</TooltipTrigger>
<TooltipContent side="bottom">{description}</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
</div>
))}
</div>
</FormField>
{renderProviderSpecificFields()}
</div>

View file

@ -9,8 +9,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { HelpCircle } from "lucide-react";
import { SettingSlider } from "@/components/ui/setting-slider";
import { SettingSwitch } from "@/components/ui/setting-switch";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { FormField } from "@/components/ui/form-field";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { MODEL_CAPABILITIES, ModelCapability } from "@/constants";
interface ModelEditDialogProps {
open: boolean;
@ -43,6 +48,12 @@ export const ModelEditDialog: React.FC<ModelEditDialogProps> = ({
onUpdate(updatedModel);
};
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]" container={modalContainer}>
@ -52,46 +63,152 @@ export const ModelEditDialog: React.FC<ModelEditDialogProps> = ({
</DialogHeader>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
Temperature
<HelpCircle className="h-4 w-4 text-muted" />
</div>
</div>
<SettingSlider
value={localModel.temperature ?? 0.1}
onChange={(value) => handleUpdate("temperature", value)}
max={2}
min={0}
step={0.1}
<FormField label="Model Name" required>
<Input
type="text"
disabled={localModel.core}
value={localModel.name}
onChange={(e) => handleUpdate("name", e.target.value)}
placeholder="Enter model name"
/>
</div>
</FormField>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
Context
<HelpCircle className="h-4 w-4 text-muted" />
<FormField
label={
<div className="flex items-center gap-1.5">
<span className="leading-none">Display Name</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent align="start" className="max-w-96" side="bottom">
<div className="text-sm text-muted flex flex-col gap-0.5">
<div className="text-[12px] font-bold">Suggested format:</div>
<div className="text-accent">[Source]-[Payment]:[Pretty Model Name]</div>
<div className="text-[12px]">
Example:
<li>Direct-Paid:Ds-r1</li>
<li>OpenRouter-Paid:Ds-r1</li>
<li>Perplexity-Paid:lg</li>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<SettingSlider
value={localModel.context ?? 1000}
onChange={(value) => handleUpdate("context", value)}
max={16000}
min={0}
step={100}
}
>
<Input
type="text"
placeholder="Custom display name (optional)"
value={localModel.displayName || ""}
onChange={(e) => handleUpdate("displayName", e.target.value)}
/>
</div>
</FormField>
<div className="py-2 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">Stream output</div>
<SettingSwitch
checked={localModel.stream ?? true}
onCheckedChange={(checked) => handleUpdate("stream", checked)}
<FormField label="Model Capabilities">
<div className="flex gap-4 items-center">
{capabilityOptions.map(({ id, label, description }) => (
<div key={id} className="flex items-center gap-2">
<Checkbox
id={id}
checked={localModel.capabilities?.includes(id)}
onCheckedChange={(checked) => {
const newCapabilities = localModel.capabilities || [];
handleUpdate(
"capabilities",
checked
? [...newCapabilities, id]
: newCapabilities.filter((cap) => cap !== id)
);
}}
/>
<Label htmlFor={id} className="text-sm">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span>{label}</span>
</TooltipTrigger>
<TooltipContent side="bottom">{description}</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
</div>
))}
</div>
</FormField>
{/* <FormField
label={
<div className="flex items-center gap-2">
Temperature
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted" />
</TooltipTrigger>
<TooltipContent side="bottom">
Controls randomness: 0 is focused and deterministic, 2 is more creative
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<SettingSlider
value={localModel.temperature ?? 0.1}
onChange={(value) => handleUpdate("temperature", value)}
max={2}
min={0}
step={0.1}
/>
</FormField>
<FormField
label={
<div className="flex items-center gap-2">
Context
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted" />
</TooltipTrigger>
<TooltipContent side="bottom">
Maximum number of tokens to use for context
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<SettingSlider
value={localModel.context ?? 1000}
onChange={(value) => handleUpdate("context", value)}
max={16000}
min={0}
step={100}
/>
</FormField>*/}
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Stream output</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted" />
</TooltipTrigger>
<TooltipContent side="bottom">
Enable streaming responses from the model
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<SettingSwitch
checked={localModel.stream ?? true}
onCheckedChange={(checked) => handleUpdate("stream", checked)}
/>
</div>
</div>
</DialogContent>

View file

@ -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<CustomModel | null>(null);
const [editingModel, setEditingModel] = useState<CustomModel | null>(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 (
<div className="space-y-4">
<section>
<div className="text-xl font-bold mb-3">Chat Models</div>
<ModelTable
models={settings.activeModels}
// onEdit={setEditingModel}
onEdit={setEditingModel}
onDelete={onDeleteModel}
onAdd={() => setShowAddDialog(true)}
onUpdateModel={handleModelUpdate}
onReorderModels={handleModelReorder}
title="Chat Model"
/>
{/* model edit dialog*/}
{/* <ModelEditDialog
<ModelEditDialog
open={!!editingModel}
onOpenChange={(open) => !open && setEditingModel(null)}
model={editingModel}
onUpdate={handleModelUpdate}
/>*/}
/>
{/* model add dialog */}
<ModelAddDialog
@ -140,6 +150,7 @@ const ModelSettings: React.FC = () => {
onDelete={onDeleteEmbeddingModel}
onAdd={() => setShowAddEmbeddingDialog(true)}
onUpdateModel={handleEmbeddingModelUpdate}
onReorderModels={handleEmbeddingModelReorder}
title="Embedding Model"
/>

View file

@ -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<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
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 (
<TooltipProvider delayDuration={0}>
<div className="flex gap-2 items-center justify-center">
{model.capabilities?.map((capability) => {
const config = CAPABILITY_ICONS[capability];
if (!config) return null;
const Icon = config.icon;
return (
<Tooltip key={capability}>
<TooltipTrigger asChild>
<Icon className={`h-4 w-4 ${config.color}`} />
</TooltipTrigger>
<TooltipContent side="bottom">{config.tooltip}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
};
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<ModelCardProps> = ({ 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 (
<Card className="mb-2">
<Card
ref={setNodeRef}
style={style}
className={cn(
"mb-2",
isDragging && [
"opacity-90",
"shadow-lg",
"border-2",
"border-accent/50",
"relative",
"z-[9999]",
"bg-background",
"rounded-lg",
"transform-gpu",
],
!model.core && "touch-none"
)}
>
<CardHeader className="p-3">
<div
className="flex items-center justify-between cursor-pointer border-l-[0] border-r-[0] border-t-[0] border-b border-solid border-border pb-2"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 flex items-center justify-center">
{isExpanded ? (
<ChevronDown className="h-3 w-3 stroke-[7]" />
) : (
<ChevronRight className="h-3 w-3 stroke-[7]" />
)}
<div className="flex items-center justify-between">
{!model.core && (
<div
className="mr-2 touch-none cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
>
<Move className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="font-medium">{model.name}</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted bg-secondary">
{getProviderLabel(model.provider, model)}
</span>
{model.believerExclusive && <span className="text-sm text-accent">(Believer)</span>}
)}
<div className="flex-1 cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex items-center gap-2">
<div className="w-3 h-3 flex items-center justify-center">
{isExpanded ? (
<ChevronDown className="h-3 w-3 stroke-[7]" />
) : (
<ChevronRight className="h-3 w-3 stroke-[7]" />
)}
</div>
<div>
<div className="flex items-center gap-1">
<span className="font-medium">{model.displayName || model.name}</span>
{model.capabilities && model.capabilities.length > 0 && (
<ModelCapabilityIcons capabilities={model.capabilities} iconSize={14} />
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted bg-secondary">
{getProviderLabel(model.provider, model)}
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{onEdit && (
<Button
@ -121,104 +246,260 @@ const ModelCard: React.FC<{
);
};
const SortableTableRow: React.FC<{
model: CustomModel;
onEdit?: (model: CustomModel) => void;
onDelete: (modelKey: string) => void;
onUpdateModel: (model: CustomModel) => void;
isEmbeddingModel: boolean;
}> = ({ model, onEdit, onDelete, onUpdateModel, isEmbeddingModel }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: getModelKeyFromModel(model),
disabled: model.core,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<TableRow
ref={setNodeRef}
style={style}
className={cn(
"hover:bg-interactive-accent/10 transition-colors duration-200",
isDragging &&
"shadow-lg bg-background/90 backdrop-blur-sm relative z-[100] cursor-grabbing border-2 border-accent/50",
!isDragging && "z-auto"
)}
>
<TableCell className="w-6 px-2">
{!model.core && (
<Button
variant="ghost"
size="icon"
className="cursor-grab touch-none hover:cursor-grab active:cursor-grabbing p-0 h-6 w-6"
{...attributes}
{...listeners}
>
<Move className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
</Button>
)}
</TableCell>
<TableCell className="pl-0">{model.displayName || model.name}</TableCell>
<TableCell>{getProviderLabel(model.provider, model)}</TableCell>
<TableCell className="text-center">{renderCapabilities(model)}</TableCell>
{!isEmbeddingModel && (
<TableCell className="text-center">
<Checkbox
id={`${getModelKeyFromModel(model)}-enabled`}
checked={model.enabled}
onCheckedChange={(checked: boolean) => onUpdateModel({ ...model, enabled: checked })}
className="mx-auto"
/>
</TableCell>
)}
<TableCell className="text-center">
<Checkbox
id={`${getModelKeyFromModel(model)}-enableCors`}
checked={model.enableCors}
onCheckedChange={(checked: boolean) => onUpdateModel({ ...model, enableCors: checked })}
className="mx-auto"
/>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-2">
{onEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(model)}
className="shadow-sm hover:shadow-md transition-shadow"
>
<Pencil className="h-4 w-4" />
</Button>
)}
{!model.core && (
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(getModelKeyFromModel(model))}
className="shadow-sm hover:shadow-md transition-shadow"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
};
export const ModelTable: React.FC<ModelTableProps> = ({
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 = () => (
<div className="md:hidden relative">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[createDragModifier(true)]}
>
<SortableContext
items={models.map((model) => getModelKeyFromModel(model))}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 relative">
{models.map((model) => (
<ModelCard
key={getModelKeyFromModel(model)}
id={getModelKeyFromModel(model)}
model={model}
onEdit={onEdit ? () => onEdit(model) : undefined}
onDelete={() => onDelete(getModelKeyFromModel(model))}
onUpdateModel={onUpdateModel}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
return (
<div className="mb-4">
{/* Desktop View */}
{/* Desktop view */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Provider</TableHead>
{!isEmbeddingModel && <TableHead className="text-center">Enable</TableHead>}
<TableHead className="text-center">CORS</TableHead>
<TableHead className="w-[100px] text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.map((model) => (
<TableRow
key={getModelKeyFromModel(model)}
className="hover:bg-interactive-accent/10"
>
<TableCell>{model.name}</TableCell>
<TableCell>{getProviderLabel(model.provider, model)}</TableCell>
{!isEmbeddingModel && (
<TableCell className="text-center">
<Checkbox
id={`${getModelKeyFromModel(model)}-enabled`}
checked={model.enabled}
onCheckedChange={(checked: boolean) =>
onUpdateModel({ ...model, enabled: checked })
}
className="mx-auto"
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[createDragModifier(false)]}
>
<div className="relative overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-6 px-2"></TableHead>
<TableHead className="pl-0">Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="text-center">Capabilities</TableHead>
{!isEmbeddingModel && <TableHead className="text-center">Enable</TableHead>}
<TableHead className="text-center">CORS</TableHead>
<TableHead className="w-[100px] text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody className="relative">
<SortableContext
items={models.map((model) => getModelKeyFromModel(model))}
strategy={verticalListSortingStrategy}
>
{models.map((model) => (
<SortableTableRow
key={getModelKeyFromModel(model)}
model={model}
onEdit={onEdit ? () => onEdit(model) : undefined}
onDelete={onDelete}
onUpdateModel={onUpdateModel}
isEmbeddingModel={isEmbeddingModel}
/>
</TableCell>
)}
<TableCell className="text-center">
<Checkbox
id={`${getModelKeyFromModel(model)}-enableCors`}
checked={model.enableCors}
onCheckedChange={(checked: boolean) =>
onUpdateModel({ ...model, enableCors: checked })
}
className="mx-auto"
/>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-2">
{onEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(model)}
className="shadow-sm hover:shadow-md transition-shadow"
>
<Pencil className="h-4 w-4" />
</Button>
)}
{!model.core && (
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(getModelKeyFromModel(model))}
className="shadow-sm hover:shadow-md transition-shadow"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
))}
</SortableContext>
</TableBody>
</Table>
</div>
</DndContext>
</div>
{/* Mobile View */}
<div className="md:hidden">
<div className="space-y-2">
{models.map((model) => (
<ModelCard
key={getModelKeyFromModel(model)}
model={model}
onEdit={onEdit ? () => onEdit(model) : undefined}
onDelete={() => onDelete(getModelKeyFromModel(model))}
onUpdateModel={onUpdateModel}
/>
))}
</div>
</div>
{/* Mobile view */}
{renderMobileView()}
<div className="mt-4 flex justify-end">
<Button onClick={onAdd} variant="outline" className="flex items-center gap-2">

View file

@ -5,6 +5,8 @@ import {
ProviderInfo,
ProviderMetadata,
USER_SENDER,
DisplayKeyProviders,
ProviderSettingsKeyMap,
} from "@/constants";
import { ChatMessage } from "@/sharedState";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
@ -14,6 +16,7 @@ import { BaseChain, RetrievalQAChain } from "langchain/chains";
import moment from "moment";
import { MarkdownView, Notice, TFile, Vault, requestUrl } from "obsidian";
import { CustomModel } from "./aiParams";
import { CopilotSettings } from "@/settings/model";
// Add custom error type at the top of the file
interface APIError extends Error {
@ -780,10 +783,7 @@ export function getProviderInfo(provider: string): ProviderMetadata {
export function getProviderLabel(provider: string, model?: CustomModel): string {
const baseLabel = ProviderInfo[provider as Provider]?.label || provider;
if (model?.believerExclusive && baseLabel === "Copilot Plus") {
return "Believer";
}
return baseLabel;
return baseLabel + (model?.believerExclusive && baseLabel === "Copilot Plus" ? "(Believer)" : "");
}
export function getProviderHost(provider: string): string {
@ -885,3 +885,28 @@ export function getMessageRole(
): "system" | "human" {
return isOSeriesModel(model) ? "human" : defaultRole;
}
export function checkModelApiKey(
model: CustomModel,
settings: Readonly<CopilotSettings>
): {
hasApiKey: boolean;
errorNotice?: string;
} {
const providerKeyName = ProviderSettingsKeyMap[model.provider as DisplayKeyProviders];
const hasNoApiKey = !model.apiKey && !settings[providerKeyName];
if (hasNoApiKey) {
const notice =
`Please configure API Key for ${model.name} in settings first.` +
"\nPath: Settings > copilot plugin > Basic Tab > Set Keys";
return {
hasApiKey: false,
errorNotice: notice,
};
}
return {
hasApiKey: true,
};
}

View file

@ -34,6 +34,10 @@ module.exports = {
callout: {
warning: "rgba(var(--callout-warning),<alpha-value>)",
},
"model-capabilities": {
green: "var(--color-green)",
blue: "var(--color-blue)",
},
},
backgroundColor: {
inherit: colors.inherit,