mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
66ed00dec0
commit
28c8ccb44b
39 changed files with 1562 additions and 683 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
7
AIPrompts/QuickActionPrompts/Beautify.ts
Normal file
7
AIPrompts/QuickActionPrompts/Beautify.ts
Normal 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.`;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ export enum AgentType {
|
|||
Main = "main",
|
||||
Orchestration = "orchestration",
|
||||
Planning = "planning",
|
||||
Execution = "execution"
|
||||
Execution = "execution",
|
||||
QuickAction = "quickAction"
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
55
Services/AIServices/QuickAgent.ts
Normal file
55
Services/AIServices/QuickAgent.ts
Normal 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: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
84
Services/QuickActionsService.ts
Normal file
84
Services/QuickActionsService.ts
Normal 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));
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || "";
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
120
eslint.config.js
120
eslint.config.js
|
|
@ -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],
|
||||
},
|
||||
]);
|
||||
6
main.ts
6
main.ts
|
|
@ -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
1603
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue