diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5b1c66f995..40f5d078c4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1807,6 +1807,43 @@ export const webviewMessageHandler = async ( break } case "codebaseIndexConfig": { + // Handle test action separately + if (message.action === "test") { + try { + if (!provider.codeIndexManager) { + throw new Error("Code index manager not available") + } + + // Get the service factory from the manager + const serviceFactory = provider.codeIndexManager.getServiceFactory() + if (!serviceFactory) { + throw new Error("Service factory not available") + } + + // Test the configuration + const isValid = await serviceFactory.validateEmbedderConfig() + + // Send test result back to webview + provider.postMessageToWebview({ + type: "codebaseIndexTestResult", + success: isValid, + message: isValid ? "Configuration is valid" : "Configuration test failed", + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`[CodeIndexManager] Configuration test error: ${errorMessage}`) + + // Send error result back to webview + provider.postMessageToWebview({ + type: "codebaseIndexTestResult", + success: false, + message: errorMessage, + }) + } + break + } + + // Normal configuration update flow const codebaseIndexConfig = message.values ?? { codebaseIndexEnabled: false, codebaseIndexQdrantUrl: "http://localhost:6333", @@ -1823,16 +1860,42 @@ export const webviewMessageHandler = async ( // If now configured and enabled, start indexing automatically if (provider.codeIndexManager.isFeatureEnabled && provider.codeIndexManager.isFeatureConfigured) { if (!provider.codeIndexManager.isInitialized) { - await provider.codeIndexManager.initialize(provider.contextProxy) + try { + await provider.codeIndexManager.initialize(provider.contextProxy) + } catch (initError) { + // Initialization failed - send error status to webview + const errorMessage = initError instanceof Error ? initError.message : String(initError) + provider.log(`[CodeIndexManager] Initialization error: ${errorMessage}`) + + // Send error status update to webview + const status = provider.codeIndexManager.getCurrentStatus() + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: status, + }) + + // Re-throw to prevent indexing attempt + throw initError + } } // Start indexing in background (no await) provider.codeIndexManager.startIndexing() } } } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) provider.log( - `[CodeIndexManager] Error during background CodeIndexManager configuration/indexing: ${error.message || error}`, + `[CodeIndexManager] Error during background CodeIndexManager configuration/indexing: ${errorMessage}`, ) + + // Send error notification to webview if manager exists + if (provider.codeIndexManager) { + const status = provider.codeIndexManager.getCurrentStatus() + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: status, + }) + } } await provider.postStateToWebview() diff --git a/src/services/code-index/embedders/ollama.ts b/src/services/code-index/embedders/ollama.ts index 2f212c7745..e6c96ad557 100644 --- a/src/services/code-index/embedders/ollama.ts +++ b/src/services/code-index/embedders/ollama.ts @@ -106,4 +106,46 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { name: "ollama", } } + + /** + * Validates the Ollama configuration by attempting to connect to the endpoint. + * @param baseUrl - The base URL of the Ollama instance + * @param modelId - The model ID to check + * @returns A promise that resolves to true if valid, or throws an error with details + */ + static async validateEndpoint(baseUrl: string, modelId: string): Promise { + const url = `${baseUrl}/api/tags` + + try { + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Ollama API not found at ${baseUrl}. Is Ollama running?`) + } + throw new Error(`Failed to connect to Ollama: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + const models = data.models || [] + const modelNames = models.map((m: any) => m.name) + + // Check if the specified model exists + if (!modelNames.includes(modelId)) { + throw new Error(`Model '${modelId}' not found. Available models: ${modelNames.join(", ") || "none"}`) + } + + return true + } catch (error: any) { + if (error.message.includes("fetch failed") || error.message.includes("ECONNREFUSED")) { + throw new Error(`Cannot connect to Ollama at ${baseUrl}. Please ensure Ollama is running.`) + } + throw error + } + } } diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index 88eced8a0a..e5f46d2357 100644 --- a/src/services/code-index/embedders/openai-compatible.ts +++ b/src/services/code-index/embedders/openai-compatible.ts @@ -68,6 +68,47 @@ export class OpenAICompatibleEmbedder implements IEmbedder { this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS } + /** + * Validates the endpoint by attempting a minimal embedding request + * @param baseUrl The base URL to validate + * @param apiKey The API key to use for validation + * @param modelId Optional model ID to test with + * @returns Promise resolving to true if valid + * @throws Error with descriptive message if validation fails + */ + static async validateEndpoint(baseUrl: string, apiKey: string, modelId?: string): Promise { + try { + const client = new OpenAI({ + baseURL: baseUrl, + apiKey: apiKey, + }) + + const testModel = modelId || getDefaultModelId("openai-compatible") + + // Try a minimal embedding request + await client.embeddings.create({ + input: "test", + model: testModel, + }) + + return true + } catch (error: any) { + let errorMessage = t("embeddings:unknownError") + + if (error?.status === 401) { + errorMessage = t("embeddings:authenticationFailed") + } else if (error?.status === 404) { + errorMessage = `Endpoint not found: ${baseUrl}` + } else if (error?.code === "ECONNREFUSED" || error?.code === "ENOTFOUND") { + errorMessage = `Cannot connect to ${baseUrl}` + } else if (error?.message) { + errorMessage = error.message + } + + throw new Error(errorMessage) + } + } + /** * Creates embeddings for the given texts with batching and rate limiting * @param texts Array of text strings to embed diff --git a/src/services/code-index/embedders/openai.ts b/src/services/code-index/embedders/openai.ts index 667c2f46d4..aae5d66225 100644 --- a/src/services/code-index/embedders/openai.ts +++ b/src/services/code-index/embedders/openai.ts @@ -193,4 +193,42 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { name: "openai", } } + + /** + * Validates the OpenAI configuration by attempting to list models. + * @param apiKey - The OpenAI API key + * @param modelId - The model ID to check + * @returns A promise that resolves to true if valid, or throws an error with details + */ + static async validateEndpoint(apiKey: string, modelId: string): Promise { + const client = new OpenAI({ apiKey }) + + try { + // Try to list models to validate the API key + const models = await client.models.list() + const modelIds = models.data.map((m) => m.id) + + // Check if the specified embedding model exists or is a known model + const knownEmbeddingModels = ["text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002"] + + if (!modelIds.includes(modelId) && !knownEmbeddingModels.includes(modelId)) { + throw new Error( + `Model '${modelId}' not found. Available embedding models: ${knownEmbeddingModels.join(", ")}`, + ) + } + + return true + } catch (error: any) { + if (error?.status === 401) { + throw new Error("Invalid API key. Please check your OpenAI API key.") + } + if (error?.status === 429) { + throw new Error("Rate limit exceeded. Please try again later.") + } + if (error?.message?.includes("fetch failed") || error?.message?.includes("ECONNREFUSED")) { + throw new Error("Network error. Please check your internet connection.") + } + throw new Error(`Failed to validate OpenAI configuration: ${error?.message || "Unknown error"}`) + } + } } diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index 70e3fd9765..c34cba1aaf 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -72,6 +72,21 @@ export interface ICodeIndexManager { export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" +export interface IndexingStatus { + systemStatus: IndexingState + message?: string + processedItems?: number + totalItems?: number + currentItemUnit?: string + errorDetails?: { + type: "configuration" | "authentication" | "network" | "validation" | "unknown" + message: string + suggestion?: string + endpoint?: string + timestamp: number + } +} + export interface IndexProgressUpdate { systemStatus: IndexingState message?: string diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index 735bcee670..8f46dbd0d6 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -229,31 +229,66 @@ export class CodeIndexManager { console.error("Unexpected error loading .gitignore:", error) } - // (Re)Create shared service instances - const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices( - this.context, - this._cacheManager!, - ignoreInstance, - ) + try { + // (Re)Create shared service instances + const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices( + this.context, + this._cacheManager!, + ignoreInstance, + ) - // (Re)Initialize orchestrator - this._orchestrator = new CodeIndexOrchestrator( - this._configManager!, - this._stateManager, - this.workspacePath, - this._cacheManager!, - vectorStore, - scanner, - fileWatcher, - ) + // (Re)Initialize orchestrator + this._orchestrator = new CodeIndexOrchestrator( + this._configManager!, + this._stateManager, + this.workspacePath, + this._cacheManager!, + vectorStore, + scanner, + fileWatcher, + ) - // (Re)Initialize search service - this._searchService = new CodeIndexSearchService( - this._configManager!, - this._stateManager, - embedder, - vectorStore, - ) + // (Re)Initialize search service + this._searchService = new CodeIndexSearchService( + this._configManager!, + this._stateManager, + embedder, + vectorStore, + ) + } catch (error) { + // Handle service creation errors + console.error("Failed to create code index services:", error) + + // Determine error type and create appropriate error details + let errorType: "configuration" | "authentication" | "network" | "validation" | "unknown" = "unknown" + let errorMessage = error instanceof Error ? error.message : String(error) + let suggestion: string | undefined + + if (errorMessage.includes("configuration missing") || errorMessage.includes("missing for")) { + errorType = "configuration" + suggestion = "Please check your embedder configuration in the settings." + } else if (errorMessage.includes("authentication") || errorMessage.includes("API key")) { + errorType = "authentication" + suggestion = "Please verify your API key is correct and has the necessary permissions." + } else if (errorMessage.includes("network") || errorMessage.includes("connect")) { + errorType = "network" + suggestion = "Please check your network connection and ensure the service endpoints are accessible." + } else if (errorMessage.includes("dimension") || errorMessage.includes("model")) { + errorType = "validation" + suggestion = "Please ensure your model configuration is compatible with the selected provider." + } + + // Set error state with details + this._stateManager.setSystemState("Error", errorMessage, { + type: errorType, + message: errorMessage, + suggestion, + timestamp: Date.now(), + }) + + // Re-throw to be handled by caller + throw error + } } /** @@ -279,4 +314,12 @@ export class CodeIndexManager { } } } + + /** + * Gets the service factory instance for testing configurations. + * @returns The service factory instance or undefined if not initialized + */ + public getServiceFactory(): CodeIndexServiceFactory | undefined { + return this._serviceFactory + } } diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 2a19c8ebab..378528c9a2 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -31,7 +31,7 @@ export class CodeIndexServiceFactory { if (provider === "openai") { if (!config.openAiOptions?.openAiNativeApiKey) { - throw new Error("OpenAI configuration missing for embedder creation") + throw new Error("OpenAI API key is required. Please configure it in the settings.") } return new OpenAiEmbedder({ ...config.openAiOptions, @@ -39,7 +39,7 @@ export class CodeIndexServiceFactory { }) } else if (provider === "ollama") { if (!config.ollamaOptions?.ollamaBaseUrl) { - throw new Error("Ollama configuration missing for embedder creation") + throw new Error("Ollama base URL is required. Please configure it in the settings.") } return new CodeIndexOllamaEmbedder({ ...config.ollamaOptions, @@ -47,7 +47,12 @@ export class CodeIndexServiceFactory { }) } else if (provider === "openai-compatible") { if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) { - throw new Error("OpenAI Compatible configuration missing for embedder creation") + const missing = [] + if (!config.openAiCompatibleOptions?.baseUrl) missing.push("base URL") + if (!config.openAiCompatibleOptions?.apiKey) missing.push("API key") + throw new Error( + `OpenAI-compatible ${missing.join(" and ")} required. Please configure in the settings.`, + ) } return new OpenAICompatibleEmbedder( config.openAiCompatibleOptions.baseUrl, @@ -64,6 +69,45 @@ export class CodeIndexServiceFactory { throw new Error(`Invalid embedder type configured: ${config.embedderProvider}`) } + /** + * Validates the embedder configuration by testing the connection. + * @returns A promise that resolves to true if valid, or throws an error with details + */ + public async validateEmbedderConfig(): Promise { + const config = this.configManager.getConfig() + const provider = config.embedderProvider as EmbedderProvider + + try { + if (provider === "openai") { + if (!config.openAiOptions?.openAiNativeApiKey) { + throw new Error("OpenAI API key is required") + } + const modelId = config.modelId || "text-embedding-3-small" + return await OpenAiEmbedder.validateEndpoint(config.openAiOptions.openAiNativeApiKey, modelId) + } else if (provider === "ollama") { + if (!config.ollamaOptions?.ollamaBaseUrl) { + throw new Error("Ollama base URL is required") + } + const modelId = config.modelId || "nomic-embed-text:latest" + return await CodeIndexOllamaEmbedder.validateEndpoint(config.ollamaOptions.ollamaBaseUrl, modelId) + } else if (provider === "openai-compatible") { + if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) { + throw new Error("OpenAI-compatible base URL and API key are required") + } + const modelId = config.modelId || "text-embedding-3-small" + return await OpenAICompatibleEmbedder.validateEndpoint( + config.openAiCompatibleOptions.baseUrl, + config.openAiCompatibleOptions.apiKey, + modelId, + ) + } + throw new Error(`Invalid embedder type: ${provider}`) + } catch (error: any) { + // Re-throw with more context + throw new Error(`${provider} validation failed: ${error.message}`) + } + } + /** * Creates a vector store instance using the current configuration. */ diff --git a/src/services/code-index/state-manager.ts b/src/services/code-index/state-manager.ts index 90257fdfb1..71d7cda383 100644 --- a/src/services/code-index/state-manager.ts +++ b/src/services/code-index/state-manager.ts @@ -2,12 +2,21 @@ import * as vscode from "vscode" export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" +export interface ErrorDetails { + type: "configuration" | "authentication" | "network" | "validation" | "unknown" + message: string + suggestion?: string + endpoint?: string + timestamp: number +} + export class CodeIndexStateManager { private _systemStatus: IndexingState = "Standby" private _statusMessage: string = "" private _processedItems: number = 0 private _totalItems: number = 0 private _currentItemUnit: string = "blocks" + private _errorDetails: ErrorDetails | undefined = undefined private _progressEmitter = new vscode.EventEmitter>() // --- Public API --- @@ -25,14 +34,17 @@ export class CodeIndexStateManager { processedItems: this._processedItems, totalItems: this._totalItems, currentItemUnit: this._currentItemUnit, + errorDetails: this._errorDetails, } } // --- State Management --- - public setSystemState(newState: IndexingState, message?: string): void { + public setSystemState(newState: IndexingState, message?: string, errorDetails?: ErrorDetails): void { const stateChanged = - newState !== this._systemStatus || (message !== undefined && message !== this._statusMessage) + newState !== this._systemStatus || + (message !== undefined && message !== this._statusMessage) || + (errorDetails !== undefined && errorDetails !== this._errorDetails) if (stateChanged) { this._systemStatus = newState @@ -40,6 +52,14 @@ export class CodeIndexStateManager { this._statusMessage = message } + // Handle error details + if (newState === "Error" && errorDetails) { + this._errorDetails = errorDetails + } else if (newState !== "Error") { + // Clear error details when transitioning to non-error states + this._errorDetails = undefined + } + // Reset progress counters if moving to a non-indexing state or starting fresh if (newState !== "Indexing") { this._processedItems = 0 diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index a1fb59c89d..e2a40c0333 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -32,6 +32,13 @@ export interface IndexingStatus { processedItems: number totalItems: number currentItemUnit?: string + errorDetails?: { + type: "configuration" | "authentication" | "network" | "validation" | "unknown" + message: string + suggestion?: string + endpoint?: string + timestamp: number + } } export interface IndexingStatusUpdateMessage { @@ -100,6 +107,7 @@ export interface ExtensionMessage { | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" + | "codebaseIndexTestResult" | "marketplaceInstallResult" | "marketplaceData" | "shareTaskSuccess" @@ -154,6 +162,7 @@ export interface ExtensionMessage { marketplaceInstalledMetadata?: MarketplaceInstalledMetadata visibility?: ShareVisibility rulesFolderPath?: string + message?: string // For test results and other messages } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4cd0541828..3245c37d36 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -163,6 +163,7 @@ export interface WebviewMessage { | "indexCleared" | "focusPanelRequest" | "codebaseIndexConfig" + | "codebaseIndexTestResult" | "profileThresholds" | "setHistoryPreviewCollapsed" | "openExternal" @@ -223,6 +224,7 @@ export interface WebviewMessage { visibility?: ShareVisibility // For share visibility hasContent?: boolean // For checkRulesDirectoryResult checkOnly?: boolean // For deleteCustomMode check + action?: string // For actions like "test" in codebaseIndexConfig } export const checkoutDiffPayloadSchema = z.object({ @@ -245,6 +247,16 @@ export type CheckpointRestorePayload = z.infer = ({ codebaseIndexModels, @@ -59,7 +59,20 @@ export const CodeIndexSettings: React.FC = ({ }) => { const { t } = useAppTranslation() const DEFAULT_QDRANT_URL = "http://localhost:6333" - const [indexingStatus, setIndexingStatus] = useState({ + const [indexingStatus, setIndexingStatus] = useState<{ + systemStatus: string + message: string + processedItems: number + totalItems: number + currentItemUnit: string + errorDetails?: { + type: "configuration" | "authentication" | "network" | "validation" | "unknown" + message: string + suggestion?: string + endpoint?: string + timestamp: number + } + }>({ systemStatus: "Standby", message: "", processedItems: 0, @@ -67,6 +80,8 @@ export const CodeIndexSettings: React.FC = ({ currentItemUnit: "items", }) const [advancedExpanded, setAdvancedExpanded] = useState(false) + const [testingConfig, setTestingConfig] = useState(false) + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) // Safely calculate available models for current provider const currentProvider = codebaseIndexConfig?.codebaseIndexEmbedderProvider @@ -83,15 +98,25 @@ export const CodeIndexSettings: React.FC = ({ // Set up interval for periodic status updates // Set up message listener for status updates - const handleMessage = (event: MessageEvent) => { + const handleMessage = (event: MessageEvent) => { if (event.data.type === "indexingStatusUpdate") { + const data = event.data as IndexingStatusUpdateMessage setIndexingStatus({ - systemStatus: event.data.values.systemStatus, - message: event.data.values.message || "", - processedItems: event.data.values.processedItems, - totalItems: event.data.values.totalItems, - currentItemUnit: event.data.values.currentItemUnit || "items", + systemStatus: data.values.systemStatus, + message: data.values.message || "", + processedItems: data.values.processedItems || 0, + totalItems: data.values.totalItems || 0, + currentItemUnit: data.values.currentItemUnit || "items", + errorDetails: data.values.errorDetails, }) + } else if (event.data.type === "codebaseIndexTestResult") { + setTestingConfig(false) + setTestResult({ + success: event.data.success || false, + message: event.data.message || "Test completed", + }) + // Clear test result after 5 seconds + setTimeout(() => setTestResult(null), 5000) } } @@ -240,6 +265,39 @@ export const CodeIndexSettings: React.FC = ({ {indexingStatus.message ? ` - ${indexingStatus.message}` : ""} + {/* Error Details Display */} + {indexingStatus.systemStatus === "Error" && indexingStatus.errorDetails && ( +
+
+ +
+
+ {indexingStatus.errorDetails.type === "configuration" && "Configuration Error"} + {indexingStatus.errorDetails.type === "authentication" && + "Authentication Error"} + {indexingStatus.errorDetails.type === "network" && "Network Error"} + {indexingStatus.errorDetails.type === "validation" && "Validation Error"} + {indexingStatus.errorDetails.type === "unknown" && "Unknown Error"} +
+
+ {indexingStatus.errorDetails.message} +
+ {indexingStatus.errorDetails.suggestion && ( +
+ + {indexingStatus.errorDetails.suggestion} +
+ )} + {indexingStatus.errorDetails.endpoint && ( +
+ Endpoint: {indexingStatus.errorDetails.endpoint} +
+ )} +
+
+
+ )} + {indexingStatus.systemStatus === "Indexing" && (
= ({ )}
+ { + setTestingConfig(true) + setTestResult(null) + vscode.postMessage({ + type: "codebaseIndexConfig", + action: "test", + values: { + ...codebaseIndexConfig, + // Include API configuration values based on provider + ...(codebaseIndexConfig?.codebaseIndexEmbedderProvider === "openai" && { + codeIndexOpenAiKey: apiConfiguration.codeIndexOpenAiKey, + }), + ...(codebaseIndexConfig?.codebaseIndexEmbedderProvider === + "openai-compatible" && { + codebaseIndexOpenAiCompatibleBaseUrl: + apiConfiguration.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexOpenAiCompatibleApiKey: + apiConfiguration.codebaseIndexOpenAiCompatibleApiKey, + codebaseIndexOpenAiCompatibleModelDimension: + apiConfiguration.codebaseIndexOpenAiCompatibleModelDimension, + }), + ...(codebaseIndexConfig?.codebaseIndexEmbedderProvider === "ollama" && { + codebaseIndexEmbedderBaseUrl: + codebaseIndexConfig.codebaseIndexEmbedderBaseUrl, + }), + }, + }) + }} + disabled={ + testingConfig || + !areSettingsCommitted || + !validateIndexingConfig(codebaseIndexConfig, apiConfiguration) + } + appearance="secondary"> + {testingConfig + ? t("settings:codeIndex.testingButton") + : t("settings:codeIndex.testConfigButton")} + {(indexingStatus.systemStatus === "Error" || indexingStatus.systemStatus === "Standby") && ( vscode.postMessage({ type: "startIndexing" })} @@ -541,6 +638,26 @@ export const CodeIndexSettings: React.FC = ({ )}
+ {/* Test Result Display */} + {testResult && ( +
+
+ + {testResult.message} +
+
+ )} + {/* Advanced Configuration Section */}