mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Add optional Vim-style keyboard navigation for the chat messages area: - Hold j/k to continuously scroll messages (RAF-based smooth scrolling) - Press i to focus the input editor - Press Escape in input to return focus to messages - Click non-interactive areas to focus messages container - Configurable key mappings via settings UI (map <key> <action> format) Includes settings validation (single-char, unique, case-insensitive keys), accessibility labels on focusable regions, text selection preservation, and proper cleanup of global listeners and animation frames.
695 lines
25 KiB
TypeScript
695 lines
25 KiB
TypeScript
import { CustomModel, ProjectConfig } from "@/aiParams";
|
|
import { atom, createStore, useAtomValue } from "jotai";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
import { type ChainType } from "@/chainFactory";
|
|
import { type SortStrategy, isSortStrategy } from "@/utils/recentUsageManager";
|
|
import {
|
|
AGENT_MAX_ITERATIONS_LIMIT,
|
|
BUILTIN_CHAT_MODELS,
|
|
BUILTIN_EMBEDDING_MODELS,
|
|
COPILOT_FOLDER_ROOT,
|
|
DEFAULT_OPEN_AREA,
|
|
DEFAULT_QA_EXCLUSIONS_SETTING,
|
|
DEFAULT_SETTINGS,
|
|
DEFAULT_VIM_NAVIGATION,
|
|
EmbeddingModelProviders,
|
|
SEND_SHORTCUT,
|
|
type VimNavigationSettings,
|
|
} from "@/constants";
|
|
|
|
/**
|
|
* We used to store commands in the settings file with the following interface.
|
|
* It has been migrated to CustomCommand. This interface is needed to migrate
|
|
* the legacy commands to the new format.
|
|
*/
|
|
export interface LegacyCommandSettings {
|
|
/**
|
|
* The name of the command. The name will be turned into id by replacing
|
|
* spaces with underscores.
|
|
*/
|
|
name: string;
|
|
|
|
/**
|
|
* The model key of the command. If not provided, the current chat model will
|
|
* be used.
|
|
*/
|
|
modelKey?: string;
|
|
|
|
/**
|
|
* The prompt of the command.
|
|
*/
|
|
prompt: string;
|
|
|
|
/**
|
|
* Whether to show the command in the context menu.
|
|
*/
|
|
showInContextMenu: boolean;
|
|
}
|
|
|
|
export interface CopilotSettings {
|
|
userId: string;
|
|
plusLicenseKey: string;
|
|
openAIApiKey: string;
|
|
openAIOrgId: string;
|
|
huggingfaceApiKey: string;
|
|
cohereApiKey: string;
|
|
anthropicApiKey: string;
|
|
azureOpenAIApiKey: string;
|
|
azureOpenAIApiInstanceName: string;
|
|
azureOpenAIApiDeploymentName: string;
|
|
azureOpenAIApiVersion: string;
|
|
azureOpenAIApiEmbeddingDeploymentName: string;
|
|
googleApiKey: string;
|
|
openRouterAiApiKey: string;
|
|
xaiApiKey: string;
|
|
mistralApiKey: string;
|
|
deepseekApiKey: string;
|
|
amazonBedrockApiKey: string;
|
|
amazonBedrockRegion: string;
|
|
siliconflowApiKey: string;
|
|
// GitHub Copilot OAuth tokens
|
|
githubCopilotAccessToken: string;
|
|
githubCopilotToken: string;
|
|
githubCopilotTokenExpiresAt: number;
|
|
defaultChainType: ChainType;
|
|
defaultModelKey: string;
|
|
embeddingModelKey: string;
|
|
temperature: number;
|
|
maxTokens: number;
|
|
contextTurns: number;
|
|
lastDismissedVersion: string | null;
|
|
// DEPRECATED: Do not use this directly, migrated to file-based system prompts
|
|
userSystemPrompt: string;
|
|
openAIProxyBaseUrl: string;
|
|
openAIEmbeddingProxyBaseUrl: string;
|
|
stream: boolean;
|
|
defaultSaveFolder: string;
|
|
defaultConversationTag: string;
|
|
autosaveChat: boolean;
|
|
/**
|
|
* When enabled, generate a short AI title for chat notes on save.
|
|
* When disabled (default), use the first 10 words of the first user message.
|
|
*/
|
|
generateAIChatTitleOnSave: boolean;
|
|
autoAddActiveContentToContext: boolean;
|
|
customPromptsFolder: string;
|
|
indexVaultToVectorStore: string;
|
|
chatNoteContextPath: string;
|
|
chatNoteContextTags: string[];
|
|
enableIndexSync: boolean;
|
|
debug: boolean;
|
|
enableEncryption: boolean;
|
|
maxSourceChunks: number;
|
|
enableInlineCitations: boolean;
|
|
qaExclusions: string;
|
|
qaInclusions: string;
|
|
groqApiKey: string;
|
|
activeModels: Array<CustomModel>;
|
|
activeEmbeddingModels: Array<CustomModel>;
|
|
promptUsageTimestamps: Record<string, number>;
|
|
promptSortStrategy: string;
|
|
chatHistorySortStrategy: SortStrategy;
|
|
projectListSortStrategy: SortStrategy;
|
|
embeddingRequestsPerMin: number;
|
|
embeddingBatchSize: number;
|
|
defaultOpenArea: DEFAULT_OPEN_AREA;
|
|
defaultSendShortcut: SEND_SHORTCUT;
|
|
disableIndexOnMobile: boolean;
|
|
showSuggestedPrompts: boolean;
|
|
showRelevantNotes: boolean;
|
|
numPartitions: number;
|
|
defaultConversationNoteName: string;
|
|
// undefined means never checked
|
|
isPlusUser: boolean | undefined;
|
|
inlineEditCommands: LegacyCommandSettings[] | undefined;
|
|
projectList: Array<ProjectConfig>;
|
|
passMarkdownImages: boolean;
|
|
enableAutonomousAgent: boolean;
|
|
enableCustomPromptTemplating: boolean;
|
|
/** Enable semantic search using Orama for meaning-based document retrieval */
|
|
enableSemanticSearchV3: boolean;
|
|
/** Enable self-host mode (e.g., Miyo) - uses self-hosted services for search, LLMs, OCR, etc. */
|
|
enableSelfHostMode: boolean;
|
|
/** Enable Miyo-backed indexing and semantic search when self-host mode is active */
|
|
enableMiyo: boolean;
|
|
/** Timestamp of last successful Believer validation for self-host mode (null if never validated) */
|
|
selfHostModeValidatedAt: number | null;
|
|
/** Count of successful periodic validations (3 = permanently valid) */
|
|
selfHostValidationCount: number;
|
|
/** URL endpoint for the self-host mode backend */
|
|
selfHostUrl: string;
|
|
/** API key for the self-host mode backend (if required) */
|
|
selfHostApiKey: string;
|
|
/** Custom Miyo server URL, e.g. "http://192.168.1.10:8742" (empty = use local service discovery) */
|
|
miyoServerUrl: string;
|
|
/** Which provider to use for self-host web search */
|
|
selfHostSearchProvider: "firecrawl" | "perplexity";
|
|
/** Firecrawl API key for self-host web search */
|
|
firecrawlApiKey: string;
|
|
/** Perplexity API key for self-host web search via Sonar */
|
|
perplexityApiKey: string;
|
|
/** Supadata API key for self-host YouTube transcripts */
|
|
supadataApiKey: string;
|
|
/** Enable lexical boosts (folder and graph) in search - default: true */
|
|
enableLexicalBoosts: boolean;
|
|
/**
|
|
* RAM limit for lexical search index (in MB)
|
|
* Controls memory usage for full-text search operations
|
|
* - Range: 20-1000 MB
|
|
* - Default: 100 MB
|
|
*/
|
|
lexicalSearchRamLimit: number;
|
|
/** Whether we have suggested built-in default commands to the user once. */
|
|
suggestedDefaultCommands: boolean;
|
|
autonomousAgentMaxIterations: number;
|
|
autonomousAgentEnabledToolIds: string[];
|
|
/** Default reasoning effort for models that support it (GPT-5, O-series, etc.) */
|
|
reasoningEffort: "minimal" | "low" | "medium" | "high";
|
|
/** Default verbosity level for models that support it */
|
|
verbosity: "low" | "medium" | "high";
|
|
/** Folder where memory data is stored */
|
|
memoryFolderName: string;
|
|
/** Reference recent conversation history to provide more contextually relevant responses */
|
|
enableRecentConversations: boolean;
|
|
/** Maximum number of recent conversations to remember (10-50) */
|
|
maxRecentConversations: number;
|
|
/** Reference saved memories that user explicitly asked to remember */
|
|
enableSavedMemory: boolean;
|
|
/** Last selected model for quick command */
|
|
quickCommandModelKey: string | undefined;
|
|
/** Last checkbox state for including note context in quick command */
|
|
quickCommandIncludeNoteContext: boolean;
|
|
/** Automatically add text selections to chat context */
|
|
autoIncludeTextSelection: boolean;
|
|
autoAddSelectionToContext: boolean;
|
|
/** Automatically accept file edits without showing preview confirmation */
|
|
autoAcceptEdits: boolean;
|
|
/** Preferred diff view mode: side-by-side or split */
|
|
diffViewMode: "side-by-side" | "split";
|
|
/** Folder where user system prompts are stored */
|
|
userSystemPromptsFolder: string;
|
|
/**
|
|
* Global default system prompt title
|
|
* Used as the default for all new chat sessions
|
|
* Empty string means no custom system prompt (use builtin)
|
|
*/
|
|
defaultSystemPromptTitle: string;
|
|
/** Token threshold for auto-compacting large context (range: 64k-1M tokens, default: 128000) */
|
|
autoCompactThreshold: number;
|
|
/** Folder where converted document markdown files are saved */
|
|
convertedDocOutputFolder: string;
|
|
/** Vim-style keyboard navigation settings for the chat UI */
|
|
vimNavigation: VimNavigationSettings;
|
|
}
|
|
|
|
export const settingsStore = createStore();
|
|
export const settingsAtom = atom<CopilotSettings>(DEFAULT_SETTINGS);
|
|
|
|
/**
|
|
* Resolve a valid embedding model key for the current settings.
|
|
*
|
|
* @param settings - Current Copilot settings.
|
|
* @returns A valid embedding model key.
|
|
*/
|
|
function resolveEmbeddingModelKey(settings: CopilotSettings): string {
|
|
const activeEmbeddingModelKeys = new Set(
|
|
(settings.activeEmbeddingModels || []).map((model) => getModelKeyFromModel(model))
|
|
);
|
|
|
|
if (settings.embeddingModelKey && activeEmbeddingModelKeys.has(settings.embeddingModelKey)) {
|
|
return settings.embeddingModelKey;
|
|
}
|
|
|
|
return DEFAULT_SETTINGS.embeddingModelKey;
|
|
}
|
|
|
|
/**
|
|
* Sets the settings in the atom.
|
|
*/
|
|
export function setSettings(settings: Partial<CopilotSettings>) {
|
|
const newSettings = mergeAllActiveModelsWithCoreModels({ ...getSettings(), ...settings });
|
|
newSettings.embeddingModelKey = resolveEmbeddingModelKey(newSettings);
|
|
settingsStore.set(settingsAtom, newSettings);
|
|
}
|
|
|
|
/**
|
|
* Normalize QA exclusion patterns and guarantee the Copilot folder root is excluded.
|
|
* @param rawValue - Persisted QA exclusion setting value.
|
|
* @returns Encoded QA exclusion patterns string.
|
|
*/
|
|
/**
|
|
* Validates and sanitizes VimNavigationSettings.
|
|
* Ensures keys are single characters, non-empty, and unique (case-insensitive).
|
|
* Falls back to DEFAULT_VIM_NAVIGATION for any invalid configuration.
|
|
*/
|
|
function sanitizeVimNavigation(raw: unknown): VimNavigationSettings {
|
|
if (!raw || typeof raw !== "object") {
|
|
return { ...DEFAULT_VIM_NAVIGATION };
|
|
}
|
|
|
|
const input = raw as Partial<VimNavigationSettings>;
|
|
const enabled = typeof input.enabled === "boolean" ? input.enabled : DEFAULT_VIM_NAVIGATION.enabled;
|
|
|
|
const rawKeys = [input.scrollUpKey, input.scrollDownKey, input.focusInputKey];
|
|
|
|
// Validate and trim each key to a single character
|
|
const trimmedKeys: string[] = [];
|
|
for (const key of rawKeys) {
|
|
if (typeof key !== "string") {
|
|
return { ...DEFAULT_VIM_NAVIGATION, enabled };
|
|
}
|
|
const trimmed = key.trim();
|
|
if (trimmed.length !== 1) {
|
|
return { ...DEFAULT_VIM_NAVIGATION, enabled };
|
|
}
|
|
trimmedKeys.push(trimmed);
|
|
}
|
|
|
|
// Validate uniqueness (case-insensitive) on trimmed values
|
|
const normalized = new Set(trimmedKeys.map((k) => k.toLowerCase()));
|
|
if (normalized.size !== trimmedKeys.length) {
|
|
return { ...DEFAULT_VIM_NAVIGATION, enabled };
|
|
}
|
|
|
|
return {
|
|
enabled,
|
|
scrollUpKey: trimmedKeys[0],
|
|
scrollDownKey: trimmedKeys[1],
|
|
focusInputKey: trimmedKeys[2],
|
|
};
|
|
}
|
|
|
|
export function sanitizeQaExclusions(rawValue: unknown): string {
|
|
const rawValueString = typeof rawValue === "string" ? rawValue : DEFAULT_QA_EXCLUSIONS_SETTING;
|
|
|
|
const decodedPatterns: string[] = rawValueString
|
|
.split(",")
|
|
.map((pattern: string) => decodeURIComponent(pattern.trim()))
|
|
.filter((pattern: string) => pattern.length > 0);
|
|
|
|
const canonicalToOriginalPattern = new Map<string, string>();
|
|
|
|
decodedPatterns.forEach((pattern) => {
|
|
const canonical = pattern.replace(/\/+$/, "");
|
|
const canonicalKey = canonical.length > 0 ? canonical : pattern;
|
|
if (canonicalKey === COPILOT_FOLDER_ROOT) {
|
|
canonicalToOriginalPattern.set(COPILOT_FOLDER_ROOT, COPILOT_FOLDER_ROOT);
|
|
return;
|
|
}
|
|
if (!canonicalToOriginalPattern.has(canonicalKey)) {
|
|
const normalizedValue =
|
|
canonical.length > 0 && pattern.endsWith("/") ? `${canonical}/` : pattern;
|
|
canonicalToOriginalPattern.set(canonicalKey, normalizedValue);
|
|
}
|
|
});
|
|
|
|
canonicalToOriginalPattern.set(COPILOT_FOLDER_ROOT, COPILOT_FOLDER_ROOT);
|
|
|
|
return Array.from(canonicalToOriginalPattern.values())
|
|
.map((pattern) => encodeURIComponent(pattern))
|
|
.join(",");
|
|
}
|
|
|
|
/**
|
|
* Sets a single setting in the atom.
|
|
*/
|
|
export function updateSetting<K extends keyof CopilotSettings>(key: K, value: CopilotSettings[K]) {
|
|
const settings = getSettings();
|
|
setSettings({ ...settings, [key]: value });
|
|
}
|
|
|
|
/**
|
|
* Gets the settings from the atom. Use this if you don't need to subscribe to
|
|
* changes.
|
|
*/
|
|
export function getSettings(): Readonly<CopilotSettings> {
|
|
return settingsStore.get(settingsAtom);
|
|
}
|
|
|
|
/**
|
|
* Resets the settings to the default values.
|
|
*/
|
|
export function resetSettings(): void {
|
|
const defaultSettingsWithBuiltIns = {
|
|
...DEFAULT_SETTINGS,
|
|
activeModels: BUILTIN_CHAT_MODELS.map((model) => ({ ...model, enabled: true })),
|
|
activeEmbeddingModels: BUILTIN_EMBEDDING_MODELS.map((model) => ({ ...model, enabled: true })),
|
|
};
|
|
setSettings(defaultSettingsWithBuiltIns);
|
|
}
|
|
|
|
/**
|
|
* Subscribes to changes in the settings atom.
|
|
*/
|
|
export function subscribeToSettingsChange(
|
|
callback: (prev: CopilotSettings, next: CopilotSettings) => void
|
|
): () => void {
|
|
let previousValue = getSettings();
|
|
|
|
return settingsStore.sub(settingsAtom, () => {
|
|
const currentValue = getSettings();
|
|
callback(previousValue, currentValue);
|
|
previousValue = currentValue;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hook to get the settings value from the atom.
|
|
*/
|
|
export function useSettingsValue(): Readonly<CopilotSettings> {
|
|
return useAtomValue(settingsAtom, {
|
|
store: settingsStore,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sanitizes the settings to ensure they are valid.
|
|
* Note: This will be better handled by Zod in the future.
|
|
*/
|
|
export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
|
|
// If settings is null/undefined, use DEFAULT_SETTINGS
|
|
const settingsToSanitize = settings || DEFAULT_SETTINGS;
|
|
|
|
if (!settingsToSanitize.userId) {
|
|
settingsToSanitize.userId = uuidv4();
|
|
}
|
|
|
|
// fix: Maintain consistency between EmbeddingModelProviders.AZURE_OPENAI and ChatModelProviders.AZURE_OPENAI,
|
|
// where it was 'azure_openai' before EmbeddingModelProviders.AZURE_OPENAI.
|
|
if (!settingsToSanitize.activeEmbeddingModels) {
|
|
settingsToSanitize.activeEmbeddingModels = BUILTIN_EMBEDDING_MODELS.map((model) => ({
|
|
...model,
|
|
enabled: true,
|
|
}));
|
|
} else {
|
|
settingsToSanitize.activeEmbeddingModels = settingsToSanitize.activeEmbeddingModels.map((m) => {
|
|
return {
|
|
...m,
|
|
provider: m.provider === "azure_openai" ? EmbeddingModelProviders.AZURE_OPENAI : m.provider,
|
|
};
|
|
});
|
|
}
|
|
|
|
const sanitizedSettings: CopilotSettings = { ...settingsToSanitize };
|
|
|
|
// Migration: Rename self-hosted search settings to self-host mode (v3.2.0+)
|
|
const rawSettings = settingsToSanitize as unknown as Record<string, unknown>;
|
|
if (
|
|
rawSettings.enableSelfHostedSearch !== undefined &&
|
|
sanitizedSettings.enableSelfHostMode === undefined
|
|
) {
|
|
sanitizedSettings.enableSelfHostMode = rawSettings.enableSelfHostedSearch as boolean;
|
|
}
|
|
if (rawSettings.selfHostedSearchUrl !== undefined && !sanitizedSettings.selfHostUrl) {
|
|
sanitizedSettings.selfHostUrl = rawSettings.selfHostedSearchUrl as string;
|
|
}
|
|
if (rawSettings.selfHostedSearchApiKey !== undefined && !sanitizedSettings.selfHostApiKey) {
|
|
sanitizedSettings.selfHostApiKey = rawSettings.selfHostedSearchApiKey as string;
|
|
}
|
|
|
|
// Stuff in settings are string even when the interface has number type!
|
|
const temperature = Number(settingsToSanitize.temperature);
|
|
sanitizedSettings.temperature = isNaN(temperature) ? DEFAULT_SETTINGS.temperature : temperature;
|
|
|
|
const maxTokens = Number(settingsToSanitize.maxTokens);
|
|
sanitizedSettings.maxTokens = isNaN(maxTokens) ? DEFAULT_SETTINGS.maxTokens : maxTokens;
|
|
|
|
const contextTurns = Number(settingsToSanitize.contextTurns);
|
|
sanitizedSettings.contextTurns = isNaN(contextTurns)
|
|
? DEFAULT_SETTINGS.contextTurns
|
|
: contextTurns;
|
|
|
|
const embeddingRequestsPerMin = Number(settingsToSanitize.embeddingRequestsPerMin);
|
|
sanitizedSettings.embeddingRequestsPerMin = isNaN(embeddingRequestsPerMin)
|
|
? DEFAULT_SETTINGS.embeddingRequestsPerMin
|
|
: embeddingRequestsPerMin;
|
|
|
|
const embeddingBatchSize = Number(settingsToSanitize.embeddingBatchSize);
|
|
sanitizedSettings.embeddingBatchSize = isNaN(embeddingBatchSize)
|
|
? DEFAULT_SETTINGS.embeddingBatchSize
|
|
: embeddingBatchSize;
|
|
|
|
// Sanitize lexicalSearchRamLimit (20-1000 MB range)
|
|
const lexicalSearchRamLimit = Number(settingsToSanitize.lexicalSearchRamLimit);
|
|
if (isNaN(lexicalSearchRamLimit)) {
|
|
sanitizedSettings.lexicalSearchRamLimit = DEFAULT_SETTINGS.lexicalSearchRamLimit;
|
|
} else {
|
|
// Clamp to valid range
|
|
sanitizedSettings.lexicalSearchRamLimit = Math.min(1000, Math.max(20, lexicalSearchRamLimit));
|
|
}
|
|
|
|
// Ensure autoAddActiveContentToContext has a default value (migrate from old settings)
|
|
if (typeof sanitizedSettings.autoAddActiveContentToContext !== "boolean") {
|
|
// Migration: check old setting first (includeActiveNoteAsContext)
|
|
const oldNoteContext = (settingsToSanitize as unknown as Record<string, unknown>)
|
|
.includeActiveNoteAsContext;
|
|
if (typeof oldNoteContext === "boolean") {
|
|
sanitizedSettings.autoAddActiveContentToContext = oldNoteContext;
|
|
} else {
|
|
sanitizedSettings.autoAddActiveContentToContext =
|
|
DEFAULT_SETTINGS.autoAddActiveContentToContext;
|
|
}
|
|
}
|
|
|
|
// Ensure generateAIChatTitleOnSave has a default value
|
|
if (typeof sanitizedSettings.generateAIChatTitleOnSave !== "boolean") {
|
|
sanitizedSettings.generateAIChatTitleOnSave = DEFAULT_SETTINGS.generateAIChatTitleOnSave;
|
|
}
|
|
|
|
// Ensure enableMiyo has a default value
|
|
if (typeof sanitizedSettings.enableMiyo !== "boolean") {
|
|
sanitizedSettings.enableMiyo = DEFAULT_SETTINGS.enableMiyo;
|
|
}
|
|
|
|
// Ensure selfHostSearchProvider is a valid value
|
|
const validSearchProviders = ["firecrawl", "perplexity"] as const;
|
|
if (
|
|
!validSearchProviders.includes(
|
|
sanitizedSettings.selfHostSearchProvider as (typeof validSearchProviders)[number]
|
|
)
|
|
) {
|
|
sanitizedSettings.selfHostSearchProvider = DEFAULT_SETTINGS.selfHostSearchProvider;
|
|
}
|
|
|
|
// Ensure passMarkdownImages has a default value
|
|
if (typeof sanitizedSettings.passMarkdownImages !== "boolean") {
|
|
sanitizedSettings.passMarkdownImages = DEFAULT_SETTINGS.passMarkdownImages;
|
|
}
|
|
|
|
// Ensure enableInlineCitations has a default value
|
|
if (typeof sanitizedSettings.enableInlineCitations !== "boolean") {
|
|
sanitizedSettings.enableInlineCitations = DEFAULT_SETTINGS.enableInlineCitations;
|
|
}
|
|
|
|
// Ensure enableCustomPromptTemplating has a default value
|
|
if (typeof sanitizedSettings.enableCustomPromptTemplating !== "boolean") {
|
|
sanitizedSettings.enableCustomPromptTemplating = DEFAULT_SETTINGS.enableCustomPromptTemplating;
|
|
}
|
|
|
|
// Ensure autonomousAgentMaxIterations has a valid value
|
|
const autonomousAgentMaxIterations = Number(settingsToSanitize.autonomousAgentMaxIterations);
|
|
if (
|
|
isNaN(autonomousAgentMaxIterations) ||
|
|
autonomousAgentMaxIterations < 4 ||
|
|
autonomousAgentMaxIterations > AGENT_MAX_ITERATIONS_LIMIT
|
|
) {
|
|
sanitizedSettings.autonomousAgentMaxIterations = DEFAULT_SETTINGS.autonomousAgentMaxIterations;
|
|
} else {
|
|
sanitizedSettings.autonomousAgentMaxIterations = autonomousAgentMaxIterations;
|
|
}
|
|
|
|
// Ensure autonomousAgentEnabledToolIds is an array
|
|
if (!Array.isArray(sanitizedSettings.autonomousAgentEnabledToolIds)) {
|
|
sanitizedSettings.autonomousAgentEnabledToolIds =
|
|
DEFAULT_SETTINGS.autonomousAgentEnabledToolIds;
|
|
}
|
|
|
|
// Ensure memoryFolderName has a default value
|
|
if (
|
|
!sanitizedSettings.memoryFolderName ||
|
|
typeof sanitizedSettings.memoryFolderName !== "string"
|
|
) {
|
|
sanitizedSettings.memoryFolderName = DEFAULT_SETTINGS.memoryFolderName;
|
|
}
|
|
|
|
// Ensure enableRecentConversations has a default value
|
|
if (typeof sanitizedSettings.enableRecentConversations !== "boolean") {
|
|
sanitizedSettings.enableRecentConversations = DEFAULT_SETTINGS.enableRecentConversations;
|
|
}
|
|
|
|
// Ensure enableSavedMemory has a default value
|
|
if (typeof sanitizedSettings.enableSavedMemory !== "boolean") {
|
|
sanitizedSettings.enableSavedMemory = DEFAULT_SETTINGS.enableSavedMemory;
|
|
}
|
|
|
|
// Ensure maxRecentConversations has a valid value (10-50 range)
|
|
const maxRecentConversations = Number(settingsToSanitize.maxRecentConversations);
|
|
if (isNaN(maxRecentConversations) || maxRecentConversations < 10 || maxRecentConversations > 50) {
|
|
sanitizedSettings.maxRecentConversations = DEFAULT_SETTINGS.maxRecentConversations;
|
|
} else {
|
|
sanitizedSettings.maxRecentConversations = maxRecentConversations;
|
|
}
|
|
|
|
// Ensure autosaveChat has a default value
|
|
if (typeof sanitizedSettings.autosaveChat !== "boolean") {
|
|
sanitizedSettings.autosaveChat = DEFAULT_SETTINGS.autosaveChat;
|
|
}
|
|
|
|
// Ensure autoCompactThreshold has a valid value (64k-1M tokens range)
|
|
const autoCompactThreshold = Number(settingsToSanitize.autoCompactThreshold);
|
|
if (isNaN(autoCompactThreshold)) {
|
|
sanitizedSettings.autoCompactThreshold = DEFAULT_SETTINGS.autoCompactThreshold;
|
|
} else {
|
|
// Clamp to valid range
|
|
sanitizedSettings.autoCompactThreshold = Math.min(
|
|
1000000,
|
|
Math.max(64000, autoCompactThreshold)
|
|
);
|
|
}
|
|
|
|
// Ensure quickCommandIncludeNoteContext has a default value
|
|
if (typeof sanitizedSettings.quickCommandIncludeNoteContext !== "boolean") {
|
|
sanitizedSettings.quickCommandIncludeNoteContext =
|
|
DEFAULT_SETTINGS.quickCommandIncludeNoteContext;
|
|
}
|
|
|
|
// Ensure quickCommandModelKey is either undefined or a string
|
|
if (
|
|
settingsToSanitize.quickCommandModelKey !== undefined &&
|
|
typeof settingsToSanitize.quickCommandModelKey !== "string"
|
|
) {
|
|
sanitizedSettings.quickCommandModelKey = DEFAULT_SETTINGS.quickCommandModelKey;
|
|
}
|
|
|
|
// Ensure autoAddSelectionToContext has a default value (migrate from old settings)
|
|
if (typeof sanitizedSettings.autoAddSelectionToContext !== "boolean") {
|
|
// Migration: check old setting first (autoIncludeTextSelection)
|
|
const oldTextSelection = (settingsToSanitize as unknown as Record<string, unknown>)
|
|
.autoIncludeTextSelection;
|
|
if (typeof oldTextSelection === "boolean") {
|
|
sanitizedSettings.autoAddSelectionToContext = oldTextSelection;
|
|
} else {
|
|
sanitizedSettings.autoAddSelectionToContext = DEFAULT_SETTINGS.autoAddSelectionToContext;
|
|
}
|
|
}
|
|
|
|
// Ensure autoAcceptEdits has a default value
|
|
if (typeof sanitizedSettings.autoAcceptEdits !== "boolean") {
|
|
sanitizedSettings.autoAcceptEdits = DEFAULT_SETTINGS.autoAcceptEdits;
|
|
}
|
|
|
|
// Ensure defaultSendShortcut has a valid value
|
|
if (!Object.values(SEND_SHORTCUT).includes(sanitizedSettings.defaultSendShortcut)) {
|
|
sanitizedSettings.defaultSendShortcut = DEFAULT_SETTINGS.defaultSendShortcut;
|
|
}
|
|
|
|
// Ensure folder settings fall back to defaults when empty/whitespace
|
|
const saveFolder = (settingsToSanitize.defaultSaveFolder || "").trim();
|
|
sanitizedSettings.defaultSaveFolder =
|
|
saveFolder.length > 0 ? saveFolder : DEFAULT_SETTINGS.defaultSaveFolder;
|
|
|
|
const promptsFolder = (settingsToSanitize.customPromptsFolder || "").trim();
|
|
sanitizedSettings.customPromptsFolder =
|
|
promptsFolder.length > 0 ? promptsFolder : DEFAULT_SETTINGS.customPromptsFolder;
|
|
|
|
// Ensure chatHistorySortStrategy has a valid value (exclude "manual" which is only for custom commands)
|
|
if (
|
|
!isSortStrategy(sanitizedSettings.chatHistorySortStrategy) ||
|
|
sanitizedSettings.chatHistorySortStrategy === "manual"
|
|
) {
|
|
sanitizedSettings.chatHistorySortStrategy = DEFAULT_SETTINGS.chatHistorySortStrategy;
|
|
}
|
|
|
|
// Ensure projectListSortStrategy has a valid value (exclude "manual" which is only for custom commands)
|
|
if (
|
|
!isSortStrategy(sanitizedSettings.projectListSortStrategy) ||
|
|
sanitizedSettings.projectListSortStrategy === "manual"
|
|
) {
|
|
sanitizedSettings.projectListSortStrategy = DEFAULT_SETTINGS.projectListSortStrategy;
|
|
}
|
|
|
|
const userSystemPromptsFolder = (settingsToSanitize.userSystemPromptsFolder || "").trim();
|
|
sanitizedSettings.userSystemPromptsFolder =
|
|
userSystemPromptsFolder.length > 0
|
|
? userSystemPromptsFolder
|
|
: DEFAULT_SETTINGS.userSystemPromptsFolder;
|
|
|
|
sanitizedSettings.qaExclusions = sanitizeQaExclusions(settingsToSanitize.qaExclusions);
|
|
|
|
sanitizedSettings.vimNavigation = sanitizeVimNavigation(settingsToSanitize.vimNavigation);
|
|
|
|
return sanitizedSettings;
|
|
}
|
|
|
|
function mergeAllActiveModelsWithCoreModels(settings: CopilotSettings): CopilotSettings {
|
|
settings.activeModels = mergeActiveModels(settings.activeModels, BUILTIN_CHAT_MODELS);
|
|
settings.activeEmbeddingModels = filterUnsupportedEmbeddingModels(
|
|
mergeActiveModels(settings.activeEmbeddingModels, BUILTIN_EMBEDDING_MODELS)
|
|
);
|
|
return settings;
|
|
}
|
|
|
|
/**
|
|
* Get a unique model key from a CustomModel instance
|
|
* Format: modelName|provider
|
|
*/
|
|
export function getModelKeyFromModel(model: CustomModel): string {
|
|
return `${model.name}|${model.provider}`;
|
|
}
|
|
|
|
function mergeActiveModels(
|
|
existingActiveModels: CustomModel[],
|
|
builtInModels: CustomModel[]
|
|
): CustomModel[] {
|
|
const modelMap = new Map<string, CustomModel>();
|
|
|
|
// Add core models to the map first
|
|
builtInModels
|
|
.filter((model) => model.core)
|
|
.forEach((model) => {
|
|
modelMap.set(getModelKeyFromModel(model), { ...model });
|
|
});
|
|
|
|
// Add or update existing models in the map
|
|
existingActiveModels.forEach((model) => {
|
|
const key = getModelKeyFromModel(model);
|
|
const existingModel = modelMap.get(key);
|
|
if (existingModel) {
|
|
// If it's a built-in model, preserve all built-in properties
|
|
const builtInModel = builtInModels.find(
|
|
(m) => m.name === model.name && m.provider === model.provider
|
|
);
|
|
if (builtInModel) {
|
|
modelMap.set(key, {
|
|
...builtInModel,
|
|
...model,
|
|
isBuiltIn: true,
|
|
believerExclusive: builtInModel.believerExclusive,
|
|
});
|
|
} else {
|
|
modelMap.set(key, {
|
|
...model,
|
|
isBuiltIn: existingModel.isBuiltIn,
|
|
});
|
|
}
|
|
} else {
|
|
modelMap.set(key, model);
|
|
}
|
|
});
|
|
|
|
return Array.from(modelMap.values());
|
|
}
|
|
|
|
/**
|
|
* Remove embedding models that use unsupported providers.
|
|
*
|
|
* @param models - Embedding models to validate.
|
|
* @returns Filtered list containing only supported providers.
|
|
*/
|
|
function filterUnsupportedEmbeddingModels(models: CustomModel[]): CustomModel[] {
|
|
const supportedProviders = new Set(Object.values(EmbeddingModelProviders));
|
|
return models.filter((model) =>
|
|
supportedProviders.has(model.provider as EmbeddingModelProviders)
|
|
);
|
|
}
|