logancyang_obsidian-copilot/src/settings/v2/components/BasicSettings.tsx
2025-05-31 22:30:54 -07:00

404 lines
16 KiB
TypeScript

import { ChainType } from "@/chainFactory";
import { RebuildIndexConfirmModal } from "@/components/modals/RebuildIndexConfirmModal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getModelDisplayWithIcons } from "@/components/ui/model-display";
import { SettingItem } from "@/components/ui/setting-item";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { DEFAULT_OPEN_AREA, PLUS_UTM_MEDIUMS } from "@/constants";
import { createPlusPageUrl } from "@/plusUtils";
import VectorStoreManager from "@/search/vectorStoreManager";
import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/settings/model";
import { PlusSettings } from "@/settings/v2/components/PlusSettings";
import { checkModelApiKey, formatDateTime } from "@/utils";
import { HelpCircle, Key, Loader2 } from "lucide-react";
import { Notice } from "obsidian";
import React, { useState } from "react";
import { ApiKeyDialog } from "./ApiKeyDialog";
import { cn } from "@/lib/utils";
const ChainType2Label: Record<ChainType, string> = {
[ChainType.LLM_CHAIN]: "Chat",
[ChainType.VAULT_QA_CHAIN]: "Vault QA (Basic)",
[ChainType.COPILOT_PLUS_CHAIN]: "Copilot Plus (beta)",
[ChainType.PROJECT_CHAIN]: "Projects (alpha)",
};
export const BasicSettings: React.FC = () => {
const settings = useSettingsValue();
const [isChecking, setIsChecking] = useState(false);
const [conversationNoteName, setConversationNoteName] = useState(
settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}"
);
const handleSetDefaultEmbeddingModel = async (modelKey: string) => {
if (modelKey !== settings.embeddingModelKey) {
new RebuildIndexConfirmModal(app, async () => {
updateSetting("embeddingModelKey", modelKey);
await VectorStoreManager.getInstance().indexVaultToVectorStore(true);
}).open();
}
};
const applyCustomNoteFormat = () => {
setIsChecking(true);
try {
// Check required variables
const format = conversationNoteName || "{$date}_{$time}__{$topic}";
const requiredVars = ["{$date}", "{$time}", "{$topic}"];
const missingVars = requiredVars.filter((v) => !format.includes(v));
if (missingVars.length > 0) {
new Notice(`Error: Missing required variables: ${missingVars.join(", ")}`, 4000);
return;
}
// Check illegal characters (excluding variable placeholders)
const illegalChars = /[\\/:*?"<>|]/;
const formatWithoutVars = format
.replace(/\{\$date}/g, "")
.replace(/\{\$time}/g, "")
.replace(/\{\$topic}/g, "");
if (illegalChars.test(formatWithoutVars)) {
new Notice(`Error: Format contains illegal characters (\\/:*?"<>|)`, 4000);
return;
}
// Generate example filename
const { fileName: timestampFileName } = formatDateTime(new Date());
const firstTenWords = "test topic name";
// Create example filename
const customFileName = format
.replace("{$topic}", firstTenWords.slice(0, 100).replace(/\s+/g, "_"))
.replace("{$date}", timestampFileName.split("_")[0])
.replace("{$time}", timestampFileName.split("_")[1]);
// Save settings
updateSetting("defaultConversationNoteName", format);
setConversationNoteName(format);
new Notice(`Format applied successfully! Example: ${customFileName}`, 4000);
} catch (error) {
new Notice(`Error applying format: ${error.message}`, 4000);
} finally {
setIsChecking(false);
}
};
const defaultModelActivated = !!settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === settings.defaultModelKey
);
const enableActivatedModels = settings.activeModels
.filter((m) => m.enabled)
.map((model) => ({
label: getModelDisplayWithIcons(model),
value: getModelKeyFromModel(model),
}));
return (
<div className="tw-space-y-4">
<PlusSettings />
{/* General Section */}
<section>
<div className="tw-mb-3 tw-text-xl tw-font-bold">General</div>
<div className="tw-space-y-4">
<div className="tw-space-y-4">
{/* API Key Section */}
<SettingItem
type="custom"
title="API Keys"
description={
<div className="tw-flex tw-items-center tw-gap-1.5">
<span className="tw-leading-none">
Configure API keys for different AI providers
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="tw-size-4" />
</TooltipTrigger>
<TooltipContent className="tw-flex tw-max-w-96 tw-flex-col tw-gap-2 tw-py-4">
<div className="tw-text-sm tw-font-medium tw-text-accent">
API key required for chat and QA features
</div>
<div className="tw-text-xs tw-text-muted">
To enable chat and QA functionality, please provide an API key from your
selected provider.
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<Button
onClick={() => {
new ApiKeyDialog(app).open();
}}
variant="secondary"
className="tw-flex tw-w-full tw-items-center tw-justify-center tw-gap-2 sm:tw-w-auto sm:tw-justify-start"
>
Set Keys
<Key className="tw-size-4" />
</Button>
</SettingItem>
</div>
<SettingItem
type="select"
title="Default Chat Model"
description="Select the Chat model to use"
value={defaultModelActivated ? settings.defaultModelKey : "Select Model"}
onChange={(value) => {
const selectedModel = settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === value
);
if (!selectedModel) return;
const { hasApiKey, errorNotice } = checkModelApiKey(selectedModel, settings);
if (!hasApiKey && errorNotice) {
new Notice(errorNotice);
return;
}
updateSetting("defaultModelKey", value);
}}
options={
defaultModelActivated
? enableActivatedModels
: [{ label: "Select Model", value: "Select Model" }, ...enableActivatedModels]
}
placeholder="Model"
/>
<SettingItem
type="select"
title="Embedding Model"
description={
<div className="tw-space-y-2">
<div className="tw-flex tw-items-center tw-gap-1.5">
<span className="tw-font-medium tw-leading-none tw-text-accent">
Core Feature: Powers Semantic Search & QA
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="tw-size-4" />
</TooltipTrigger>
<TooltipContent className="tw-flex tw-max-w-96 tw-flex-col tw-gap-2">
<div className="tw-pt-2 tw-text-sm tw-text-muted">
This model converts text into vector representations, essential for
semantic search and QA functionality. Changing the embedding model will:
</div>
<ul className="tw-pl-4 tw-text-sm tw-text-muted">
<li>Require rebuilding your vault&#39;s vector index</li>
<li>Affect semantic search quality</li>
<li>Impact QA feature performance</li>
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
}
value={settings.embeddingModelKey}
onChange={handleSetDefaultEmbeddingModel}
options={settings.activeEmbeddingModels.map((model) => ({
label: getModelDisplayWithIcons(model),
value: getModelKeyFromModel(model),
}))}
placeholder="Model"
/>
{/* Basic Configuration Group */}
<SettingItem
type="select"
title="Default Mode"
description={
<div className="tw-flex tw-items-center tw-gap-1.5">
<span className="tw-leading-none">Select the default chat mode</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="tw-size-4" />
</TooltipTrigger>
<TooltipContent className="tw-flex tw-max-w-96 tw-flex-col tw-gap-2">
<ul className="tw-pl-4 tw-text-sm tw-text-muted">
<li>
<strong>Chat:</strong> Regular chat mode for general conversations and
tasks. <i>Free to use with your own API key.</i>
</li>
<li>
<strong>Vault QA (Basic):</strong> Ask questions about your vault content
with semantic search. <i>Free to use with your own API key.</i>
</li>
<li>
<strong>Copilot Plus:</strong> Covers all features of the 2 free modes,
plus advanced paid features including chat context menu, advanced search,
AI agents, and more. Check out{" "}
<a
href={createPlusPageUrl(PLUS_UTM_MEDIUMS.MODE_SELECT_TOOLTIP)}
target="_blank"
rel="noopener noreferrer"
className="tw-text-accent hover:tw-text-accent-hover"
>
obsidiancopilot.com
</a>{" "}
for more details.
</li>
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
value={settings.defaultChainType}
onChange={(value) => updateSetting("defaultChainType", value as ChainType)}
options={Object.entries(ChainType2Label).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
<SettingItem
type="select"
title="Open Plugin In"
description="Choose where to open the plugin"
value={settings.defaultOpenArea}
onChange={(value) => updateSetting("defaultOpenArea", value as DEFAULT_OPEN_AREA)}
options={[
{ label: "Sidebar View", value: DEFAULT_OPEN_AREA.VIEW },
{ label: "Editor", value: DEFAULT_OPEN_AREA.EDITOR },
]}
/>
<SettingItem
type="text"
title="Default Conversation Folder Name"
description="The default folder name where chat conversations will be saved. Default is 'copilot-conversations'"
value={settings.defaultSaveFolder}
onChange={(value) => updateSetting("defaultSaveFolder", value)}
placeholder="copilot-conversations"
/>
<SettingItem
type="text"
title="Custom Prompts Folder Name"
description="The default folder name where custom prompts will be saved. Default is 'copilot-custom-prompts'"
value={settings.customPromptsFolder}
onChange={(value) => updateSetting("customPromptsFolder", value)}
placeholder="copilot-custom-prompts"
/>
<SettingItem
type="text"
title="Default Conversation Tag"
description="The default tag to be used when saving a conversation. Default is 'ai-conversations'"
value={settings.defaultConversationTag}
onChange={(value) => updateSetting("defaultConversationTag", value)}
placeholder="ai-conversations"
/>
<SettingItem
type="custom"
title="Conversation Filename Template"
description={
<div className="tw-flex tw-items-start tw-gap-1.5 ">
<span className="tw-leading-none">
Customize the format of saved conversation note names.
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="tw-size-4" />
</TooltipTrigger>
<TooltipContent className="tw-flex tw-max-w-96 tw-flex-col tw-gap-2 tw-py-4">
<div className="tw-text-sm tw-font-medium tw-text-accent">
Note: All the following variables must be included in the template.
</div>
<div>
<div className="tw-text-sm tw-font-medium tw-text-muted">
Available variables:
</div>
<ul className="tw-pl-4 tw-text-sm tw-text-muted">
<li>
<strong>{"{$date}"}</strong>: Date in YYYYMMDD format
</li>
<li>
<strong>{"{$time}"}</strong>: Time in HHMMSS format
</li>
<li>
<strong>{"{$topic}"}</strong>: Chat conversation topic
</li>
</ul>
<i className="tw-mt-2 tw-text-sm tw-text-muted">
Example: {"{$date}_{$time}__{$topic}"}
20250114_153232__polish_this_article_[[Readme]]
</i>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<div className="tw-flex tw-w-[320px] tw-items-center tw-gap-1.5">
<Input
type="text"
className={cn(
"tw-min-w-[80px] tw-grow tw-transition-all tw-duration-200",
isChecking ? "tw-w-[80px]" : "tw-w-[120px]"
)}
placeholder="{$date}_{$time}__{$topic}"
value={conversationNoteName}
onChange={(e) => setConversationNoteName(e.target.value)}
disabled={isChecking}
/>
<Button
onClick={() => applyCustomNoteFormat()}
disabled={isChecking}
variant="secondary"
>
{isChecking ? (
<>
<Loader2 className="tw-mr-2 tw-size-4 tw-animate-spin" />
Apply
</>
) : (
"Apply"
)}
</Button>
</div>
</SettingItem>
{/* Feature Toggle Group */}
<SettingItem
type="switch"
title="Autosave Chat"
description="Automatically save the chat when starting a new one or when the plugin reloads"
checked={settings.autosaveChat}
onCheckedChange={(checked) => updateSetting("autosaveChat", checked)}
/>
<SettingItem
type="switch"
title="Suggested Prompts"
description="Show suggested prompts in the chat view"
checked={settings.showSuggestedPrompts}
onCheckedChange={(checked) => updateSetting("showSuggestedPrompts", checked)}
/>
<SettingItem
type="switch"
title="Relevant Notes"
description="Show relevant notes in the chat view"
checked={settings.showRelevantNotes}
onCheckedChange={(checked) => updateSetting("showRelevantNotes", checked)}
/>
</div>
</section>
</div>
);
};