From 0fb17e7b3ad239fb0e998088d23ff498575b9056 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Mon, 5 Jan 2026 21:49:51 +0000 Subject: [PATCH] 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. --- AIClasses/BaseAIClass.ts | 6 +- AIClasses/Claude/Claude.ts | 50 ++--- AIClasses/Gemini/Gemini.ts | 87 +++++++-- AIClasses/Gemini/GeminiTypes.ts | 12 ++ AIClasses/IAIClass.ts | 2 +- AIClasses/OpenAI/OpenAI.ts | 74 +++++--- .../OpenAI/OpenAIConversationNamingService.ts | 4 +- AIClasses/OpenAI/OpenAITypes.ts | 17 ++ Components/ChatInput.svelte | 110 ++++++++++- Components/ChatWindow.svelte | 2 +- Components/InputDisplay.svelte | 8 +- Enums/ApiProvider.ts | 7 - Enums/Copy.ts | 13 +- Enums/Event.ts | 3 +- Services/AIControllerService.ts | 20 +- Services/AIFunctionService.ts | 1 + Services/ConversationNamingService.ts | 2 +- Services/EventService.ts | 1 + Services/SettingsService.ts | 4 +- Services/StreamingService.ts | 9 +- Styles/custom_styles.css | 29 +++ Types/ExecutionPlan.ts | 2 +- .../VaultkeeperAISettingTab.ts | 176 +++++++++--------- __mocks__/obsidian.ts | 63 +++++++ .../OpenAIConversationNamingService.test.ts | 63 +++---- .../Services/AIControllerService.test.ts | 12 +- .../ConversationNamingService.test.ts | 2 +- __tests__/Services/SettingsService.test.ts | 9 +- __tests__/Services/StreamingService.test.ts | 6 +- __tests__/setup.ts | 27 +-- main.ts | 2 +- 31 files changed, 568 insertions(+), 255 deletions(-) rename VaultkeeperAISettingTab.ts => Views/VaultkeeperAISettingTab.ts (56%) diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index 6f71cc3..232044a 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -62,7 +62,7 @@ export abstract class BaseAIClass implements IAIClass { this._toolDefinitions = toolDefinitions; } - public abstract streamRequest(conversation: Conversation): AsyncGenerator; + public abstract streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator; 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)) { diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 5924ff6..085a576 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -37,7 +37,7 @@ export class Claude extends BaseAIClass { super(AIProvider.Claude); } - public async* streamRequest(conversation: Conversation): AsyncGenerator { + public async* streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator { 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; + } } \ No newline at end of file diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 8f449f3..6852e12 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -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 { + public async* streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator { // next request should use web search only (gemini api doesn't support custom tooling and grounding at the same time) const requestWebSearch = this.accumulatedFunctionName == this.REQUEST_WEB_SEARCH; @@ -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; + 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); } diff --git a/AIClasses/Gemini/GeminiTypes.ts b/AIClasses/Gemini/GeminiTypes.ts index f9ad412..c74a41b 100644 --- a/AIClasses/Gemini/GeminiTypes.ts +++ b/AIClasses/Gemini/GeminiTypes.ts @@ -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[]; + }; } \ No newline at end of file diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index 59ef3bc..44a0f81 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -8,6 +8,6 @@ export interface IAIClass { set userInstruction(userInstruction: string); set toolDefinitions(toolDefinitions: IAIFunctionDefinition[]); - streamRequest(conversation: Conversation): AsyncGenerator; + streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator; formatBinaryFiles(attachments: Attachment[]): string; } \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 34ae48a..cacfcd2 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -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 { + public async* streamRequest(conversation: Conversation, isPlanningAgent: boolean): AsyncGenerator { // 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); } diff --git a/AIClasses/OpenAI/OpenAIConversationNamingService.ts b/AIClasses/OpenAI/OpenAIConversationNamingService.ts index 105fd99..7cc4d81 100644 --- a/AIClasses/OpenAI/OpenAIConversationNamingService.ts +++ b/AIClasses/OpenAI/OpenAIConversationNamingService.ts @@ -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; diff --git a/AIClasses/OpenAI/OpenAITypes.ts b/AIClasses/OpenAI/OpenAITypes.ts index 3ebe3df..e73fc46 100644 --- a/AIClasses/OpenAI/OpenAITypes.ts +++ b/AIClasses/OpenAI/OpenAITypes.ts @@ -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; + }>; + }>; } \ No newline at end of file diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 5515d1a..f2d8567 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -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 | 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(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; + } diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 32e4113..1318268 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -66,7 +66,7 @@ const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null; if (!link) { - return; + return; } const href = link.getAttribute('href'); diff --git a/Components/InputDisplay.svelte b/Components/InputDisplay.svelte index 6da77c5..81011ca 100644 --- a/Components/InputDisplay.svelte +++ b/Components/InputDisplay.svelte @@ -1,5 +1,4 @@