Improve plus user onboarding (#1160)

* Improve plus user onboarding
* Update embedding switch logic
* Fix reset settings and new user
* make isplususer default to false
* update default state
This commit is contained in:
Zero Liu 2025-02-04 00:15:45 -08:00 committed by GitHub
parent b83aac6d81
commit a5300eb707
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 599 additions and 358 deletions

View file

@ -1,8 +1,10 @@
import { BREVILABS_API_BASE_URL } from "@/constants";
import { getDecryptedKey } from "@/encryptionService";
import { logInfo } from "@/logger";
import { getSettings } from "@/settings/model";
import { safeFetch } from "@/utils";
import { extractErrorDetail, safeFetch } from "@/utils";
import { Notice } from "obsidian";
import { turnOnPlus, turnOffPlus } from "@/plusUtils";
export interface BrocaResponse {
response: {
@ -93,7 +95,12 @@ export class BrevilabsClient {
this.pluginVersion = pluginVersion;
}
private async makeRequest<T>(endpoint: string, body: any, method = "POST"): Promise<T> {
private async makeRequest<T>(
endpoint: string,
body: any,
method = "POST",
excludeAuthHeader = false
): Promise<T> {
this.checkLicenseKey();
const url = new URL(`${BREVILABS_API_BASE_URL}${endpoint}`);
@ -103,12 +110,13 @@ export class BrevilabsClient {
url.searchParams.append(key, value as string);
});
}
const response = await safeFetch(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`,
...(!excludeAuthHeader && {
Authorization: `Bearer ${await getDecryptedKey(getSettings().plusLicenseKey)}`,
}),
"X-Client-Version": this.pluginVersion,
},
...(method === "POST" && { body: JSON.stringify(body) }),
@ -154,6 +162,37 @@ export class BrevilabsClient {
return data;
}
/**
* Validate the license key and update the isPlusUser setting.
* @returns true if the license key is valid, false if the license key is invalid, and undefined if
* unknown error.
*/
async validateLicenseKey(): Promise<boolean | undefined> {
try {
logInfo("settings value", getSettings().plusLicenseKey);
const response = await this.makeRequest(
"/license",
{
license_key: await getDecryptedKey(getSettings().plusLicenseKey),
},
"POST",
true
);
logInfo("validateLicenseKey: true", response);
turnOnPlus();
return true;
} catch (error) {
if (extractErrorDetail(error).reason === "Invalid license key") {
logInfo("validateLicenseKey: false");
turnOffPlus();
return false;
}
return;
// Do nothing if the error is not about the invalid license key
}
}
async broca(userMessage: string): Promise<BrocaResponse> {
const brocaResponse = await this.makeRequest<BrocaResponse>("/broca", {
message: userMessage,

View file

@ -34,6 +34,7 @@ import { App, Notice } from "obsidian";
import ChatModelManager from "./chatModelManager";
import MemoryManager from "./memoryManager";
import PromptManager from "./promptManager";
import { logError, logInfo } from "@/logger";
export default class ChainManager {
private static chain: RunnableSequence;
@ -125,10 +126,10 @@ export default class ChainManager {
// retrieves the old chain without the chatModel change if it exists!
// Create a new chain with the new chatModel
this.setChain(getChainType());
console.log(`Setting model to ${newModelKey}`);
logInfo(`Setting model to ${newModelKey}`);
} catch (error) {
console.error("createChainWithNewModel failed: ", error);
console.log("modelKey:", newModelKey);
logError(`createChainWithNewModel failed: ${error}`);
logInfo(`modelKey: ${newModelKey}`);
}
}

View file

@ -265,6 +265,7 @@ export default class ChatModelManager {
async setChatModel(model: CustomModel): Promise<void> {
const modelKey = getModelKeyFromModel(model);
setModelKey(modelKey);
if (!ChatModelManager.modelMap.hasOwnProperty(modelKey)) {
throw new Error(`No model found for: ${modelKey}`);
}
@ -280,7 +281,6 @@ export default class ChatModelManager {
const modelConfig = await this.getModelConfig(model);
setModelKey(modelKey);
try {
const newModelInstance = new selectedModel.AIConstructor({
...modelConfig,

View file

@ -7,6 +7,7 @@ import {
RefreshCw,
MessageCirclePlus,
ChevronDown,
SquareArrowOutUpRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
@ -18,6 +19,8 @@ import { useChainType } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { Notice } from "obsidian";
import VectorStoreManager from "@/search/vectorStoreManager";
import { navigateToPlusPage, useIsPlusUser } from "@/plusUtils";
import { PLUS_UTM_MEDIUMS } from "@/constants";
export async function refreshVaultIndex() {
try {
@ -37,6 +40,8 @@ interface ChatControlsProps {
export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
const settings = useSettingsValue();
const [selectedChain, setSelectedChain] = useChainType();
const isPlusUser = useIsPlusUser();
return (
<div className="w-full py-1 flex justify-between items-center px-1">
<div className="flex-1">
@ -44,8 +49,13 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
<DropdownMenuTrigger asChild>
<Button variant="ghost2" size="fit" className="ml-1">
{selectedChain === ChainType.LLM_CHAIN && "chat"}
{selectedChain === ChainType.VAULT_QA_CHAIN && "vault QA (basic)"}
{selectedChain === ChainType.COPILOT_PLUS_CHAIN && "copilot plus (beta)"}
{selectedChain === ChainType.VAULT_QA_CHAIN && "vault QA"}
{selectedChain === ChainType.COPILOT_PLUS_CHAIN && (
<div className="flex items-center gap-1">
<Sparkles className="size-4" />
copilot plus (beta)
</div>
)}
<ChevronDown className="size-5 mt-0.5" />
</Button>
</DropdownMenuTrigger>
@ -54,11 +64,25 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.VAULT_QA_CHAIN)}>
vault QA (basic)
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.COPILOT_PLUS_CHAIN)}>
copilot plus (beta)
vault QA
</DropdownMenuItem>
{isPlusUser ? (
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.COPILOT_PLUS_CHAIN)}>
<div className="flex items-center gap-1">
<Sparkles className="size-4" />
copilot plus (beta)
</div>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onSelect={() => {
navigateToPlusPage(PLUS_UTM_MEDIUMS.CHAT_MODE_SELECT);
}}
>
copilot plus (beta)
<SquareArrowOutUpRight className="size-3" />
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View file

@ -0,0 +1,71 @@
import React from "react";
import { App, Modal } from "obsidian";
import { createRoot } from "react-dom/client";
import { Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { isPlusModel, navigateToPlusPage } from "@/plusUtils";
import { PLUS_UTM_MEDIUMS } from "@/constants";
import { ExternalLink } from "lucide-react";
import { getSettings } from "@/settings/model";
function CopilotPlusExpiredModalContent({ onCancel }: { onCancel: () => void }) {
const settings = getSettings();
const isUsingPlusModels =
isPlusModel(settings.defaultModelKey) && isPlusModel(settings.embeddingModelKey);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div>
Your Copilot Plus license key is no longer valid. Please renew your subscription to
continue using Copilot Plus.
</div>
{isUsingPlusModels && (
<div className="text-sm text-warning">
The Copilot Plus exclusive models will stop working. You can switch to the default
models in the Settings.
</div>
)}
</div>
<div className="flex gap-2 justify-end w-full">
<Button variant="ghost" onClick={onCancel}>
Close
</Button>
<Button
variant="default"
onClick={() => {
navigateToPlusPage(PLUS_UTM_MEDIUMS.EXPIRED_MODAL);
}}
>
Renew Now <ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
export class CopilotPlusExpiredModal extends Modal {
private root: Root;
constructor(app: App) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
// @ts-ignore
this.setTitle("Thanks for being a Copilot Plus user 👋");
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleCancel = () => {
this.close();
};
this.root.render(<CopilotPlusExpiredModalContent onCancel={handleCancel} />);
}
onClose() {
this.root.unmount();
}
}

View file

@ -0,0 +1,98 @@
import React from "react";
import { App, Modal } from "obsidian";
import { createRoot } from "react-dom/client";
import { Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import {
DEFAULT_COPILOT_PLUS_CHAT_MODEL,
DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL,
DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL_KEY,
applyPlusSettings,
} from "@/plusUtils";
import { getSettings } from "@/settings/model";
import { TriangleAlert } from "lucide-react";
function CopilotPlusWelcomeModalContent({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel: () => void;
}) {
const settings = getSettings();
return (
<div className="flex flex-col gap-4">
<div>
<p>
Thanks for purchasing <b>Copilot Plus</b>! You have unlocked the full power of Copilot,
featuring chat context, PDF and image support, exclusive chat and embedding models, and
much more!
</p>
<p>
Would you like to apply the Copilot Plus settings now? You can always change this later in
Settings.
</p>
<ul className="pl-4">
<li>
Default mode: <b className="text-accent">Copilot Plus</b>
</li>
<li>
Chat model: <b className="text-accent">{DEFAULT_COPILOT_PLUS_CHAT_MODEL}</b>
</li>
<li>
<div>
Embedding model: <b className="text-accent">{DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL}</b>
</div>
{settings.embeddingModelKey !== DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL_KEY && (
<div className="text-sm text-warning flex items-center gap-1">
<TriangleAlert className="size-4" /> It will rebuild your embeddings for the entire
vault
</div>
)}
</li>
</ul>
</div>
<div className="flex gap-2 justify-end w-full">
<Button variant="ghost" onClick={onCancel}>
Apply Later
</Button>
<Button variant="default" onClick={onConfirm}>
Apply Now
</Button>
</div>
</div>
);
}
export class CopilotPlusWelcomeModal extends Modal {
private root: Root;
constructor(app: App) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
// @ts-ignore
this.setTitle("Welcome to Copilot Plus 🚀");
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleConfirm = () => {
applyPlusSettings();
this.close();
};
const handleCancel = () => {
this.close();
};
this.root.render(
<CopilotPlusWelcomeModalContent onConfirm={handleConfirm} onCancel={handleCancel} />
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -15,6 +15,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { SettingSlider } from "@/components/ui/setting-slider";
import { debounce } from "@/utils";
// 定义输入控件的类型
type InputType =
@ -107,17 +108,6 @@ type SettingItemProps =
| SliderSettingItemProps
| DialogSettingItemProps;
function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function SettingItem(props: SettingItemProps) {
const { title, description, className, disabled } = props;
const { modalContainer } = useTab();

View file

@ -30,6 +30,13 @@ export const LOADING_MESSAGES = {
READING_FILES: "Reading files",
SEARCHING_WEB: "Searching the web",
};
export const PLUS_UTM_MEDIUMS = {
SETTINGS: "settings",
EXPIRED_MODAL: "expired_modal",
CHAT_MODE_SELECT: "chat_mode_select",
MODE_SELECT_TOOLTIP: "mode_select_tooltip",
};
export type PlusUtmMedium = (typeof PLUS_UTM_MEDIUMS)[keyof typeof PLUS_UTM_MEDIUMS];
export enum ChatModels {
COPILOT_PLUS_FLASH = "copilot-plus-flash",
@ -473,6 +480,7 @@ export const PROCESS_SELECTION_COMMANDS = [
];
export const DEFAULT_SETTINGS: CopilotSettings = {
isPlusUser: false,
plusLicenseKey: "",
openAIApiKey: "",
openAIOrgId: "",
@ -488,8 +496,7 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
openRouterAiApiKey: "",
defaultChainType: ChainType.LLM_CHAIN,
defaultModelKey: ChatModels.GPT_4o + "|" + ChatModelProviders.OPENAI,
embeddingModelKey:
EmbeddingModels.COPILOT_PLUS_SMALL + "|" + EmbeddingModelProviders.COPILOT_PLUS,
embeddingModelKey: EmbeddingModels.OPENAI_EMBEDDING_SMALL + "|" + EmbeddingModelProviders.OPENAI,
temperature: 0.1,
maxTokens: 1000,
contextTurns: 15,

13
src/logger.ts Normal file
View file

@ -0,0 +1,13 @@
import { getSettings } from "@/settings/model";
export function logInfo(...args: any[]) {
if (getSettings().debug) {
console.log(...args);
}
}
export function logError(...args: any[]) {
if (getSettings().debug) {
console.error(...args);
}
}

View file

@ -8,6 +8,7 @@ import { LoadChatHistoryModal } from "@/components/modals/LoadChatHistoryModal";
import { CHAT_VIEWTYPE, DEFAULT_OPEN_AREA, EVENT_NAMES } from "@/constants";
import { registerContextMenu } from "@/contextMenu";
import { encryptAllKeys } from "@/encryptionService";
import { checkIsPlusUser } from "@/plusUtils";
import { HybridRetriever } from "@/search/hybridRetriever";
import VectorStoreManager from "@/search/vectorStoreManager";
import { CopilotSettingTab } from "@/settings/SettingsPage";
@ -63,6 +64,7 @@ export default class CopilotPlugin extends Plugin {
// Initialize BrevilabsClient
this.brevilabsClient = BrevilabsClient.getInstance();
this.brevilabsClient.setPluginVersion(this.manifest.version);
await checkIsPlusUser();
this.chainManager = new ChainManager(this.app, this.vectorStoreManager);

82
src/plusUtils.ts Normal file
View file

@ -0,0 +1,82 @@
import { ChainType } from "@/chainFactory";
import {
ChatModelProviders,
ChatModels,
EmbeddingModelProviders,
EmbeddingModels,
PlusUtmMedium,
} from "@/constants";
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { getSettings, setSettings, updateSetting, useSettingsValue } from "@/settings/model";
import { setChainType, setModelKey } from "@/aiParams";
import { CopilotPlusExpiredModal } from "@/components/modals/CopilotPlusExpiredModal";
import VectorStoreManager from "@/search/vectorStoreManager";
export const DEFAULT_COPILOT_PLUS_CHAT_MODEL = ChatModels.COPILOT_PLUS_FLASH;
export const DEFAULT_COPILOT_PLUS_CHAT_MODEL_KEY =
DEFAULT_COPILOT_PLUS_CHAT_MODEL + "|" + ChatModelProviders.COPILOT_PLUS;
export const DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL = EmbeddingModels.COPILOT_PLUS_SMALL;
export const DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL_KEY =
DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL + "|" + EmbeddingModelProviders.COPILOT_PLUS;
/** Check if the model key is a Copilot Plus model. */
export function isPlusModel(modelKey: string): boolean {
return modelKey.split("|")[1] === EmbeddingModelProviders.COPILOT_PLUS;
}
/** Hook to get the isPlusUser setting. */
export function useIsPlusUser(): boolean | undefined {
const settings = useSettingsValue();
return settings.isPlusUser;
}
/** Check if the user is a Plus user. */
export async function checkIsPlusUser(): Promise<boolean | undefined> {
if (!getSettings().plusLicenseKey) {
turnOffPlus();
return false;
}
const brevilabsClient = BrevilabsClient.getInstance();
return await brevilabsClient.validateLicenseKey();
}
/**
* Apply the Copilot Plus settings.
* WARNING! If the embedding model is changed, the vault will be indexed. Use it
* with caution.
*/
export function applyPlusSettings(): void {
const defaultModelKey = DEFAULT_COPILOT_PLUS_CHAT_MODEL_KEY;
const embeddingModelKey = DEFAULT_COPILOT_PLUS_EMBEDDING_MODEL_KEY;
const previousEmbeddingModelKey = getSettings().embeddingModelKey;
setModelKey(defaultModelKey);
setChainType(ChainType.COPILOT_PLUS_CHAIN);
setSettings({
defaultModelKey,
embeddingModelKey,
defaultChainType: ChainType.COPILOT_PLUS_CHAIN,
});
if (previousEmbeddingModelKey !== embeddingModelKey) {
VectorStoreManager.getInstance().indexVaultToVectorStore(true);
}
}
export function createPlusPageUrl(medium: PlusUtmMedium): string {
return `https://www.obsidiancopilot.com?utm_source=obsidian&utm_medium=${medium}`;
}
export function navigateToPlusPage(medium: PlusUtmMedium): void {
window.open(createPlusPageUrl(medium), "_blank");
}
export function turnOnPlus(): void {
updateSetting("isPlusUser", true);
}
export function turnOffPlus(): void {
const previousIsPlusUser = getSettings().isPlusUser;
updateSetting("isPlusUser", false);
if (previousIsPlusUser) {
new CopilotPlusExpiredModal(app).open();
}
}

View file

@ -61,6 +61,8 @@ export interface CopilotSettings {
showRelevantNotes: boolean;
numPartitions: number;
defaultConversationNoteName: string;
// undefined means never checked
isPlusUser: boolean | undefined;
}
export const settingsStore = createStore();

View file

@ -86,28 +86,39 @@ interface SettingsMainV2Props {
plugin: CopilotPlugin;
}
const SettingsMainV2: React.FC<SettingsMainV2Props> = ({ plugin }) => (
<TabProvider>
<div>
<div className="flex flex-col gap-2">
<h1 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
Copilot Settings <span className="text-xs">v{plugin.manifest.version}</span>
</div>
<div className="self-end sm:self-auto">
<Button
variant="outline"
size="sm"
onClick={() => new ResetSettingsConfirmModal(app, () => resetSettings()).open()}
>
Reset Settings
</Button>
</div>
</h1>
const SettingsMainV2: React.FC<SettingsMainV2Props> = ({ plugin }) => {
// Add a key state that we'll change when resetting
const [resetKey, setResetKey] = React.useState(0);
const handleReset = async () => {
const modal = new ResetSettingsConfirmModal(app, async () => {
resetSettings();
// Increment the key to force re-render of all components
setResetKey((prev) => prev + 1);
});
modal.open();
};
return (
<TabProvider>
<div>
<div className="flex flex-col gap-2">
<h1 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
Copilot Settings <span className="text-xs">v{plugin.manifest.version}</span>
</div>
<div className="self-end sm:self-auto">
<Button variant="outline" size="sm" onClick={handleReset}>
Reset Settings
</Button>
</div>
</h1>
</div>
{/* Add the key prop to force re-render */}
<SettingsContent key={resetKey} plugin={plugin} />
</div>
<SettingsContent plugin={plugin} />
</div>
</TabProvider>
);
</TabProvider>
);
};
export default SettingsMainV2;

View file

@ -3,18 +3,24 @@ import { isCommandEnabled } from "@/commands";
import { RebuildIndexConfirmModal } from "@/components/modals/RebuildIndexConfirmModal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { SettingItem } from "@/components/ui/setting-item";
import { SettingSwitch } from "@/components/ui/setting-switch";
import { COMMAND_NAMES, DEFAULT_OPEN_AREA, DISABLEABLE_COMMANDS } from "@/constants";
import {
COMMAND_NAMES,
DEFAULT_OPEN_AREA,
DISABLEABLE_COMMANDS,
PLUS_UTM_MEDIUMS,
} from "@/constants";
import { useTab } from "@/contexts/TabContext";
import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/settings/model";
import { formatDateTime, getProviderLabel } from "@/utils";
import { ArrowRight, ExternalLink, HelpCircle, Key, Loader2 } from "lucide-react";
import { HelpCircle, Key, Loader2 } from "lucide-react";
import { Notice } from "obsidian";
import React, { useState } from "react";
import ApiKeyDialog from "./ApiKeyDialog";
import { PlusSettings } from "@/settings/v2/components/PlusSettings";
import { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { createPlusPageUrl } from "@/plusUtils";
const ChainType2Label: Record<ChainType, string> = {
[ChainType.LLM_CHAIN]: "Chat",
[ChainType.VAULT_QA_CHAIN]: "Vault QA (Basic)",
@ -26,9 +32,8 @@ interface BasicSettingsProps {
}
const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }) => {
const { setSelectedTab, modalContainer } = useTab();
const { modalContainer } = useTab();
const settings = useSettingsValue();
const [openPopoverIds, setOpenPopoverIds] = useState<Set<string>>(new Set());
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [conversationNoteName, setConversationNoteName] = useState(
@ -44,18 +49,6 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
}
};
const handlePopoverOpen = (id: string) => {
setOpenPopoverIds((prev) => new Set([...prev, id]));
};
const handlePopoverClose = (id: string) => {
setOpenPopoverIds((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
};
const applyCustomNoteFormat = () => {
setIsChecking(true);
@ -105,173 +98,60 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
return (
<div className="space-y-4">
<section>
<div className="text-2xl font-bold mb-3">Copilot Plus (beta)</div>
<div className="space-y-4">
{/* copilot-plus */}
<SettingItem
type="password"
title="License Key"
description={
<div className="flex items-center gap-1.5">
<span className="leading-none">
Copilot Plus brings powerful AI agent capabilities
</span>
<Popover
open={openPopoverIds.has("license-help")}
onOpenChange={(open) => {
if (open) {
handlePopoverOpen("license-help");
} else {
handlePopoverClose("license-help");
}
}}
>
<PopoverTrigger asChild>
<HelpCircle
className="h-5 w-5 sm:h-4 sm:w-4 cursor-pointer text-muted hover:text-accent translate-y-[1px]"
onMouseEnter={() => handlePopoverOpen("license-help")}
onMouseLeave={() => handlePopoverClose("license-help")}
/>
</PopoverTrigger>
<PopoverContent
container={modalContainer}
className="w-[90vw] max-w-[400px] p-4 bg-primary border border-solid border-border shadow-sm"
side="bottom"
align="center"
sideOffset={5}
onMouseEnter={() => handlePopoverOpen("license-help")}
onMouseLeave={() => handlePopoverClose("license-help")}
>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium text-normal">
Copilot Plus brings powerful AI agent capabilities to Obsidian.
</p>
<p className="text-xs text-muted">
Copilot Plus is currently in beta. We are actively working on improving
the product and adding more features! Join now to lock in the lowest
price!
</p>
</div>
<div className="text-sm text-muted">
Learn more at{" "}
<a
href="https://obsidiancopilot.com"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover"
>
obsidiancopilot.com
</a>
</div>
</div>
</PopoverContent>
</Popover>
</div>
}
value={settings.plusLicenseKey}
onChange={(value) => {
updateSetting("plusLicenseKey", value);
}}
/>
<div className="flex justify-end -mt-2">
<Button
variant="outline"
onClick={() => window.open("https://obsidiancopilot.com", "_blank")}
>
Get Copilot Plus
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{/* Keys Section */}
<section>
<div className="text-2xl font-bold mb-4">API Keys (Required)</div>
<div className="space-y-4">
{/* API Key Section */}
<SettingItem
type="custom"
title="API Keys"
description={
<div className="flex items-center gap-1.5">
<span className="leading-none">Configure API keys for different AI providers</span>
<Popover
open={openPopoverIds.has("api-keys-help")}
onOpenChange={(open) => {
if (open) {
handlePopoverOpen("api-keys-help");
} else {
handlePopoverClose("api-keys-help");
}
}}
>
<PopoverTrigger asChild>
<HelpCircle
className="h-5 w-5 sm:h-4 sm:w-4 cursor-pointer text-muted hover:text-accent translate-y-[1px]"
onMouseEnter={() => handlePopoverOpen("api-keys-help")}
onMouseLeave={() => handlePopoverClose("api-keys-help")}
/>
</PopoverTrigger>
<PopoverContent
container={modalContainer}
className="w-[90vw] max-w-[400px] p-4 bg-primary border border-solid border-border shadow-sm"
side="bottom"
align="center"
sideOffset={5}
onMouseEnter={() => handlePopoverOpen("api-keys-help")}
onMouseLeave={() => handlePopoverClose("api-keys-help")}
>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium text-accent">
API key is required for chat and QA features
</p>
<p className="text-xs text-muted">
You&#39;ll need to provide an API key from your chosen provider to use the
chat and QA functionality.
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
}
>
<Button
onClick={() => setIsApiKeyDialogOpen(true)}
variant="outline"
className="flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-start"
>
Set Keys
<Key className="h-4 w-4" />
</Button>
</SettingItem>
{/* API Key Dialog */}
<ApiKeyDialog
open={isApiKeyDialogOpen}
onOpenChange={setIsApiKeyDialogOpen}
settings={settings}
updateSetting={updateSetting}
modalContainer={modalContainer}
/>
<div className="flex justify-end -mt-2">
<Button onClick={() => setSelectedTab("model")} variant="outline">
More Model Settings
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</section>
<PlusSettings />
{/* General Section */}
<section>
<div className="text-2xl font-bold mb-3">General</div>
<div className="text-xl font-bold mb-3">General</div>
<div className="space-y-4">
<div className="space-y-4">
{/* API Key Section */}
<SettingItem
type="custom"
title="API Keys"
description={
<div className="flex items-center gap-1.5">
<span className="leading-none">
Configure API keys for different AI providers
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent className="max-w-96 flex flex-col gap-2 py-4">
<div className="text-sm font-medium text-accent">
API key required for chat and QA features
</div>
<div className="text-xs text-muted">
To enable chat and QA functionality, please provide an API key from your
selected provider.
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<Button
onClick={() => setIsApiKeyDialogOpen(true)}
variant="outline"
className="flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-start"
>
Set Keys
<Key className="h-4 w-4" />
</Button>
</SettingItem>
{/* API Key Dialog */}
<ApiKeyDialog
open={isApiKeyDialogOpen}
onOpenChange={setIsApiKeyDialogOpen}
settings={settings}
updateSetting={updateSetting}
modalContainer={modalContainer}
/>
</div>
<SettingItem
type="select"
title="Default Chat Model"
@ -298,46 +178,24 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
<span className="leading-none font-medium text-accent">
Core Feature: Powers Semantic Search & QA
</span>
<Popover
open={openPopoverIds.has("embedding-model-help")}
onOpenChange={(open) => {
if (open) {
handlePopoverOpen("embedding-model-help");
} else {
handlePopoverClose("embedding-model-help");
}
}}
>
<PopoverTrigger asChild>
<HelpCircle
className="h-5 w-5 sm:h-4 sm:w-4 cursor-pointer text-muted hover:text-accent translate-y-[1px]"
onMouseEnter={() => handlePopoverOpen("embedding-model-help")}
onMouseLeave={() => handlePopoverClose("embedding-model-help")}
/>
</PopoverTrigger>
<PopoverContent
container={modalContainer}
className="w-[90vw] max-w-[400px] p-4 bg-primary border border-solid border-border shadow-sm"
side="bottom"
align="center"
sideOffset={5}
onMouseEnter={() => handlePopoverOpen("embedding-model-help")}
onMouseLeave={() => handlePopoverClose("embedding-model-help")}
>
<div className="space-y-2">
<p className="text-sm text-muted mb-3">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent className="max-w-96 flex flex-col gap-2">
<div className="text-sm text-muted pt-2">
This model converts text into vector representations, essential for
semantic search and QA functionality.
</p>
<p className="text-sm font-medium">Changing the embedding model will:</p>
<ul className="text-xs text-muted list-disc pl-4 space-y-1">
semantic search and QA functionality. Changing the embedding model will:
</div>
<ul className="text-sm text-muted pl-4">
<li>Require rebuilding your vault&#39;s vector index</li>
<li>Affect semantic search quality</li>
<li>Impact QA feature performance</li>
</ul>
</div>
</PopoverContent>
</Popover>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
}
@ -357,62 +215,39 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
description={
<div className="flex items-center gap-1.5">
<span className="leading-none">Select the default chat mode</span>
<Popover
open={openPopoverIds.has("default-mode-help")}
onOpenChange={(open) => {
if (open) {
handlePopoverOpen("default-mode-help");
} else {
handlePopoverClose("default-mode-help");
}
}}
>
<PopoverTrigger asChild>
<HelpCircle
className="h-5 w-5 sm:h-4 sm:w-4 cursor-pointer text-muted hover:text-accent translate-y-[1px]"
onMouseEnter={() => handlePopoverOpen("default-mode-help")}
onMouseLeave={() => handlePopoverClose("default-mode-help")}
/>
</PopoverTrigger>
<PopoverContent
container={modalContainer}
className="w-[90vw] max-w-[400px] p-4 bg-primary border border-solid border-border shadow-sm"
side="bottom"
align="center"
sideOffset={5}
onMouseEnter={() => handlePopoverOpen("default-mode-help")}
onMouseLeave={() => handlePopoverClose("default-mode-help")}
>
<div className="space-y-2">
<div className="space-y-1">
<ul className="space-y-2 text-xs 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="https://obsidiancopilot.com"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover"
>
obsidiancopilot.com
</a>{" "}
for more details.
</li>
</ul>
</div>
</div>
</PopoverContent>
</Popover>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent className="max-w-96 flex flex-col gap-2">
<ul className="text-sm text-muted pl-4">
<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="text-accent hover:text-accent-hover"
>
obsidiancopilot.com
</a>{" "}
for more details.
</li>
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
value={settings.defaultChainType}
@ -470,39 +305,18 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
<span className="leading-none">
Customize the format of saved conversation note names.
</span>
<Popover
open={openPopoverIds.has("note-format-help")}
onOpenChange={(open) => {
if (open) {
handlePopoverOpen("note-format-help");
} else {
handlePopoverClose("note-format-help");
}
}}
>
<PopoverTrigger asChild>
<HelpCircle
className="h-5 w-5 sm:h-4 sm:w-4 cursor-pointer text-muted hover:text-accent"
onMouseEnter={() => handlePopoverOpen("note-format-help")}
onMouseLeave={() => handlePopoverClose("note-format-help")}
/>
</PopoverTrigger>
<PopoverContent
container={modalContainer}
className="w-[90vw] max-w-[400px] p-4 bg-primary border border-solid border-border shadow-sm"
side="bottom"
align="center"
sideOffset={5}
onMouseEnter={() => handlePopoverOpen("note-format-help")}
onMouseLeave={() => handlePopoverClose("note-format-help")}
>
<div className="space-y-2">
<div className="text-[10px] font-medium text-warning p-2 rounded-md">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent className="max-w-96 flex flex-col gap-2 py-4">
<div className="text-sm font-medium text-accent">
Note: All the following variables must be included in the template.
</div>
<div className="space-y-1">
<div className="text-sm font-medium">Available variables:</div>
<ul className="space-y-2 text-xs text-muted">
<div>
<div className="text-sm font-medium text-muted">Available variables:</div>
<ul className="text-sm text-muted pl-4">
<li>
<strong>{"{$date}"}</strong>: Date in YYYYMMDD format
</li>
@ -513,14 +327,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
<strong>{"{$topic}"}</strong>: Chat conversation topic
</li>
</ul>
<i className="text-[10px] mt-2">
<i className="text-sm text-muted mt-2">
Example: {"{$date}_{$time}__{$topic}"}
20250114_153232__polish_this_article_[[Readme]]
</i>
</div>
</div>
</PopoverContent>
</Popover>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>

View file

@ -58,7 +58,7 @@ const ModelSettings: React.FC = () => {
return (
<div className="space-y-4">
<section>
<div className="text-2xl font-bold mb-3">Chat Models</div>
<div className="text-xl font-bold mb-3">Chat Models</div>
<ModelTable
models={settings.activeModels}
// onEdit={setEditingModel}
@ -134,7 +134,7 @@ const ModelSettings: React.FC = () => {
</section>
<section>
<div className="text-2xl font-bold mb-3">Embedding Models</div>
<div className="text-xl font-bold mb-3">Embedding Models</div>
<ModelTable
models={settings.activeEmbeddingModels}
onDelete={onDeleteEmbeddingModel}

View file

@ -0,0 +1,76 @@
import { CopilotPlusWelcomeModal } from "@/components/modals/CopilotPlusWelcomeModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PasswordInput } from "@/components/ui/password-input";
import { PLUS_UTM_MEDIUMS } from "@/constants";
import { checkIsPlusUser, navigateToPlusPage, useIsPlusUser } from "@/plusUtils";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { ExternalLink, Loader2 } from "lucide-react";
import React, { useEffect, useState } from "react";
export function PlusSettings() {
const settings = useSettingsValue();
const [error, setError] = useState<string | null>(null);
const [isChecking, setIsChecking] = useState(false);
const isPlusUser = useIsPlusUser();
const [localLicenseKey, setLocalLicenseKey] = useState(settings.plusLicenseKey);
useEffect(() => {
setLocalLicenseKey(settings.plusLicenseKey);
}, [settings.plusLicenseKey]);
return (
<section className="flex flex-col gap-4 bg-secondary p-4 rounded-lg">
<div className="text-xl font-bold flex items-center gap-2 justify-between">
<span>Copilot Plus (beta)</span>
{isPlusUser && (
<Badge variant="outline" className="text-success">
Active
</Badge>
)}
</div>
<div className="text-sm text-muted flex flex-col gap-2">
<div>
Copilot Plus takes your Obsidian experience to the next level with cutting-edge AI
capabilities. This premium tier unlocks advanced features, including chat context, PDF and
image support, web search integration, exclusive chat and embedding models, and much more.
</div>
<div>
Currently in beta, Copilot Plus is evolving fast, with new features and improvements
rolling out regularly. Join now to secure the lowest price and get early access!
</div>
</div>
<div className="flex items-center gap-2">
<PasswordInput
className="w-full"
placeholder="Enter your license key"
value={localLicenseKey}
onChange={(value) => {
setLocalLicenseKey(value);
}}
/>
<Button
disabled={isChecking}
onClick={async () => {
updateSetting("plusLicenseKey", localLicenseKey);
setIsChecking(true);
const result = await checkIsPlusUser();
setIsChecking(false);
if (!result) {
setError("Invalid license key");
} else {
setError(null);
new CopilotPlusWelcomeModal(app).open();
}
}}
className="min-w-20"
>
{isChecking ? <Loader2 className="h-4 w-4 animate-spin" /> : "Apply"}
</Button>
<Button variant="default" onClick={() => navigateToPlusPage(PLUS_UTM_MEDIUMS.SETTINGS)}>
Join Now <ExternalLink className="size-4" />
</Button>
</div>
<div className="text-error">{error}</div>
</section>
);
}

View file

@ -812,3 +812,14 @@ export async function insertIntoEditor(message: string, replace: boolean = false
}
new Notice("Message inserted into the active note.");
}
export function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}