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