feat: add planning model selection and rate limit countdown UI

Introduce separate planning model setting to allow using different models for planning vs execution. Add visual countdown display when rate limits are hit, with improved retry delay parsing across providers (Claude, OpenAI, Gemini). Refactor settings tab into Views directory and enhance mobile layout for input controls.
This commit is contained in:
Andrew Beal 2026-01-05 21:49:51 +00:00
parent 4614ec5639
commit 0fb17e7b3a
31 changed files with 568 additions and 255 deletions

View file

@ -62,7 +62,7 @@ export abstract class BaseAIClass implements IAIClass {
this._toolDefinitions = toolDefinitions;
}
public abstract streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
public abstract streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator<IStreamChunk, void, unknown>;
public abstract formatBinaryFiles(attachments: Attachment[]): string;
@ -70,6 +70,10 @@ export abstract class BaseAIClass implements IAIClass {
protected abstract extractContents(conversationContent: ConversationContent[]): unknown;
protected abstract mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object;
protected model(isPlanningAgent: boolean): string {
return isPlanningAgent ? this.settingsService.settings.planningModel : this.settingsService.settings.model;
}
protected filterConversationContents(conversationContent: ConversationContent[]): ConversationContent[] {
return conversationContent.filter((content, index, array) => {
if (!content.content && !content.functionCall && !content.functionResponse && (!content.attachments || content.attachments.length === 0)) {

View file

@ -37,7 +37,7 @@ export class Claude extends BaseAIClass {
super(AIProvider.Claude);
}
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
public async* streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator<IStreamChunk, void, unknown> {
this.accumulatedFunctionName = null;
this.accumulatedFunctionArgs = "";
@ -71,7 +71,7 @@ export class Claude extends BaseAIClass {
webSearchTool, ...this.mapFunctionDefinitions(this.toolDefinitions)]);
const requestBody = {
model: this.settingsService.settings.model,
model: this.model(isPlanningAgent),
max_tokens: 16384,
system: systemPrompt,
messages: messages,
@ -312,29 +312,6 @@ export class Claude extends BaseAIClass {
return JSON.stringify(contentBlocks);
}
private extractRetryDelay(error: ApiError): number | undefined {
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
return undefined;
}
const retryAfter = error.info.responseHeaders.get('Retry-After');
if (!retryAfter) return undefined;
// Try parsing as seconds (number)
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) return seconds;
// Try parsing as HTTP date
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
const now = Date.now();
const delayMs = date.getTime() - now;
return Math.max(0, Math.ceil(delayMs / 1000));
}
return undefined;
}
private isSupportedMimeType(mimeType: MimeType): boolean {
return this.SUPPORTED_MIMETYPES.includes(mimeType);
}
@ -387,4 +364,27 @@ export class Claude extends BaseAIClass {
return cachedMessages;
}
private extractRetryDelay(error: ApiError): number | undefined {
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
return undefined;
}
const retryAfter = error.info.responseHeaders.get('Retry-After');
if (!retryAfter) return undefined;
// Try parsing as seconds (number)
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) return seconds;
// Try parsing as HTTP date
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
const now = Date.now();
const delayMs = date.getTime() - now;
return Math.max(0, Math.ceil(delayMs / 1000));
}
return undefined;
}
}

View file

@ -16,6 +16,7 @@ import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
import { Exception } from "Helpers/Exception";
import { ApiError, ApiErrorType } from "Types/ApiError";
import { parseFunctionCall, parseFunctionResponse } from "Helpers/ResponseHelper";
import type { GeminiRetryInfo, GeminiErrorResponse } from "./GeminiTypes";
export class Gemini extends BaseAIClass {
@ -78,7 +79,7 @@ export class Gemini extends BaseAIClass {
super(AIProvider.Gemini);
}
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
public async* streamRequest(conversation: Conversation, isPlanningAgent: 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;
@ -137,7 +138,7 @@ export class Gemini extends BaseAIClass {
};
yield* this.streamingService.streamRequest(
`${AIProviderURL.Gemini}/${this.settingsService.settings.model}:streamGenerateContent?key=${this.apiKey}&alt=sse`,
`${AIProviderURL.Gemini}/${this.model(isPlanningAgent)}:streamGenerateContent?key=${this.apiKey}&alt=sse`,
requestBody,
(chunk: string) => this.parseStreamChunk(chunk),
undefined, // No additional headers
@ -359,20 +360,47 @@ export class Gemini extends BaseAIClass {
}
try {
const parsed = JSON.parse(error.info.responseBody) as {
error?: {
details?: Array<{ retryDelay?: string }>
const parsed: unknown = JSON.parse(error.info.responseBody);
// Handle root array quirk (some APIs wrap the response in an array)
const responseObj: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
if (!this.isGeminiErrorResponse(responseObj)) {
return undefined;
}
const details = responseObj.error?.details;
if (!Array.isArray(details)) {
return undefined;
}
// Find RetryInfo object - check for @type field or presence of retry delay fields
const retryInfo = details.find((d: unknown): d is GeminiRetryInfo =>
this.isRetryInfoDetail(d)
);
if (!retryInfo) {
return undefined;
}
// Extract delay (support both camelCase and snake_case)
const rawDelay: unknown = retryInfo.retry_delay ?? retryInfo.retryDelay;
if (!rawDelay) {
return undefined;
}
// Handle object format: { seconds: 10, nanos: 500000000 }
if (typeof rawDelay === 'object' && rawDelay !== null && 'seconds' in rawDelay) {
const seconds = (rawDelay as { seconds: unknown }).seconds;
if (typeof seconds === 'number' || typeof seconds === 'string') {
return Math.ceil(Number(seconds));
}
};
return undefined;
}
const retryDelay = parsed.error?.details?.[0]?.retryDelay;
if (!retryDelay) return undefined;
// Parse duration string (e.g., "60s", "1.5s")
const match = retryDelay.match(/^(\d+\.?\d*)s$/);
if (match) {
const seconds = parseFloat(match[1]);
return Math.ceil(seconds);
// Handle string format: "10s", "1.5s", "500ms"
if (typeof rawDelay === 'string') {
return this.parseGoogleDuration(rawDelay);
}
return undefined;
@ -381,6 +409,37 @@ export class Gemini extends BaseAIClass {
}
}
private isGeminiErrorResponse(obj: unknown): obj is GeminiErrorResponse {
return typeof obj === 'object' &&
obj !== null &&
'error' in obj;
}
private isRetryInfoDetail(d: unknown): d is GeminiRetryInfo {
if (typeof d !== 'object' || d === null) {
return false;
}
const detail = d as Record<string, unknown>;
return detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo' ||
detail.retryDelay !== undefined ||
detail.retry_delay !== undefined;
}
private parseGoogleDuration(duration: string): number | undefined {
const trimmed = duration.trim();
const match = trimmed.match(/^(\d+\.?\d*)(s|ms)$/);
if (!match) return undefined;
const value = parseFloat(match[1]);
if (Number.isNaN(value)) return undefined;
const unit = match[2];
return unit === 'ms'
? Math.ceil(value / 1000)
: Math.ceil(value);
}
private isSupportedMimeType(mimeType: MimeType): boolean {
return this.SUPPORTED_MIMETYPES.includes(mimeType);
}

View file

@ -21,4 +21,16 @@ export interface GeminiListFilesResponse {
export interface GeminiUploadResponse {
file: GeminiFile;
}
export interface GeminiRetryInfo {
'@type'?: string;
retryDelay?: unknown;
retry_delay?: unknown;
}
export interface GeminiErrorResponse {
error?: {
details?: unknown[];
};
}

View file

@ -8,6 +8,6 @@ export interface IAIClass {
set userInstruction(userInstruction: string);
set toolDefinitions(toolDefinitions: IAIFunctionDefinition[]);
streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator<IStreamChunk, void, unknown>;
formatBinaryFiles(attachments: Attachment[]): string;
}

View file

@ -3,7 +3,7 @@ import type { IStreamChunk } from "Services/StreamingService";
import type { Conversation } from "Conversations/Conversation";
import type { ConversationContent } from "Conversations/ConversationContent";
import type { Attachment } from "Conversations/Attachment";
import { AIProvider, AIProviderURL, toProviderModel } from "Enums/ApiProvider";
import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
import { AIFunctionCall } from "AIClasses/AIFunctionCall";
import { fromString as aiFunctionFromString } from "Enums/AIFunction";
import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition";
@ -29,7 +29,7 @@ export class OpenAI extends BaseAIClass {
super(AIProvider.OpenAI);
}
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
public async* streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator<IStreamChunk, void, unknown> {
// Refresh file cache only if conversation has attachments
if (conversation.hasAttachments()) {
@ -45,7 +45,7 @@ export class OpenAI extends BaseAIClass {
}, ...this.mapFunctionDefinitions(this.toolDefinitions)];
const requestBody = {
model: toProviderModel(this.settingsService.settings.model),
model: this.model(isPlanningAgent),
instructions: systemPrompt,
input: input,
tools: tools,
@ -354,34 +354,60 @@ export class OpenAI extends BaseAIClass {
if (error.info.type !== ApiErrorType.RATE_LIMIT || !error.info.responseHeaders) {
return undefined;
}
const headers = error.info.responseHeaders;
// Try x-ratelimit-reset-requests first (most common)
const resetRequests = headers.get('x-ratelimit-reset-requests');
if (resetRequests) {
const resetTimestamp = parseInt(resetRequests, 10);
if (!isNaN(resetTimestamp)) {
const now = Math.floor(Date.now() / 1000);
const delaySeconds = Math.max(0, resetTimestamp - now);
return delaySeconds;
// 1. Prefer standard Retry-After header (seconds or HTTP-date)
const retryAfter = headers.get('retry-after');
if (retryAfter) {
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds)) {
return Math.max(0, seconds);
}
}
// Fallback to x-ratelimit-reset-tokens
const resetTokens = headers.get('x-ratelimit-reset-tokens');
if (resetTokens) {
const resetTimestamp = parseInt(resetTokens, 10);
if (!isNaN(resetTimestamp)) {
const now = Math.floor(Date.now() / 1000);
const delaySeconds = Math.max(0, resetTimestamp - now);
return delaySeconds;
}
// 2. Fallback to provider-specific headers (e.g., OpenAI)
const resetHeader =
headers.get('x-ratelimit-reset-requests') ??
headers.get('x-ratelimit-reset-tokens');
if (resetHeader) {
return this.parseDurationToSeconds(resetHeader);
}
return undefined;
}
/**
* Parses duration strings (e.g., "15s", "600ms", "2m", "1h") into seconds.
* Returns undefined if parsing fails.
*/
private parseDurationToSeconds(value: string): number | undefined {
const trimmed = value.trim();
const numericValue = parseFloat(trimmed);
if (Number.isNaN(numericValue)) {
return undefined;
}
// Parse based on suffix
if (trimmed.endsWith('ms')) {
return Math.max(0, Math.ceil(numericValue / 1000));
}
if (trimmed.endsWith('s')) {
return Math.max(0, numericValue);
}
if (trimmed.endsWith('m')) {
return Math.max(0, numericValue * 60);
}
if (trimmed.endsWith('h')) {
return Math.max(0, numericValue * 3600);
}
// Fallback: treat as raw seconds
return Math.max(0, numericValue);
}
private isSupportedMimeType(mimeType: MimeType): boolean {
return this.SUPPORTED_MIMETYPES.includes(mimeType);
}

View file

@ -5,9 +5,9 @@ import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider";
import { Role } from "Enums/Role";
import { NamePrompt } from "AIPrompts/NamePrompt";
import type { SettingsService } from "Services/SettingsService";
import type OpenAI from "openai";
import { Exception } from "Helpers/Exception";
import type { AbortService } from "Services/AbortService";
import type { ResponsesAPINonStreamingResponse } from "./OpenAITypes";
export class OpenAIConversationNamingService implements IConversationNamingService {
private readonly apiKey: string;
@ -47,7 +47,7 @@ export class OpenAIConversationNamingService implements IConversationNamingServi
Exception.throw(`OpenAI API error: ${response.status} ${response.statusText} - ${await response.text()}`);
}
const data = await response.json();
const data = await response.json() as ResponsesAPINonStreamingResponse;
// Find text from any message-type output
let generatedName: string | undefined;

View file

@ -140,4 +140,21 @@ export interface OpenAIDeleteResponse {
id: string;
object: "file";
deleted: boolean;
}
/**
* Non-streaming Responses API response
* Used when stream: false is set in the request
*/
export interface ResponsesAPINonStreamingResponse {
id: string;
status: string;
output: Array<{
type: string;
role?: string;
content?: Array<{
type: string;
text?: string;
}>;
}>;
}

View file

@ -18,7 +18,9 @@
import ChatAttachments from "./ChatAttachments.svelte";
import InputDisplay from "./InputDisplay.svelte";
import { InputMode } from "Enums/InputMode";
import { Copy } from "Enums/Copy";
import { Copy, replaceCopy } from "Enums/Copy";
import { HelpModal } from "Modals/HelpModal";
import { sleep } from "Helpers/Helpers";
export let attachments: Attachment[] = [];
@ -51,12 +53,18 @@
let inputMode: InputMode = InputMode.Normal;
let questionResolver: ((answer: string) => void) | null = null;
let countdownIntervalId: ReturnType<typeof setInterval> | null = null;
let countdownSecondsRemaining: number = 0;
const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { inputMode = InputMode.Diff; focusInput(); });
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
onDestroy(() => {
eventService.offref(diffOpenedRef);
eventService.offref(diffClosedRef);
eventService.offref(rateLimitCountdownRef);
stopCountdown();
});
export function focusInput(onMobile: boolean = false) {
@ -73,10 +81,71 @@
}
export function clearDisplayItem() {
stopCountdown();
inputDisplay.clearDisplayItem();
inputMode = InputMode.Normal;
}
async function startCountdown(delayMs: number) {
stopCountdown();
countdownSecondsRemaining = Math.ceil(delayMs / 1000);
updateCountdownDisplay();
countdownIntervalId = setInterval(() => {
countdownSecondsRemaining--;
if (countdownSecondsRemaining <= 0) {
clearDisplayItem();
} else {
updateCountdownDisplay();
}
}, 1000);
}
function stopCountdown() {
if (countdownIntervalId !== null) {
clearInterval(countdownIntervalId);
countdownIntervalId = null;
}
}
function openTroubleshootingModal() {
const modal = Resolve<HelpModal>(Services.HelpModal);
modal.open(3); // 3 = Troubleshooting
}
function updateCountdownDisplay() {
const countdownDisplay = createEl("div");
countdownDisplay.addClass("rate-limit-container");
const countdown = createEl("span");
countdown.addClass("rate-limit-countdown");
countdown.textContent = replaceCopy(Copy.RateLimitCountdown, [countdownSecondsRemaining.toString()]);
const info1 = createEl("span");
info1.addClass("rate-limit-info");
info1.appendText(Copy.RateLimitInfo1);
const link = createEl("span");
link.addClass("rate-limit-link");
link.textContent = Copy.RateLimitInfoLink;
link.setAttribute("role", "link");
link.setAttribute("tabindex", "-1");
link.addEventListener("click", openTroubleshootingModal);
info1.append(link);
const info2 = createEl("span");
info2.addClass("rate-limit-info");
info2.appendText(Copy.RateLimitInfo2);
info1.append(info2);
countdownDisplay.append(countdown);
countdownDisplay.append(createEl("br"));
countdownDisplay.append(info1);
inputDisplay.setDisplayItem(countdownDisplay);
}
export function enterQuestionMode(resolver: (answer: string) => void) {
questionResolver = resolver;
inputMode = InputMode.Question;
@ -652,4 +721,43 @@
cursor: pointer;
background-color: var(--alt-interactive-accent-hover);
}
/* Narrow/mobile layout: input above, buttons below */
:global(.is-mobile) #input-container {
grid-template-rows: auto auto auto auto var(--size-4-3) 1fr var(--size-4-2) auto var(--size-4-3);
grid-template-columns: var(--size-4-3) auto 1fr auto var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
}
:global(.is-mobile) #input-display-container,
:global(.is-mobile) #input-attachments-container,
:global(.is-mobile) #diff-controls-container,
:global(.is-mobile) #input-search-results-container,
:global(.is-mobile) #user-instruction-container {
grid-column: 2 / 9;
}
:global(.is-mobile) #input-field {
grid-row: 6;
grid-column: 2 / 9;
}
:global(.is-mobile) #user-instruction-button {
grid-row: 8;
grid-column: 2;
}
:global(.is-mobile) #edit-mode-button {
grid-row: 8;
grid-column: 4;
}
:global(.is-mobile) #planning-mode-button {
grid-row: 8;
grid-column: 6;
}
:global(.is-mobile) #submit-button {
grid-row: 8;
grid-column: 8;
}
</style>

View file

@ -66,7 +66,7 @@
const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null;
if (!link) {
return;
return;
}
const href = link.getAttribute('href');

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { Platform } from "obsidian";
import { slide } from "svelte/transition";
export let editModeActive = false;
@ -23,7 +22,7 @@
{#if displayItem}
<div id="input-display-wrapper" transition:slide>
<div id="input-display-container" class:edit-mode={editModeActive} style:max-height={Platform.isMobile ? "15vh" : "30vh"} bind:this={contentDiv}>
<div id="input-display-container" class:edit-mode={editModeActive} bind:this={contentDiv}>
</div>
</div>
{/if}
@ -50,8 +49,13 @@
font-size: var(--font-ui-medium);
font-family: var(--font-interface-theme);
transition: border-color 0.5s ease-out;
max-height: 30vh;
}
:global(.is-mobile) #input-display-container {
max-height: 15vh;
}
#input-display-container.edit-mode {
border-color: var(--alt-interactive-accent);
box-shadow: inset 0px 0px 2px 1px var(--alt-interactive-accent);

View file

@ -15,13 +15,6 @@ export function fromModel(model: string): AIProvider {
}
}
export function toProviderModel(model: string): AIProviderModel {
if (isValidProviderModel(model)) {
return model;
}
Exception.throw(`Invalid model: ${model}`);
}
function isClaudeModel(model: string): boolean {
return isValidProviderModel(model) && model.startsWith("claude-");
}

View file

@ -38,6 +38,7 @@ export enum Copy {
// Settings Copy
SettingModel = "Model",
SettingPlanningModel = "Planning Model",
SettingApiKey = "API Key",
SettingFileExclusions = "File Exclusions",
SettingContext = "Context",
@ -47,6 +48,8 @@ export enum Copy {
// Settings Descriptions
SettingModelDesc = "Select the AI model to use.",
SettingPlanningModelDesc = "Select the AI model to use when planning complex tasks.",
SettingPlanningModelTip = "Tip: You can reduce cost by using a more powerful model for planning and a cheaper model for the regular agent.",
SettingApiKeyDesc = "Enter your API key here.",
SettingFileExclusionsDesc = "Set which directories and files the AI should ignore. Enter one path per line - supports glob patterns like folder/**, *.md",
SettingSearchResultsLimitDesc = "Set the maximum number of results provided to the AI when it searches through files in your vault. Higher values provide more context but increase search time.",
@ -66,6 +69,12 @@ export enum Copy {
AIThoughtMessage = "Thinking...",
// Rate Limit Countdown
RateLimitCountdown = "Rate limit exceeded retrying in {seconds}...",
RateLimitInfo1 = "Tip: See info on ",
RateLimitInfoLink = "API tiers",
RateLimitInfo2 = " if you often exceed your rate limit.",
SafeContinue= "Continue",
// Chat Input Placeholders
@ -83,6 +92,8 @@ export enum Copy {
ButtonTurnOffPlanningMode = "Turn off Planning Mode",
ButtonTurnOnPlanningMode = "Turn on Planning Mode",
ConfirmationFalse = "Confirmation was false, no action taken.",
// Execution Plan Messages
PlanningFailedError = `Failed to generate plan. You should attempt to recover from this.
### Next Actions
@ -93,9 +104,7 @@ export enum Copy {
StepCompletedWithNextStep = "Step {stepNumber} completed successfully. Now proceed with step {nextStepNumber}.",
AllStepsCompleted = "Step {stepNumber} completed successfully. All steps in the execution plan have been completed. Provide a final summary to the user based on the completed steps and overall success criteria.",
PlanExecutionCancelled = "Plan execution cancelled. Provide a summary to the user explaining what happened and any partial progress made.",
PlanExecutionNotCancelled = "Confirmation was false, no action taken.",
PlanExecutionComplete = "Plan execution complete.",
PlanCompletionNotConfirmed = "Confirmation was false, no action taken.",
PlanningToolDenial = "Invalid tool call - this is an execution tool and cannot be called during the planning phase.",
PlanningModeError = "First create a plan before executing any functions!",
PlanSubmissionRequired = "Error: Attempted to exit planning but plan has not yet been submitted!",

View file

@ -1,4 +1,5 @@
export enum Event {
DiffOpened = "diffOpened",
DiffClosed = "diffClosed"
DiffClosed = "diffClosed",
RateLimitCountdown = "rateLimitCountdown"
}

View file

@ -272,7 +272,7 @@ export class AIControllerService {
planExecutionCancelled = parseResult.data.confirm_cancellation;
conversation.addFunctionResponse(new AIFunctionResponse(
functionCallName,
{ message: planExecutionCancelled ? Copy.PlanExecutionCancelled : Copy.PlanExecutionNotCancelled },
{ message: planExecutionCancelled ? Copy.PlanExecutionCancelled : Copy.ConfirmationFalse },
functionCall.toolId
));
return { shouldExit: planExecutionCancelled };
@ -358,11 +358,11 @@ export class AIControllerService {
}
private async runAgentLoop(conversation: Conversation, callbacks: IChatServiceCallbacks,
handleFunctionCall: (functionCall: AIFunctionCall) => Promise<{ shouldExit: boolean }>, isPlanningConversation: boolean = false
handleFunctionCall: (functionCall: AIFunctionCall) => Promise<{ shouldExit: boolean }>, isPlanningAgent: boolean = false
): Promise<void> {
let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningConversation);
let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningAgent);
if (!isPlanningConversation) {
if (!isPlanningAgent) {
await this.onSaveConversation?.(conversation);
}
@ -370,7 +370,7 @@ export class AIControllerService {
if (response.functionCall) {
const result = await handleFunctionCall(response.functionCall);
if (result.shouldExit) {
if (!isPlanningConversation) {
if (!isPlanningAgent) {
await this.onSaveConversation?.(conversation);
}
return;
@ -379,9 +379,9 @@ export class AIControllerService {
callbacks.onThoughtUpdate(Copy.AIThoughtMessage);
}
response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningConversation);
response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningAgent);
if (!isPlanningConversation) {
if (!isPlanningAgent) {
await this.onSaveConversation?.(conversation);
}
}
@ -407,7 +407,7 @@ export class AIControllerService {
return conversation;
}
private async streamRequestResponse(conversation: Conversation, callbacks: IChatServiceCallbacks, isPlanningConversation: boolean
private async streamRequestResponse(conversation: Conversation, callbacks: IChatServiceCallbacks, isPlanningAgent: boolean
): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> {
if (!this.ai) { // this should never happen
return { functionCall: null, shouldContinue: false };
@ -420,7 +420,7 @@ export class AIControllerService {
let capturedFunctionCall: AIFunctionCall | null = null;
let capturedShouldContinue = false;
for await (const chunk of this.ai.streamRequest(conversation)) {
for await (const chunk of this.ai.streamRequest(conversation, isPlanningAgent)) {
if (chunk.error && chunk.errorType) {
conversationContent.content = chunk.error;
conversationContent.errorType = chunk.errorType;
@ -440,7 +440,7 @@ export class AIControllerService {
accumulatedContent += chunk.content;
conversationContent.content = accumulatedContent;
if (accumulatedContent.trim() !== "" && !isPlanningConversation) {
if (accumulatedContent.trim() !== "" && !isPlanningAgent) {
callbacks.onThoughtUpdate(null);
}
}

View file

@ -126,6 +126,7 @@ export class AIFunctionService {
case AIFunction.SubmitPlan:
case AIFunction.AskUserQuestion:
case AIFunction.CompleteStep:
case AIFunction.CompletePlan:
case AIFunction.CancelPlan: {
Exception.log(`Multi-agent function ${functionCall.name} should not be handled by AIFunctionService`);
return new AIFunctionResponse(

View file

@ -86,7 +86,7 @@ export class ConversationNamingService {
index++;
if (index > this.stackLimit) {
Exception.log(`Stack limit reached when trying to generate conversation name for "${cleanedTitle}"`);
Exception.throw(`Stack limit reached when trying to generate conversation name for "${cleanedTitle}"`);
}
}
return availableTitle;

View file

@ -5,6 +5,7 @@ export class EventService extends Events {
public trigger(name: Event.DiffOpened, data?: unknown): void;
public trigger(name: Event.DiffClosed, data?: unknown): void;
public trigger(name: Event.RateLimitCountdown, delayMs: number): void;
public trigger(name: string, ...data: unknown[]): void {
super.trigger(name, ...data);

View file

@ -7,7 +7,8 @@ const DEFAULT_SETTINGS: IVaultkeeperAISettings = {
firstTimeStart: true,
userInstruction: "",
model: AIProviderModel.ClaudeSonnet_4_5,
model: AIProviderModel.ClaudeHaiku_4_5,
planningModel: AIProviderModel.ClaudeSonnet_4_5,
apiKeys: {
claude: "",
openai: "",
@ -24,6 +25,7 @@ export interface IVaultkeeperAISettings {
userInstruction: string;
model: string;
planningModel: string;
apiKeys: {
claude: string;
openai: string;

View file

@ -1,8 +1,10 @@
import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
import { Event } from "Enums/Event";
import { Exception } from "Helpers/Exception";
import { ApiError, ApiErrorType } from "Types/ApiError";
import { AbortService } from "./AbortService";
import { Resolve } from "./DependencyService";
import { EventService } from "./EventService";
import { Services } from "./Services";
import { sleep } from "Helpers/Helpers";
@ -21,9 +23,11 @@ export class StreamingService {
private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // ms
private readonly abortService: AbortService;
private readonly eventService: EventService;
public constructor() {
this.abortService = Resolve<AbortService>(Services.AbortService);
this.eventService = Resolve<EventService>(Services.EventService);
}
public async* streamRequest(url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => IStreamChunk,
@ -187,8 +191,9 @@ export class StreamingService {
if (error instanceof ApiError && error.info.type === ApiErrorType.RATE_LIMIT && extractRetryDelay) {
const providerDelaySeconds = extractRetryDelay(error);
if (providerDelaySeconds !== undefined) {
// Convert to milliseconds
return providerDelaySeconds * 1000;
const delayMs = providerDelaySeconds * 1000;
this.eventService.trigger(Event.RateLimitCountdown, delayMs);
return delayMs;
}
}

View file

@ -122,6 +122,12 @@
display: none;
}
.planning-model-description-tip {
font-size: var(--font-smaller);
font-style: italic;
color: var(--text-muted);
}
.top-bar-button {
margin: var(--size-4-2) 0px var(--size-4-2) 0px;
padding: var(--size-4-1) var(--size-4-2) var(--size-4-1) var(--size-4-2);
@ -143,6 +149,29 @@
margin: var(--size-4-2) 0;
}
.rate-limit-container {
text-align: center;
}
.rate-limit-countdown {
font-size: var(--font-smaller);
}
.rate-limit-info {
font-size: var(--font-smaller);
font-style: italic;
color: var(--text-muted);
}
.rate-limit-link {
color: var(--interactive-accent);
cursor: pointer;
}
.rate-limit-link:hover {
text-decoration: underline;
}
/* ============================== */
/* CSS Variables for Theming */
/* ============================== */

View file

@ -90,7 +90,7 @@ export class ExecutionPlan {
public completeExecutionPlan(confirmation: boolean): object {
if (!confirmation) {
return { error: Copy.PlanCompletionNotConfirmed };
return { error: Copy.ConfirmationFalse };
}
if (!this.completed()) {
return { error: replaceCopy(Copy.IncompleteExecutionAttempt,

View file

@ -3,7 +3,7 @@ import { Copy } from "Enums/Copy";
import { Selector } from "Enums/Selector";
import type VaultkeeperAIPlugin from "main";
import { HelpModal } from "Modals/HelpModal";
import { PluginSettingTab, Setting, setIcon, setTooltip } from "obsidian";
import { DropdownComponent, PluginSettingTab, Setting, setIcon, setTooltip } from "obsidian";
import { Resolve } from "Services/DependencyService";
import type { SettingsService } from "Services/SettingsService";
import { Services } from "Services/Services";
@ -15,6 +15,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
private apiKeySetting: Setting | null = null;
private apiKeyInputEl: HTMLInputElement | null = null;
private fileDisclaimerSetting: Setting | null = null;
private planningModelDropdown: DropdownComponent | null = null;
constructor() {
const plugin = Resolve<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
@ -22,7 +23,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
}
display() {
public display() {
const { containerEl } = this;
containerEl.empty();
@ -32,97 +33,7 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
.setName(Copy.SettingModel)
.setDesc(Copy.SettingModelDesc)
.addDropdown((dropdown) => {
const select = dropdown.selectEl;
// Claude models group
const claudeGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderClaude } });
claudeGroup.createEl("option", {
value: AIProviderModel.ClaudeSonnet_4_5,
text: Copy.ClaudeSonnet_4_5
});
claudeGroup.createEl("option", {
value: AIProviderModel.ClaudeSonnet_4,
text: Copy.ClaudeSonnet_4
});
claudeGroup.createEl("option", {
value: AIProviderModel.ClaudeSonnet_3_7,
text: Copy.ClaudeSonnet_3_7
});
claudeGroup.createEl("option", {
value: AIProviderModel.ClaudeOpus_4_5,
text: Copy.ClaudeOpus_4_5
});
claudeGroup.createEl("option", {
value: AIProviderModel.ClaudeOpus_4_1,
text: Copy.ClaudeOpus_4_1
});
claudeGroup.createEl("option", {
value: AIProviderModel.ClaudeOpus_4,
text: Copy.ClaudeOpus_4
});
claudeGroup.createEl("option", {
value: AIProviderModel.ClaudeHaiku_4_5,
text: Copy.ClaudeHaiku_4_5
});
// OpenAI models group
const openaiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderOpenAI } });
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_5_1,
text: Copy.GPT_5_1
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_5,
text: Copy.GPT_5
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_5_Mini,
text: Copy.GPT_5_Mini
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_5_Nano,
text: Copy.GPT_5_Nano
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_5_Pro,
text: Copy.GPT_5_Pro
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_4o,
text: Copy.GPT_4o
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_4o_Mini,
text: Copy.GPT_4o_Mini
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_4_1,
text: Copy.GPT_4_1
});
openaiGroup.createEl("option", {
value: AIProviderModel.GPT_4_1_Mini,
text: Copy.GPT_4_1_Mini
});
// Gemini models group
const geminiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderGemini } });
geminiGroup.createEl("option", {
value: AIProviderModel.GeminiFlash_2_5_Lite,
text: Copy.GeminiFlash_2_5_Lite
});
geminiGroup.createEl("option", {
value: AIProviderModel.GeminiFlash_2_5,
text: Copy.GeminiFlash_2_5
});
geminiGroup.createEl("option", {
value: AIProviderModel.GeminiPro_2_5,
text: Copy.GeminiPro_2_5
});
geminiGroup.createEl("option", {
value: AIProviderModel.GeminiPro_3_Preview,
text: Copy.GeminiPro_3_Preview
});
this.populateModelDropdown(dropdown);
dropdown.setValue(this.settingsService.settings.model);
dropdown.onChange(async (value) => {
this.settingsService.settings.model = value;
@ -132,6 +43,27 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.highlightApiKey();
}
this.updateFileDisclaimer();
await this.updatePlanningModelDropdown();
});
});
/* Planning Model Selection Setting */
const currentProvider = fromModel(this.settingsService.settings.model);
const planningModelDescFragment = document.createDocumentFragment();
planningModelDescFragment.appendText(Copy.SettingPlanningModelDesc);
planningModelDescFragment.createEl("br");
planningModelDescFragment.createEl("br");
planningModelDescFragment.createEl("span", { text: Copy.SettingPlanningModelTip, cls: "planning-model-description-tip" });
new Setting(containerEl)
.setName(Copy.SettingPlanningModel)
.setDesc(planningModelDescFragment)
.addDropdown((dropdown) => {
this.planningModelDropdown = dropdown;
this.populateModelDropdown(dropdown, currentProvider);
dropdown.setValue(this.settingsService.settings.planningModel);
dropdown.onChange(async (value) => {
this.settingsService.settings.planningModel = value;
await this.settingsService.saveSettings();
});
});
@ -238,6 +170,64 @@ export class VaultkeeperAISettingTab extends PluginSettingTab {
this.updateFileDisclaimer();
}
private populateModelDropdown(dropdown: DropdownComponent, providerFilter?: AIProvider): void {
const select = dropdown.selectEl;
// Claude models
if (!providerFilter || providerFilter === AIProvider.Claude) {
const claudeGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderClaude } });
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeSonnet_4_5, text: Copy.ClaudeSonnet_4_5 });
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeSonnet_4, text: Copy.ClaudeSonnet_4 });
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeSonnet_3_7, text: Copy.ClaudeSonnet_3_7 });
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeOpus_4_5, text: Copy.ClaudeOpus_4_5 });
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeOpus_4_1, text: Copy.ClaudeOpus_4_1 });
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeOpus_4, text: Copy.ClaudeOpus_4 });
claudeGroup.createEl("option", { value: AIProviderModel.ClaudeHaiku_4_5, text: Copy.ClaudeHaiku_4_5 });
}
// OpenAI models
if (!providerFilter || providerFilter === AIProvider.OpenAI) {
const openaiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderOpenAI } });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5_1, text: Copy.GPT_5_1 });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5, text: Copy.GPT_5 });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5_Mini, text: Copy.GPT_5_Mini });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5_Nano, text: Copy.GPT_5_Nano });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_5_Pro, text: Copy.GPT_5_Pro });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_4o, text: Copy.GPT_4o });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_4o_Mini, text: Copy.GPT_4o_Mini });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_4_1, text: Copy.GPT_4_1 });
openaiGroup.createEl("option", { value: AIProviderModel.GPT_4_1_Mini, text: Copy.GPT_4_1_Mini });
}
// Gemini models
if (!providerFilter || providerFilter === AIProvider.Gemini) {
const geminiGroup = select.createEl("optgroup", { attr: { label: Copy.ProviderGemini } });
geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_2_5_Lite, text: Copy.GeminiFlash_2_5_Lite });
geminiGroup.createEl("option", { value: AIProviderModel.GeminiFlash_2_5, text: Copy.GeminiFlash_2_5 });
geminiGroup.createEl("option", { value: AIProviderModel.GeminiPro_2_5, text: Copy.GeminiPro_2_5 });
geminiGroup.createEl("option", { value: AIProviderModel.GeminiPro_3_Preview, text: Copy.GeminiPro_3_Preview });
}
}
private async updatePlanningModelDropdown(): Promise<void> {
if (!this.planningModelDropdown) return;
const currentProvider = fromModel(this.settingsService.settings.model);
const planningProvider = fromModel(this.settingsService.settings.planningModel);
// Clear existing options
this.planningModelDropdown.selectEl.empty();
this.populateModelDropdown(this.planningModelDropdown, currentProvider);
// If planning model provider doesn't match, reset to main model
if (planningProvider !== currentProvider) {
this.settingsService.settings.planningModel = this.settingsService.settings.model;
await this.settingsService.saveSettings();
}
this.planningModelDropdown.setValue(this.settingsService.settings.planningModel);
}
private highlightApiKey() {
if (this.apiKeySetting) {
const currentApiKey = this.settingsService.getApiKeyForCurrentModel();

View file

@ -1,5 +1,24 @@
import { vi } from 'vitest';
export 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: () => void): void { void cb; }
public registerEvent(eventRef: { unload: () => void }): void { void eventRef; }
public registerDomEvent(el: HTMLElement, type: string, callback: EventListener, options?: AddEventListenerOptions): void { void el; void type; void callback; void options; }
public registerInterval(id: number): number {
return id;
}
}
export class Plugin {
app: unknown;
manifest: unknown;
@ -153,3 +172,47 @@ export class Setting {
addSlider() { return this; }
then() { return this; }
}
export class Events {
private events: Map<string, Array<(...data: unknown[]) => unknown>> = new Map();
on(name: string, callback: (...data: unknown[]) => unknown, ctx?: unknown): { unload: () => void } {
if (!this.events.has(name)) {
this.events.set(name, []);
}
const boundCallback: (...data: unknown[]) => unknown = ctx
? (...args: unknown[]) => callback.apply(ctx, args) as unknown
: callback;
this.events.get(name)!.push(boundCallback);
return {
unload: () => this.off(name, boundCallback)
};
}
off(name: string, callback: (...data: unknown[]) => unknown): void {
const callbacks = this.events.get(name);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
offref(ref: { unload: () => void }): void {
ref.unload();
}
trigger(name: string, ...data: unknown[]): void {
const callbacks = this.events.get(name);
if (callbacks) {
for (const callback of callbacks) {
callback(...data);
}
}
}
tryTrigger(name: string, ...data: unknown[]): void {
this.trigger(name, ...data);
}
}

View file

@ -63,7 +63,6 @@ describe('OpenAIConversationNamingService', () => {
json: async () => ({
id: 'resp_123',
created_at: 1234567890,
output_text: 'Test Conversation',
error: null,
incomplete_details: null,
instructions: null,
@ -75,7 +74,21 @@ describe('OpenAIConversationNamingService', () => {
tool_choice: 'auto',
tools: [],
top_p: null,
output: []
output: [
{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'Test Conversation',
annotations: []
}
]
}
]
})
});
@ -95,7 +108,6 @@ describe('OpenAIConversationNamingService', () => {
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(requestBody.model).toBe(AIProviderModel.OpenAINamer);
expect(requestBody.max_output_tokens).toBe(100);
expect(requestBody.instructions).toBeDefined();
expect(requestBody.input).toHaveLength(1);
expect(requestBody.input[0].role).toBe(Role.User);
@ -104,40 +116,12 @@ describe('OpenAIConversationNamingService', () => {
expect(requestBody.messages).toBeUndefined();
});
it('should return generated name from output_text', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
id: 'resp_123',
created_at: 1234567890,
output_text: 'Generated Name',
error: null,
incomplete_details: null,
instructions: null,
metadata: null,
model: AIProviderModel.OpenAINamer,
object: 'response',
parallel_tool_calls: true,
temperature: null,
tool_choice: 'auto',
tools: [],
top_p: null,
output: []
})
});
const result = await service.generateName('Test prompt');
expect(result).toBe('Generated Name');
});
it('should return generated name from output array', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
id: 'resp_123',
created_at: 1234567890,
output_text: '',
error: null,
incomplete_details: null,
instructions: null,
@ -204,7 +188,6 @@ describe('OpenAIConversationNamingService', () => {
json: async () => ({
id: 'resp_123',
created_at: 1234567890,
output_text: 'Name',
error: null,
incomplete_details: null,
instructions: null,
@ -216,7 +199,21 @@ describe('OpenAIConversationNamingService', () => {
tool_choice: 'auto',
tools: [],
top_p: null,
output: []
output: [
{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'Name',
annotations: []
}
]
}
]
})
});

View file

@ -88,6 +88,7 @@ describe('AIControllerService - Integration Tests', () => {
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanningQuestion: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
@ -101,7 +102,7 @@ describe('AIControllerService - Integration Tests', () => {
});
service.resolveAIProvider();
await service.runMainAgent(conversation, true, callbacks);
await service.runMainAgent(conversation, true, false, callbacks);
// Verify system prompt and user instruction were set
expect(mockPrompt.systemInstruction).toHaveBeenCalled();
@ -118,6 +119,7 @@ describe('AIControllerService - Integration Tests', () => {
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanningQuestion: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
@ -131,7 +133,7 @@ describe('AIControllerService - Integration Tests', () => {
});
service.resolveAIProvider();
await service.runMainAgent(conversation, true, callbacks);
await service.runMainAgent(conversation, true, false, callbacks);
// Should have added assistant message to conversation
expect(conversation.contents.length).toBeGreaterThan(1);
@ -148,6 +150,7 @@ describe('AIControllerService - Integration Tests', () => {
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanningQuestion: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
@ -156,7 +159,7 @@ describe('AIControllerService - Integration Tests', () => {
};
// Don't call resolveAIProvider()
await expect(service.runMainAgent(conversation, true, callbacks))
await expect(service.runMainAgent(conversation, true, false, callbacks))
.rejects.toThrow('Error: No AI provider has been set!');
});
});
@ -173,6 +176,7 @@ describe('AIControllerService - Integration Tests', () => {
onThoughtUpdate: vi.fn(),
onPlanningStarted: vi.fn(),
onPlanningFinished: vi.fn(),
onPlanningQuestion: vi.fn(),
onPlanUpdate: vi.fn(),
onPlanStepUpdate: vi.fn(),
onPlanComplete: vi.fn(),
@ -185,7 +189,7 @@ describe('AIControllerService - Integration Tests', () => {
});
service.resolveAIProvider();
await service.runMainAgent(conversation, true, callbacks);
await service.runMainAgent(conversation, true, false, callbacks);
// Should have inserted Continue message between last assistant and new assistant
const continueMessage = conversation.contents.find(c =>

View file

@ -144,7 +144,7 @@ describe('ConversationNamingService', () => {
await service.requestName(conversation, 'User prompt', onNameChanged);
expect(mockNamingProvider.generateName).toHaveBeenCalledWith('User prompt');
expect(mockNamingProvider.generateName).toHaveBeenCalledWith('<message_to_title>\nUser prompt\n</message_to_title>');
expect(mockConversationService.updateConversationTitle).toHaveBeenCalledWith(
'conversations/test.json',
'New Conversation Title'

View file

@ -26,7 +26,7 @@ describe('SettingsService', () => {
settingsService = new SettingsService({});
expect(settingsService.settings.firstTimeStart).toBe(true);
expect(settingsService.settings.model).toBe(AIProviderModel.ClaudeSonnet_4_5);
expect(settingsService.settings.model).toBe(AIProviderModel.ClaudeHaiku_4_5);
expect(settingsService.settings.apiKeys).toEqual({
claude: '',
openai: '',
@ -42,6 +42,7 @@ describe('SettingsService', () => {
const loadedSettings: Partial<IVaultkeeperAISettings> = {
firstTimeStart: false,
model: AIProviderModel.GeminiFlash_2_5,
planningModel: AIProviderModel.GeminiPro_2_5,
apiKeys: {
claude: 'claude-key-123',
openai: 'openai-key-456',
@ -89,6 +90,7 @@ describe('SettingsService', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: 'claude-api-key',
openai: 'openai-api-key',
@ -129,6 +131,7 @@ describe('SettingsService', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
@ -149,6 +152,7 @@ describe('SettingsService', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.GPT_4o,
planningModel: AIProviderModel.GPT_5,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
@ -169,6 +173,7 @@ describe('SettingsService', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.GeminiFlash_2_5,
planningModel: AIProviderModel.GeminiPro_2_5,
apiKeys: {
claude: 'claude-key',
openai: 'openai-key',
@ -214,6 +219,7 @@ describe('SettingsService', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: '',
openai: '',
@ -268,6 +274,7 @@ describe('SettingsService', () => {
const loadedSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
planningModel: AIProviderModel.ClaudeOpus_4,
apiKeys: {
claude: 'test-key',
openai: '',

View file

@ -4,11 +4,12 @@ import { Exception } from '../../Helpers/Exception';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { AbortService } from '../../Services/AbortService';
import { EventService } from '../../Services/EventService';
/**
* UNIT TESTS
*
* StreamingService now depends on AbortService, so we mock that dependency.
* StreamingService now depends on AbortService and EventService, so we mock those dependencies.
* We also mock the global fetch API to test streaming behavior.
*/
@ -17,11 +18,14 @@ describe('StreamingService', () => {
let mockFetch: any;
let originalFetch: any;
let abortService: AbortService;
let eventService: EventService;
beforeEach(() => {
DeregisterAllServices();
abortService = new AbortService();
eventService = new EventService();
RegisterSingleton<AbortService>(Services.AbortService, abortService);
RegisterSingleton<EventService>(Services.EventService, eventService);
service = new StreamingService();
originalFetch = global.fetch;
mockFetch = vi.fn();

View file

@ -9,31 +9,8 @@ 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;
}
}
};
});
// Note: Component class is now defined in __mocks__/obsidian.ts
// The vitest alias in vitest.config.ts handles mocking 'obsidian' imports
// Add Obsidian's .empty() method to HTMLElement prototype for testing
if (typeof HTMLElement !== 'undefined') {

View file

@ -1,7 +1,7 @@
import { WorkspaceLeaf, Plugin } from "obsidian";
import { MainView, VIEW_TYPE_MAIN } from "Views/MainView";
import { RegisterDependencies, RegisterPlugin } from "Services/ServiceRegistration";
import { VaultkeeperAISettingTab } from "VaultkeeperAISettingTab";
import { VaultkeeperAISettingTab } from "Views/VaultkeeperAISettingTab";
import { DiffView, VIEW_TYPE_DIFF } from "Views/DiffView";
import { Services } from "Services/Services";
import { DeregisterAllServices, Resolve } from "Services/DependencyService";