mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
4614ec5639
commit
0fb17e7b3a
31 changed files with 568 additions and 255 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
|
||||
const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null;
|
||||
if (!link) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export enum Event {
|
||||
DiffOpened = "diffOpened",
|
||||
DiffClosed = "diffClosed"
|
||||
DiffClosed = "diffClosed",
|
||||
RateLimitCountdown = "rateLimitCountdown"
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
/* ============================== */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
2
main.ts
2
main.ts
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue