mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
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:
parent
d93e61bc9d
commit
28772e7d0e
56 changed files with 2616 additions and 1111 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export interface IConversationNamingService {
|
||||
generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string>;
|
||||
generateName(userPrompt: string): Promise<string>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
156
Components/CancellationIndicator.svelte
Normal file
156
Components/CancellationIndicator.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
4
Enums/Environment.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum Environment {
|
||||
PROD = "production",
|
||||
DEV = "development"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
68
Services/AbortService.ts
Normal 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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class StreamingMarkdownService {
|
|||
|
||||
constructor() {
|
||||
this.processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkEmoji)
|
||||
.use(remarkMath)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
/* ============================== */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
513
__tests__/Services/AbortService.test.ts
Normal file
513
__tests__/Services/AbortService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
827
__tests__/Services/DiffService.test.ts
Normal file
827
__tests__/Services/DiffService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
1
main.ts
1
main.ts
|
|
@ -14,7 +14,6 @@ import type { Diff2HtmlUIConfig } from "diff2html/lib/ui/js/diff2html-ui";
|
|||
import "katex/dist/katex.min.css";
|
||||
import 'highlight.js/styles/monokai.min.css';
|
||||
import 'diff2html/bundles/css/diff2html.min.css';
|
||||
import 'diff2html/bundles/js/diff2html-ui.min.js';
|
||||
|
||||
export default class VaultkeeperAIPlugin extends Plugin {
|
||||
|
||||
|
|
|
|||
744
package-lock.json
generated
744
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -25,28 +25,28 @@
|
|||
"@types/express": "^5.0.5",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.47.0",
|
||||
"@typescript-eslint/parser": "8.47.0",
|
||||
"@vitest/ui": "^4.0.13",
|
||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||
"@typescript-eslint/parser": "8.48.0",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"builtin-modules": "5.0.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild-svelte": "^0.9.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
"eslint-plugin-obsidianmd": "^0.1.9",
|
||||
"happy-dom": "^20.0.10",
|
||||
"happy-dom": "^20.0.11",
|
||||
"obsidian": "latest",
|
||||
"svelte": "^5.43.14",
|
||||
"svelte": "^5.45.2",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.13"
|
||||
"vitest": "^4.0.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.70.1",
|
||||
"@anthropic-ai/sdk": "^0.71.0",
|
||||
"@google/genai": "^1.30.0",
|
||||
"@shikijs/rehype": "^3.15.0",
|
||||
"@shikijs/rehype": "^3.17.0",
|
||||
"core-js": "^3.47.0",
|
||||
"diff": "^8.0.2",
|
||||
"diff2html": "^3.4.52",
|
||||
|
|
@ -70,6 +70,6 @@
|
|||
"remark-wiki-link": "^2.0.1",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^4.1.13"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue