diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index 29f225c..868573b 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -15,16 +15,24 @@ import { Role } from "Enums/Role"; import { StringTools } from "Helpers/StringTools"; import { Exception } from "Helpers/Exception"; import { ApiErrorType } from "Types/ApiError"; +import type { AbortService } from "Services/AbortService"; export abstract class BaseAIClass implements IAIClass { protected readonly apiKey: string; - protected readonly aiPrompt: IPrompt = Resolve(Services.IPrompt); - protected readonly settingsService: SettingsService = Resolve(Services.SettingsService); - protected readonly streamingService: StreamingService = Resolve(Services.StreamingService); - protected readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); + protected readonly aiPrompt: IPrompt; + protected readonly abortService: AbortService; + protected readonly settingsService: SettingsService; + protected readonly streamingService: StreamingService; + protected readonly aiFunctionDefinitions: AIFunctionDefinitions; protected constructor(provider: AIProvider) { + this.aiPrompt = Resolve(Services.IPrompt); + this.abortService = Resolve(Services.AbortService); + this.settingsService = Resolve(Services.SettingsService); + this.streamingService = Resolve(Services.StreamingService); + this.aiFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); + this.apiKey = this.settingsService.getApiKeyForProvider(provider); } diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index c825804..fb75567 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -24,7 +24,7 @@ export class Claude extends BaseAIClass { } public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal + conversation: Conversation, allowDestructiveActions: boolean ): AsyncGenerator { this.accumulatedFunctionName = null; this.accumulatedFunctionArgs = ""; @@ -62,7 +62,6 @@ export class Claude extends BaseAIClass { AIProviderURL.Claude, requestBody, (chunk: string) => this.parseStreamChunk(chunk), - abortSignal, headers ); } diff --git a/AIClasses/Claude/ClaudeConversationNamingService.ts b/AIClasses/Claude/ClaudeConversationNamingService.ts index 4481612..e1b75e6 100644 --- a/AIClasses/Claude/ClaudeConversationNamingService.ts +++ b/AIClasses/Claude/ClaudeConversationNamingService.ts @@ -7,51 +7,55 @@ import { NamePrompt } from "AIClasses/NamePrompt"; import type { SettingsService } from "Services/SettingsService"; import type Anthropic from '@anthropic-ai/sdk'; import { Exception } from "Helpers/Exception"; +import type { AbortService } from "Services/AbortService"; export class ClaudeConversationNamingService implements IConversationNamingService { private readonly apiKey: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Claude); + this.abortService = Resolve(Services.AbortService); } - public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise { - - const requestBody = { - model: AIProviderModel.ClaudeNamer, - max_tokens: 100, - system: NamePrompt, - messages: [{ - role: Role.User, - content: userPrompt - }] - }; - - const response = await fetch(AIProviderURL.Claude, { - method: 'POST', - headers: { - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01', - 'anthropic-dangerous-direct-browser-access': 'true', - 'content-type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: abortSignal + public async generateName(userPrompt: string): Promise { + return await this.abortService.abortableOperation(async () => { + const requestBody = { + model: AIProviderModel.ClaudeNamer, + max_tokens: 100, + system: NamePrompt, + messages: [{ + role: Role.User, + content: userPrompt + }] + }; + + const response = await fetch(AIProviderURL.Claude, { + method: 'POST', + headers: { + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + 'content-type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal() + }); + + if (!response.ok) { + Exception.throw(`Claude API error: ${response.status} ${response.statusText} - ${await response.text()}`); + } + + const data = await response.json() as Anthropic.Messages.Message; + const firstContent = data.content?.[0]; + + if (!firstContent || firstContent.type !== 'text') { + Exception.throw("Failed to generate conversation name"); + } + + return firstContent.text; }); - - if (!response.ok) { - Exception.throw(`Claude API error: ${response.status} ${response.statusText} - ${await response.text()}`); - } - - const data = await response.json() as Anthropic.Messages.Message; - const firstContent = data.content?.[0]; - - if (!firstContent || firstContent.type !== 'text') { - Exception.throw("Failed to generate conversation name"); - } - - return firstContent.text; } } \ No newline at end of file diff --git a/AIClasses/Claude/ClaudeTokenService.ts b/AIClasses/Claude/ClaudeTokenService.ts index 37a579a..b8df627 100644 --- a/AIClasses/Claude/ClaudeTokenService.ts +++ b/AIClasses/Claude/ClaudeTokenService.ts @@ -5,11 +5,13 @@ import { Services } from "Services/Services"; import { Role } from "Enums/Role"; import { AIProvider } from "Enums/ApiProvider"; import type { SettingsService } from "Services/SettingsService"; +import type { AbortService } from "Services/AbortService"; export class ClaudeTokenService implements ITokenService { private ai: Anthropic; private model: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); @@ -18,6 +20,7 @@ export class ClaudeTokenService implements ITokenService { dangerouslyAllowBrowser: true }); this.model = settingsService.settings.model; + this.abortService = Resolve(Services.AbortService); } public async countTokens(input: string): Promise { diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index c28a5ab..8bfc0c1 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -22,7 +22,7 @@ export class Gemini extends BaseAIClass { } public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal + conversation: Conversation, allowDestructiveActions: boolean ): AsyncGenerator { // next request should use web search only (gemini api doesn't support custom tooling and grounding at the same time) const requestWebSearch = this.accumulatedFunctionName == this.REQUEST_WEB_SEARCH; @@ -78,8 +78,7 @@ export class Gemini extends BaseAIClass { yield* this.streamingService.streamRequest( `${AIProviderURL.Gemini}/${this.settingsService.settings.model}:streamGenerateContent?key=${this.apiKey}&alt=sse`, requestBody, - (chunk: string) => this.parseStreamChunk(chunk), - abortSignal + (chunk: string) => this.parseStreamChunk(chunk) ); } diff --git a/AIClasses/Gemini/GeminiConversationNamingService.ts b/AIClasses/Gemini/GeminiConversationNamingService.ts index 98e6c8e..99a1248 100644 --- a/AIClasses/Gemini/GeminiConversationNamingService.ts +++ b/AIClasses/Gemini/GeminiConversationNamingService.ts @@ -7,48 +7,52 @@ import { NamePrompt } from "AIClasses/NamePrompt"; import type { GenerateContentResponse } from "@google/genai"; import type { SettingsService } from "Services/SettingsService"; import { Exception } from "Helpers/Exception"; +import type { AbortService } from "Services/AbortService"; export class GeminiConversationNamingService implements IConversationNamingService { - + private readonly apiKey: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Gemini); + this.abortService = Resolve(Services.AbortService); } - public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise { + public async generateName(userPrompt: string): Promise { + return await this.abortService.abortableOperation(async () => { + const requestBody = { + system_instruction: { + parts: [{ text: NamePrompt }] + }, + contents: [{ + role: Role.User, + parts: [{ text: userPrompt }] + }] + }; - const requestBody = { - system_instruction: { - parts: [{ text: NamePrompt }] - }, - contents: [{ - role: Role.User, - parts: [{ text: userPrompt }] - }] - }; + const response = await fetch(`${AIProviderURL.Gemini}/${AIProviderModel.GeminiNamer}:generateContent?key=${this.apiKey}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal() + }); - const response = await fetch(`${AIProviderURL.Gemini}/${AIProviderModel.GeminiNamer}:generateContent?key=${this.apiKey}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: abortSignal + if (!response.ok) { + Exception.throw(`Gemini API error: ${response.status} ${response.statusText} - ${await response.text()}`); + } + + const data = await response.json() as GenerateContentResponse; + const generatedName = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!generatedName) { + Exception.throw("Failed to generate conversation name"); + } + + return generatedName; }); - - if (!response.ok) { - Exception.throw(`Gemini API error: ${response.status} ${response.statusText} - ${await response.text()}`); - } - - const data = await response.json() as GenerateContentResponse; - const generatedName = data.candidates?.[0]?.content?.parts?.[0]?.text; - - if (!generatedName) { - Exception.throw("Failed to generate conversation name"); - } - - return generatedName; } } diff --git a/AIClasses/Gemini/GeminiTokenService.ts b/AIClasses/Gemini/GeminiTokenService.ts index a122825..2806c81 100644 --- a/AIClasses/Gemini/GeminiTokenService.ts +++ b/AIClasses/Gemini/GeminiTokenService.ts @@ -4,11 +4,13 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import { AIProvider } from "Enums/ApiProvider"; import type { SettingsService } from "Services/SettingsService"; +import type { AbortService } from "Services/AbortService"; export class GeminiTokenService implements ITokenService { private readonly ai: GoogleGenAI; private model: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); @@ -16,6 +18,7 @@ export class GeminiTokenService implements ITokenService { apiKey: settingsService.getApiKeyForProvider(AIProvider.Gemini) }); this.model = settingsService.settings.model; + this.abortService = Resolve(Services.AbortService); } public async countTokens(input: string): Promise { diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index a4de5ec..496d245 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -2,5 +2,5 @@ import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; export interface IAIClass { - streamRequest(conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal): AsyncGenerator; + streamRequest(conversation: Conversation, allowDestructiveActions: boolean): AsyncGenerator; } \ No newline at end of file diff --git a/AIClasses/IConversationNamingService.ts b/AIClasses/IConversationNamingService.ts index f10ea63..aabdbb4 100644 --- a/AIClasses/IConversationNamingService.ts +++ b/AIClasses/IConversationNamingService.ts @@ -1,3 +1,3 @@ export interface IConversationNamingService { - generateName(userPrompt: string, abortSignal?: AbortSignal): Promise; + generateName(userPrompt: string): Promise; } diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 14dcb9e..f12acc6 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -16,7 +16,7 @@ export class OpenAI extends BaseAIClass { } public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal + conversation: Conversation, allowDestructiveActions: boolean ): AsyncGenerator { const systemPrompt = await this.buildSystemPrompt(); @@ -46,7 +46,6 @@ export class OpenAI extends BaseAIClass { AIProviderURL.OpenAI, requestBody, (chunk: string) => this.parseStreamChunk(chunk), - abortSignal, headers ); } diff --git a/AIClasses/OpenAI/OpenAIConversationNamingService.ts b/AIClasses/OpenAI/OpenAIConversationNamingService.ts index 1c6fc7d..e7d09a4 100644 --- a/AIClasses/OpenAI/OpenAIConversationNamingService.ts +++ b/AIClasses/OpenAI/OpenAIConversationNamingService.ts @@ -7,64 +7,68 @@ import { NamePrompt } from "AIClasses/NamePrompt"; import type { SettingsService } from "Services/SettingsService"; import type OpenAI from "openai"; import { Exception } from "Helpers/Exception"; +import type { AbortService } from "Services/AbortService"; export class OpenAIConversationNamingService implements IConversationNamingService { - + private readonly apiKey: string; + private readonly abortService: AbortService; public constructor() { const settingsService = Resolve(Services.SettingsService); this.apiKey = settingsService.getApiKeyForProvider(AIProvider.OpenAI); + this.abortService = Resolve(Services.AbortService); } - public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise { + public async generateName(userPrompt: string): Promise { + return await this.abortService.abortableOperation(async () => { + const requestBody = { + model: AIProviderModel.OpenAINamer, + max_output_tokens: 100, + instructions: NamePrompt, + input: [ + { + role: Role.User, + content: userPrompt + } + ], + stream: false + }; - const requestBody = { - model: AIProviderModel.OpenAINamer, - max_output_tokens: 100, - instructions: NamePrompt, - input: [ - { - role: Role.User, - content: userPrompt - } - ], - stream: false - }; + const response = await fetch(AIProviderURL.OpenAI, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: this.abortService.signal() + }); - const response = await fetch(AIProviderURL.OpenAI, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: abortSignal + if (!response.ok) { + Exception.throw(`OpenAI API error: ${response.status} ${response.statusText} - ${await response.text()}`); + } + + const data = await response.json() as OpenAI.Responses.Response; + + // Try to get the name from output_text first (most common case) + if (data.output_text && data.output_text.trim()) { + return data.output_text.trim(); + } + + // Fall back to checking the output array + const firstOutput = data.output?.[0]; + const generatedName = firstOutput && 'content' in firstOutput + ? firstOutput.content?.[0]?.type === 'output_text' + ? firstOutput.content[0].text + : undefined + : undefined; + + if (!generatedName) { + Exception.throw("Failed to generate conversation name"); + } + + return generatedName; }); - - if (!response.ok) { - Exception.throw(`OpenAI API error: ${response.status} ${response.statusText} - ${await response.text()}`); - } - - const data = await response.json() as OpenAI.Responses.Response; - - // Try to get the name from output_text first (most common case) - if (data.output_text && data.output_text.trim()) { - return data.output_text.trim(); - } - - // Fall back to checking the output array - const firstOutput = data.output?.[0]; - const generatedName = firstOutput && 'content' in firstOutput - ? firstOutput.content?.[0]?.type === 'output_text' - ? firstOutput.content[0].text - : undefined - : undefined; - - if (!generatedName) { - Exception.throw("Failed to generate conversation name"); - } - - return generatedName; } } \ No newline at end of file diff --git a/Components/CancellationIndicator.svelte b/Components/CancellationIndicator.svelte new file mode 100644 index 0000000..f7cfcb6 --- /dev/null +++ b/Components/CancellationIndicator.svelte @@ -0,0 +1,156 @@ + + +
+
+ C + A + N + C + E + L + L + I + N + G + . + . + . +
+
+ + \ No newline at end of file diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 849c465..3e35cc0 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -2,15 +2,16 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import type { StreamingMarkdownService } from "Services/StreamingMarkdownService"; - import ChatAreaThought from "./ChatAreaThought.svelte"; + import ThoughtIndicator from "./ThoughtIndicator.svelte"; import StreamingIndicator from "./StreamingIndicator.svelte"; + import CancellationIndicator from "./CancellationIndicator.svelte"; import { Greeting } from "Enums/Greeting"; import { Role } from "Enums/Role"; import type { ConversationContent } from "Conversations/ConversationContent"; import { tick } from "svelte"; - import { Copy } from "Enums/Copy"; import { Selector } from "Enums/Selector"; + export let cancelling: boolean = false; export let messages: ConversationContent[] = []; export let currentThought: string | null = null; export let isSubmitting: boolean = false; @@ -19,6 +20,7 @@ export let editModeActive: boolean = false; export function resetChatArea() { + cancelling = false; messageElements = []; lastProcessedContent.clear(); currentStreamFinalized = false; @@ -67,8 +69,6 @@ let settled: boolean = false; - let thoughtElement: HTMLElement | undefined; - let streamingElement: HTMLElement | undefined; let chatAreaPaddingElement: HTMLElement | undefined; let streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService); @@ -120,11 +120,6 @@ // For assistant messages that aren't streaming, use traditional parsing if (!isCurrentlyStreaming) { - - if (message.content.includes(Selector.ApiRequestAborted)) { - return `${Copy.ApiRequestAborted}`; - } - if (message.errorType) { return `
${message.content}
`; } @@ -204,12 +199,16 @@ {/each} {#if settled} - + {#if isSubmitting} - + {/if} {/if} + {#if cancelling} + + {/if} +
{#if messages.length === 0} diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 0b1ce53..c27c976 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -24,6 +24,7 @@ let chatArea: ChatArea; let chatInput: ChatInput; + let cancelling = false; let hasNoApiKey = false; let isSubmitting = false; let editModeActive = false; @@ -85,7 +86,6 @@ function handleStop() { chatService.stop(); currentThought = null; - isSubmitting = false; chatArea.scrollChatArea("smooth"); } @@ -108,10 +108,14 @@ onThoughtUpdate: (thought) => { currentThought = thought; }, - onComplete: () => { + onComplete: async () => { + cancelling = false; isSubmitting = false; chatArea.scrollChatArea(undefined); - chatService.updateTokenDisplay(conversation); + await chatService.updateTokenDisplay(conversation); + }, + onCancel: () => { + cancelling = true; } }); } @@ -119,11 +123,21 @@ $: if ($conversationStore.shouldReset) { conversation = new Conversation(); chatService.setStatusBarTokens(0, 0); + + isSubmitting = false; + currentStreamingMessageId = null; + currentThought = null; + conversationStore.clearResetFlag(); } $: if ($conversationStore.conversationToLoad) { conversation.contents = []; + + isSubmitting = false; + currentStreamingMessageId = null; + currentThought = null; + chatArea.resetChatArea(); tick().then(() => { @@ -147,7 +161,7 @@
-
diff --git a/Components/StreamingIndicator.svelte b/Components/StreamingIndicator.svelte index 4195616..05988aa 100644 --- a/Components/StreamingIndicator.svelte +++ b/Components/StreamingIndicator.svelte @@ -1,9 +1,8 @@ -
+
diff --git a/Components/ChatAreaThought.svelte b/Components/ThoughtIndicator.svelte similarity index 90% rename from Components/ChatAreaThought.svelte rename to Components/ThoughtIndicator.svelte index 49a7af6..fd2f0b0 100644 --- a/Components/ChatAreaThought.svelte +++ b/Components/ThoughtIndicator.svelte @@ -1,12 +1,11 @@ {#if isVisible} -
+
{thought}
diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index 7ad3568..b647d42 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -50,4 +50,16 @@ export class ConversationContent { (!("errorType" in data) || typeof data.errorType === "string") ); } + + public static safeContinue() { + return new ConversationContent( + Role.User, + "Continue", + "Continue", + "", + new Date(), + false, + true // isFunctionCallResponse = true (hides from UI) + ); + } } \ No newline at end of file diff --git a/Enums/Copy.ts b/Enums/Copy.ts index 13735af..903fd50 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -1,6 +1,5 @@ export enum Copy { // General Copy - ApiRequestAborted = "Request has been cancelled", UserInstructions1 = "You can create custom ", UserInstructions2 = "instructions", UserInstructions3 = " that the AI will follow.", diff --git a/Enums/Environment.ts b/Enums/Environment.ts new file mode 100644 index 0000000..fcb5c3d --- /dev/null +++ b/Enums/Environment.ts @@ -0,0 +1,4 @@ +export enum Environment { + PROD = "production", + DEV = "development" +} \ No newline at end of file diff --git a/Enums/Selector.ts b/Enums/Selector.ts index d6ed2c3..03896e0 100644 --- a/Enums/Selector.ts +++ b/Enums/Selector.ts @@ -7,12 +7,5 @@ export enum Selector { HelpModal = "help-modal", ContextSettingItemDescription = "context-setting-item-description", - ApiRequestAborted = "api-request-aborted", - APIRequestError = "api-request-error", - ErrorSelector = "error-selector" -} - -export function isErrorSelector(selector: Selector) { - return selector === Selector.ApiRequestAborted || selector === Selector.APIRequestError; } \ No newline at end of file diff --git a/Helpers/Exception.ts b/Helpers/Exception.ts index 059bf2d..892eea6 100644 --- a/Helpers/Exception.ts +++ b/Helpers/Exception.ts @@ -1,3 +1,5 @@ +import { Environment } from "Enums/Environment"; + export abstract class Exception { public static throw(error: unknown): never { @@ -10,7 +12,7 @@ export abstract class Exception { } public static log(error: unknown) { - if (process.env.NODE_ENV !== "production") { + if (process.env.NODE_ENV === Environment.DEV) { const e: Error = this.new(error); console.error(e.message, e); } diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index f4c5ed4..1a71d95 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -5,6 +5,7 @@ import { AIFunction } from "Enums/AIFunction"; import { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse"; import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { ISearchMatch } from "../Helpers/SearchTypes"; +import { AbortService } from "./AbortService"; import { normalizePath, TAbstractFile, TFile } from "obsidian"; import { SearchVaultFilesArgsSchema, @@ -17,96 +18,104 @@ import { export class AIFunctionService { - private fileSystemService: FileSystemService = Resolve(Services.FileSystemService); + private readonly fileSystemService: FileSystemService; + private readonly abortService: AbortService; + + public constructor() { + this.fileSystemService = Resolve(Services.FileSystemService); + this.abortService = Resolve(Services.AbortService); + } public async performAIFunction(functionCall: AIFunctionCall): Promise { - switch (functionCall.name) { - case AIFunction.SearchVaultFiles: { - const parseResult = SearchVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { + return await this.abortService.abortableOperation(async () => { + switch (functionCall.name) { + case AIFunction.SearchVaultFiles: { + const parseResult = SearchVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for SearchVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.searchVaultFiles(parseResult.data.search_terms), functionCall.toolId); + } + + case AIFunction.ReadVaultFiles: { + const parseResult = ReadVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for ReadVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.readVaultFiles(parseResult.data.file_paths), functionCall.toolId); + } + + case AIFunction.WriteVaultFile: { + const parseResult = WriteVaultFileArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for WriteVaultFile: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.writeVaultFile(parseResult.data.file_path, parseResult.data.content), functionCall.toolId); + } + + case AIFunction.DeleteVaultFiles: { + const parseResult = DeleteVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for DeleteVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.deleteVaultFiles(parseResult.data.file_paths, parseResult.data.confirm_deletion), functionCall.toolId); + } + + case AIFunction.MoveVaultFiles: { + const parseResult = MoveVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for MoveVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.moveVaultFiles(parseResult.data.source_paths, parseResult.data.destination_paths), functionCall.toolId); + } + + case AIFunction.ListVaultFiles: { + const parseResult = ListVaultFilesArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + return new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for ListVaultFiles: ${parseResult.error.message}` }, + functionCall.toolId + ); + } + return new AIFunctionResponse(functionCall.name, await this.ListVaultFiles(parseResult.data.path, parseResult.data.recursive), functionCall.toolId); + } + + // this is only used by gemini + case AIFunction.RequestWebSearch: + return new AIFunctionResponse(functionCall.name, {}, functionCall.toolId) + + default: { + const error = `Unknown function request ${functionCall.name as string}` + console.error(error); return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for SearchVaultFiles: ${parseResult.error.message}` }, + { error: error }, functionCall.toolId ); } - return new AIFunctionResponse(functionCall.name, await this.searchVaultFiles(parseResult.data.search_terms), functionCall.toolId); } - - case AIFunction.ReadVaultFiles: { - const parseResult = ReadVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for ReadVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.readVaultFiles(parseResult.data.file_paths), functionCall.toolId); - } - - case AIFunction.WriteVaultFile: { - const parseResult = WriteVaultFileArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for WriteVaultFile: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.writeVaultFile(parseResult.data.file_path, parseResult.data.content), functionCall.toolId); - } - - case AIFunction.DeleteVaultFiles: { - const parseResult = DeleteVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for DeleteVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.deleteVaultFiles(parseResult.data.file_paths, parseResult.data.confirm_deletion), functionCall.toolId); - } - - case AIFunction.MoveVaultFiles: { - const parseResult = MoveVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for MoveVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.moveVaultFiles(parseResult.data.source_paths, parseResult.data.destination_paths), functionCall.toolId); - } - - case AIFunction.ListVaultFiles: { - const parseResult = ListVaultFilesArgsSchema.safeParse(functionCall.arguments); - if (!parseResult.success) { - return new AIFunctionResponse( - functionCall.name, - { error: `Invalid arguments for ListVaultFiles: ${parseResult.error.message}` }, - functionCall.toolId - ); - } - return new AIFunctionResponse(functionCall.name, await this.ListVaultFiles(parseResult.data.path, parseResult.data.recursive), functionCall.toolId); - } - - // this is only used by gemini - case AIFunction.RequestWebSearch: - return new AIFunctionResponse(functionCall.name, {}, functionCall.toolId) - - default: { - const error = `Unknown function request ${functionCall.name as string}` - console.error(error); - return new AIFunctionResponse( - functionCall.name, - { error: error }, - functionCall.toolId - ); - } - } + }); } private async searchVaultFiles(searchTerms: string[]): Promise { diff --git a/Services/AbortService.ts b/Services/AbortService.ts new file mode 100644 index 0000000..250855d --- /dev/null +++ b/Services/AbortService.ts @@ -0,0 +1,68 @@ +// This class is designed to provide a single centralised abort controller +export class AbortService { + private abortController: AbortController = new AbortController(); + + public static isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; + } + + public initialiseAbortController(): void { + this.abortController.abort(); + this.abortController = new AbortController(); + } + + public signal(): AbortSignal { + return this.abortController.signal; + } + + public reason(): Error { + return this.signal().reason instanceof Error ? this.signal().reason as Error : new Error("Aborted"); + } + + public abort(reason?: string): void { + this.abortController.abort( + new DOMException(reason ?? "Aborted", "AbortError") + ); + } + + // useful if you need to rethrow the abort error + public throw(): never { + throw this.reason(); + } + + public async abortableOperation(executor: () => Promise): Promise { + const signal = this.abortController.signal; + + if (signal.aborted) { + this.throw(); + } + + let abortHandler: () => void; + let abortWon = false; + + const executorPromise = executor(); + + const abortPromise = new Promise((_, reject) => { + abortHandler = () => { + abortWon = true; + reject(this.reason()); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + }); + + // Suppress AbortErrors from executor once abort has won the race + executorPromise.catch(error => { + if (abortWon && AbortService.isAbortError(error)) { + return; + } + }); + + abortPromise.catch(() => {}); + + try { + return await Promise.race([executorPromise, abortPromise]); + } finally { + signal.removeEventListener("abort", abortHandler!); + } + } +} \ No newline at end of file diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 0f317a7..54f343a 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -15,12 +15,14 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { Notice } from "obsidian"; import type { EventService } from "./EventService"; import { Event } from "Enums/Event"; +import { AbortService } from "./AbortService"; export interface IChatServiceCallbacks { onSubmit: () => void; onStreamingUpdate: (streamingMessageId: string | null) => void; onThoughtUpdate: (thought: string | null) => void; onComplete: () => void; + onCancel: () => void; } export class ChatService { @@ -32,10 +34,10 @@ export class ChatService { private prompt: IPrompt; private statusBarService: StatusBarService; private eventService: EventService; + private abortService: AbortService; private semaphore: Semaphore; private semaphoreHeld: boolean = false; - private abortController: AbortController | null = null; constructor() { this.conversationService = Resolve(Services.ConversationFileSystemService); @@ -44,6 +46,7 @@ export class ChatService { this.prompt = Resolve(Services.IPrompt); this.statusBarService = Resolve(Services.StatusBarService); this.eventService = Resolve(Services.EventService); + this.abortService = Resolve(Services.AbortService); this.semaphore = new Semaphore(1, false); } @@ -66,43 +69,49 @@ export class ChatService { return; } - this.abortController = new AbortController(); + this.abortService.initialiseAbortController(); - conversation.contents.push(new ConversationContent(Role.User, userRequest, formattedRequest)); - await this.saveConversation(conversation); + await this.abortService.abortableOperation(async () => { + conversation.contents.push(new ConversationContent(Role.User, userRequest, formattedRequest)); + await this.saveConversation(conversation); - callbacks.onSubmit(); - callbacks.onStreamingUpdate(null); + callbacks.onSubmit(); + callbacks.onStreamingUpdate(null); - if (conversation.contents.length === 1) { - this.onNameChanged?.(conversation.title); // on change for initial conversation name - await this.namingService.requestName(conversation, formattedRequest, this.onNameChanged, this.abortController); - } - - // Process AI responses and function calls - let response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); - while (response.functionCall || response.shouldContinue) { - if (response.functionCall) { - const userMessage = response.functionCall.arguments.user_message; - if (userMessage && typeof userMessage === "string") { - callbacks.onThoughtUpdate(userMessage); - } - - const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); - - const functionResponseString = functionResponse.toConversationString(); - conversation.contents.push(new ConversationContent( - Role.User, functionResponseString, functionResponseString, "", new Date(), false, true, functionResponse.toolId - )); + if (conversation.contents.length === 1) { + this.onNameChanged?.(conversation.title); // on change for initial conversation name + await this.namingService.requestName(conversation, formattedRequest, this.onNameChanged); } - this.ensureCorrectConversationStructure(conversation); - response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); + // Process AI responses and function calls + let response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); + while (response.functionCall || response.shouldContinue) { + if (response.functionCall) { + const userMessage = response.functionCall.arguments.user_message; + if (userMessage && typeof userMessage === "string") { + callbacks.onThoughtUpdate(userMessage); + } + + const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); + + const functionResponseString = functionResponse.toConversationString(); + conversation.contents.push(new ConversationContent( + Role.User, functionResponseString, functionResponseString, "", new Date(), false, true, functionResponse.toolId + )); + } + + this.ensureCorrectConversationStructure(conversation); + response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); + } + }); + } catch (error) { + if (AbortService.isAbortError(error)) { + callbacks.onCancel(); } } finally { + // reset "Cancelling..." flag this needs to get to window (window may actually handle this itself) this.eventService.trigger(Event.DiffClosed); await this.saveConversation(conversation); - this.abortController = null; if (this.semaphoreHeld) { this.semaphoreHeld = false; this.semaphore.release(); @@ -113,11 +122,8 @@ export class ChatService { } public stop() { - if (this.abortController) { - this.abortController.abort(); - this.abortController = null; - } - this.semaphore.release(); + this.abortService.abort("User requested cancellation"); + this.eventService.trigger(Event.DiffClosed); } public async updateTokenDisplay(conversation: Conversation) { @@ -163,15 +169,7 @@ export class ChatService { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { // Insert a hidden "Continue" message to maintain proper conversation structure - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true // isFunctionCallResponse = true (hides from UI) - )); + conversation.contents.push(ConversationContent.safeContinue()); } } } @@ -191,7 +189,7 @@ export class ChatService { let capturedFunctionCall: AIFunctionCall | null = null; let capturedShouldContinue = false; - for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions, this.abortController?.signal)) { + for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions)) { if (chunk.error && chunk.errorType) { conversation.setMostRecentError(chunk.error, chunk.errorType); callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); @@ -208,6 +206,7 @@ export class ChatService { if (chunk.content) { accumulatedContent += chunk.content; + conversation.setMostRecentContent(accumulatedContent); if (accumulatedContent.trim() !== "") { callbacks.onThoughtUpdate(null); diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 9b98e50..1587ff7 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -4,7 +4,6 @@ import { FileSystemService } from "./FileSystemService"; import { Services } from "./Services"; import { Conversation } from "Conversations/Conversation"; import { ConversationContent } from "Conversations/ConversationContent"; -import { Copy } from "Enums/Copy"; import { Exception } from "Helpers/Exception"; export class ConversationFileSystemService { @@ -23,6 +22,12 @@ export class ConversationFileSystemService { public async saveConversation(conversation: Conversation): Promise { if (!this.currentConversationPath) { this.currentConversationPath = this.generateConversationPath(conversation); + } else { + // can happen if the conversation is deleted during an active request + const fileExists = await this.fileSystemService.exists(this.currentConversationPath, true); + if (!fileExists) { + return this.currentConversationPath; + } } conversation.updated = new Date(); @@ -32,7 +37,6 @@ export class ConversationFileSystemService { created: conversation.created.toISOString(), updated: conversation.updated.toISOString(), contents: conversation.contents - .filter(content => content.content !== Copy.ApiRequestAborted.toString()) .map(content => ({ role: content.role, content: content.content, @@ -72,7 +76,7 @@ export class ConversationFileSystemService { return; } - const result = await this.fileSystemService.deleteFile(this.currentConversationPath, true); + const result = await this.fileSystemService.deleteFile(this.currentConversationPath, true, false); if (result instanceof Error) { return result; diff --git a/Services/ConversationNamingService.ts b/Services/ConversationNamingService.ts index 12e0a87..8997cee 100644 --- a/Services/ConversationNamingService.ts +++ b/Services/ConversationNamingService.ts @@ -7,6 +7,7 @@ import type { VaultService } from "./VaultService"; import { Path } from "Enums/Path"; import { Exception } from "Helpers/Exception"; import { Notice } from "obsidian"; +import { AbortService } from "./AbortService"; export class ConversationNamingService { private readonly stackLimit: number = 1000; @@ -14,54 +15,60 @@ export class ConversationNamingService { private namingProvider: IConversationNamingService | undefined; private conversationService: ConversationFileSystemService; private vaultService: VaultService; + private abortService: AbortService; constructor() { this.conversationService = Resolve(Services.ConversationFileSystemService); this.vaultService = Resolve(Services.VaultService); + this.abortService = Resolve(Services.AbortService); } public resolveNamingProvider() { this.namingProvider = Resolve(Services.IConversationNamingService); } - public async requestName(conversation: Conversation, userPrompt: string, onNameChanged: ((name: string) => void) | undefined, abortController: AbortController) { - if (!this.namingProvider) { - return; - } - - const conversationPath = this.conversationService.getCurrentConversationPath(); - - if (!conversationPath) { - return; - } - - try { - const generatedName: string = await this.namingProvider.generateName(userPrompt, abortController.signal); - const validatedName: string = await this.validateName(generatedName); - - const stillExists = this.conversationService.getCurrentConversationPath() === conversationPath; - if (!stillExists) { + public async requestName(conversation: Conversation, userPrompt: string, onNameChanged: ((name: string) => void) | undefined) { + await this.abortService.abortableOperation(async () => { + if (!this.namingProvider) { return; } - - const updateResult = await this.conversationService.updateConversationTitle(conversationPath, validatedName); + + const conversationPath = this.conversationService.getCurrentConversationPath(); - if (updateResult instanceof Error) { - Exception.throw(updateResult); + if (!conversationPath) { + return; } - - conversation.title = validatedName; - const saveResult = await this.conversationService.saveConversation(conversation); - - if (saveResult instanceof Error) { - Exception.throw(saveResult); + + try { + const generatedName: string = await this.namingProvider.generateName(userPrompt); + const validatedName: string = await this.validateName(generatedName); + + const stillExists = this.conversationService.getCurrentConversationPath() === conversationPath; + if (!stillExists) { + return; + } + + const updateResult = await this.conversationService.updateConversationTitle(conversationPath, validatedName); + + if (updateResult instanceof Error) { + Exception.throw(updateResult); + } + + conversation.title = validatedName; + const saveResult = await this.conversationService.saveConversation(conversation); + + if (saveResult instanceof Error) { + Exception.throw(saveResult); + } + + onNameChanged?.(conversation.title); + } catch (error) { + if (!AbortService.isAbortError(error)) { + Exception.log(error); + new Notice(`Failed to name conversation '${conversation.title}'`); + } } - - onNameChanged?.(conversation.title); - } catch (error) { - Exception.log(error); - new Notice(`Failed to name conversation '${conversation.title}'`); - } + }); } private async validateName(generatedName: string): Promise { diff --git a/Services/DiffService.ts b/Services/DiffService.ts index da021ca..4d57802 100644 --- a/Services/DiffService.ts +++ b/Services/DiffService.ts @@ -6,7 +6,8 @@ import type { EventService } from './EventService'; import { Event } from 'Enums/Event'; import type { Diff2HtmlUIConfig } from 'diff2html/lib/ui/js/diff2html-ui'; import { ColorSchemeType, OutputFormatType } from 'diff2html/lib/types'; -import { Component, Platform } from 'obsidian'; +import { Component } from 'obsidian'; +import { AbortService } from './AbortService'; interface DiffResult { accepted: boolean; @@ -17,6 +18,7 @@ export class DiffService extends Component { private readonly plugin: VaultkeeperAIPlugin; private readonly eventService: EventService; + private readonly abortService: AbortService; private diffResolve?: (result: DiffResult) => void; @@ -26,6 +28,7 @@ export class DiffService extends Component { super(); this.plugin = Resolve(Services.VaultkeeperAIPlugin); this.eventService = Resolve(Services.EventService); + this.abortService = Resolve(Services.AbortService); this.registerEvent(this.eventService.on(Event.DiffClosed, () => { this.cancelPendingDiff(); @@ -35,7 +38,7 @@ export class DiffService extends Component { public async requestDiff(oldFileName: string, newFileName: string, oldContent: string, newContent: string): Promise { const diffString = this.createDiffString(oldFileName, newFileName, oldContent, newContent); - const outputFormat: OutputFormatType = (Platform.isMobile || oldContent.trim() === "") ? "line-by-line" : "side-by-side"; + const outputFormat: OutputFormatType = "line-by-line"; const config: Diff2HtmlUIConfig = { drawFileList: false, @@ -50,8 +53,24 @@ export class DiffService extends Component { this.ongoingDiff = true; - return new Promise((resolve) => { - this.diffResolve = resolve; + const signal = this.abortService.signal(); + return new Promise((resolve, reject) => { + if (signal.aborted) { + this.finishDiff(); + reject(this.abortService.reason()); + return; + } + + const abortHandler = () => { + this.finishDiff(); + reject(this.abortService.reason()); + }; + signal.addEventListener("abort", abortHandler, { once: true }); + + this.diffResolve = (result: DiffResult) => { + signal.removeEventListener("abort", abortHandler); + resolve(result); + }; void this.plugin.activateDiffView(diffString, config); this.eventService.trigger(Event.DiffOpened); @@ -89,7 +108,7 @@ export class DiffService extends Component { } private createDiffString(oldFileName: string, newFileName: string, oldContent: string, newContent: string): string { - return Diff.createTwoFilesPatch(oldFileName, newFileName, oldContent, newContent); + return Diff.createTwoFilesPatch(oldFileName, newFileName, oldContent, newContent, undefined, undefined, { context: Infinity }); } private finishDiff() { diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index 9cd9ccf..745b0e6 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -13,6 +13,10 @@ export class FileSystemService { this.vaultService = Resolve(Services.VaultService); } + public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + return await this.vaultService.exists(filePath, allowAccessToPluginRoot); + } + public async readFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); if (file && file instanceof TFile) { @@ -21,28 +25,22 @@ export class FileSystemService { return Exception.new(`Path is a folder, not a file: ${filePath}`); } - public async writeFile(filePath: string, content: string, allowAccessToPluginRoot: boolean = false): Promise { - try { - const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); - if (file == null || !(file instanceof TFile)) { - return await this.vaultService.create(filePath, content, allowAccessToPluginRoot); - } - return await this.vaultService.modify(file, content, allowAccessToPluginRoot); - } - catch (error) { - Exception.log(error); - return Exception.new(error); + public async writeFile(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { + const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); + if (file == null || !(file instanceof TFile)) { + return await this.vaultService.create(filePath, content, allowAccessToPluginRoot, requiresConfirmation); } + return await this.vaultService.modify(file, content, allowAccessToPluginRoot, requiresConfirmation); } - public async deleteFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + public async deleteFile(filePath: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); if (!file) { return Exception.new(`File does not exist: ${filePath}`); } - return await this.vaultService.delete(file, allowAccessToPluginRoot); + return await this.vaultService.delete(file, allowAccessToPluginRoot, requiresConfirmation); } public async moveFile(sourcePath: string, destinationPath: string, allowAccessToPluginRoot: boolean = false): Promise { @@ -61,11 +59,11 @@ export class FileSystemService { return await this.vaultService.listDirectoryContents(dirPath, recursive, allowAccessToPluginRoot); } - public async readObjectFromFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + public async readObjectFromFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise | Error> { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); if (file && file instanceof TFile) { const result = await this.vaultService.read(file, allowAccessToPluginRoot); - return typeof result === "string" ? JSON.parse(result) as object : result; + return typeof result === "string" ? JSON.parse(result) as Record : result; } return Exception.new(`File not found: ${filePath}`); } @@ -73,15 +71,15 @@ export class FileSystemService { public async writeObjectToFile(filePath: string, data: object, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); - let result: TFile | Error; - if (file && file instanceof TFile) { - result = await this.vaultService.modify(file, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); - } - else { - result = await this.vaultService.create(filePath, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); - } + let result: TFile | Error; + if (file && file instanceof TFile) { + result = await this.vaultService.modify(file, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); + } + else { + result = await this.vaultService.create(filePath, JSON.stringify(data, null, 4), allowAccessToPluginRoot, requiresConfirmation); + } - return result; + return result; } public async searchVaultFiles(searchTerm: string, allowAccessToPluginRoot: boolean = false): Promise { diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 6a0f4e0..74ce8cf 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -37,6 +37,7 @@ import { SettingsService, type IVaultkeeperAISettings } from "./SettingsService" import { HelpModal } from "Modals/HelpModal"; import { EventService } from "./EventService"; import { DiffService } from "./DiffService"; +import { AbortService } from "./AbortService"; export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) { RegisterSingleton(Services.VaultkeeperAIPlugin, plugin); @@ -45,6 +46,7 @@ export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) { export function RegisterDependencies() { RegisterSingleton(Services.EventService, new EventService()); + RegisterSingleton(Services.AbortService, new AbortService()); RegisterSingleton(Services.StatusBarService, new StatusBarService()); RegisterSingleton(Services.HTMLService, new HTMLService()); RegisterSingleton(Services.SanitiserService, new SanitiserService()); diff --git a/Services/Services.ts b/Services/Services.ts index a4581de..ebd9af4 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -2,6 +2,7 @@ export class Services { static VaultkeeperAIPlugin = Symbol("VaultkeeperAIPlugin"); static SettingsService = Symbol("SettingsService"); static EventService = Symbol("EventService"); + static AbortService = Symbol("AbortService"); static StatusBarService = Symbol("StatusBarService"); static HTMLService = Symbol("HTMLService"); static VaultService = Symbol("VaultService"); diff --git a/Services/StreamingMarkdownService.ts b/Services/StreamingMarkdownService.ts index 232c25f..14c2ce5 100644 --- a/Services/StreamingMarkdownService.ts +++ b/Services/StreamingMarkdownService.ts @@ -32,7 +32,7 @@ export class StreamingMarkdownService { constructor() { this.processor = unified() - .use(remarkParse) + .use(remarkParse) .use(remarkGfm) .use(remarkEmoji) .use(remarkMath) diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index f7dc23d..37f0eeb 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -1,7 +1,9 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; -import { Selector } from "Enums/Selector"; import { Exception } from "Helpers/Exception"; import { ApiError, ApiErrorType } from "Types/ApiError"; +import { AbortService } from "./AbortService"; +import { Resolve } from "./DependencyService"; +import { Services } from "./Services"; export interface IStreamChunk { content: string; @@ -17,14 +19,20 @@ export class StreamingService { private static readonly MAX_RETRIES = 3; private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // ms + private readonly abortService: AbortService; + + public constructor() { + this.abortService = Resolve(Services.AbortService); + } + public async* streamRequest(url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => IStreamChunk, - abortSignal?: AbortSignal, additionalHeaders?: Record): AsyncGenerator { + additionalHeaders?: Record): AsyncGenerator { let lastError: Error | null = null; for (let attempt = 0; attempt <= StreamingService.MAX_RETRIES; attempt++) { try { - const response = await this.makeRequest(url, requestBody, additionalHeaders, abortSignal); + const response = await this.makeRequest(url, requestBody, additionalHeaders); const reader = response.body?.getReader(); if (!reader) { @@ -42,9 +50,8 @@ export class StreamingService { } catch (error) { lastError = error instanceof Error ? error : Exception.new(error); - if (error instanceof Error && error.name === 'AbortError') { - yield this.createErrorChunk(lastError, true); - return; + if (AbortService.isAbortError(error)) { + throw error; } if (!this.shouldRetry(error, attempt)) { @@ -64,7 +71,7 @@ export class StreamingService { } private async makeRequest(url: string, requestBody: unknown, - additionalHeaders?: Record, abortSignal?: AbortSignal): Promise { + additionalHeaders?: Record): Promise { const response = await fetch(url, { method: "POST", headers: { @@ -72,7 +79,7 @@ export class StreamingService { ...additionalHeaders, }, body: JSON.stringify(requestBody), - signal: abortSignal, + signal: this.abortService.signal(), }); if (!response.ok) { @@ -85,13 +92,16 @@ export class StreamingService { private async* processStream(reader: ReadableStreamDefaultReader, parseStreamChunk: (chunk: string) => IStreamChunk): AsyncGenerator { - let buffer = ""; let lastChunkWasComplete = false; const decoder = new TextDecoder(); while (true) { + if (this.abortService.signal().aborted) { + this.abortService.throw(); + } + const { done, value } = await reader.read(); buffer += decoder.decode(value, { stream: true }); @@ -106,6 +116,9 @@ export class StreamingService { lastChunkWasComplete = chunk.isComplete; yield chunk; } catch (error) { + if (AbortService.isAbortError(error)) { + throw error; + } Exception.log(error); yield { content: "", @@ -125,14 +138,7 @@ export class StreamingService { return lastChunkWasComplete; } - private createErrorChunk(error: Error | ApiError, isAborted = false): IStreamChunk { - if (isAborted) { - return { - content: Selector.ApiRequestAborted, - isComplete: true - }; - } - + private createErrorChunk(error: Error | ApiError): IStreamChunk { if (error instanceof ApiError) { return { content: "", @@ -151,14 +157,12 @@ export class StreamingService { } private shouldRetry(error: unknown, attempt: number): boolean { - // Don't retry abort errors - if (error instanceof Error && error.name === 'AbortError') { - return false; + if (AbortService.isAbortError(error)) { + return false; // Don't retry abort errors } - // Don't retry non-retryable errors if (error instanceof ApiError && !error.info.isRetryable) { - return false; + return false; // Don't retry non-retryable errors } return attempt < StreamingService.MAX_RETRIES; diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 1ba6bc2..52fc50a 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -14,6 +14,7 @@ import type { EventService } from "./EventService"; import { DiffService } from "./DiffService"; import * as path from "path-browserify"; import { Event } from "Enums/Event"; +import { AbortService } from "./AbortService"; interface IFileEventArgs { oldPath: string; @@ -111,12 +112,21 @@ export class VaultService { }); } - public async delete(file: TAbstractFile, allowAccessToPluginRoot: boolean = false): Promise { + public async delete(file: TAbstractFile, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const filePath = this.sanitiserService.sanitize(file.path); if (this.isExclusion(file.path, allowAccessToPluginRoot)) { Exception.log(`Plugin attempted to delete a file that is in the exclusions list: ${filePath}`) return Exception.new(`File does not exist: ${filePath}`); } + + // handle file deletion + if (file instanceof TFile) { + return this.proposeChange(file.name, file.name, await this.vault.read(file), "", requiresConfirmation, async () => { + await this.fileManager.trashFile(file); + }); + } + + // handle folder deletion try { await this.fileManager.trashFile(file); } catch (error) { @@ -428,12 +438,16 @@ export class VaultService { let response = "User rejected this change. Stop all actions and consult with the user"; if (result.suggestion) { - response = `User has rejected the input with the following suggestion: ${result.suggestion}`; + response = `User has rejected the change with the following suggestion: ${result.suggestion}`; } return Exception.new(response); } catch (error) { + if (AbortService.isAbortError(error)) { + throw error; + } this.eventService.trigger(Event.DiffClosed); Exception.log(error); + return Exception.new(error); } } diff --git a/Styles/custom_styles.css b/Styles/custom_styles.css index dcd95dc..3eb0c19 100644 --- a/Styles/custom_styles.css +++ b/Styles/custom_styles.css @@ -13,6 +13,16 @@ padding-top: 0; } +/* ============================== */ +/* Diff View Customization */ +/* ============================== */ + +.workspace-leaf-content[data-type="vaultkeeper-ai-diff-view"] .view-content { + container-type: size; + container-name: diff-container; + overflow: hidden; +} + /* ============================== */ /* Settings Styles */ /* ============================== */ diff --git a/Styles/diff2html_styles.css b/Styles/diff2html_styles.css index 09e5ae1..c9b8d2d 100644 --- a/Styles/diff2html_styles.css +++ b/Styles/diff2html_styles.css @@ -2,6 +2,7 @@ /* Main diff container background */ .d2h-wrapper { + transform: translateZ(0); background-color: var(--background-primary) !important; box-shadow: 0px 0px 4px 1px var(--color-accent) !important; } @@ -17,8 +18,8 @@ background-color: var(--background-primary-alt) !important; } -.d2h-code-line { - padding: 0 5em; +.d2h-code-linenumber { + cursor: default; } /* Line number backgrounds - only override context lines */ @@ -40,15 +41,42 @@ /* File diff background */ .d2h-file-diff { + overflow: auto; background-color: var(--background-primary) !important; border-color: var(--background-modifier-border) !important; } +/* File diff size */ +@container diff-container (max-height: 199px) { + .d2h-file-diff { max-height: 90cqh; } +} + +@container diff-container (min-height: 200px) { + .d2h-file-diff { max-height: 95cqh; } +} + +@container diff-container (min-height: 400px) { + .d2h-file-diff { max-height: 97cqh; } +} + +@container diff-container (min-height: 550px) { + .d2h-file-diff { max-height: 98cqh; } +} + +@container diff-container (min-height: 1000px) { + .d2h-file-diff { max-height: 99cqh; } +} + /* Empty placeholder */ .d2h-emptyplaceholder { background-color: var(--background-secondary-alt) !important; } +.d2h-code-side-linenumber { + position: relative; + display: table-cell; +} + /* Code side backgrounds for side-by-side view - only override context lines */ .d2h-code-side-linenumber.d2h-cntx, .d2h-code-side-line.d2h-cntx { @@ -140,9 +168,12 @@ .d2h-code-linenumber, .d2h-info { color: var(--text-normal) !important; - width: 4.5em; } +.d2h-diff-table { + position: relative; + } + /* Light mode syntax highlighting overrides for better visibility */ .theme-light .d2h-code-side-line { color: var(--text-normal) !important; diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index 9c9b409..8ce1589 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -11,6 +11,8 @@ import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; +import { AbortService } from '../../Services/AbortService'; +import { Exception } from '../../Helpers/Exception'; describe('Claude', () => { let claude: Claude; @@ -19,6 +21,7 @@ describe('Claude', () => { let mockPlugin: any; let mockSettingsService: any; let mockFunctionDefinitions: any; + let abortService: AbortService; beforeEach(() => { // Mock IPrompt @@ -52,6 +55,10 @@ describe('Claude', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + // Mock StreamingService mockStreamingService = { streamRequest: vi.fn() @@ -244,7 +251,7 @@ describe('Claude', () => { (claude as any).accumulatedFunctionArgs = 'invalid json {'; (claude as any).accumulatedFunctionId = 'func_789'; - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const chunk = JSON.stringify({ type: 'content_block_stop' @@ -253,9 +260,9 @@ describe('Claude', () => { const result = (claude as any).parseStreamChunk(chunk); expect(result.functionCall).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should handle malformed chunk JSON', () => { @@ -341,7 +348,7 @@ describe('Claude', () => { }); it('should handle invalid JSON in function call gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.Assistant, @@ -358,13 +365,13 @@ describe('Claude', () => { expect(result).toHaveLength(1); expect(result[0].content).toHaveLength(1); expect(result[0].content[0].type).toBe('text'); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should handle invalid JSON in function response gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.User, @@ -380,9 +387,9 @@ describe('Claude', () => { expect(result[0].content).toHaveLength(1); expect(result[0].content[0].type).toBe('text'); expect(result[0].content[0].text).toBe('invalid json {'); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should filter out empty content', () => { @@ -727,8 +734,7 @@ describe('Claude', () => { yield { content: 'response', isComplete: true }; }); - const abortSignal = new AbortController().signal; - const generator = claude.streamRequest(conversation, true, abortSignal); + const generator = claude.streamRequest(conversation, true); // Consume the generator for await (const chunk of generator) { @@ -746,7 +752,6 @@ describe('Claude', () => { stream: true }), expect.any(Function), // parseStreamChunk - abortSignal, expect.objectContaining({ 'x-api-key': 'test-claude-key', 'anthropic-version': '2023-06-01', diff --git a/__tests__/AIClasses/ClaudeConversationNamingService.test.ts b/__tests__/AIClasses/ClaudeConversationNamingService.test.ts index 29f1cf9..6da9215 100644 --- a/__tests__/AIClasses/ClaudeConversationNamingService.test.ts +++ b/__tests__/AIClasses/ClaudeConversationNamingService.test.ts @@ -4,12 +4,12 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende import { Services } from '../../Services/Services'; import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider'; import { Role } from '../../Enums/Role'; -import { SettingsService } from '../../Services/SettingsService'; describe('ClaudeConversationNamingService', () => { let service: ClaudeConversationNamingService; let mockPlugin: any; let mockSettingsService: any; + let mockAbortService: any; let fetchMock: any; beforeEach(() => { @@ -36,6 +36,13 @@ describe('ClaudeConversationNamingService', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Mock AbortService + mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn((fn) => fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + // Mock global fetch fetchMock = vi.fn(); global.fetch = fetchMock; @@ -58,7 +65,7 @@ describe('ClaudeConversationNamingService', () => { }) }); - await service.generateName('User prompt', undefined); + await service.generateName('User prompt'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -91,7 +98,7 @@ describe('ClaudeConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -104,7 +111,7 @@ describe('ClaudeConversationNamingService', () => { text: async () => 'Invalid API key' }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Claude API error: 401 Unauthorized - Invalid API key'); }); @@ -116,13 +123,11 @@ describe('ClaudeConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); it('should pass abort signal to fetch', async () => { - const abortController = new AbortController(); - fetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -130,12 +135,12 @@ describe('ClaudeConversationNamingService', () => { }) }); - await service.generateName('Test', abortController.signal); + await service.generateName('Test'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: expect.any(AbortSignal) }) ); }); @@ -149,7 +154,7 @@ describe('ClaudeConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); }); diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index 2b5ca08..31ad32b 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -11,6 +11,8 @@ import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; +import { AbortService } from '../../Services/AbortService'; +import { Exception } from '../../Helpers/Exception'; describe('Gemini', () => { let gemini: Gemini; @@ -19,6 +21,7 @@ describe('Gemini', () => { let mockPlugin: any; let mockSettingsService: any; let mockFunctionDefinitions: any; + let abortService: AbortService; beforeEach(() => { // Mock IPrompt @@ -52,6 +55,10 @@ describe('Gemini', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + // Mock StreamingService mockStreamingService = { streamRequest: vi.fn() @@ -401,7 +408,7 @@ describe('Gemini', () => { }); it('should handle invalid JSON in function call gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.Assistant, @@ -416,13 +423,13 @@ describe('Gemini', () => { // Should be filtered out since it has no valid parts (no text, invalid function call) expect(result).toHaveLength(0); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should handle invalid JSON in function response gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); const invalidContent = new ConversationContent( Role.User, @@ -437,9 +444,9 @@ describe('Gemini', () => { expect(result).toHaveLength(1); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0]).toEqual({ text: 'invalid json {' }); - expect(consoleSpy).toHaveBeenCalled(); + expect(exceptionSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('should filter out empty content', () => { diff --git a/__tests__/AIClasses/GeminiConversationNamingService.test.ts b/__tests__/AIClasses/GeminiConversationNamingService.test.ts index 016c38d..c76ed20 100644 --- a/__tests__/AIClasses/GeminiConversationNamingService.test.ts +++ b/__tests__/AIClasses/GeminiConversationNamingService.test.ts @@ -4,12 +4,12 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende import { Services } from '../../Services/Services'; import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider'; import { Role } from '../../Enums/Role'; -import { SettingsService } from '../../Services/SettingsService'; describe('GeminiConversationNamingService', () => { let service: GeminiConversationNamingService; let mockPlugin: any; let mockSettingsService: any; + let mockAbortService: any; let fetchMock: any; beforeEach(() => { @@ -36,6 +36,13 @@ describe('GeminiConversationNamingService', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Mock AbortService + mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn((fn) => fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + // Mock global fetch fetchMock = vi.fn(); global.fetch = fetchMock; @@ -58,7 +65,7 @@ describe('GeminiConversationNamingService', () => { }) }); - await service.generateName('User prompt', undefined); + await service.generateName('User prompt'); expect(fetchMock).toHaveBeenCalled(); const fetchUrl = fetchMock.mock.calls[0][0]; @@ -95,7 +102,7 @@ describe('GeminiConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -108,7 +115,7 @@ describe('GeminiConversationNamingService', () => { text: async () => 'Invalid request' }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Gemini API error: 400 Bad Request - Invalid request'); }); @@ -120,13 +127,11 @@ describe('GeminiConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); it('should pass abort signal to fetch', async () => { - const abortController = new AbortController(); - fetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -134,12 +139,12 @@ describe('GeminiConversationNamingService', () => { }) }); - await service.generateName('Test', abortController.signal); + await service.generateName('Test'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: expect.any(AbortSignal) }) ); }); @@ -153,7 +158,7 @@ describe('GeminiConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); }); diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 1101506..cb1641e 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -12,6 +12,7 @@ import { Role } from '../../Enums/Role'; import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; import { Exception } from '../../Helpers/Exception'; +import { AbortService } from '../../Services/AbortService'; describe('OpenAI', () => { let openai: OpenAI; @@ -20,6 +21,7 @@ describe('OpenAI', () => { let mockPlugin: any; let mockSettingsService: any; let mockFunctionDefinitions: any; + let abortService: AbortService; beforeEach(() => { // Mock Exception methods @@ -56,6 +58,10 @@ describe('OpenAI', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Create real AbortService instance + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); + // Mock StreamingService mockStreamingService = { streamRequest: vi.fn() @@ -727,8 +733,7 @@ describe('OpenAI', () => { yield { content: 'response', isComplete: true }; }); - const abortSignal = new AbortController().signal; - const generator = openai.streamRequest(conversation, true, abortSignal); + const generator = openai.streamRequest(conversation, true); for await (const chunk of generator) { // Just consume @@ -744,7 +749,6 @@ describe('OpenAI', () => { stream: true }), expect.any(Function), // parseStreamChunk - abortSignal, expect.objectContaining({ 'Authorization': 'Bearer test-openai-key', 'Content-Type': 'application/json' diff --git a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts index 812cea9..2d20995 100644 --- a/__tests__/AIClasses/OpenAIConversationNamingService.test.ts +++ b/__tests__/AIClasses/OpenAIConversationNamingService.test.ts @@ -4,12 +4,12 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende import { Services } from '../../Services/Services'; import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider'; import { Role } from '../../Enums/Role'; -import { SettingsService } from '../../Services/SettingsService'; describe('OpenAIConversationNamingService', () => { let service: OpenAIConversationNamingService; let mockPlugin: any; let mockSettingsService: any; + let mockAbortService: any; let fetchMock: any; beforeEach(() => { @@ -36,6 +36,13 @@ describe('OpenAIConversationNamingService', () => { }; RegisterSingleton(Services.SettingsService, mockSettingsService); + // Mock AbortService + mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn((fn) => fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + // Mock global fetch fetchMock = vi.fn(); global.fetch = fetchMock; @@ -72,7 +79,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - await service.generateName('User prompt', undefined); + await service.generateName('User prompt'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), @@ -119,7 +126,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -160,7 +167,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - const result = await service.generateName('Test prompt', undefined); + const result = await service.generateName('Test prompt'); expect(result).toBe('Generated Name'); }); @@ -173,7 +180,7 @@ describe('OpenAIConversationNamingService', () => { text: async () => 'Rate limit exceeded' }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('OpenAI API error: 429 Too Many Requests - Rate limit exceeded'); }); @@ -187,13 +194,11 @@ describe('OpenAIConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); it('should pass abort signal to fetch', async () => { - const abortController = new AbortController(); - fetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -215,12 +220,12 @@ describe('OpenAIConversationNamingService', () => { }) }); - await service.generateName('Test', abortController.signal); + await service.generateName('Test'); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: expect.any(AbortSignal) }) ); }); @@ -235,7 +240,7 @@ describe('OpenAIConversationNamingService', () => { }) }); - await expect(service.generateName('Test', undefined)) + await expect(service.generateName('Test')) .rejects.toThrow('Failed to generate conversation name'); }); }); diff --git a/__tests__/Services/AIFunctionService.test.ts b/__tests__/Services/AIFunctionService.test.ts index 4e583f1..382d5e2 100644 --- a/__tests__/Services/AIFunctionService.test.ts +++ b/__tests__/Services/AIFunctionService.test.ts @@ -5,6 +5,7 @@ import { Services } from '../../Services/Services'; import { AIFunction } from '../../Enums/AIFunction'; import { TFile } from 'obsidian'; import { Exception } from '../../Helpers/Exception'; +import { AbortService } from '../../Services/AbortService'; /** * INTEGRATION TESTS - AIFunctionService @@ -24,6 +25,7 @@ import { Exception } from '../../Helpers/Exception'; describe('AIFunctionService - Integration Tests', () => { let service: AIFunctionService; let mockFileSystemService: any; + let abortService: AbortService; beforeEach(() => { // Mock FileSystemService with common operations @@ -36,13 +38,17 @@ describe('AIFunctionService - Integration Tests', () => { moveFile: vi.fn() }; - // Register the mock + // Create real AbortService instance + abortService = new AbortService(); + + // Register the mocks RegisterSingleton(Services.FileSystemService, mockFileSystemService); + RegisterSingleton(Services.AbortService, abortService); // Mock Exception.log vi.spyOn(Exception, 'log').mockImplementation(() => {}); - // Create service - it will resolve the mock FileSystemService + // Create service - it will resolve the mock FileSystemService and real AbortService service = new AIFunctionService(); }); @@ -211,8 +217,8 @@ describe('AIFunctionService - Integration Tests', () => { expect(result.response).toEqual({ results: [ { path: 'exists.md', contents: 'Existing content' }, - { path: 'missing1.md', error: error1 }, - { path: 'missing2.md', error: error2 } + { path: 'missing1.md', error: 'File not found' }, + { path: 'missing2.md', error: 'File not found' } ] }); }); @@ -231,7 +237,7 @@ describe('AIFunctionService - Integration Tests', () => { const results = result.response.results; expect(results[0].contents).toBe('Content A'); - expect(results[1].error).toBeInstanceOf(Error); + expect(results[1].error).toBe('File not found'); expect(results[2].contents).toBe('Content B'); }); @@ -399,7 +405,7 @@ describe('AIFunctionService - Integration Tests', () => { expect(result.response.results).toEqual([ { path: 'a.md', success: true }, - { path: 'missing.md', success: false, error: error }, + { path: 'missing.md', success: false, error: 'File not found' }, { path: 'c.md', success: true } ]); }); @@ -502,7 +508,7 @@ describe('AIFunctionService - Integration Tests', () => { expect(result.response.results).toEqual([ { path: 'new/a.md', success: true }, - { path: 'existing.md', success: false, error: error }, + { path: 'existing.md', success: false, error: 'Destination exists' }, { path: 'new/c.md', success: true } ]); }); diff --git a/__tests__/Services/AbortService.test.ts b/__tests__/Services/AbortService.test.ts new file mode 100644 index 0000000..9883f06 --- /dev/null +++ b/__tests__/Services/AbortService.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AbortService } from '../../Services/AbortService'; +import { DeregisterAllServices } from '../../Services/DependencyService'; + +describe('AbortService', () => { + let service: AbortService; + + beforeEach(() => { + service = new AbortService(); + }); + + afterEach(() => { + DeregisterAllServices(); + vi.restoreAllMocks(); + }); + + describe('Constructor & Initialization', () => { + it('should initialize with a non-aborted controller', () => { + expect(service.signal().aborted).toBe(false); + }); + + it('should provide a valid abort signal', () => { + const signal = service.signal(); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it('should have undefined reason before abort', () => { + expect(service.signal().reason).toBeUndefined(); + }); + }); + + describe('isAbortError - Static Method', () => { + it('should return true for DOMException with name AbortError', () => { + const error = new DOMException('Aborted', 'AbortError'); + expect(AbortService.isAbortError(error)).toBe(true); + }); + + it('should return false for DOMException with different name', () => { + const error = new DOMException('Error', 'NetworkError'); + expect(AbortService.isAbortError(error)).toBe(false); + }); + + it('should return false for generic Error', () => { + const error = new Error('Aborted'); + expect(AbortService.isAbortError(error)).toBe(false); + }); + + it('should return false for null', () => { + expect(AbortService.isAbortError(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(AbortService.isAbortError(undefined)).toBe(false); + }); + + it('should return false for string', () => { + expect(AbortService.isAbortError('AbortError')).toBe(false); + }); + }); + + describe('initialiseAbortController', () => { + it('should abort the current controller', () => { + const originalSignal = service.signal(); + service.initialiseAbortController(); + expect(originalSignal.aborted).toBe(true); + }); + + it('should create a new non-aborted controller', () => { + service.abort(); + expect(service.signal().aborted).toBe(true); + + service.initialiseAbortController(); + expect(service.signal().aborted).toBe(false); + }); + + it('should provide a new signal after initialization', () => { + const oldSignal = service.signal(); + service.initialiseAbortController(); + const newSignal = service.signal(); + + expect(oldSignal).not.toBe(newSignal); + }); + + it('should be callable multiple times in succession', () => { + expect(() => { + service.initialiseAbortController(); + service.initialiseAbortController(); + service.initialiseAbortController(); + }).not.toThrow(); + + expect(service.signal().aborted).toBe(false); + }); + + it('should abort ongoing listeners on old signal', () => { + const listener = vi.fn(); + const oldSignal = service.signal(); + oldSignal.addEventListener('abort', listener); + + service.initialiseAbortController(); + + expect(listener).toHaveBeenCalled(); + expect(oldSignal.aborted).toBe(true); + }); + }); + + describe('signal()', () => { + it('should return the current abort signal', () => { + const signal = service.signal(); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it('should return the same signal on multiple calls', () => { + const signal1 = service.signal(); + const signal2 = service.signal(); + expect(signal1).toBe(signal2); + }); + + it('should return new signal after re-initialization', () => { + const signal1 = service.signal(); + service.initialiseAbortController(); + const signal2 = service.signal(); + + expect(signal1).not.toBe(signal2); + }); + }); + + describe('abort()', () => { + it('should abort the controller with default reason', () => { + service.abort(); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason).toBeDefined(); + expect(service.signal().reason.message).toContain('Aborted'); + }); + + it('should abort with custom reason', () => { + service.abort('Custom abort message'); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason.message).toContain('Custom abort message'); + }); + + it('should create DOMException with AbortError name', () => { + service.abort('Test'); + + const reason = service.signal().reason; + expect(reason).toBeInstanceOf(DOMException); + expect(reason.name).toBe('AbortError'); + }); + + it('should trigger abort event on signal', () => { + const listener = vi.fn(); + service.signal().addEventListener('abort', listener); + + service.abort(); + + expect(listener).toHaveBeenCalled(); + }); + + it('should be idempotent - multiple abort calls', () => { + service.abort('First abort'); + const firstReason = service.signal().reason; + + service.abort('Second abort'); + service.abort('Third abort'); + + expect(service.signal().reason).toBe(firstReason); + }); + + it('should work with empty string reason', () => { + service.abort(''); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason).toBeDefined(); + }); + + it('should work with very long reason string', () => { + const longReason = 'X'.repeat(1000); + service.abort(longReason); + + expect(service.signal().aborted).toBe(true); + expect(service.signal().reason.message).toContain(longReason); + }); + }); + + describe('reason()', () => { + it('should return Error when signal has Error reason', () => { + service.abort('Test error'); + const reason = service.reason(); + + expect(reason).toBeInstanceOf(Error); + }); + + it('should return default Error before abort', () => { + const reason = service.reason(); + + expect(reason).toBeInstanceOf(Error); + expect(reason.message).toBe('Aborted'); + }); + + it('should return DOMException after abort', () => { + service.abort('Custom'); + const reason = service.reason(); + + expect(reason).toBeInstanceOf(DOMException); + }); + + it('should preserve custom abort message', () => { + service.abort('Custom message'); + const reason = service.reason(); + + expect(reason.message).toContain('Custom message'); + }); + + it('should handle reason() after re-initialization', () => { + service.abort('Original'); + service.initialiseAbortController(); + const reason = service.reason(); + + expect(reason.message).toBe('Aborted'); + }); + }); + + describe('throw()', () => { + it('should throw the abort reason', () => { + service.abort('Test error'); + + expect(() => service.throw()).toThrow('Test error'); + }); + + it('should throw Error type', () => { + service.abort('Test'); + + try { + service.throw(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + it('should throw even before abort', () => { + expect(() => service.throw()).toThrow('Aborted'); + }); + + it('should never return', () => { + service.abort('Test'); + + let didNotReturn = true; + try { + const result = service.throw(); + didNotReturn = false; + // TypeScript ensures result type is 'never' + expect(result).toBeUndefined(); // Should never reach + } catch { + expect(didNotReturn).toBe(true); + } + }); + }); + + describe('abortableOperation - Core Functionality', () => { + it('should execute and return result when not aborted', async () => { + const executor = () => Promise.resolve('success'); + + const result = await service.abortableOperation(executor); + + expect(result).toBe('success'); + }); + + it('should immediately throw if already aborted', async () => { + service.abort('Already aborted'); + + const executor = vi.fn(() => Promise.resolve('test')); + + await expect(service.abortableOperation(executor)).rejects.toThrow(); + expect(executor).not.toHaveBeenCalled(); + }); + + it('should throw when aborted during execution', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort('Aborted during execution'), 50); + + await expect(opPromise).rejects.toThrow(); + const error = await opPromise.catch(e => e); + expect(AbortService.isAbortError(error)).toBe(true); + }); + + it('should return executor result when it completes first', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('fast'), 10)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 100); + + const result = await opPromise; + expect(result).toBe('fast'); + }); + + it('should clean up event listener on success', async () => { + const signal = service.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + await service.abortableOperation(() => Promise.resolve('done')); + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should clean up event listener on abort', async () => { + const signal = service.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 10); + + await expect(opPromise).rejects.toThrow(); + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should handle executor that rejects with non-abort error', async () => { + const executor = () => Promise.reject(new Error('Network failed')); + + await expect(service.abortableOperation(executor)).rejects.toThrow('Network failed'); + }); + + it('should suppress AbortError from executor after abort wins', async () => { + const executorAbortError = new DOMException('Executor aborted', 'AbortError'); + const executor = () => new Promise((_, reject) => + setTimeout(() => reject(executorAbortError), 100) + ); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort('Service abort'), 10); + + await expect(opPromise).rejects.toThrow('Service abort'); + }); + + it('should handle executor that throws synchronously', async () => { + const executor = () => { + throw new Error('Sync error'); + }; + + await expect(service.abortableOperation(executor)).rejects.toThrow('Sync error'); + }); + + it('should handle multiple concurrent operations', async () => { + const executor1 = () => new Promise(resolve => setTimeout(() => resolve('op1'), 100)); + const executor2 = () => new Promise(resolve => setTimeout(() => resolve('op2'), 100)); + + const op1Promise = service.abortableOperation(executor1); + const op2Promise = service.abortableOperation(executor2); + + setTimeout(() => service.abort(), 50); + + await expect(op1Promise).rejects.toThrow(); + await expect(op2Promise).rejects.toThrow(); + }); + + it('should handle executor returning immediately resolved promise', async () => { + const executor = () => Promise.resolve('immediate'); + + const result = await service.abortableOperation(executor); + + expect(result).toBe('immediate'); + }); + + it('should handle executor with zero delay', async () => { + const executor = async () => { + return 'zero-delay'; + }; + + const result = await service.abortableOperation(executor); + + expect(result).toBe('zero-delay'); + }); + }); + + describe('abortableOperation - Timing & Race Conditions', () => { + it('should handle abort triggered immediately after operation starts', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 50)); + + const opPromise = service.abortableOperation(executor); + service.abort('Immediate abort'); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should handle very fast executor', async () => { + const executor = () => Promise.resolve('instant'); + + const result = await service.abortableOperation(executor); + + expect(result).toBe('instant'); + }); + + it('should handle executor that never resolves with abort', async () => { + const executor = () => new Promise(() => {}); // Never resolves + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 50); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should handle abort called during Promise.race evaluation', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 30)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.abort(), 15); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should handle multiple abort calls during operation', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + + const opPromise = service.abortableOperation(executor); + + // Abort multiple times - only first abort matters due to idempotence + setTimeout(() => { + service.abort('First'); + service.abort('Second'); + service.abort('Third'); + }, 10); + + await expect(opPromise).rejects.toThrow('First'); + }); + + it('should handle re-initialization during operation', async () => { + const executor = () => new Promise(resolve => setTimeout(() => resolve('done'), 100)); + + const opPromise = service.abortableOperation(executor); + + setTimeout(() => service.initialiseAbortController(), 50); + + await expect(opPromise).rejects.toThrow(); + }); + }); + + describe('Integration Scenarios', () => { + it('should work with fetch-like operations', async () => { + const mockFetch = vi.fn().mockImplementation((_url, options) => { + return new Promise((resolve, reject) => { + const abortHandler = () => { + reject(new DOMException('Fetch aborted', 'AbortError')); + }; + + if (options.signal.aborted) { + reject(new DOMException('Already aborted', 'AbortError')); + return; + } + + options.signal.addEventListener('abort', abortHandler); + + setTimeout(() => { + options.signal.removeEventListener('abort', abortHandler); + resolve({ data: 'response' }); + }, 100); + }); + }); + + const executor = () => mockFetch('https://api.example.com', { signal: service.signal() }); + const opPromise = service.abortableOperation(executor); + + // Use immediate abort to avoid timing issues with test cleanup + service.abort(); + + await expect(opPromise).rejects.toThrow(); + }); + + it('should chain multiple abortable operations', async () => { + const executor1 = () => Promise.resolve('result1'); + const executor2 = () => Promise.resolve('result2'); + + const result1 = await service.abortableOperation(executor1); + const result2 = await service.abortableOperation(executor2); + + expect(result1).toBe('result1'); + expect(result2).toBe('result2'); + }); + + it('should work with async/await in executor', async () => { + const executor = async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => setTimeout(resolve, 10)); + return 'multi-await'; + }; + + const result = await service.abortableOperation(executor); + + expect(result).toBe('multi-await'); + }); + + it('should handle full lifecycle - abort, reset, run operation', async () => { + service.abort('Initial abort'); + expect(service.signal().aborted).toBe(true); + + service.initialiseAbortController(); + expect(service.signal().aborted).toBe(false); + + const result = await service.abortableOperation(() => Promise.resolve('success')); + + expect(result).toBe('success'); + }); + }); +}); diff --git a/__tests__/Services/ChatService.test.ts b/__tests__/Services/ChatService.test.ts index 0eb63f6..227f3fe 100644 --- a/__tests__/Services/ChatService.test.ts +++ b/__tests__/Services/ChatService.test.ts @@ -7,6 +7,7 @@ import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { AIFunctionCall } from '../../AIClasses/AIFunctionCall'; import { AIFunction, fromString } from '../../Enums/AIFunction'; +import { AbortService } from '../../Services/AbortService'; /** * INTEGRATION TESTS - Simplified @@ -26,6 +27,8 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { let mockPrompt: any; let mockStatusBarService: any; let mockTokenService: any; + let mockEventService: any; + let abortService: AbortService; beforeEach(() => { // Setup minimal mocks @@ -54,12 +57,24 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { countTokens: vi.fn().mockResolvedValue(100) }; + // Mock EventService since it extends Obsidian's Events class + mockEventService = { + trigger: vi.fn(), + on: vi.fn(), + off: vi.fn() + }; + + // Create real AbortService instance + abortService = new AbortService(); + // Register dependencies RegisterSingleton(Services.ConversationFileSystemService, mockConversationService); RegisterSingleton(Services.AIFunctionService, mockAIFunctionService); RegisterSingleton(Services.ConversationNamingService, mockNamingService); RegisterSingleton(Services.IPrompt, mockPrompt); RegisterSingleton(Services.StatusBarService, mockStatusBarService); + RegisterSingleton(Services.EventService, mockEventService); + RegisterSingleton(Services.AbortService, abortService); // Create service service = new ChatService(); @@ -227,15 +242,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -260,15 +267,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -286,15 +285,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -311,15 +302,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } @@ -355,15 +338,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { if (conversation.contents.length > 0) { const lastMessage = conversation.contents[conversation.contents.length - 1]; if (lastMessage.role === Role.Assistant) { - conversation.contents.push(new ConversationContent( - Role.User, - "Continue", - "Continue", - "", - new Date(), - false, - true - )); + conversation.contents.push(ConversationContent.safeContinue()); } } diff --git a/__tests__/Services/ConversationFileSystemService.test.ts b/__tests__/Services/ConversationFileSystemService.test.ts index 2730e80..7098315 100644 --- a/__tests__/Services/ConversationFileSystemService.test.ts +++ b/__tests__/Services/ConversationFileSystemService.test.ts @@ -5,7 +5,6 @@ import { Services } from '../../Services/Services'; import { Conversation } from '../../Conversations/Conversation'; import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; -import { Copy } from '../../Enums/Copy'; import { TFile } from 'obsidian'; import { Exception } from '../../Helpers/Exception'; @@ -20,7 +19,6 @@ import { Exception } from '../../Helpers/Exception'; * - Path management * - Conversation title updates * - Listing all conversations - * - Filtering aborted requests */ describe('ConversationFileSystemService - Integration Tests', () => { @@ -34,7 +32,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { readObjectFromFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn(), - listFilesInDirectory: vi.fn() + listFilesInDirectory: vi.fn(), + exists: vi.fn().mockResolvedValue(true) }; // Register the mock @@ -142,21 +141,6 @@ describe('ConversationFileSystemService - Integration Tests', () => { expect(conversation.updated.getTime()).toBeGreaterThanOrEqual(originalUpdated.getTime()); }); - it('should filter out aborted request messages', async () => { - const conversation = createTestConversation('Test Filter'); - conversation.contents.push( - new ConversationContent(Role.Assistant, Copy.ApiRequestAborted, '', '', new Date()) - ); - - await service.saveConversation(conversation); - - const savedData = mockFileSystemService.writeObjectToFile.mock.calls[0][1]; - const savedContents = savedData.contents; - - // Should have 2 contents (original user and assistant), not 3 - expect(savedContents).toHaveLength(2); - expect(savedContents.every((c: any) => c.content !== Copy.ApiRequestAborted)).toBe(true); - }); it('should set current conversation path on first save', async () => { const conversation = createTestConversation('First Save'); @@ -328,7 +312,8 @@ describe('ConversationFileSystemService - Integration Tests', () => { expect(result).toBeUndefined(); // void = success expect(mockFileSystemService.deleteFile).toHaveBeenCalledWith( 'Vaultkeeper AI/Conversations/To Delete.json', - true + true, + false ); expect(service.getCurrentConversationPath()).toBeNull(); }); diff --git a/__tests__/Services/ConversationNamingService.test.ts b/__tests__/Services/ConversationNamingService.test.ts index e493fd9..028ba4c 100644 --- a/__tests__/Services/ConversationNamingService.test.ts +++ b/__tests__/Services/ConversationNamingService.test.ts @@ -32,6 +32,13 @@ describe('ConversationNamingService', () => { }; RegisterSingleton(Services.VaultService, mockVaultService); + // Mock AbortService + const mockAbortService = { + signal: vi.fn(() => new AbortController().signal), + abortableOperation: vi.fn(async (fn) => await fn()) + }; + RegisterSingleton(Services.AbortService, mockAbortService); + service = new ConversationNamingService(); }); @@ -120,13 +127,11 @@ describe('ConversationNamingService', () => { describe('requestName', () => { let conversation: Conversation; - let abortController: AbortController; let onNameChanged: any; beforeEach(() => { conversation = new Conversation(); conversation.title = 'Old Title'; - abortController = new AbortController(); onNameChanged = vi.fn(); RegisterSingleton(Services.IConversationNamingService, mockNamingProvider); @@ -137,9 +142,9 @@ describe('ConversationNamingService', () => { mockNamingProvider.generateName.mockResolvedValue('New Conversation Title'); mockVaultService.exists.mockReturnValue(false); - await service.requestName(conversation, 'User prompt', onNameChanged, abortController); + await service.requestName(conversation, 'User prompt', onNameChanged); - expect(mockNamingProvider.generateName).toHaveBeenCalledWith('User prompt', abortController.signal); + expect(mockNamingProvider.generateName).toHaveBeenCalledWith('User prompt'); expect(mockConversationService.updateConversationTitle).toHaveBeenCalledWith( 'conversations/test.json', 'New Conversation Title' @@ -152,7 +157,7 @@ describe('ConversationNamingService', () => { it('should return early if naming provider is not resolved', async () => { (service as any).namingProvider = undefined; - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(mockNamingProvider.generateName).not.toHaveBeenCalled(); expect(onNameChanged).not.toHaveBeenCalled(); @@ -161,7 +166,7 @@ describe('ConversationNamingService', () => { it('should return early if no conversation path exists', async () => { mockConversationService.getCurrentConversationPath.mockReturnValue(null); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(mockNamingProvider.generateName).not.toHaveBeenCalled(); expect(onNameChanged).not.toHaveBeenCalled(); @@ -171,7 +176,7 @@ describe('ConversationNamingService', () => { mockNamingProvider.generateName.mockResolvedValue('"Quoted Name"'); mockVaultService.exists.mockReturnValue(false); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(conversation.title).toBe('Quoted Name'); // Quotes removed }); @@ -181,7 +186,7 @@ describe('ConversationNamingService', () => { .mockReturnValueOnce('conversations/original.json') .mockReturnValueOnce('conversations/different.json'); // Changed! - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(mockNamingProvider.generateName).toHaveBeenCalled(); // Should not update or save because path changed @@ -197,7 +202,7 @@ describe('ConversationNamingService', () => { const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); // Should not throw, but will log the error (behavior changed with new error handling) expect(exceptionSpy).toHaveBeenCalledWith(abortError); @@ -212,7 +217,7 @@ describe('ConversationNamingService', () => { const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {}); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(exceptionSpy).toHaveBeenCalledWith(error); expect(onNameChanged).not.toHaveBeenCalled(); @@ -225,7 +230,7 @@ describe('ConversationNamingService', () => { mockVaultService.exists.mockReturnValue(false); // Pass undefined for callback - await service.requestName(conversation, 'Test', undefined, abortController); + await service.requestName(conversation, 'Test', undefined); expect(conversation.title).toBe('New Title'); expect(mockConversationService.saveConversation).toHaveBeenCalled(); @@ -238,7 +243,7 @@ describe('ConversationNamingService', () => { return path === `${Path.Conversations}/Popular Name.json`; }); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(conversation.title).toBe('Popular Name(1)'); expect(mockConversationService.updateConversationTitle).toHaveBeenCalledWith( @@ -251,7 +256,7 @@ describe('ConversationNamingService', () => { mockNamingProvider.generateName.mockResolvedValue('One Two Three Four Five Six Seven Eight Nine'); mockVaultService.exists.mockReturnValue(false); - await service.requestName(conversation, 'Test', onNameChanged, abortController); + await service.requestName(conversation, 'Test', onNameChanged); expect(conversation.title).toBe('One Two Three Four Five Six'); }); diff --git a/__tests__/Services/DiffService.test.ts b/__tests__/Services/DiffService.test.ts new file mode 100644 index 0000000..ee45d9c --- /dev/null +++ b/__tests__/Services/DiffService.test.ts @@ -0,0 +1,827 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DiffService } from '../../Services/DiffService'; +import { AbortService } from '../../Services/AbortService'; +import { DeregisterAllServices, RegisterSingleton } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { Event } from '../../Enums/Event'; +import type VaultkeeperAIPlugin from '../../main'; +import type { EventService } from '../../Services/EventService'; + +describe('DiffService', () => { + let service: DiffService; + let mockPlugin: any; + let mockEventService: any; + let abortService: AbortService; + let mockEventListeners: Map; + + beforeEach(() => { + DeregisterAllServices(); + + mockEventListeners = new Map(); + + mockPlugin = { + activateDiffView: vi.fn().mockResolvedValue(undefined) + }; + + mockEventService = { + trigger: vi.fn((event: Event, data?: unknown) => { + const listeners = mockEventListeners.get(event) || []; + listeners.forEach(listener => listener(data)); + }), + on: vi.fn((event: Event, callback: Function) => { + if (!mockEventListeners.has(event)) { + mockEventListeners.set(event, []); + } + mockEventListeners.get(event)!.push(callback); + return { listener: callback }; + }), + off: vi.fn() + }; + + abortService = new AbortService(); + + RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin); + RegisterSingleton(Services.EventService, mockEventService); + RegisterSingleton(Services.AbortService, abortService); + + service = new DiffService(); + }); + + afterEach(() => { + DeregisterAllServices(); + vi.restoreAllMocks(); + }); + + describe('Constructor & Initialization', () => { + it('should initialize with all required dependencies', () => { + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(DiffService); + }); + + it('should register DiffClosed event listener', () => { + expect(mockEventService.on).toHaveBeenCalledWith(Event.DiffClosed, expect.any(Function)); + }); + + it('should initialize with ongoingDiff as false', () => { + expect((service as any).ongoingDiff).toBe(false); + }); + + it('should initialize with no diffResolve callback', () => { + expect((service as any).diffResolve).toBeUndefined(); + }); + }); + + describe('requestDiff - Promise Resolution Paths', () => { + it('should create diff string and activate diff view', async () => { + const diffPromise = service.requestDiff('old.md', 'new.md', 'old content', 'new content'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockPlugin.activateDiffView).toHaveBeenCalledWith( + expect.stringContaining('old.md'), + expect.objectContaining({ + outputFormat: 'line-by-line', + highlight: true + }) + ); + + service.onAccept(); + await diffPromise; + }); + + it('should trigger DiffOpened event', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'a', 'b'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffOpened); + + service.onAccept(); + await diffPromise; + }); + + it('should set ongoingDiff to true', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'a', 'b'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).ongoingDiff).toBe(true); + + service.onAccept(); + await diffPromise; + }); + + it('should resolve with accepted:true on onAccept()', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: true }); + }); + + it('should resolve with accepted:false on onReject()', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should resolve with suggestion on onSuggest()', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('Please add comments'); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: 'Please add comments' }); + }); + + it('should reject if abort signal already aborted', async () => { + abortService.abort('Already aborted'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('should reject when aborted during pending diff', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + abortService.abort('Aborted during diff'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('should clean up abort listener on accept', async () => { + const signal = abortService.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + await diffPromise; + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should clean up abort listener on reject', async () => { + const signal = abortService.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + await diffPromise; + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should clean up abort listener on suggest', async () => { + const signal = abortService.signal(); + const removeListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('suggestion'); + await diffPromise; + + expect(removeListenerSpy).toHaveBeenCalled(); + }); + + it('should handle concurrent requestDiff calls', async () => { + const diff1Promise = service.requestDiff('a.md', 'b.md', 'old1', 'new1'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Start second diff (may override first) + const diff2Promise = service.requestDiff('c.md', 'd.md', 'old2', 'new2'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + // Second diff should resolve + const result2 = await diff2Promise; + expect(result2).toEqual({ accepted: true }); + + // First diff callback was overridden, so it won't resolve the same way + // This documents current behavior + }); + }); + + describe('onAccept()', () => { + it('should call diffResolve with accepted:true', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: true }); + }); + + it('should call finishDiff()', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should be safe to call when no pending diff', () => { + expect(() => service.onAccept()).not.toThrow(); + }); + + it('should be safe to call multiple times', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + await diffPromise; + + expect(() => service.onAccept()).not.toThrow(); + }); + + it('should set ongoingDiff to false', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).ongoingDiff).toBe(true); + + service.onAccept(); + await diffPromise; + + expect((service as any).ongoingDiff).toBe(false); + }); + }); + + describe('onReject()', () => { + it('should call diffResolve with accepted:false', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should call finishDiff()', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should be safe to call when no pending diff', () => { + expect(() => service.onReject()).not.toThrow(); + }); + + it('should be safe to call multiple times', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + await diffPromise; + + expect(() => service.onReject()).not.toThrow(); + }); + + it('should clear diffResolve callback', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).diffResolve).toBeDefined(); + + service.onReject(); + await diffPromise; + + expect((service as any).diffResolve).toBeUndefined(); + }); + }); + + describe('onSuggest()', () => { + it('should call diffResolve with suggestion', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('Add error handling'); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: 'Add error handling' }); + }); + + it('should call finishDiff()', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('text'); + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should handle empty suggestion string', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest(''); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: '' }); + }); + + it('should handle very long suggestion', async () => { + const longSuggestion = 'X'.repeat(10000); + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest(longSuggestion); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false, suggestion: longSuggestion }); + }); + + it('should be safe when no pending diff', () => { + expect(() => service.onSuggest('test')).not.toThrow(); + }); + + it('should handle special characters in suggestion', async () => { + const specialSuggestion = 'Line 1\nLine 2\t"quoted"'; + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest(specialSuggestion); + + const result = await diffPromise; + expect(result.suggestion).toBe(specialSuggestion); + }); + }); + + describe('cancelPendingDiff - Event Handling', () => { + it('should resolve with accepted:false when diff is ongoing', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Trigger DiffClosed event + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should call finishDiff() when ongoing', async () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + await diffPromise; + + expect(finishDiffSpy).toHaveBeenCalled(); + }); + + it('should do nothing when no ongoing diff', () => { + const finishDiffSpy = vi.spyOn(service as any, 'finishDiff'); + + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + expect(finishDiffSpy).not.toHaveBeenCalled(); + }); + + it('should be called via registered event listener', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockEventListeners.has(Event.DiffClosed)).toBe(true); + + mockEventService.trigger(Event.DiffClosed); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + + it('should handle race with onAccept', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + // Try to trigger cancel after accept + const listeners = mockEventListeners.get(Event.DiffClosed) || []; + listeners.forEach(listener => listener()); + + const result = await diffPromise; + expect(result).toEqual({ accepted: true }); + }); + + it('should handle multiple DiffClosed events', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + mockEventService.trigger(Event.DiffClosed); + mockEventService.trigger(Event.DiffClosed); + + const result = await diffPromise; + expect(result).toEqual({ accepted: false }); + }); + }); + + describe('createDiffString - Private Method', () => { + const getCreateDiffString = () => (service as any).createDiffString.bind(service); + + it('should return a string', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', 'old', 'new'); + + expect(typeof result).toBe('string'); + }); + + it('should handle identical content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('file.md', 'file.md', 'same', 'same'); + + expect(typeof result).toBe('string'); + }); + + it('should handle empty old content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', '', 'new content'); + + expect(result).toContain('new content'); + }); + + it('should handle empty new content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', 'old content', ''); + + expect(result).toContain('old content'); + }); + + it('should handle both empty', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', '', ''); + + expect(typeof result).toBe('string'); + }); + + it('should handle multiline content', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('a.md', 'b.md', 'line1\nline2\nline3', 'line1\nMODIFIED\nline3'); + + expect(result).toContain('MODIFIED'); + }); + + it('should handle special characters in content', () => { + const createDiffString = getCreateDiffString(); + const specialContent = 'Content with "quotes" and \\backslashes\\ and \u2713 unicode'; + + expect(() => createDiffString('a.md', 'b.md', specialContent, 'new')).not.toThrow(); + }); + + it('should create valid diff format', () => { + const createDiffString = getCreateDiffString(); + const result = createDiffString('old.md', 'new.md', 'old content', 'new content'); + + expect(result).toContain('---'); + expect(result).toContain('+++'); + expect(result).toContain('@@'); + }); + }); + + describe('finishDiff - Private Method', () => { + const getFinishDiff = () => (service as any).finishDiff.bind(service); + + it('should set ongoingDiff to false', () => { + (service as any).ongoingDiff = true; + + const finishDiff = getFinishDiff(); + finishDiff(); + + expect((service as any).ongoingDiff).toBe(false); + }); + + it('should clear diffResolve callback', () => { + (service as any).diffResolve = vi.fn(); + + const finishDiff = getFinishDiff(); + finishDiff(); + + expect((service as any).diffResolve).toBeUndefined(); + }); + + it('should trigger DiffClosed event', () => { + const finishDiff = getFinishDiff(); + finishDiff(); + + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffClosed); + }); + + it('should be safe to call multiple times', () => { + const finishDiff = getFinishDiff(); + + expect(() => { + finishDiff(); + finishDiff(); + finishDiff(); + }).not.toThrow(); + }); + + it('should complete all cleanup operations in order', () => { + (service as any).ongoingDiff = true; + (service as any).diffResolve = vi.fn(); + + const finishDiff = getFinishDiff(); + finishDiff(); + + expect((service as any).ongoingDiff).toBe(false); + expect((service as any).diffResolve).toBeUndefined(); + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffClosed); + }); + }); + + describe('Diff Configuration', () => { + it('should create config with correct output format', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.outputFormat).toBe('line-by-line'); + + service.onAccept(); + await diffPromise; + }); + + it('should enable highlighting in config', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.highlight).toBe(true); + + service.onAccept(); + await diffPromise; + }); + + it('should set matching to words', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.matching).toBe('words'); + + service.onAccept(); + await diffPromise; + }); + + it('should use auto color scheme', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + const configArg = mockPlugin.activateDiffView.mock.calls[0][1]; + expect(configArg.colorScheme).toBe('auto'); + + service.onAccept(); + await diffPromise; + }); + }); + + describe('Integration & State Management', () => { + it('full lifecycle - request, accept', async () => { + expect((service as any).ongoingDiff).toBe(false); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect((service as any).ongoingDiff).toBe(true); + expect(mockEventService.trigger).toHaveBeenCalledWith(Event.DiffOpened); + + service.onAccept(); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: true }); + expect((service as any).ongoingDiff).toBe(false); + expect((service as any).diffResolve).toBeUndefined(); + }); + + it('full lifecycle - request, reject', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: false }); + expect((service as any).ongoingDiff).toBe(false); + }); + + it('full lifecycle - request, suggest', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onSuggest('Add more tests'); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: false, suggestion: 'Add more tests' }); + expect((service as any).ongoingDiff).toBe(false); + }); + + it('full lifecycle - request, abort', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + abortService.abort('User cancelled'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('full lifecycle - request, cancel via event', async () => { + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + mockEventService.trigger(Event.DiffClosed); + + const result = await diffPromise; + + expect(result).toEqual({ accepted: false }); + expect((service as any).ongoingDiff).toBe(false); + }); + + it('sequential diffs - accept first, start second', async () => { + const diff1Promise = service.requestDiff('a.md', 'b.md', 'old1', 'new1'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onAccept(); + + const result1 = await diff1Promise; + expect(result1).toEqual({ accepted: true }); + + // Start second diff + const diff2Promise = service.requestDiff('c.md', 'd.md', 'old2', 'new2'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + service.onReject(); + + const result2 = await diff2Promise; + expect(result2).toEqual({ accepted: false }); + }); + + it('should clean up Component event listeners on unload', () => { + // DiffService extends Component which handles cleanup + // Verify service was created with event registration + expect(mockEventService.on).toHaveBeenCalledWith(Event.DiffClosed, expect.any(Function)); + }); + + it('state consistency after various operation sequences', async () => { + // Request + const diff1 = service.requestDiff('a', 'b', 'old', 'new'); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Cancel + mockEventService.trigger(Event.DiffClosed); + await diff1; + + expect((service as any).ongoingDiff).toBe(false); + + // Request again + const diff2 = service.requestDiff('c', 'd', 'old2', 'new2'); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Accept + service.onAccept(); + await diff2; + + expect((service as any).ongoingDiff).toBe(false); + expect((service as any).diffResolve).toBeUndefined(); + }); + }); + + describe('Error Handling & Edge Cases', () => { + it('should handle activateDiffView rejection', async () => { + mockPlugin.activateDiffView.mockRejectedValue(new Error('View error')); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Promise creation succeeds, error happens in background + expect((service as any).ongoingDiff).toBe(true); + + service.onAccept(); + await diffPromise; + }); + + it('should handle very large diff content', () => { + const largeContent = 'X'.repeat(100000); + + expect(() => { + service.requestDiff('a.md', 'b.md', largeContent, 'Y'.repeat(100000)); + }).not.toThrow(); + }); + + it('should handle diff request with same filenames', async () => { + const diffPromise = service.requestDiff('same.md', 'same.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockPlugin.activateDiffView).toHaveBeenCalled(); + + service.onAccept(); + await diffPromise; + }); + + it('should handle abort during diff view activation', async () => { + mockPlugin.activateDiffView.mockImplementation(() => + new Promise(resolve => setTimeout(resolve, 100)) + ); + + const diffPromise = service.requestDiff('a.md', 'b.md', 'old', 'new'); + + await new Promise(resolve => setTimeout(resolve, 10)); + + abortService.abort('Abort during activation'); + + await expect(diffPromise).rejects.toThrow(); + }); + + it('should handle null content by throwing error', () => { + const createDiffString = (service as any).createDiffString.bind(service); + + // The underlying diff library does not handle null/undefined - it throws + expect(() => { + createDiffString('a.md', 'b.md', null, undefined); + }).toThrow(); + }); + + it('should handle missing dependencies error', () => { + DeregisterAllServices(); + + // Attempting to create service without dependencies should fail + expect(() => new DiffService()).toThrow(); + }); + }); +}); diff --git a/__tests__/Services/FileSystemService.test.ts b/__tests__/Services/FileSystemService.test.ts index fe3404a..f1f86df 100644 --- a/__tests__/Services/FileSystemService.test.ts +++ b/__tests__/Services/FileSystemService.test.ts @@ -134,7 +134,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.writeFile('new.md', 'content'); expect(result).toBe(mockFile); - expect(mockVaultService.create).toHaveBeenCalledWith('new.md', 'content', false); + expect(mockVaultService.create).toHaveBeenCalledWith('new.md', 'content', false, true); expect(mockVaultService.modify).not.toHaveBeenCalled(); }); @@ -147,7 +147,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.writeFile('existing.md', 'new content'); expect(result).toBe(mockFile); - expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, 'new content', false); + expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, 'new content', false, true); expect(mockVaultService.create).not.toHaveBeenCalled(); }); @@ -158,14 +158,14 @@ describe('FileSystemService', () => { await fileSystemService.writeFile('plugin/data.json', 'content', true); expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/data.json', true); - expect(mockVaultService.create).toHaveBeenCalledWith('plugin/data.json', 'content', true); + expect(mockVaultService.create).toHaveBeenCalledWith('plugin/data.json', 'content', true, true); }); it('should return error object when create fails', async () => { const error = new Error('Create failed'); mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null); - mockVaultService.create = vi.fn().mockRejectedValue(error); + mockVaultService.create = vi.fn().mockResolvedValue(error); const result = await fileSystemService.writeFile('error.md', 'content'); @@ -178,7 +178,7 @@ describe('FileSystemService', () => { const error = new Error('Modify failed'); mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile); - mockVaultService.modify = vi.fn().mockRejectedValue(error); + mockVaultService.modify = vi.fn().mockResolvedValue(error); const result = await fileSystemService.writeFile('existing.md', 'content'); @@ -197,7 +197,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.deleteFile('delete-me.md'); expect(result).toBeUndefined(); - expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, false); + expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, false, true); }); it('should return error when file does not exist', async () => { @@ -219,7 +219,7 @@ describe('FileSystemService', () => { await fileSystemService.deleteFile('plugin/temp.json', true); expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/temp.json', true); - expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, true); + expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, true, true); }); it('should delete folder successfully (supports both files and folders)', async () => { @@ -231,7 +231,7 @@ describe('FileSystemService', () => { const result = await fileSystemService.deleteFile('folder'); expect(result).toBeUndefined(); - expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false); + expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false, true); }); }); diff --git a/__tests__/Services/StreamingService.test.ts b/__tests__/Services/StreamingService.test.ts index a6911a7..fd0cc05 100644 --- a/__tests__/Services/StreamingService.test.ts +++ b/__tests__/Services/StreamingService.test.ts @@ -1,21 +1,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { StreamingService, IStreamChunk } from '../../Services/StreamingService'; -import { Selector } from '../../Enums/Selector'; import { Exception } from '../../Helpers/Exception'; +import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; +import { Services } from '../../Services/Services'; +import { AbortService } from '../../Services/AbortService'; /** * UNIT TESTS * - * StreamingService has no dependencies, so we use pure unit tests. - * We mock the global fetch API to test streaming behavior. + * StreamingService now depends on AbortService, so we mock that dependency. + * We also mock the global fetch API to test streaming behavior. */ describe('StreamingService', () => { let service: StreamingService; let mockFetch: any; let originalFetch: any; + let abortService: AbortService; beforeEach(() => { + DeregisterAllServices(); + abortService = new AbortService(); + RegisterSingleton(Services.AbortService, abortService); service = new StreamingService(); originalFetch = global.fetch; mockFetch = vi.fn(); @@ -130,7 +136,6 @@ describe('StreamingService', () => { 'https://api.example.com/stream', { prompt: 'test' }, simpleParser, - undefined, { 'Authorization': 'Bearer token123', 'X-Custom': 'value' } )) { // Just consume the stream @@ -436,13 +441,10 @@ describe('StreamingService', () => { body: createMockStream(['data: {"content":"test","done":true}\n']) }); - const abortController = new AbortController(); - for await (const chunk of service.streamRequest( 'https://api.example.com/stream', {}, - simpleParser, - abortController.signal + simpleParser )) { // Just consume the stream } @@ -450,31 +452,28 @@ describe('StreamingService', () => { expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - signal: abortController.signal + signal: abortService.signal() }) ); }); it('should handle abort error gracefully', async () => { - const abortError = new Error('The operation was aborted'); - abortError.name = 'AbortError'; + const abortError = new DOMException('The operation was aborted', 'AbortError'); mockFetch.mockRejectedValue(abortError); const results: IStreamChunk[] = []; - for await (const chunk of service.streamRequest( - 'https://api.example.com/stream', - {}, - simpleParser, - new AbortController().signal - )) { - results.push(chunk); + try { + for await (const chunk of service.streamRequest( + 'https://api.example.com/stream', + {}, + simpleParser + )) { + results.push(chunk); + } + } catch (error) { + // Abort errors are now thrown instead of yielded as chunks + expect(AbortService.isAbortError(error)).toBe(true); } - - expect(results).toHaveLength(1); - expect(results[0]).toEqual({ - content: Selector.ApiRequestAborted, - isComplete: true - }); }); }); diff --git a/__tests__/Services/VaultCacheService.performance.test.ts b/__tests__/Services/VaultCacheService.performance.test.ts index a502032..6255954 100644 --- a/__tests__/Services/VaultCacheService.performance.test.ts +++ b/__tests__/Services/VaultCacheService.performance.test.ts @@ -14,6 +14,24 @@ vi.mock('obsidian', async () => { const actual = await vi.importActual('obsidian'); return { ...actual, + Component: class Component { + public load() {} + public onload() {} + public unload() {} + public onunload() {} + public addChild(component: T): T { + return component; + } + public removeChild(component: T): T { + return component; + } + public register(_cb: () => any): void {} + public registerEvent(_eventRef: any): void {} + public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {} + public registerInterval(id: number): number { + return id; + } + }, getAllTags: vi.fn((metadata: any) => { if (!metadata || !metadata.tags) return null; return metadata.tags.map((t: any) => t.tag); diff --git a/__tests__/Services/VaultCacheService.test.ts b/__tests__/Services/VaultCacheService.test.ts index aaa896c..18f73fc 100644 --- a/__tests__/Services/VaultCacheService.test.ts +++ b/__tests__/Services/VaultCacheService.test.ts @@ -11,6 +11,24 @@ vi.mock('obsidian', async () => { const actual = await vi.importActual('obsidian'); return { ...actual, + Component: class Component { + public load() {} + public onload() {} + public unload() {} + public onunload() {} + public addChild(component: T): T { + return component; + } + public removeChild(component: T): T { + return component; + } + public register(_cb: () => any): void {} + public registerEvent(_eventRef: any): void {} + public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {} + public registerInterval(id: number): number { + return id; + } + }, getAllTags: vi.fn((metadata: any) => { if (!metadata || !metadata.tags) return null; return metadata.tags.map((t: any) => t.tag); diff --git a/__tests__/setup.ts b/__tests__/setup.ts index af42f44..020d629 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -9,6 +9,32 @@ if (typeof global.window === 'undefined') { global.window = {} as any; } +// Mock Obsidian's Component class +vi.mock('obsidian', async () => { + const actual = await vi.importActual('obsidian'); + return { + ...actual, + Component: class Component { + public load() {} + public onload() {} + public unload() {} + public onunload() {} + public addChild(component: T): T { + return component; + } + public removeChild(component: T): T { + return component; + } + public register(_cb: () => any): void {} + public registerEvent(_eventRef: any): void {} + public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {} + public registerInterval(id: number): number { + return id; + } + } + }; +}); + // Add Obsidian's .empty() method to HTMLElement prototype for testing if (typeof HTMLElement !== 'undefined') { HTMLElement.prototype.empty = function() { diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 00ea1aa..77bcac2 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,13 +1,13 @@ import esbuild from "esbuild"; import process from "process"; import builtins from "builtin-modules"; -import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, unlinkSync } from "fs"; +import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, unlinkSync, watch as fsWatch } from "fs"; import { join } from "path"; import esbuildSvelte from "esbuild-svelte"; import { sveltePreprocess } from "svelte-preprocess"; const banner = -`/* + `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ @@ -84,71 +84,84 @@ const cssMergerPlugin = { } // Remove KaTeX font files - let anyRemoved = false; + let anyRemoved = false; const fontExtensions = [".woff", ".woff2", ".ttf"]; const files = readdirSync("."); for (const file of files) { const ext = file.substring(file.lastIndexOf(".")); if (fontExtensions.includes(ext)) { unlinkSync(file); - anyRemoved = true; + anyRemoved = true; } } - if (anyRemoved) { - console.log("🗑️ Removed KaTeX Font Files"); - } + if (anyRemoved) { + console.log("🗑️ Removed KaTeX Font Files"); + } } }); } }; const buildOptions = { - plugins: [ - esbuildSvelte({ - compilerOptions: { css: "injected" }, - preprocess: sveltePreprocess(), - }), - cssMergerPlugin, - ], - banner: { - js: banner, - }, - entryPoints: ["main.ts"], - bundle: true, - external: [ - "obsidian", - "electron", - "@codemirror/autocomplete", - "@codemirror/collab", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", - "@lezer/lr", - ...builtins], - format: "cjs", - target: "es2018", - logLevel: "info", - sourcemap: prod ? false : "inline", - treeShaking: true, - outfile: "main.js", - minify: prod, - loader: { - ".css": "css", - ".ttf": "file", - ".woff": "file", - ".woff2": "file", - }, + plugins: [ + esbuildSvelte({ + compilerOptions: { css: "injected" }, + preprocess: sveltePreprocess(), + }), + cssMergerPlugin, + ], + banner: { + js: banner, + }, + entryPoints: ["main.ts"], + bundle: true, + define: { + "process.env.NODE_ENV": JSON.stringify(prod ? "production" : "development"), + }, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, + loader: { + ".css": "css", + ".ttf": "file", + ".woff": "file", + ".woff2": "file", + }, }; if (prod) { - await esbuild.build(buildOptions); - console.log("✅ Production build complete!"); + await esbuild.build(buildOptions); + console.log("✅ Production build complete!"); } else { - const ctx = await esbuild.context(buildOptions); - await ctx.watch(); + const ctx = await esbuild.context(buildOptions); + await ctx.watch(); + + // Watch Styles directory for CSS changes + if (existsSync("Styles")) { + fsWatch("Styles", { recursive: true }, (_eventType, filename) => { + if (filename && filename.endsWith(".css")) { + console.log(`🔄 CSS file changed: ${filename} - Rebuilding...`); + ctx.rebuild(); + } + }); + } } \ No newline at end of file diff --git a/main.ts b/main.ts index 6497202..58e8809 100644 --- a/main.ts +++ b/main.ts @@ -14,7 +14,6 @@ import type { Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui"; import "katex/dist/katex.min.css"; import 'highlight.js/styles/monokai.min.css'; import 'diff2html/bundles/css/diff2html.min.css'; -import 'diff2html/bundles/js/diff2html-ui.min.js'; export default class VaultkeeperAIPlugin extends Plugin { diff --git a/package-lock.json b/package-lock.json index e0836bc..e0ec5ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.70.1", + "@anthropic-ai/sdk": "^0.71.0", "@google/genai": "^1.30.0", - "@shikijs/rehype": "^3.15.0", + "@shikijs/rehype": "^3.17.0", "core-js": "^3.47.0", "diff": "^8.0.2", "diff2html": "^3.4.52", @@ -35,7 +35,7 @@ "remark-wiki-link": "^2.0.1", "unified": "^11.0.5", "uuid": "^13.0.0", - "zod": "^4.1.12" + "zod": "^4.1.13" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -43,29 +43,29 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "@types/path-browserify": "^1.0.3", - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@vitest/ui": "^4.0.13", + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@vitest/ui": "^4.0.14", "builtin-modules": "5.0.0", "esbuild": "^0.27.0", "esbuild-svelte": "^0.9.3", "eslint": "^9.39.1", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-obsidianmd": "^0.1.9", - "happy-dom": "^20.0.10", + "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.43.14", + "svelte": "^5.45.2", "svelte-check": "^4.3.4", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", "typescript": "^5.9.3", - "vitest": "^4.0.13" + "vitest": "^4.0.14" } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.70.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.70.1.tgz", - "integrity": "sha512-AGEhifuvE22VxfQ5ROxViTgM8NuVQzEvqcN8bttR4AP24ythmNE/cL/SrOz79xiv7/osrsmCyErjsistJi7Z8A==", + "version": "0.71.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.0.tgz", + "integrity": "sha512-go1XeWXmpxuiTkosSXpb8tokLk2ZLkIRcXpbWVwJM6gH5OBtHOVsfPfGuqI1oW7RRt4qc59EmYbrXRZ0Ng06Jw==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -986,44 +986,6 @@ "ret": "~0.1.10" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1370,74 +1332,74 @@ "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.15.0.tgz", - "integrity": "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.17.0.tgz", + "integrity": "sha512-/HjeOnbc62C+n33QFNFrAhUlIADKwfuoS50Ht0pxujxP4QjZAlFp5Q+OkDo531SCTzivx5T18khwyBdKoPdkuw==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.15.0.tgz", - "integrity": "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.17.0.tgz", + "integrity": "sha512-WwF99xdP8KfuDrIbT4wxyypfhoIxMeeOCp1AiuvzzZ6JT5B3vIuoclL8xOuuydA6LBeeNXUF/XV5zlwwex1jlA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.3" + "oniguruma-to-es": "^4.3.4" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz", - "integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.17.0.tgz", + "integrity": "sha512-flSbHZAiOZDNTrEbULY8DLWavu/TyVu/E7RChpLB4WvKX4iHMfj80C6Hi3TjIWaQtHOW0KC6kzMcuB5TO1hZ8Q==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz", - "integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.17.0.tgz", + "integrity": "sha512-icmur2n5Ojb+HAiQu6NEcIIJ8oWDFGGEpiqSCe43539Sabpx7Y829WR3QuUW2zjTM4l6V8Sazgb3rrHO2orEAw==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0" + "@shikijs/types": "3.17.0" } }, "node_modules/@shikijs/rehype": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-3.15.0.tgz", - "integrity": "sha512-U+tqD1oxL+85N8FaW5XYIlMZ8KAa2g9IdplEZxPWflGRJf2gQRiBMMrpdG1USz3PN350YnMUHWcz9Twt3wJjXQ==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-3.17.0.tgz", + "integrity": "sha512-977PWiZHaJf1ez+Q3ViCjDVNEch+XKDaQbEgsku8p9WfuncbajX1xMGzBax0Az4ATXPQF3p8wpNMPtlRksSbMg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0", + "@shikijs/types": "3.17.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", - "shiki": "3.15.0", + "shiki": "3.17.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" } }, "node_modules/@shikijs/themes": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz", - "integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.17.0.tgz", + "integrity": "sha512-/xEizMHLBmMHwtx4JuOkRf3zwhWD2bmG5BRr0IPjpcWpaq4C3mYEuTk/USAEglN0qPrTwEHwKVpSu/y2jhferA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.15.0" + "@shikijs/types": "3.17.0" } }, "node_modules/@shikijs/types": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz", - "integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.17.0.tgz", + "integrity": "sha512-wjLVfutYWVUnxAjsWEob98xgyaGv0dTEnMZDruU5mRjVN7szcGOfgO+997W2yR6odp+1PtSBNeSITRRTfUzK/g==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -1779,17 +1741,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", - "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/type-utils": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1803,22 +1765,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.47.0", + "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", - "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "engines": { @@ -1834,14 +1796,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", - "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.47.0", - "@typescript-eslint/types": "^8.47.0", + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "engines": { @@ -1856,14 +1818,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1874,9 +1836,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", - "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", "dev": true, "license": "MIT", "engines": { @@ -1891,15 +1853,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", - "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1916,9 +1878,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", "dev": true, "license": "MIT", "engines": { @@ -1930,21 +1892,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", - "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.47.0", - "@typescript-eslint/tsconfig-utils": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -1959,16 +1920,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", - "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0" + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1983,13 +1944,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2020,16 +1981,16 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.13.tgz", - "integrity": "sha512-zYtcnNIBm6yS7Gpr7nFTmq8ncowlMdOJkWLqYvhr/zweY6tFbDkDi8BPPOeHxEtK1rSI69H7Fd4+1sqvEGli6w==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.14.tgz", + "integrity": "sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.13", - "@vitest/utils": "4.0.13", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -2038,13 +1999,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.13.tgz", - "integrity": "sha512-eNCwzrI5djoauklwP1fuslHBjrbR8rqIVbvNlAnkq1OTa6XT+lX68mrtPirNM9TnR69XUPt4puBCx2Wexseylg==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.14.tgz", + "integrity": "sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.13", + "@vitest/spy": "4.0.14", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2065,9 +2026,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.13.tgz", - "integrity": "sha512-ooqfze8URWbI2ozOeLDMh8YZxWDpGXoeY3VOgcDnsUxN0jPyPWSUvjPQWqDGCBks+opWlN1E4oP1UYl3C/2EQA==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.14.tgz", + "integrity": "sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2078,13 +2039,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.13.tgz", - "integrity": "sha512-9IKlAru58wcVaWy7hz6qWPb2QzJTKt+IOVKjAx5vb5rzEFPTL6H4/R9BMvjZ2ppkxKgTrFONEJFtzvnyEpiT+A==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.14.tgz", + "integrity": "sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.13", + "@vitest/utils": "4.0.14", "pathe": "^2.0.3" }, "funding": { @@ -2092,13 +2053,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.13.tgz", - "integrity": "sha512-hb7Usvyika1huG6G6l191qu1urNPsq1iFc2hmdzQY3F5/rTgqQnwwplyf8zoYHkpt7H6rw5UfIw6i/3qf9oSxQ==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.14.tgz", + "integrity": "sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.13", + "@vitest/pretty-format": "4.0.14", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2107,9 +2068,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.13.tgz", - "integrity": "sha512-hSu+m4se0lDV5yVIcNWqjuncrmBgwaXa2utFLIrBkQCQkt+pSwyZTPFQAZiiF/63j8jYa8uAeUZ3RSfcdWaYWw==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.14.tgz", + "integrity": "sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==", "dev": true, "license": "MIT", "funding": { @@ -2117,13 +2078,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.13.tgz", - "integrity": "sha512-MFV6GhTflgBj194+vowTB2iLI5niMZhqiW7/NV7U4AfWbX/IAtsq4zA+gzCLyGzpsQUdJlX26hrQ1vuWShq2BQ==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.14.tgz", + "integrity": "sha512-fvDz8o7SQpFLoSBo6Cudv+fE85/fPCkwTnLAN85M+Jv7k59w2mSIjT9Q5px7XwGrmYqqKBEYxh/09IBGd1E7AQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.13", + "@vitest/utils": "4.0.14", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", @@ -2135,17 +2096,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.13" + "vitest": "4.0.14" } }, "node_modules/@vitest/utils": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.13.tgz", - "integrity": "sha512-ydozWyQ4LZuu8rLp47xFUWis5VOKMdHjXCWhs1LuJsTNKww+pTHQNK4e0assIB9K80TxFyskENL6vCu3j34EYA==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.14.tgz", + "integrity": "sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.13", + "@vitest/pretty-format": "4.0.14", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2513,23 +2474,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -2541,19 +2506,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3023,6 +2975,13 @@ "node": ">=6" } }, + "node_modules/devalue": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz", + "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==", + "dev": true, + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -4160,9 +4119,9 @@ } }, "node_modules/esrap": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz", - "integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.0.tgz", + "integrity": "sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -4286,36 +4245,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4347,14 +4276,22 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fetch-blob": { @@ -4400,19 +4337,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -4834,9 +4758,9 @@ } }, "node_modules/happy-dom": { - "version": "20.0.10", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.10.tgz", - "integrity": "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==", + "version": "20.0.11", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", + "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", "dev": true, "license": "MIT", "dependencies": { @@ -5199,15 +5123,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -5550,16 +5478,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -6320,9 +6238,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -6459,16 +6377,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -7060,20 +6968,6 @@ ], "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -7426,6 +7320,17 @@ "@codemirror/view": "6.38.6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7704,13 +7609,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -7860,27 +7765,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7905,22 +7789,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -8256,17 +8124,6 @@ "node": ">=0.12" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -8340,30 +8197,6 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -8595,17 +8428,17 @@ } }, "node_modules/shiki": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz", - "integrity": "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.17.0.tgz", + "integrity": "sha512-lUZfWsyW7czITYTdo/Tb6ZM4VfyXlzmKYBQBjTz+pBzPPkP08RgIt00Ls1Z50Cl3SfwJsue6WbJeF3UgqLVI9Q==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.15.0", - "@shikijs/engine-javascript": "3.15.0", - "@shikijs/engine-oniguruma": "3.15.0", - "@shikijs/langs": "3.15.0", - "@shikijs/themes": "3.15.0", - "@shikijs/types": "3.15.0", + "@shikijs/core": "3.17.0", + "@shikijs/engine-javascript": "3.17.0", + "@shikijs/engine-oniguruma": "3.17.0", + "@shikijs/langs": "3.17.0", + "@shikijs/themes": "3.17.0", + "@shikijs/types": "3.17.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } @@ -9045,9 +8878,9 @@ } }, "node_modules/svelte": { - "version": "5.43.14", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.14.tgz", - "integrity": "sha512-pHeUrp1A5S6RGaXhJB7PtYjL1VVjbVrJ2EfuAoPu9/1LeoMaJa/pcdCsCSb0gS4eUHAHnhCbUDxORZyvGK6kOQ==", + "version": "5.45.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.2.tgz", + "integrity": "sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==", "dev": true, "license": "MIT", "dependencies": { @@ -9059,8 +8892,9 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.5.0", "esm-env": "^1.2.1", - "esrap": "^2.1.0", + "esrap": "^2.2.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -9094,24 +8928,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-check/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/svelte-preprocess": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz", @@ -9240,37 +9056,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -9281,19 +9066,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -9521,17 +9293,17 @@ } }, "node_modules/typescript-eslint": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz", - "integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0" + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10337,55 +10109,24 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.13.tgz", - "integrity": "sha512-QSD4I0fN6uZQfftryIXuqvqgBxTvJ3ZNkF6RWECd82YGAYAfhcppBLFXzXJHQAAhVFyYEuFTrq6h0hQqjB7jIQ==", + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.14.tgz", + "integrity": "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.13", - "@vitest/mocker": "4.0.13", - "@vitest/pretty-format": "4.0.13", - "@vitest/runner": "4.0.13", - "@vitest/snapshot": "4.0.13", - "@vitest/spy": "4.0.13", - "@vitest/utils": "4.0.13", - "debug": "^4.4.3", + "@vitest/expect": "4.0.14", + "@vitest/mocker": "4.0.14", + "@vitest/pretty-format": "4.0.14", + "@vitest/runner": "4.0.14", + "@vitest/snapshot": "4.0.14", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", @@ -10408,12 +10149,11 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.13", - "@vitest/browser-preview": "4.0.13", - "@vitest/browser-webdriverio": "4.0.13", - "@vitest/ui": "4.0.13", + "@vitest/browser-playwright": "4.0.14", + "@vitest/browser-preview": "4.0.14", + "@vitest/browser-webdriverio": "4.0.14", + "@vitest/ui": "4.0.14", "happy-dom": "*", "jsdom": "*" }, @@ -10424,9 +10164,6 @@ "@opentelemetry/api": { "optional": true }, - "@types/debug": { - "optional": true - }, "@types/node": { "optional": true }, @@ -10450,19 +10187,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10751,9 +10475,9 @@ } }, "node_modules/yaml-eslint-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz", - "integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.1.tgz", + "integrity": "sha512-MdSgP9YA9QjtAO2+lt4O7V2bnH22LPnfeVLiQqjY3cOyn8dy/Ief8otjIe6SPPTK03nM7O3Yl0LTfWuF7l+9yw==", "dev": true, "license": "MIT", "dependencies": { @@ -10788,9 +10512,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 08cd58d..f23f472 100644 --- a/package.json +++ b/package.json @@ -25,28 +25,28 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "@types/path-browserify": "^1.0.3", - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@vitest/ui": "^4.0.13", + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@vitest/ui": "^4.0.14", "builtin-modules": "5.0.0", "esbuild": "^0.27.0", "esbuild-svelte": "^0.9.3", "eslint": "^9.39.1", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-obsidianmd": "^0.1.9", - "happy-dom": "^20.0.10", + "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.43.14", + "svelte": "^5.45.2", "svelte-check": "^4.3.4", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", "typescript": "^5.9.3", - "vitest": "^4.0.13" + "vitest": "^4.0.14" }, "dependencies": { - "@anthropic-ai/sdk": "^0.70.1", + "@anthropic-ai/sdk": "^0.71.0", "@google/genai": "^1.30.0", - "@shikijs/rehype": "^3.15.0", + "@shikijs/rehype": "^3.17.0", "core-js": "^3.47.0", "diff": "^8.0.2", "diff2html": "^3.4.52", @@ -70,6 +70,6 @@ "remark-wiki-link": "^2.0.1", "unified": "^11.0.5", "uuid": "^13.0.0", - "zod": "^4.1.12" + "zod": "^4.1.13" } }