feat: add quick actions system with provider-aware model settings

Introduce QuickActionsService and QuickAgent for lightweight, single-shot AI
operations. Add a dedicated quickActionModel setting alongside a new top-level
provider setting, with validation ensuring all models match the selected provider
and provider-specific defaults. Refactor FileSystemService to offer both TFile
and path-based overloads (readFile/readFilePath, writeToFile/writeToFilePath,
patchFile/patchFileAtPath). Replace window/document globals with activeWindow/
activeDocument throughout InputService and VaultkeeperAISettingTab for Obsidian
mobile compatibility.
Update linting packages to latest.
This commit is contained in:
Andrew Beal 2026-04-20 20:20:22 +01:00
parent 66ed00dec0
commit 28c8ccb44b
39 changed files with 1562 additions and 683 deletions

View file

@ -104,6 +104,8 @@ export abstract class BaseAIClass implements IAIClass {
return this.settingsService.settings.planningModel;
case AgentType.Execution:
return this.settingsService.settings.model;
case AgentType.QuickAction:
return this.settingsService.settings.quickActionModel;
}
}

View file

@ -138,7 +138,7 @@ export class Claude extends BaseAIClass {
const args = JSON.parse(this.accumulatedFunctionArgs) as Record<string, unknown>;
toolCall = new AIToolCall(
aiToolFromString(this.accumulatedFunctionName),
args as Record<string, object>,
args,
this.accumulatedFunctionId || undefined,
undefined // thoughtSignature not used by Claude
);

View file

@ -190,7 +190,7 @@ export class Gemini extends BaseAIClass {
if (isComplete && this.accumulatedFunctionName) {
toolCall = new AIToolCall(
aiToolFromString(this.accumulatedFunctionName),
this.accumulatedFunctionArgs as Record<string, object>,
this.accumulatedFunctionArgs,
undefined, // toolId not used by Gemini
this.accumulatedThoughtSignature || undefined
);
@ -419,7 +419,7 @@ export class Gemini extends BaseAIClass {
// Handle object format: { seconds: 10, nanos: 500000000 }
if (typeof rawDelay === 'object' && rawDelay !== null && 'seconds' in rawDelay) {
const seconds = (rawDelay as { seconds: unknown }).seconds;
const seconds = (rawDelay).seconds;
if (typeof seconds === 'number' || typeof seconds === 'string') {
return Math.ceil(Number(seconds));
}

View file

@ -168,7 +168,7 @@ export class Mistral extends BaseAIClass {
const args = JSON.parse(firstToolCall.args) as Record<string, unknown>;
toolCall = new AIToolCall(
aiToolFromString(firstToolCall.name),
args as Record<string, object>,
args,
firstToolCall.id || undefined,
undefined
);

View file

@ -164,7 +164,7 @@ export class OpenAI extends BaseAIClass {
const args = JSON.parse(itemDoneEvent.item.arguments) as Record<string, unknown>;
toolCall = new AIToolCall(
aiToolFromString(itemDoneEvent.item.name),
args as Record<string, object>,
args,
itemDoneEvent.item.call_id || itemDoneEvent.item_id,
undefined // thoughtSignature not used by OpenAI
);

View file

@ -26,7 +26,7 @@ Do NOT use this function:
properties: {
description: {
type: "string",
description: "Brief summary of what this step accomplishes (e.g., 'Search for ML notes', 'Create index file'). This is user-facing and should be concise."
description: "Brief summary of what this step accomplishes (e.g., 'Search for ML notes', 'Create index file'). This is user-facing and should be very concise."
},
instruction: {
type: "string",

View file

@ -82,7 +82,7 @@ export class AIPrompt implements IPrompt {
}
public async userInstruction(): Promise<string> {
const result = await this.fileSystemService.readFile(this.settingsService.settings.userInstruction, true);
const result = await this.fileSystemService.readFilePath(this.settingsService.settings.userInstruction, true);
return result instanceof Error ? "" : result;
}
}

View file

@ -0,0 +1,7 @@
export const BeautifyPrompt: string = `You are a writing editor. Your task is to beautify the provided text.
Improve clarity, flow, and readability while preserving the author's voice and meaning. Fix grammar and awkward phrasing.
Use Markdown formatting (headings, bold, italics, lists, blockquotes) where it enhances structure and readability in Obsidian.
Do not add new ideas, remove content, or change the structure unless it clearly improves readability.
Return only the improved text with no explanation, preamble, or commentary.`;

View file

@ -36,7 +36,7 @@ export enum AITool {
export function fromString(functionName: string): AITool {
const enumValue = Object.values(AITool).find((value: string) => value === functionName);
if (enumValue) {
return enumValue as AITool;
return enumValue;
}
return AITool.Unknown;
}

View file

@ -2,5 +2,6 @@ export enum AgentType {
Main = "main",
Orchestration = "orchestration",
Planning = "planning",
Execution = "execution"
Execution = "execution",
QuickAction = "quickAction"
}

View file

@ -17,6 +17,10 @@ export function fromModel(model: string): AIProvider {
}
}
export function isvalidProvider(value: string): value is AIProvider {
return Object.values(AIProvider).includes(value as AIProvider);
}
export function isValidProviderModel(model: string): model is AIProviderModel {
return Object.values(AIProviderModel).includes(model as AIProviderModel);
}
@ -37,6 +41,19 @@ function isMistralModel(model: string): boolean {
return isValidProviderModel(model) && model.startsWith("mistral-");
}
export function modelMatchesProvider(model: AIProviderModel, provider: AIProvider): boolean {
switch (provider) {
case AIProvider.Claude:
return isClaudeModel(model);
case AIProvider.Gemini:
return isGeminiModel(model);
case AIProvider.OpenAI:
return isOpenAIModel(model);
case AIProvider.Mistral:
return isMistralModel(model);
}
}
export enum AIProvider {
Claude = "Claude",
Gemini = "Gemini",
@ -99,4 +116,18 @@ export enum AIFileServiceURL {
export enum MistralAgentEndpoint {
Url = "https://api.mistral.ai/v1/agents",
ConversationsUrl = "https://api.mistral.ai/v1/conversations"
}
}
export const DEFAULT_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
[AIProvider.Claude]: AIProviderModel.ClaudeHaiku_4_5,
[AIProvider.Gemini]: AIProviderModel.GeminiFlash_2_5_Lite,
[AIProvider.OpenAI]: AIProviderModel.GPT_5_4_Nano,
[AIProvider.Mistral]: AIProviderModel.MistralSmall,
};
export const DEFAULT_PLANNING_MODEL_BY_PROVIDER: Record<AIProvider, AIProviderModel> = {
[AIProvider.Claude]: AIProviderModel.ClaudeSonnet_4_6,
[AIProvider.Gemini]: AIProviderModel.GeminiPro_2_5,
[AIProvider.OpenAI]: AIProviderModel.GPT_5_4_Pro,
[AIProvider.Mistral]: AIProviderModel.MistralLarge,
};

View file

@ -62,6 +62,8 @@ export enum Copy {
SettingModelDesc = "Select the AI model to use.",
SettingPlanningModelDesc = "Select the AI model to use when planning complex tasks.",
SettingPlanningModelTip = "Tip: You can reduce cost by using a more powerful model for planning and a cheaper model for the regular agent.",
SettingQuickActionModel = "Quick Actions Model",
SettingQuickActionModelDesc = "Select the AI model to use for quick actions. A fast, lightweight model is recommended.",
SettingApiKeyDesc = "Enter your API key here.",
SettingFileExclusionsDesc = "Set which directories and files the AI should ignore. Enter one path per line - supports glob patterns like folder/**, *.md",
SettingSearchResultsLimitDesc = "Set the maximum number of results provided to the AI when it searches through files in your vault. Higher values provide more context but increase search time.",

View file

@ -56,5 +56,5 @@ export function pathExtname(filePath: string) {
}
export async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise(resolve => activeWindow.setTimeout(resolve, ms));
}

View file

@ -112,7 +112,7 @@ export abstract class StringTools {
}
}
const canvas = document.createElement("canvas");
const canvas = activeDocument.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");

View file

@ -281,7 +281,7 @@ export class AIToolService {
private async readVaultFiles(filePaths: string[]): Promise<AIToolResponsePayload> {
const results = await Promise.all(
filePaths.map(async (filePath) => {
const result = await this.fileSystemService.readFile(filePath);
const result = await this.fileSystemService.readFilePath(filePath);
if (result instanceof Error) {
return { path: filePath, error: result.message };
}
@ -324,7 +324,7 @@ export class AIToolService {
}
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.writeFile(normalizePath(filePath), content);
const result = await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}
@ -332,7 +332,7 @@ export class AIToolService {
}
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.patchFile(normalizePath(filePath), oldContent, newContent);
const result = await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}

View file

@ -0,0 +1,55 @@
import { AgentType } from "Enums/AgentType";
import { BaseAgent } from "./BaseAgent";
import { Conversation } from "Conversations/Conversation";
import { Exception } from "Helpers/Exception";
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
import type { IChatServiceCallbacks } from "Services/ChatService";
import { ConversationContent } from "Conversations/ConversationContent";
import { Role } from "Enums/Role";
export class QuickAgent extends BaseAgent {
public async quickAction(action: string, context: string): Promise<string> {
this.setAgentPromptAndTools(action);
const conversation = new Conversation();
const conversationContent = new ConversationContent({
role: Role.User,
content: context,
});
conversation.contents.push(conversationContent);
return await this.requestAgentResponse(AgentType.QuickAction, conversation, this.callbacks());
}
private setAgentPromptAndTools(instruction: string): void {
if (!this.ai) {
Exception.throw("Error: No AI provider has been set!");
}
this.ai.agentType = AgentType.QuickAction;
this.ai.aiToolUsageMode = AIToolUsageMode.Disabled;
this.ai.systemPrompt = instruction;
this.ai.userInstruction = ""; // do not include user instruction for quick agent
this.ai.aiToolDefinitions = []; // no tools for quick agent
}
private callbacks(): IChatServiceCallbacks {
return {
onSubmit: () => {},
onStreamingUpdate: () => {},
onThoughtUpdate: () => {},
onToolCallStarted: () => {},
onPlanningStarted: () => {},
onPlanningFinished: () => {},
onUserQuestion: async () => {
return new Promise<string>(() => {});
},
onPlanUpdate: () => {},
onPlanStepUpdate: () => {},
onPlanReset: () => {},
onComplete: () => {},
};
}
}

View file

@ -112,7 +112,7 @@ export class ChatService {
await this.mainAgent.runMainAgent(conversation, chatMode, callbacks);
if (namingPromise) {
const timeout = new Promise<void>(resolve => setTimeout(resolve, 5000));
const timeout = new Promise<void>(resolve => activeWindow.setTimeout(resolve, 5000));
await Promise.race([namingPromise, timeout]);
}
});

View file

@ -17,7 +17,11 @@ export class FileSystemService {
return await this.vaultService.exists(filePath, allowAccessToPluginRoot);
}
public async readFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<string | Error> {
public async readFile(file: TFile, allowAccessToPluginRoot: boolean = false): Promise<string | Error> {
return await this.vaultService.read(file, allowAccessToPluginRoot);
}
public async readFilePath(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<string | Error> {
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
if (file == null) {
return Exception.new(`File does not exist: ${filePath}`);
@ -43,7 +47,11 @@ export class FileSystemService {
return Exception.new(`Path is a folder, not a file: ${filePath}`);
}
public async writeFile(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
public async writeToFile(file: TFile, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
return await this.vaultService.modify(file, content, allowAccessToPluginRoot, requiresConfirmation);
}
public async writeToFilePath(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
if (file == null || !(file instanceof TFile)) {
return await this.vaultService.create(filePath, content, allowAccessToPluginRoot, requiresConfirmation);
@ -59,7 +67,11 @@ export class FileSystemService {
return await this.vaultService.modifyBinary(file, data, allowAccessToPluginRoot);
}
public async patchFile(filePath: string, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
public async patchFile(file: TFile, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
return await this.vaultService.patch(file, oldContent, newContent, allowAccessToPluginRoot, requiresConfirmation);
}
public async patchFileAtPath(filePath: string, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
let fileToPatch: TFile;
@ -67,7 +79,7 @@ export class FileSystemService {
fileToPatch = file;
} else {
// if the file doesn't exist we may as well create it even though this is just a patch operation
const result = await this.writeFile(filePath, "", allowAccessToPluginRoot, requiresConfirmation);
const result = await this.writeToFilePath(filePath, "", allowAccessToPluginRoot, requiresConfirmation);
if (result instanceof Error) {
return result;
}

View file

@ -12,7 +12,7 @@ export class HTMLService {
public parseHTMLString(htmlString: string): DocumentFragment {
const parser = new DOMParser();
const fragment = document.createDocumentFragment();
const fragment = activeDocument.createDocumentFragment();
const doc = parser.parseFromString(htmlString, "text/html");
// Transfer all nodes from the parsed body to the fragment
@ -26,7 +26,7 @@ export class HTMLService {
// Creates a temporary container, parses HTML, and returns the container.
// Useful for parsing HTML when you need to traverse the resulting DOM structure.
public parseHTMLToContainer(htmlString: string): HTMLDivElement {
const container = document.createElement("div");
const container = activeDocument.createElement("div");
const fragment = this.parseHTMLString(htmlString);
container.appendChild(fragment);
return container;

View file

@ -166,7 +166,7 @@ export class InputService {
}
public getCursorPosition(element: HTMLElement): number {
const selection = window.getSelection() || new Selection();
const selection = activeWindow.window.getSelection() || new Selection();
if (selection.rangeCount === 0) {
return -1;
@ -201,8 +201,8 @@ export class InputService {
try {
// Create range and selection
const range = document.createRange();
const selection = window.getSelection() ?? new Selection();
const range = activeWindow.document.createRange();
const selection = activeWindow.getSelection() ?? new Selection();
// Find the text node and position
const result = this.findTextNodeAndOffset(element, targetPosition);
@ -236,7 +236,7 @@ export class InputService {
return;
}
const selection = window.getSelection();
const selection = activeWindow.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
@ -244,7 +244,7 @@ export class InputService {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
const textNode = activeDocument.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
@ -259,7 +259,7 @@ export class InputService {
return;
}
const selection = window.getSelection();
const selection = activeWindow.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
@ -283,13 +283,13 @@ export class InputService {
return;
}
const selection = window.getSelection();
const selection = activeWindow.getSelection();
if (!selection) {
return;
}
try {
const range = document.createRange();
const range = activeDocument.createRange();
// Find the text nodes and offsets for start and end positions
const startResult = this.findTextNodeAndOffset(element, startPos);
@ -323,7 +323,7 @@ export class InputService {
* If it is, repositions the cursor to a valid location.
*/
private ensureCursorNotInNonEditableElement(element: HTMLElement) {
const selection = window.getSelection();
const selection = activeWindow.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
@ -349,13 +349,13 @@ export class InputService {
* Positions the cursor immediately after the given element.
*/
private positionCursorAfterElement(targetElement: HTMLElement, container: HTMLElement) {
const selection = window.getSelection();
const selection = activeWindow.getSelection();
if (!selection) {
return;
}
try {
const range = document.createRange();
const range = activeDocument.createRange();
// Try to position cursor in the next text node or after the element
const nextSibling = targetElement.nextSibling;

View file

@ -26,7 +26,7 @@ export class MemoriesService {
}
public async readMemories(): Promise<string> {
const result = await this.fileSystemService.readFile(Path.Memories, true);
const result = await this.fileSystemService.readFilePath(Path.Memories, true);
if (result instanceof Error) {
return Copy.MemoriesEmpty;
@ -41,7 +41,7 @@ export class MemoriesService {
[this.maxMemoriesLength.toString(), this.maxMemoriesLineLength.toString()]);
}
const result = await this.fileSystemService.writeFile(Path.Memories, newMemories, true, false);
const result = await this.fileSystemService.writeToFilePath(Path.Memories, newMemories, true, false);
return result instanceof Error ? result : Copy.MemoriesUpdatedSuccess;
}

View file

@ -0,0 +1,84 @@
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import { AbortService } from "./AbortService";
import type { QuickAgent } from "./AIServices/QuickAgent";
import type VaultkeeperAIPlugin from "main";
import type { Editor, MarkdownFileInfo, MarkdownView, Menu } from "obsidian";
import type { FileSystemService } from "./FileSystemService";
import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/Beautify";
export class QuickActionsService {
private plugin: VaultkeeperAIPlugin;
private abortService: AbortService;
private fileSystemService: FileSystemService;
public constructor() {
this.plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
this.abortService = Resolve<AbortService>(Services.AbortService);
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
this.registerEditorMenuActions();
}
/* Edit Menu Action Definitions */
private async beautify(menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
const file = view.file;
if (!file) {
return;
}
const selection = editor.getSelection();
const content = await this.fileSystemService.readFile(file);
if (content instanceof Error) {
return; // Likely an excluded file
}
if (selection.length > 0) {
const result = await this.newAction(BeautifyPrompt, selection);
await this.fileSystemService.patchFile(file, [selection], [result], false, false);
} else {
const result = await this.newAction(BeautifyPrompt, content);
await this.fileSystemService.writeToFile(file, result, false, false);
}
}
private async applyTemplate(menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
}
private async newAction(action: string, context: string): Promise<string> {
return this.abortService.abortableOperation(async () => {
const agent = Resolve<QuickAgent>(Services.QuickAgent);
agent.resolveAIProvider();
return agent.quickAction(action, context);
});
}
/* Registered Edit Menu Actions */
private registerEditorMenuActions() {
// Beautify
this.plugin.registerEvent(
this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => {
menu.addItem((item) => {
item.setTitle("Beautify")
.setIcon("palette")
.onClick(async () => this.beautify(menu, editor, view));
});
})
);
// Apply Template
this.plugin.registerEvent(
this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => {
menu.addItem((item) => {
item.setTitle("Apply template")
.setIcon("notepad-text-dashed")
.onClick(async () => this.applyTemplate(menu, editor, view));
});
})
);
}
}

View file

@ -56,6 +56,8 @@ import { OpenAIFileService } from "AIClasses/OpenAI/OpenAIFileService";
// Prompts
import { AIPrompt, type IPrompt } from "AIPrompts/IPrompt";
import { QuickAgent } from "./AIServices/QuickAgent";
import { QuickActionsService } from "./QuickActionsService";
export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) {
RegisterSingleton<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin, plugin);
@ -82,6 +84,7 @@ export function RegisterDependencies() {
RegisterSingleton<MemoriesService>(Services.MemoriesService, new MemoriesService());
RegisterSingleton<ConversationFileSystemService>(Services.ConversationFileSystemService, new ConversationFileSystemService());
RegisterSingleton<ConversationNamingService>(Services.ConversationNamingService, new ConversationNamingService());
RegisterSingleton<QuickActionsService>(Services.QuickActionsService, new QuickActionsService());
RegisterTransient<WebViewerService>(Services.WebViewerService, () => new WebViewerService());
@ -91,6 +94,7 @@ export function RegisterDependencies() {
RegisterSingleton<StreamingService>(Services.StreamingService, new StreamingService());
RegisterSingleton<ChatService>(Services.ChatService, new ChatService());
RegisterTransient<QuickAgent>(Services.QuickAgent, () => new QuickAgent());
RegisterTransient<StreamingMarkdownService>(Services.StreamingMarkdownService, () => new StreamingMarkdownService());
RegisterTransient<InputService>(Services.InputService, () => new InputService());

View file

@ -11,11 +11,13 @@ export class Services {
static FileSystemService = Symbol("FileSystemService");
static ConversationFileSystemService = Symbol("ConversationFileSystemService");
static ConversationNamingService = Symbol("ConversationNamingService");
static QuickActionsService = Symbol("QuickActionsService");
static StreamingService = Symbol("StreamingService");
static MarkdownService = Symbol("MarkdownService");
static StreamingMarkdownService = Symbol("StreamingMarkdownService");
static AIToolService = Symbol("AIToolService");
static MainAgent = Symbol("MainAgent");
static QuickAgent = Symbol("QuickAgent");
static ChatService = Symbol("ChatService");
static SanitiserService = Symbol("SanitiserService");
static InputService = Symbol("InputService");

View file

@ -1,14 +1,17 @@
import type VaultkeeperAIPlugin from "main";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import { AIProvider, AIProviderModel, fromModel, isValidProviderModel } from "Enums/ApiProvider";
import { AIProvider, AIProviderModel, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PLANNING_MODEL_BY_PROVIDER, fromModel, isvalidProvider, isValidProviderModel, modelMatchesProvider } from "Enums/ApiProvider";
const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
firstTimeStart: true,
userInstruction: "",
provider: AIProvider.Claude,
model: AIProviderModel.ClaudeHaiku_4_5,
planningModel: AIProviderModel.ClaudeSonnet_4_6,
quickActionModel: AIProviderModel.ClaudeHaiku_4_5,
apiKeys: {
claude: "",
openai: "",
@ -31,8 +34,11 @@ export interface IVaultkeeperAISettings {
firstTimeStart: boolean;
userInstruction: string;
model: string;
planningModel: string;
provider: AIProvider;
model: AIProviderModel;
planningModel: AIProviderModel;
quickActionModel: AIProviderModel;
apiKeys: {
claude: string;
openai: string;
@ -106,12 +112,31 @@ export class SettingsService {
}
private ensureValidModels(): void {
const validModel = isValidProviderModel(this.settings.model);
const validPlanningModel = isValidProviderModel(this.settings.model);
let changed = false;
if (!validModel || !validPlanningModel) {
this.settings.model = AIProviderModel.ClaudeSonnet_4_6;
this.settings.planningModel = AIProviderModel.ClaudeSonnet_4_6;
let provider = this.settings.provider;
if (!isvalidProvider(provider)) {
provider = DEFAULT_SETTINGS.provider;
changed = true;
}
if (!isValidProviderModel(this.settings.model) || !modelMatchesProvider(this.settings.model, provider)) {
this.settings.model = DEFAULT_MODEL_BY_PROVIDER[provider];
changed = true;
}
if (!isValidProviderModel(this.settings.planningModel) || !modelMatchesProvider(this.settings.planningModel, provider)) {
this.settings.planningModel = DEFAULT_PLANNING_MODEL_BY_PROVIDER[provider];
changed = true;
}
if (!isValidProviderModel(this.settings.quickActionModel) || !modelMatchesProvider(this.settings.quickActionModel, provider)) {
this.settings.quickActionModel = DEFAULT_MODEL_BY_PROVIDER[provider];
changed = true;
}
if (changed) {
void this.saveSettings();
}
}

View file

@ -99,12 +99,12 @@ export class StreamingMarkdownService {
this.debouncedRender(messageId);
}
private renderTimeouts = new Map<string, NodeJS.Timeout>();
private renderTimeouts = new Map<string, ReturnType<typeof activeWindow.setTimeout>>();
private debouncedRender(messageId: string, immediate: boolean = false) {
const existingTimeout = this.renderTimeouts.get(messageId);
if (existingTimeout) {
clearTimeout(existingTimeout);
activeWindow.clearTimeout(existingTimeout);
}
const render = () => {
@ -127,7 +127,7 @@ export class StreamingMarkdownService {
if (immediate) {
render();
} else {
const timeout = setTimeout(render, 50); // 50ms debounce
const timeout = activeWindow.setTimeout(render, 50); // 50ms debounce
this.renderTimeouts.set(messageId, timeout);
}
}
@ -148,7 +148,7 @@ export class StreamingMarkdownService {
this.streamingStates.delete(messageId);
const timeout = this.renderTimeouts.get(messageId);
if (timeout) {
clearTimeout(timeout);
activeWindow.clearTimeout(timeout);
this.renderTimeouts.delete(messageId);
}
}

View file

@ -72,7 +72,7 @@ export class WebViewerService {
if (Date.now() - start > timeoutMs) {
return false;
}
await new Promise(r => setTimeout(r, 200));
await new Promise(r => activeWindow.setTimeout(r, 200));
}
return true;
}

View file

@ -34,7 +34,7 @@ export class ApiError extends Error {
let isRetryable: boolean;
// Parse response body for provider-specific messages
let providerMessage = "";
let providerMessage: string;
try {
const parsed = JSON.parse(responseBody) as { error?: { message?: string }; message?: string };
providerMessage = parsed.error?.message || parsed.message || "";

View file

@ -1,4 +1,4 @@
import { AIProvider, AIProviderModel, fromModel } from "Enums/ApiProvider";
import { AIProvider, AIProviderModel, fromModel, isValidProviderModel } from "Enums/ApiProvider";
import { Copy } from "Enums/Copy";
import { Selector } from "Enums/Selector";
import type VaultkeeperAIPlugin from "main";
@ -20,6 +20,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
private apiKeyInputEl: HTMLInputElement | null = null;
private fileDisclaimerSetting: Setting | null = null;
private planningModelDropdown: DropdownComponent | null = null;
private quickActionModelDropdown: DropdownComponent | null = null;
private allowUpdatingMemoriesSetting: Setting | null = null;
private allowUpdatingMemoriesToggleComponent: ToggleComponent | null = null;
@ -46,34 +47,24 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.populateModelDropdown(dropdown);
dropdown.setValue(this.settingsService.settings.model);
dropdown.onChange(async (value) => {
if (!isValidProviderModel(value)) {
return;
}
this.settingsService.settings.model = value;
this.settingsService.settings.provider = fromModel(value);
await this.settingsService.saveSettings(() => RegisterAiProvider());
if (this.apiKeyInputEl) {
this.apiKeyInputEl.value = this.settingsService.getApiKeyForCurrentModel();
this.highlightApiKey();
}
this.updateFileDisclaimer();
await this.updatePlanningModelDropdown();
await this.updateModelDropdowns();
});
});
/* Model files API disclaimer */
this.fileDisclaimerSetting = new Setting(containerEl)
.setDesc(Copy.SettingFileMonitoringClaude)
.addExtraButton(button => {
button
.setTooltip(Copy.TooltipLearnMoreFileMonitoring)
.onClick(() => {
const modal = Resolve<HelpModal>(Services.HelpModal);
modal.open(2); // Opens HelpModal to "Plugin Guide" (topic 2)
});
setIcon(button.extraSettingsEl, "help-circle");
});
this.updateFileDisclaimer();
/* Planning Model Selection Setting */
const currentProvider = fromModel(this.settingsService.settings.model);
const planningModelDescFragment = document.createDocumentFragment();
const planningModelDescFragment = activeDocument.createDocumentFragment();
planningModelDescFragment.appendText(Copy.SettingPlanningModelDesc);
planningModelDescFragment.createEl("br");
planningModelDescFragment.createEl("br");
@ -86,11 +77,31 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.populateModelDropdown(dropdown, currentProvider);
dropdown.setValue(this.settingsService.settings.planningModel);
dropdown.onChange(async (value) => {
if (!isValidProviderModel(value)) {
return;
}
this.settingsService.settings.planningModel = value;
await this.settingsService.saveSettings();
});
});
/* Quick Action Model Selection Setting */
new Setting(containerEl)
.setName(Copy.SettingQuickActionModel)
.setDesc(Copy.SettingQuickActionModelDesc)
.addDropdown((dropdown) => {
this.quickActionModelDropdown = dropdown;
this.populateModelDropdown(dropdown);
dropdown.setValue(this.settingsService.settings.quickActionModel);
dropdown.onChange(async (value) => {
if (!isValidProviderModel(value)) {
return;
}
this.settingsService.settings.quickActionModel = value;
await this.settingsService.saveSettings();
});
});
/* API Key Setting */
this.apiKeySetting = new Setting(containerEl)
.setName(Copy.SettingApiKey)
@ -125,6 +136,20 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
});
this.highlightApiKey();
/* Model files API disclaimer */
this.fileDisclaimerSetting = new Setting(containerEl)
.setDesc(Copy.SettingFileMonitoringClaude)
.addExtraButton(button => {
button
.setTooltip(Copy.TooltipLearnMoreFileMonitoring)
.onClick(() => {
const modal = Resolve<HelpModal>(Services.HelpModal);
modal.open(2); // Opens HelpModal to "Plugin Guide" (topic 2)
});
setIcon(button.extraSettingsEl, "help-circle");
});
this.updateFileDisclaimer();
/* Exclusions Setting */
new Setting(containerEl)
.setName(Copy.SettingFileExclusions)
@ -286,23 +311,39 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
}
}
private async updatePlanningModelDropdown(): Promise<void> {
if (!this.planningModelDropdown) return;
private async updateModelDropdowns(): Promise<void> {
const currentProvider = fromModel(this.settingsService.settings.model);
const planningProvider = fromModel(this.settingsService.settings.planningModel);
let shouldSave = false;
// Clear existing options
this.planningModelDropdown.selectEl.empty();
this.populateModelDropdown(this.planningModelDropdown, currentProvider);
if (this.planningModelDropdown) {
const planningProvider = fromModel(this.settingsService.settings.planningModel);
this.planningModelDropdown.selectEl.empty();
this.populateModelDropdown(this.planningModelDropdown, currentProvider);
// If planning model provider doesn't match, reset to main model
if (planningProvider !== currentProvider) {
this.settingsService.settings.planningModel = this.settingsService.settings.model;
await this.settingsService.saveSettings();
if (planningProvider !== currentProvider) {
this.settingsService.settings.planningModel = this.settingsService.settings.model;
shouldSave = true;
}
this.planningModelDropdown.setValue(this.settingsService.settings.planningModel);
}
this.planningModelDropdown.setValue(this.settingsService.settings.planningModel);
if (this.quickActionModelDropdown) {
const quickActionProvider = fromModel(this.settingsService.settings.quickActionModel);
this.quickActionModelDropdown.selectEl.empty();
this.populateModelDropdown(this.quickActionModelDropdown);
if (quickActionProvider !== currentProvider) {
this.settingsService.settings.quickActionModel = this.settingsService.settings.model;
shouldSave = true;
}
this.quickActionModelDropdown.setValue(this.settingsService.settings.quickActionModel);
}
if (shouldSave) {
await this.settingsService.saveSettings();
}
}
private highlightApiKey() {

View file

@ -83,7 +83,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(fileContent);
const result = await fileSystemService.readFile('test.md');
const result = await fileSystemService.readFilePath('test.md');
expect(result).toBe(fileContent);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('test.md', false);
@ -93,7 +93,7 @@ describe('FileSystemService', () => {
it('should return Error when file does not exist', async () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
const result = await fileSystemService.readFile('nonexistent.md');
const result = await fileSystemService.readFilePath('nonexistent.md');
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('File does not exist: ');
@ -107,7 +107,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(fileContent);
await fileSystemService.readFile('plugin/config.json', true);
await fileSystemService.readFilePath('plugin/config.json', true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/config.json', true);
expect(mockVaultService.read).toHaveBeenCalledWith(mockFile, true);
@ -118,7 +118,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFolder);
const result = await fileSystemService.readFile('folder');
const result = await fileSystemService.readFilePath('folder');
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('Path is a folder, not a file');
@ -132,7 +132,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(mockFile);
const result = await fileSystemService.writeFile('new.md', 'content');
const result = await fileSystemService.writeToFilePath('new.md', 'content');
expect(result).toBe(mockFile);
expect(mockVaultService.create).toHaveBeenCalledWith('new.md', 'content', false, true);
@ -145,7 +145,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.modify = vi.fn().mockResolvedValue(mockFile);
const result = await fileSystemService.writeFile('existing.md', 'new content');
const result = await fileSystemService.writeToFilePath('existing.md', 'new content');
expect(result).toBe(mockFile);
expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, 'new content', false, true);
@ -156,7 +156,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
await fileSystemService.writeFile('plugin/data.json', 'content', true);
await fileSystemService.writeToFilePath('plugin/data.json', 'content', true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/data.json', true);
expect(mockVaultService.create).toHaveBeenCalledWith('plugin/data.json', 'content', true, true);
@ -168,7 +168,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(error);
const result = await fileSystemService.writeFile('error.md', 'content');
const result = await fileSystemService.writeToFilePath('error.md', 'content');
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('Create failed');
@ -181,7 +181,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.modify = vi.fn().mockResolvedValue(error);
const result = await fileSystemService.writeFile('existing.md', 'content');
const result = await fileSystemService.writeToFilePath('existing.md', 'content');
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('Modify failed');
@ -197,7 +197,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.patch = vi.fn().mockResolvedValue(mockFile);
const result = await fileSystemService.patchFile('existing.md', oldContent, newContent);
const result = await fileSystemService.patchFileAtPath('existing.md', oldContent, newContent);
expect(result).toBe(mockFile);
expect(mockVaultService.patch).toHaveBeenCalledWith(mockFile, oldContent, newContent, false, true);
@ -215,7 +215,7 @@ describe('FileSystemService', () => {
mockVaultService.create = vi.fn().mockResolvedValue(mockFile);
mockVaultService.patch = vi.fn().mockResolvedValue(mockFile);
const result = await fileSystemService.patchFile('new.md', oldContent, newContent);
const result = await fileSystemService.patchFileAtPath('new.md', oldContent, newContent);
// writeFile should create the file with empty content
expect(mockVaultService.create).toHaveBeenCalledWith('new.md', '', false, true);
@ -232,7 +232,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.patch = vi.fn().mockResolvedValue(error);
const result = await fileSystemService.patchFile('existing.md', oldContent, newContent);
const result = await fileSystemService.patchFileAtPath('existing.md', oldContent, newContent);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('Content to replace was not found in the file');
@ -246,7 +246,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(error);
const result = await fileSystemService.patchFile('new.md', oldContent, newContent);
const result = await fileSystemService.patchFileAtPath('new.md', oldContent, newContent);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('Create failed');
@ -261,7 +261,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.patch = vi.fn().mockResolvedValue(mockFile);
await fileSystemService.patchFile('plugin/config.md', oldContent, newContent, true);
await fileSystemService.patchFileAtPath('plugin/config.md', oldContent, newContent, true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/config.md', true);
expect(mockVaultService.patch).toHaveBeenCalledWith(mockFile, oldContent, newContent, true, true);
@ -275,7 +275,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.patch = vi.fn().mockResolvedValue(mockFile);
const result = await fileSystemService.patchFile('document.md', oldContent, newContent);
const result = await fileSystemService.patchFileAtPath('document.md', oldContent, newContent);
expect(result).toBe(mockFile);
expect(mockVaultService.patch).toHaveBeenCalledWith(mockFile, oldContent, newContent, false, true);
@ -289,7 +289,7 @@ describe('FileSystemService', () => {
mockVaultService.create = vi.fn().mockResolvedValue(createMockFile('new.md'));
mockVaultService.patch = vi.fn().mockResolvedValue(createMockFile('new.md'));
await fileSystemService.patchFile('new.md', oldContent, newContent);
await fileSystemService.patchFileAtPath('new.md', oldContent, newContent);
// Should call patch on the created file
expect(mockVaultService.create).toHaveBeenCalledWith('new.md', '', false, true);
@ -304,7 +304,7 @@ describe('FileSystemService', () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.patch = vi.fn().mockResolvedValue(mockFile);
await fileSystemService.patchFile('test.md', oldContent, newContent, false, true);
await fileSystemService.patchFileAtPath('test.md', oldContent, newContent, false, true);
expect(mockVaultService.patch).toHaveBeenCalledWith(mockFile, oldContent, newContent, false, true);
});

View file

@ -103,7 +103,9 @@ describe('SettingsService', () => {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
});
@ -148,7 +150,9 @@ describe('SettingsService', () => {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
@ -173,7 +177,9 @@ describe('SettingsService', () => {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
@ -198,7 +204,9 @@ describe('SettingsService', () => {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
@ -248,7 +256,9 @@ describe('SettingsService', () => {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
});
@ -307,7 +317,9 @@ describe('SettingsService', () => {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
settingsService = new SettingsService(loadedSettings);
});

View file

@ -6,7 +6,7 @@ import { SettingsService, type IVaultkeeperAISettings } from '../../Services/Set
import { SanitiserService } from '../../Services/SanitiserService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProviderModel } from '../../Enums/ApiProvider';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import type VaultkeeperAIPlugin from '../../main';
// Mock getAllTags from obsidian
@ -103,7 +103,9 @@ const mockSettings: IVaultkeeperAISettings = {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
let settingsService: SettingsService;

View file

@ -5,7 +5,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
import { Services } from '../../Services/Services';
import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProviderModel } from '../../Enums/ApiProvider';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import { Exception } from '../../Helpers/Exception';
import * as PDFHelper from '../../Helpers/DocumentHelper';
import type { IPageText } from '../../Types/SearchTypes';
@ -63,7 +63,9 @@ const mockSettings: IVaultkeeperAISettings = {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
const mockPlugin = {

View file

@ -5,7 +5,7 @@ import { SettingsService, type IVaultkeeperAISettings } from '../../Services/Set
import { SanitiserService } from '../../Services/SanitiserService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AIProviderModel } from '../../Enums/ApiProvider';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import type VaultkeeperAIPlugin from '../../main';
/**
@ -65,7 +65,9 @@ const mockSettings: IVaultkeeperAISettings = {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
let settingsService: SettingsService;

View file

@ -6,7 +6,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
import { Services } from '../../Services/Services';
import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProviderModel } from '../../Enums/ApiProvider';
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
import { Exception } from '../../Helpers/Exception';
/**
@ -62,7 +62,9 @@ const mockSettings: IVaultkeeperAISettings = {
enableMemories: false,
allowUpdatingMemories: true,
enableWebSearch: true,
enableWebViewer: false
enableWebViewer: false,
provider: AIProvider.Claude,
quickActionModel: AIProviderModel.ClaudeSonnet_4_6
};
const mockPlugin = {

View file

@ -1,61 +1,51 @@
// eslint.config.js
import { defineConfig } from "eslint/config";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import obsidianmd from "eslint-plugin-obsidianmd";
import eslintComments from "eslint-plugin-eslint-comments";
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
import globals from "globals";
export default [
// Ignore patterns
export default defineConfig([
{
ignores: ["node_modules/**", "main.js", "__tests__/**", "*.test.ts", "esbuild.config.mjs", "version-bump.mjs", "vitest.config.ts"]
ignores: [
"node_modules/**",
"main.js",
"eslint.config.js",
"**/__tests__/**",
"**/*.test.ts",
"esbuild.config.mjs",
"version-bump.mjs",
"vitest.config.ts",
],
},
// Base ESLint recommended rules
js.configs.recommended,
// TypeScript recommended rules (type-checked rules disabled for non-TS files)
...tseslint.configs.recommendedTypeChecked.map(config => ({
...config,
files: ["**/*.ts"],
})),
// Project-specific configuration
{
files: ["**/*.ts"],
extends: [
tseslint.configs.recommendedTypeChecked,
obsidianmd.configs.recommended,
],
plugins: {
obsidianmd: obsidianmd,
"eslint-comments": eslintComments,
"@eslint-community/eslint-comments": eslintComments,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: true,
sourceType: "module"
projectService: true,
sourceType: "module",
},
globals: {
console: "readonly",
AsyncGenerator: "readonly",
require: "readonly",
process: "readonly",
...globals.browser,
...globals.node,
createEl: "readonly",
join: "readonly",
__dirname: "readonly",
document: "readonly",
window: "readonly",
performance: "readonly",
requestAnimationFrame: "readonly",
cancelAnimationFrame: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
NodeJS: "readonly",
Fuzzysort: "readonly",
}
AsyncGenerator: "readonly",
},
},
rules: {
// Obsidian plugin recommended rules
...obsidianmd.configs.recommended,
// Enable additional TypeScript rules for PR issues
"@typescript-eslint/require-await": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-namespace": "error",
@ -63,57 +53,35 @@ export default [
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/no-explicit-any": "error",
// ESLint directive comment rules
"eslint-comments/require-description": "error",
"eslint-comments/no-unused-disable": "error",
"eslint-comments/no-restricted-disable": [
"@eslint-community/eslint-comments/require-description": "error",
"@eslint-community/eslint-comments/no-unused-disable": "error",
"@eslint-community/eslint-comments/no-restricted-disable": [
"error",
"@typescript-eslint/no-explicit-any",
"no-restricted-globals"
"no-restricted-globals",
],
// Console usage (allow warn, error, debug only)
"no-console": ["error", { allow: ["warn", "error", "debug"] }],
// Restricted globals
// Note: fetch is intentionally not restricted here because this plugin legitimately needs
// fetch for streaming and AbortSignal support, which requestUrl doesn't provide
"no-restricted-globals": [
"error",
{
name: "app",
message: "Avoid using the global app object. Instead use the reference provided by your plugin instance.",
},
{
name: "localStorage",
message: "Prefer `App#saveLocalStorage` / `App#loadLocalStorage` functions to write / read localStorage data that's unique to a vault."
}
{ name: "app", message: "Avoid using the global app object. Instead use the reference provided by your plugin instance." },
{ name: "localStorage", message: "Prefer `App#saveLocalStorage` / `App#loadLocalStorage` functions to write / read localStorage data that's unique to a vault." },
],
// Restricted imports
"no-restricted-imports": [
"error",
{
name: "axios",
message: "Use the built-in `requestUrl` function instead of `axios`.",
},
{
name: "superagent",
message: "Use the built-in `requestUrl` function instead of `superagent`.",
},
{
name: "got",
message: "Use the built-in `requestUrl` function instead of `got`.",
},
{
name: "node-fetch",
message: "Use the built-in `requestUrl` function instead of `node-fetch`.",
},
{
name: "moment",
message: "The 'moment' package is bundled with Obsidian. Please import it from 'obsidian' instead.",
},
{ name: "axios", message: "Use the built-in `requestUrl` function instead of `axios`." },
{ name: "superagent", message: "Use the built-in `requestUrl` function instead of `superagent`." },
{ name: "got", message: "Use the built-in `requestUrl` function instead of `got`." },
{ name: "node-fetch", message: "Use the built-in `requestUrl` function instead of `node-fetch`." },
{ name: "moment", message: "The 'moment' package is bundled with Obsidian. Please import it from 'obsidian' instead." },
],
}
}
];
},
},
{
files: ["**/*.js", "**/*.mjs", "**/*.cjs"],
extends: [tseslint.configs.disableTypeChecked],
},
]);

View file

@ -56,7 +56,7 @@ export default class VaultkeeperAIPlugin extends Plugin {
public async activateMainView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
let leaf: WorkspaceLeaf | null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_MAIN);
if (leaves.length > 0) {
@ -74,10 +74,8 @@ export default class VaultkeeperAIPlugin extends Plugin {
public async activateDiffView(diffString: string, config: Diff2HtmlUIConfig) {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_DIFF);
leaf = leaves.length > 0 ? leaves[0] : workspace.getLeaf("tab");
const leaf = leaves.length > 0 ? leaves[0] : workspace.getLeaf("tab");
await leaf?.setViewState({
type: VIEW_TYPE_DIFF,

1603
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,20 +20,20 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.2",
"@eslint/js": "^10.0.1",
"@testing-library/svelte": "^5.3.1",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"@types/path-browserify": "^1.0.3",
"@typescript-eslint/eslint-plugin": "8.58.2",
"@typescript-eslint/parser": "8.58.2",
"@typescript-eslint/eslint-plugin": "8.59.0",
"@typescript-eslint/parser": "8.59.0",
"@vitest/ui": "^4.1.4",
"builtin-modules": "5.1.0",
"esbuild": "^0.28.0",
"esbuild-svelte": "^0.9.4",
"eslint": "^9.39.2",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-obsidianmd": "^0.2.3",
"eslint": "^10.2.1",
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.1",
"eslint-plugin-obsidianmd": "^0.2.4",
"happy-dom": "^20.9.0",
"obsidian": "latest",
"svelte": "^5.55.4",