feat: implement centralized abort controller with enhanced cancellation UX

Introduce a new AbortService to centralize cancellation logic across all async operations, replacing scattered AbortSignal parameters with a unified singleton service. This improves maintainability and provides consistent cancellation behavior throughout the application.

Key changes:
- Add AbortService for centralized abort signal management with automatic cleanup
- Refactor all AI providers (Claude, Gemini, OpenAI) to use AbortService instead of passing AbortSignal parameters
- Update streaming operations to use centralized abort handling
- Add CancellationIndicator component to show visual feedback during operation cancellation
- Rename ChatAreaThought to ThoughtIndicator for better semantic clarity
- Add Environment enum for consistent environment detection
- Enhance ChatService lifecycle with proper cancellation state management
- Remove scattered abort-related UI selectors and error messages in favor of dedicated indicator
- Add safeContinue() factory method to ConversationContent for internal continuations
- Update all tests to reflect new abort handling architecture

This change simplifies the API surface by removing AbortSignal parameters from method signatures while improving the user experience with clearer cancellation feedback.
This commit is contained in:
Andrew Beal 2025-12-04 23:04:20 +00:00
parent d93e61bc9d
commit 28772e7d0e
56 changed files with 2616 additions and 1111 deletions

View file

@ -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<IPrompt>(Services.IPrompt);
protected readonly settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
protected readonly streamingService: StreamingService = Resolve<StreamingService>(Services.StreamingService);
protected readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve<AIFunctionDefinitions>(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<IPrompt>(Services.IPrompt);
this.abortService = Resolve<AbortService>(Services.AbortService);
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.streamingService = Resolve<StreamingService>(Services.StreamingService);
this.aiFunctionDefinitions = Resolve<AIFunctionDefinitions>(Services.AIFunctionDefinitions);
this.apiKey = this.settingsService.getApiKeyForProvider(provider);
}

View file

@ -24,7 +24,7 @@ export class Claude extends BaseAIClass {
}
public async* streamRequest(
conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal
conversation: Conversation, allowDestructiveActions: boolean
): AsyncGenerator<IStreamChunk, void, unknown> {
this.accumulatedFunctionName = null;
this.accumulatedFunctionArgs = "";
@ -62,7 +62,6 @@ export class Claude extends BaseAIClass {
AIProviderURL.Claude,
requestBody,
(chunk: string) => this.parseStreamChunk(chunk),
abortSignal,
headers
);
}

View file

@ -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<SettingsService>(Services.SettingsService);
this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Claude);
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string> {
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<string> {
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;
}
}

View file

@ -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<SettingsService>(Services.SettingsService);
@ -18,6 +20,7 @@ export class ClaudeTokenService implements ITokenService {
dangerouslyAllowBrowser: true
});
this.model = settingsService.settings.model;
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async countTokens(input: string): Promise<number> {

View file

@ -22,7 +22,7 @@ export class Gemini extends BaseAIClass {
}
public async* streamRequest(
conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal
conversation: Conversation, allowDestructiveActions: boolean
): AsyncGenerator<IStreamChunk, void, unknown> {
// 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)
);
}

View file

@ -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<SettingsService>(Services.SettingsService);
this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Gemini);
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string> {
public async generateName(userPrompt: string): Promise<string> {
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;
}
}

View file

@ -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<SettingsService>(Services.SettingsService);
@ -16,6 +18,7 @@ export class GeminiTokenService implements ITokenService {
apiKey: settingsService.getApiKeyForProvider(AIProvider.Gemini)
});
this.model = settingsService.settings.model;
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async countTokens(input: string): Promise<number> {

View file

@ -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<IStreamChunk, void, unknown>;
streamRequest(conversation: Conversation, allowDestructiveActions: boolean): AsyncGenerator<IStreamChunk, void, unknown>;
}

View file

@ -1,3 +1,3 @@
export interface IConversationNamingService {
generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string>;
generateName(userPrompt: string): Promise<string>;
}

View file

@ -16,7 +16,7 @@ export class OpenAI extends BaseAIClass {
}
public async* streamRequest(
conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal
conversation: Conversation, allowDestructiveActions: boolean
): AsyncGenerator<IStreamChunk, void, unknown> {
const systemPrompt = await this.buildSystemPrompt();
@ -46,7 +46,6 @@ export class OpenAI extends BaseAIClass {
AIProviderURL.OpenAI,
requestBody,
(chunk: string) => this.parseStreamChunk(chunk),
abortSignal,
headers
);
}

View file

@ -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<SettingsService>(Services.SettingsService);
this.apiKey = settingsService.getApiKeyForProvider(AIProvider.OpenAI);
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string> {
public async generateName(userPrompt: string): Promise<string> {
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;
}
}

View file

@ -0,0 +1,156 @@
<!-- https://codepen.io/aybukeceylan/pen/abLNeox -->
<div class="loading-container">
<div class="loading-text">
<span>C</span>
<span>A</span>
<span>N</span>
<span>C</span>
<span>E</span>
<span>L</span>
<span>L</span>
<span>I</span>
<span>N</span>
<span>G</span>
<span>.</span>
<span>.</span>
<span>.</span>
</div>
</div>
<style>
* {
box-sizing: border-box;
}
.loading-container {
--cancelling-color: var(--text-muted);
display: block;
text-align: center;
position: relative;
color: var(--cancelling-color, currentColor);
}
.loading-container:before {
content: '';
position: absolute;
width: 100%;
height: 1px;
background-color: currentColor;
bottom: 0;
left: 0;
animation: movingLine 3.5s infinite ease-in-out;
}
@keyframes movingLine {
0% {
opacity: 0;
width: 0;
}
23%, 80% {
opacity: 0.8;
width: 100%;
}
92% {
width: 0;
left: initial;
right: 0;
opacity: 1;
}
100% {
opacity: 0;
width: 0;
}
}
.loading-text {
font-size: 1em;
line-height: 1.5;
letter-spacing: 0.05em;
margin-bottom: 0.25em;
padding-bottom: 0.25em;
display: flex;
justify-content: center;
gap: 0.1em;
}
.loading-text span {
animation: moveLetters 3.5s infinite ease-in-out;
transform: translatex(0);
position: relative;
display: inline-block;
opacity: 0;
}
.loading-text span:nth-child(1) {
animation-delay: 0.1s;
}
.loading-text span:nth-child(2) {
animation-delay: 0.2s;
}
.loading-text span:nth-child(3) {
animation-delay: 0.3s;
}
.loading-text span:nth-child(4) {
animation-delay: 0.4s;
}
.loading-text span:nth-child(5) {
animation-delay: 0.5s;
}
.loading-text span:nth-child(6) {
animation-delay: 0.6s;
}
.loading-text span:nth-child(7) {
animation-delay: 0.7s;
}
.loading-text span:nth-child(8) {
animation-delay: 0.8s;
}
.loading-text span:nth-child(9) {
animation-delay: 0.9s;
}
.loading-text span:nth-child(10) {
animation-delay: 1.0s;
}
.loading-text span:nth-child(11) {
animation-delay: 1.1s;
}
.loading-text span:nth-child(12) {
animation-delay: 1.2s;
}
.loading-text span:nth-child(13) {
animation-delay: 1.3s;
}
@keyframes moveLetters {
0% {
transform: translateX(-15vw);
opacity: 0;
}
23%, 80% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(15vw);
opacity: 0;
}
}
</style>

View file

@ -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<StreamingMarkdownService>(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 `<span class="${Selector.ErrorSelector}">${Copy.ApiRequestAborted}</span>`;
}
if (message.errorType) {
return `<div class="${Selector.ErrorSelector}">${message.content}</div>`;
}
@ -204,12 +199,16 @@
{/each}
{#if settled}
<ChatAreaThought bind:thoughtElement thought={currentThought}/>
<ThoughtIndicator thought={currentThought}/>
{#if isSubmitting}
<StreamingIndicator bind:streamingElement editModeActive={editModeActive}/>
<StreamingIndicator editModeActive={editModeActive}/>
{/if}
{/if}
{#if cancelling}
<CancellationIndicator/>
{/if}
<div bind:this={chatAreaPaddingElement}></div>
{#if messages.length === 0}

View file

@ -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 @@
<main class="container">
<div id="chat-container">
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer
<ChatArea messages={conversation.contents} bind:this={chatArea} bind:currentThought bind:isSubmitting bind:chatContainer bind:cancelling
currentStreamingMessageId={currentStreamingMessageId} editModeActive={editModeActive}/>
</div>

View file

@ -1,9 +1,8 @@
<script lang="ts">
export let streamingElement: HTMLElement | undefined;
export let editModeActive: boolean = false;
</script>
<div class="loader" class:edit-mode={editModeActive} bind:this={streamingElement}>
<div class="loader" class:edit-mode={editModeActive}>
<div class="circle">
<div class="dot"></div>
<div class="outline"></div>

View file

@ -1,12 +1,11 @@
<script lang="ts">
import { fade } from "svelte/transition";
export let thought: string | null = null;
export let thoughtElement: HTMLElement | undefined;
$: isVisible = thought !== null && thought.trim().length > 0;
</script>
{#if isVisible}
<div class="ai-thought-container" bind:this={thoughtElement} in:fade={{ duration: 200 }} out:fade={{ duration: 200 }}>
<div class="ai-thought-container" in:fade={{ duration: 200 }} out:fade={{ duration: 200 }}>
<div class="ai-thought-bubble">
<span>{thought}</span>
</div>

View file

@ -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)
);
}
}

View file

@ -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.",

4
Enums/Environment.ts Normal file
View file

@ -0,0 +1,4 @@
export enum Environment {
PROD = "production",
DEV = "development"
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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<FileSystemService>(Services.FileSystemService);
private readonly fileSystemService: FileSystemService;
private readonly abortService: AbortService;
public constructor() {
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async performAIFunction(functionCall: AIFunctionCall): Promise<AIFunctionResponse> {
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<object> {

68
Services/AbortService.ts Normal file
View file

@ -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<T>(executor: () => Promise<T>): Promise<T> {
const signal = this.abortController.signal;
if (signal.aborted) {
this.throw();
}
let abortHandler: () => void;
let abortWon = false;
const executorPromise = executor();
const abortPromise = new Promise<never>((_, 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!);
}
}
}

View file

@ -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<ConversationFileSystemService>(Services.ConversationFileSystemService);
@ -44,6 +46,7 @@ export class ChatService {
this.prompt = Resolve<IPrompt>(Services.IPrompt);
this.statusBarService = Resolve<StatusBarService>(Services.StatusBarService);
this.eventService = Resolve<EventService>(Services.EventService);
this.abortService = Resolve<AbortService>(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);

View file

@ -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<string | Error> {
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;

View file

@ -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<ConversationFileSystemService>(Services.ConversationFileSystemService);
this.vaultService = Resolve<VaultService>(Services.VaultService);
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public resolveNamingProvider() {
this.namingProvider = Resolve<IConversationNamingService>(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<string> {

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
this.eventService = Resolve<EventService>(Services.EventService);
this.abortService = Resolve<AbortService>(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<DiffResult> {
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<DiffResult>((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() {

View file

@ -13,6 +13,10 @@ export class FileSystemService {
this.vaultService = Resolve<VaultService>(Services.VaultService);
}
public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<boolean> {
return await this.vaultService.exists(filePath, allowAccessToPluginRoot);
}
public async readFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<string | Error> {
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<TFile | Error> {
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<TFile | Error> {
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
if (file == null || !(file instanceof TFile)) {
return await this.vaultService.create(filePath, content, allowAccessToPluginRoot, requiresConfirmation);
}
return await this.vaultService.modify(file, content, allowAccessToPluginRoot, requiresConfirmation);
}
public async deleteFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<Error | void> {
public async deleteFile(filePath: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<Error | void> {
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<void | Error> {
@ -61,11 +59,11 @@ export class FileSystemService {
return await this.vaultService.listDirectoryContents(dirPath, recursive, allowAccessToPluginRoot);
}
public async readObjectFromFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<object | Error> {
public async readObjectFromFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<Record<string, unknown> | 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<string, unknown> : 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<TFile | Error> {
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<ISearchMatch[]> {

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin, plugin);
@ -45,6 +46,7 @@ export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) {
export function RegisterDependencies() {
RegisterSingleton<EventService>(Services.EventService, new EventService());
RegisterSingleton<AbortService>(Services.AbortService, new AbortService());
RegisterSingleton<StatusBarService>(Services.StatusBarService, new StatusBarService());
RegisterSingleton<HTMLService>(Services.HTMLService, new HTMLService());
RegisterSingleton<SanitiserService>(Services.SanitiserService, new SanitiserService());

View file

@ -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");

View file

@ -32,7 +32,7 @@ export class StreamingMarkdownService {
constructor() {
this.processor = unified()
.use(remarkParse)
.use(remarkParse)
.use(remarkGfm)
.use(remarkEmoji)
.use(remarkMath)

View file

@ -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<AbortService>(Services.AbortService);
}
public async* streamRequest(url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => IStreamChunk,
abortSignal?: AbortSignal, additionalHeaders?: Record<string, string>): AsyncGenerator<IStreamChunk, void, unknown> {
additionalHeaders?: Record<string, string>): AsyncGenerator<IStreamChunk, void, unknown> {
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<string, string>, abortSignal?: AbortSignal): Promise<Response> {
additionalHeaders?: Record<string, string>): Promise<Response> {
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<Uint8Array>,
parseStreamChunk: (chunk: string) => IStreamChunk): AsyncGenerator<IStreamChunk, boolean, unknown> {
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;

View file

@ -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<void | Error> {
public async delete(file: TAbstractFile, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<void | Error> {
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);
}
}

View file

@ -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 */
/* ============================== */

View file

@ -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;

View file

@ -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',

View file

@ -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');
});
});

View file

@ -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', () => {

View file

@ -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');
});
});

View file

@ -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'

View file

@ -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');
});
});

View file

@ -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 }
]);
});

View file

@ -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');
});
});
});

View file

@ -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());
}
}

View file

@ -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();
});

View file

@ -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');
});

View file

@ -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<Event, Function[]>;
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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin, mockPlugin);
RegisterSingleton<EventService>(Services.EventService, mockEventService);
RegisterSingleton<AbortService>(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();
});
});
});

View file

@ -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);
});
});

View file

@ -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<AbortService>(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
});
});
});

View file

@ -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<T extends Component>(component: T): T {
return component;
}
public removeChild<T extends Component>(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);

View file

@ -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<T extends Component>(component: T): T {
return component;
}
public removeChild<T extends Component>(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);

View file

@ -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<T extends Component>(component: T): T {
return component;
}
public removeChild<T extends Component>(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() {

View file

@ -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();
}
});
}
}

View file

@ -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 {

744
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}