diff --git a/src/services/code-index/embedders/__tests__/ollama.spec.ts b/src/services/code-index/embedders/__tests__/ollama.spec.ts index 30e6057388..7d625a83fe 100644 --- a/src/services/code-index/embedders/__tests__/ollama.spec.ts +++ b/src/services/code-index/embedders/__tests__/ollama.spec.ts @@ -127,7 +127,7 @@ describe("CodeIndexOllamaEmbedder", () => { const result = await embedder.validateConfiguration() expect(result.valid).toBe(false) - expect(result.error).toBe("Connection to Ollama timed out at http://localhost:11434") + expect(result.error).toBe("embeddings:ollama.serviceNotRunning") }) it("should fail validation when tags endpoint returns 404", async () => { @@ -141,9 +141,7 @@ describe("CodeIndexOllamaEmbedder", () => { const result = await embedder.validateConfiguration() expect(result.valid).toBe(false) - expect(result.error).toBe( - "Ollama service is not running at http://localhost:11434. Please start Ollama first.", - ) + expect(result.error).toBe("embeddings:ollama.serviceNotRunning") }) it("should fail validation when tags endpoint returns other error", async () => { @@ -157,7 +155,7 @@ describe("CodeIndexOllamaEmbedder", () => { const result = await embedder.validateConfiguration() expect(result.valid).toBe(false) - expect(result.error).toBe("Ollama service is unavailable at http://localhost:11434. HTTP status: 500") + expect(result.error).toBe("embeddings:ollama.serviceUnavailable") }) it("should fail validation when model does not exist", async () => { @@ -176,9 +174,7 @@ describe("CodeIndexOllamaEmbedder", () => { const result = await embedder.validateConfiguration() expect(result.valid).toBe(false) - expect(result.error).toBe( - "Model 'nomic-embed-text' not found. Available models: llama2:latest, mistral:latest", - ) + expect(result.error).toBe("embeddings:ollama.modelNotFound") }) it("should fail validation when model exists but doesn't support embeddings", async () => { @@ -205,7 +201,7 @@ describe("CodeIndexOllamaEmbedder", () => { const result = await embedder.validateConfiguration() expect(result.valid).toBe(false) - expect(result.error).toBe("Model 'nomic-embed-text' is not embedding capable") + expect(result.error).toBe("embeddings:ollama.modelNotEmbeddingCapable") }) it("should handle ECONNREFUSED errors", async () => { @@ -214,7 +210,7 @@ describe("CodeIndexOllamaEmbedder", () => { const result = await embedder.validateConfiguration() expect(result.valid).toBe(false) - expect(result.error).toBe("Connection to Ollama timed out at http://localhost:11434") + expect(result.error).toBe("embeddings:ollama.serviceNotRunning") }) it("should handle ENOTFOUND errors", async () => { @@ -223,7 +219,7 @@ describe("CodeIndexOllamaEmbedder", () => { const result = await embedder.validateConfiguration() expect(result.valid).toBe(false) - expect(result.error).toBe("Ollama host not found: http://localhost:11434") + expect(result.error).toBe("embeddings:ollama.hostNotFound") }) it("should handle generic network errors", async () => { diff --git a/src/services/code-index/embedders/ollama.ts b/src/services/code-index/embedders/ollama.ts index f9001a743e..8b7ad79d7b 100644 --- a/src/services/code-index/embedders/ollama.ts +++ b/src/services/code-index/embedders/ollama.ts @@ -56,6 +56,11 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { try { // Note: Standard Ollama API uses 'prompt' for single text, not 'input' for array. // Implementing based on user's specific request structure. + + // Add timeout to prevent indefinite hanging + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout + const response = await fetch(url, { method: "POST", headers: { @@ -65,7 +70,9 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { model: modelToUse, input: processedTexts, // Using 'input' as requested }), + signal: controller.signal, }) + clearTimeout(timeoutId) if (!response.ok) { let errorBody = t("embeddings:ollama.couldNotReadErrorBody") @@ -97,6 +104,16 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { } catch (error: any) { // Log the original error for debugging purposes console.error("Ollama embedding failed:", error) + + // Handle specific error types with better messages + if (error.name === "AbortError") { + throw new Error(t("embeddings:validation.connectionFailed")) + } else if (error.message?.includes("fetch failed") || error.code === "ECONNREFUSED") { + throw new Error(t("embeddings:ollama.serviceNotRunning", { baseUrl: this.baseUrl })) + } else if (error.code === "ENOTFOUND") { + throw new Error(t("embeddings:ollama.hostNotFound", { baseUrl: this.baseUrl })) + } + // Re-throw a more specific error for the caller throw new Error(t("embeddings:ollama.embeddingFailed", { message: error.message })) } @@ -129,12 +146,12 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { if (modelsResponse.status === 404) { return { valid: false, - error: t("embeddings:errors.ollama.serviceNotRunning", { baseUrl: this.baseUrl }), + error: t("embeddings:ollama.serviceNotRunning", { baseUrl: this.baseUrl }), } } return { valid: false, - error: t("embeddings:errors.ollama.serviceUnavailable", { + error: t("embeddings:ollama.serviceUnavailable", { baseUrl: this.baseUrl, status: modelsResponse.status, }), @@ -159,8 +176,8 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { const availableModels = models.map((m: any) => m.name).join(", ") return { valid: false, - error: t("embeddings:errors.ollama.modelNotFound", { - model: this.defaultModelId, + error: t("embeddings:ollama.modelNotFound", { + modelId: this.defaultModelId, availableModels, }), } @@ -189,7 +206,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { if (!testResponse.ok) { return { valid: false, - error: t("embeddings:errors.ollama.modelNotEmbedding", { model: this.defaultModelId }), + error: t("embeddings:ollama.modelNotEmbeddingCapable", { modelId: this.defaultModelId }), } } @@ -199,21 +216,26 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { { beforeStandardHandling: (error: any) => { // Handle Ollama-specific connection errors - if (error?.message === "ECONNREFUSED") { + // Check for fetch failed errors which indicate Ollama is not running + if ( + error?.message?.includes("fetch failed") || + error?.code === "ECONNREFUSED" || + error?.message?.includes("ECONNREFUSED") + ) { return { valid: false, - error: t("embeddings:errors.ollama.connectionTimeout", { baseUrl: this.baseUrl }), + error: t("embeddings:ollama.serviceNotRunning", { baseUrl: this.baseUrl }), } - } else if (error?.message === "ENOTFOUND") { + } else if (error?.code === "ENOTFOUND" || error?.message?.includes("ENOTFOUND")) { return { valid: false, - error: t("embeddings:errors.ollama.hostNotFound", { baseUrl: this.baseUrl }), + error: t("embeddings:ollama.hostNotFound", { baseUrl: this.baseUrl }), } } else if (error?.name === "AbortError") { // Handle timeout return { valid: false, - error: t("embeddings:errors.ollama.connectionTimeout", { baseUrl: this.baseUrl }), + error: t("embeddings:validation.connectionFailed"), } } // Let standard handling take over diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 7479115081..4e49d9fae7 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect, useMemo } from "react" +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react" import { Trans } from "react-i18next" +import { z } from "zod" import { VSCodeButton, VSCodeTextField, @@ -34,11 +35,16 @@ import { Slider, StandardTooltip, } from "@src/components/ui" +import { AlertTriangle } from "lucide-react" import { useRooPortal } from "@src/components/ui/hooks/useRooPortal" import type { EmbedderProvider } from "@roo/embeddingModels" import type { IndexingStatus } from "@roo/ExtensionMessage" import { CODEBASE_INDEX_DEFAULTS } from "@roo-code/types" +// Default URLs for providers +const DEFAULT_QDRANT_URL = "http://localhost:6333" +const DEFAULT_OLLAMA_URL = "http://localhost:11434" + interface CodeIndexPopoverProps { children: React.ReactNode indexingStatus: IndexingStatus @@ -63,6 +69,62 @@ interface LocalCodeIndexSettings { codebaseIndexGeminiApiKey?: string } +// Validation schema for codebase index settings +const createValidationSchema = (provider: EmbedderProvider, t: any) => { + const baseSchema = z.object({ + codebaseIndexQdrantUrl: z + .string() + .min(1, t("settings:codeIndex.validation.qdrantUrlRequired")) + .url(t("settings:codeIndex.validation.invalidQdrantUrl")), + codeIndexQdrantApiKey: z.string().optional(), + }) + + switch (provider) { + case "openai": + return baseSchema.extend({ + codeIndexOpenAiKey: z.string().min(1, t("settings:codeIndex.validation.openaiApiKeyRequired")), + codebaseIndexEmbedderModelId: z + .string() + .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), + }) + + case "ollama": + return baseSchema.extend({ + codebaseIndexEmbedderBaseUrl: z + .string() + .min(1, t("settings:codeIndex.validation.ollamaBaseUrlRequired")) + .url(t("settings:codeIndex.validation.invalidOllamaUrl")), + codebaseIndexEmbedderModelId: z.string().min(1, t("settings:codeIndex.validation.modelIdRequired")), + }) + + case "openai-compatible": + return baseSchema.extend({ + codebaseIndexOpenAiCompatibleBaseUrl: z + .string() + .min(1, t("settings:codeIndex.validation.baseUrlRequired")) + .url(t("settings:codeIndex.validation.invalidBaseUrl")), + codebaseIndexOpenAiCompatibleApiKey: z + .string() + .min(1, t("settings:codeIndex.validation.apiKeyRequired")), + codebaseIndexEmbedderModelId: z.string().min(1, t("settings:codeIndex.validation.modelIdRequired")), + codebaseIndexEmbedderModelDimension: z + .number() + .min(1, t("settings:codeIndex.validation.modelDimensionRequired")), + }) + + case "gemini": + return baseSchema.extend({ + codebaseIndexGeminiApiKey: z.string().min(1, t("settings:codeIndex.validation.geminiApiKeyRequired")), + codebaseIndexEmbedderModelId: z + .string() + .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), + }) + + default: + return baseSchema + } +} + export const CodeIndexPopover: React.FC = ({ children, indexingStatus: externalIndexingStatus, @@ -79,6 +141,13 @@ export const CodeIndexPopover: React.FC = ({ const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle") const [saveError, setSaveError] = useState(null) + // Form validation state + const [formErrors, setFormErrors] = useState>({}) + + // Discard changes dialog state + const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) + const confirmDialogHandler = useRef<(() => void) | null>(null) + // Default settings template const getDefaultSettings = (): LocalCodeIndexSettings => ({ codebaseIndexEnabled: false, @@ -255,9 +324,96 @@ export const CodeIndexPopover: React.FC = ({ const updateSetting = (key: keyof LocalCodeIndexSettings, value: any) => { setCurrentSettings((prev) => ({ ...prev, [key]: value })) + // Clear validation error for this field when user starts typing + if (formErrors[key]) { + setFormErrors((prev) => { + const newErrors = { ...prev } + delete newErrors[key] + return newErrors + }) + } + } + + // Validation function + const validateSettings = (): boolean => { + const schema = createValidationSchema(currentSettings.codebaseIndexEmbedderProvider, t) + + // Prepare data for validation + const dataToValidate: any = {} + for (const [key, value] of Object.entries(currentSettings)) { + // For secret fields with placeholder values, treat them as valid (they exist in backend) + if (value === SECRET_PLACEHOLDER) { + // Add a dummy value that will pass validation for these fields + if ( + key === "codeIndexOpenAiKey" || + key === "codebaseIndexOpenAiCompatibleApiKey" || + key === "codebaseIndexGeminiApiKey" + ) { + dataToValidate[key] = "placeholder-valid" + } + } else { + dataToValidate[key] = value + } + } + + try { + // Validate using the schema + schema.parse(dataToValidate) + setFormErrors({}) + return true + } catch (error) { + if (error instanceof z.ZodError) { + const errors: Record = {} + error.errors.forEach((err) => { + if (err.path[0]) { + errors[err.path[0] as string] = err.message + } + }) + setFormErrors(errors) + } + return false + } } + // Discard changes functionality + const checkUnsavedChanges = useCallback( + (then: () => void) => { + if (hasUnsavedChanges) { + confirmDialogHandler.current = then + setDiscardDialogShow(true) + } else { + then() + } + }, + [hasUnsavedChanges], + ) + + const onConfirmDialogResult = useCallback( + (confirm: boolean) => { + if (confirm) { + // Discard changes: Reset to initial settings + setCurrentSettings(initialSettings) + setFormErrors({}) // Clear any validation errors + confirmDialogHandler.current?.() // Execute the pending action (e.g., close popover) + } + setDiscardDialogShow(false) + }, + [initialSettings], + ) + + // Handle popover close with unsaved changes check + const handlePopoverClose = useCallback(() => { + checkUnsavedChanges(() => { + setOpen(false) + }) + }, [checkUnsavedChanges]) + const handleSaveSettings = () => { + // Validate settings before saving + if (!validateSettings()) { + return + } + setSaveStatus("saving") setSaveError(null) @@ -302,523 +458,680 @@ export const CodeIndexPopover: React.FC = ({ const portalContainer = useRooPortal("roo-portal") return ( - - {children} - -
-
-

{t("settings:codeIndex.title")}

-
-

- - - -

-
- -
- {/* Status Section */} -
-

{t("settings:codeIndex.statusTitle")}

-
- - {t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)} - {indexingStatus.message ? ` - ${indexingStatus.message}` : ""} + <> + { + if (!newOpen) { + // User is trying to close the popover + handlePopoverClose() + } else { + setOpen(newOpen) + } + }}> + {children} + +
+
+

{t("settings:codeIndex.title")}

+

+ + + +

+
- {indexingStatus.systemStatus === "Indexing" && ( -
- - - +
+ {/* Status Section */} +
+

{t("settings:codeIndex.statusTitle")}

+
+ + {t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)} + {indexingStatus.message ? ` - ${indexingStatus.message}` : ""}
- )} -
- {/* Setup Settings Disclosure */} -
- - - {isSetupSettingsOpen && ( -
- {/* Embedder Provider Section */} -
- - + {indexingStatus.systemStatus === "Indexing" && ( +
+ + +
+ )} +
- {/* Provider-specific settings */} - {currentSettings.codebaseIndexEmbedderProvider === "openai" && ( - <> -
- - - updateSetting("codeIndexOpenAiKey", e.target.value) - } - placeholder={t("settings:codeIndex.openAiKeyPlaceholder")} - className="w-full" - /> -
+ {/* Setup Settings Disclosure */} +
+ -
- - - updateSetting("codebaseIndexEmbedderModelId", e.target.value) - } - className="w-full"> - - {t("settings:codeIndex.selectModel")} - - {getAvailableModels().map((modelId) => { - const model = - codebaseIndexModels?.[ - currentSettings.codebaseIndexEmbedderProvider - ]?.[modelId] - return ( - - {modelId}{" "} - {model - ? t("settings:codeIndex.modelDimensions", { - dimension: model.dimension, - }) - : ""} - - ) - })} - -
- - )} + {isSetupSettingsOpen && ( +
+ {/* Embedder Provider Section */} +
+ + +
- {currentSettings.codebaseIndexEmbedderProvider === "ollama" && ( - <> -
- - - updateSetting("codebaseIndexEmbedderBaseUrl", e.target.value) - } - placeholder={t("settings:codeIndex.ollamaUrlPlaceholder")} - className="w-full" - /> -
+ {/* Provider-specific settings */} + {currentSettings.codebaseIndexEmbedderProvider === "openai" && ( + <> +
+ + + updateSetting("codeIndexOpenAiKey", e.target.value) + } + placeholder={t("settings:codeIndex.openAiKeyPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codeIndexOpenAiKey, + })} + /> + {formErrors.codeIndexOpenAiKey && ( +

+ {formErrors.codeIndexOpenAiKey} +

+ )} +
-
- - - updateSetting("codebaseIndexEmbedderModelId", e.target.value) - } - className="w-full"> - - {t("settings:codeIndex.selectModel")} - - {getAvailableModels().map((modelId) => { - const model = - codebaseIndexModels?.[ - currentSettings.codebaseIndexEmbedderProvider - ]?.[modelId] - return ( - - {modelId}{" "} - {model - ? t("settings:codeIndex.modelDimensions", { - dimension: model.dimension, - }) - : ""} - - ) - })} - -
- - )} +
+ + + updateSetting("codebaseIndexEmbedderModelId", e.target.value) + } + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexEmbedderModelId, + })}> + + {t("settings:codeIndex.selectModel")} + + {getAvailableModels().map((modelId) => { + const model = + codebaseIndexModels?.[ + currentSettings.codebaseIndexEmbedderProvider + ]?.[modelId] + return ( + + {modelId}{" "} + {model + ? t("settings:codeIndex.modelDimensions", { + dimension: model.dimension, + }) + : ""} + + ) + })} + + {formErrors.codebaseIndexEmbedderModelId && ( +

+ {formErrors.codebaseIndexEmbedderModelId} +

+ )} +
+ + )} - {currentSettings.codebaseIndexEmbedderProvider === "openai-compatible" && ( - <> -
- - - updateSetting( - "codebaseIndexOpenAiCompatibleBaseUrl", - e.target.value, - ) - } - placeholder={t("settings:codeIndex.openAiCompatibleBaseUrlPlaceholder")} - className="w-full" - /> -
+ {currentSettings.codebaseIndexEmbedderProvider === "ollama" && ( + <> +
+ + + updateSetting("codebaseIndexEmbedderBaseUrl", e.target.value) + } + onBlur={(e: any) => { + // Set default Ollama URL if field is empty + if (!e.target.value.trim()) { + e.target.value = DEFAULT_OLLAMA_URL + updateSetting( + "codebaseIndexEmbedderBaseUrl", + DEFAULT_OLLAMA_URL, + ) + } + }} + placeholder={t("settings:codeIndex.ollamaUrlPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexEmbedderBaseUrl, + })} + /> + {formErrors.codebaseIndexEmbedderBaseUrl && ( +

+ {formErrors.codebaseIndexEmbedderBaseUrl} +

+ )} +
-
- - - updateSetting("codebaseIndexOpenAiCompatibleApiKey", e.target.value) - } - placeholder={t("settings:codeIndex.openAiCompatibleApiKeyPlaceholder")} - className="w-full" - /> -
+
+ + + updateSetting("codebaseIndexEmbedderModelId", e.target.value) + } + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexEmbedderModelId, + })}> + + {t("settings:codeIndex.selectModel")} + + {getAvailableModels().map((modelId) => { + const model = + codebaseIndexModels?.[ + currentSettings.codebaseIndexEmbedderProvider + ]?.[modelId] + return ( + + {modelId}{" "} + {model + ? t("settings:codeIndex.modelDimensions", { + dimension: model.dimension, + }) + : ""} + + ) + })} + + {formErrors.codebaseIndexEmbedderModelId && ( +

+ {formErrors.codebaseIndexEmbedderModelId} +

+ )} +
+ + )} -
- - - updateSetting("codebaseIndexEmbedderModelId", e.target.value) - } - placeholder={t("settings:codeIndex.modelPlaceholder")} - className="w-full" - /> -
+ {currentSettings.codebaseIndexEmbedderProvider === "openai-compatible" && ( + <> +
+ + + updateSetting( + "codebaseIndexOpenAiCompatibleBaseUrl", + e.target.value, + ) + } + placeholder={t( + "settings:codeIndex.openAiCompatibleBaseUrlPlaceholder", + )} + className={cn("w-full", { + "border-red-500": + formErrors.codebaseIndexOpenAiCompatibleBaseUrl, + })} + /> + {formErrors.codebaseIndexOpenAiCompatibleBaseUrl && ( +

+ {formErrors.codebaseIndexOpenAiCompatibleBaseUrl} +

+ )} +
-
- - { - const value = e.target.value ? parseInt(e.target.value) : undefined - updateSetting("codebaseIndexEmbedderModelDimension", value) - }} - placeholder={t("settings:codeIndex.modelDimensionPlaceholder")} - className="w-full" - /> -
- - )} +
+ + + updateSetting( + "codebaseIndexOpenAiCompatibleApiKey", + e.target.value, + ) + } + placeholder={t( + "settings:codeIndex.openAiCompatibleApiKeyPlaceholder", + )} + className={cn("w-full", { + "border-red-500": + formErrors.codebaseIndexOpenAiCompatibleApiKey, + })} + /> + {formErrors.codebaseIndexOpenAiCompatibleApiKey && ( +

+ {formErrors.codebaseIndexOpenAiCompatibleApiKey} +

+ )} +
- {currentSettings.codebaseIndexEmbedderProvider === "gemini" && ( - <> -
- - - updateSetting("codebaseIndexGeminiApiKey", e.target.value) - } - placeholder={t("settings:codeIndex.geminiApiKeyPlaceholder")} - className="w-full" - /> -
+
+ + + updateSetting("codebaseIndexEmbedderModelId", e.target.value) + } + placeholder={t("settings:codeIndex.modelPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexEmbedderModelId, + })} + /> + {formErrors.codebaseIndexEmbedderModelId && ( +

+ {formErrors.codebaseIndexEmbedderModelId} +

+ )} +
-
- - - updateSetting("codebaseIndexEmbedderModelId", e.target.value) - } - className="w-full"> - - {t("settings:codeIndex.selectModel")} - - {getAvailableModels().map((modelId) => { - const model = - codebaseIndexModels?.[ - currentSettings.codebaseIndexEmbedderProvider - ]?.[modelId] - return ( - - {modelId}{" "} - {model - ? t("settings:codeIndex.modelDimensions", { - dimension: model.dimension, - }) - : ""} - - ) - })} - -
- - )} +
+ + { + const value = e.target.value + ? parseInt(e.target.value) + : undefined + updateSetting("codebaseIndexEmbedderModelDimension", value) + }} + placeholder={t("settings:codeIndex.modelDimensionPlaceholder")} + className={cn("w-full", { + "border-red-500": + formErrors.codebaseIndexEmbedderModelDimension, + })} + /> + {formErrors.codebaseIndexEmbedderModelDimension && ( +

+ {formErrors.codebaseIndexEmbedderModelDimension} +

+ )} +
+ + )} - {/* Qdrant Settings */} -
- - updateSetting("codebaseIndexQdrantUrl", e.target.value)} - placeholder={t("settings:codeIndex.qdrantUrlPlaceholder")} - className="w-full" - /> -
+ {currentSettings.codebaseIndexEmbedderProvider === "gemini" && ( + <> +
+ + + updateSetting("codebaseIndexGeminiApiKey", e.target.value) + } + placeholder={t("settings:codeIndex.geminiApiKeyPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexGeminiApiKey, + })} + /> + {formErrors.codebaseIndexGeminiApiKey && ( +

+ {formErrors.codebaseIndexGeminiApiKey} +

+ )} +
-
- - updateSetting("codeIndexQdrantApiKey", e.target.value)} - placeholder={t("settings:codeIndex.qdrantApiKeyPlaceholder")} - className="w-full" - /> -
-
- )} -
+
+ + + updateSetting("codebaseIndexEmbedderModelId", e.target.value) + } + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexEmbedderModelId, + })}> + + {t("settings:codeIndex.selectModel")} + + {getAvailableModels().map((modelId) => { + const model = + codebaseIndexModels?.[ + currentSettings.codebaseIndexEmbedderProvider + ]?.[modelId] + return ( + + {modelId}{" "} + {model + ? t("settings:codeIndex.modelDimensions", { + dimension: model.dimension, + }) + : ""} + + ) + })} + + {formErrors.codebaseIndexEmbedderModelId && ( +

+ {formErrors.codebaseIndexEmbedderModelId} +

+ )} +
+ + )} - {/* Advanced Settings Disclosure */} -
- - - {isAdvancedSettingsOpen && ( -
- {/* Search Score Threshold Slider */} -
-
+ {/* Qdrant Settings */} +
- - - -
-
- - updateSetting("codebaseIndexSearchMinScore", values[0]) + + updateSetting("codebaseIndexQdrantUrl", e.target.value) } - className="flex-1" - data-testid="search-min-score-slider" + onBlur={(e: any) => { + // Set default Qdrant URL if field is empty + if (!e.target.value.trim()) { + currentSettings.codebaseIndexQdrantUrl = DEFAULT_QDRANT_URL + updateSetting("codebaseIndexQdrantUrl", DEFAULT_QDRANT_URL) + } + }} + placeholder={t("settings:codeIndex.qdrantUrlPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexQdrantUrl, + })} /> - - {( - currentSettings.codebaseIndexSearchMinScore ?? - CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE - ).toFixed(2)} - - - updateSetting( - "codebaseIndexSearchMinScore", - CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE, - ) - }> - - + {formErrors.codebaseIndexQdrantUrl && ( +

+ {formErrors.codebaseIndexQdrantUrl} +

+ )}
-
- {/* Maximum Search Results Slider */} -
-
+
- - - -
-
- - updateSetting("codebaseIndexSearchMaxResults", values[0]) - } - className="flex-1" - data-testid="search-max-results-slider" + updateSetting("codeIndexQdrantApiKey", e.target.value)} + placeholder={t("settings:codeIndex.qdrantApiKeyPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codeIndexQdrantApiKey, + })} /> - - {currentSettings.codebaseIndexSearchMaxResults ?? - CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS} - - - updateSetting( - "codebaseIndexSearchMaxResults", - CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS, - ) - }> - - + {formErrors.codeIndexQdrantApiKey && ( +

+ {formErrors.codeIndexQdrantApiKey} +

+ )}
-
- )} -
- - {/* Action Buttons */} -
-
- {(indexingStatus.systemStatus === "Error" || indexingStatus.systemStatus === "Standby") && ( - vscode.postMessage({ type: "startIndexing" })} - disabled={saveStatus === "saving" || hasUnsavedChanges}> - {t("settings:codeIndex.startIndexingButton")} - )} +
- {(indexingStatus.systemStatus === "Indexed" || indexingStatus.systemStatus === "Error") && ( - - - - {t("settings:codeIndex.clearIndexDataButton")} - - - - - - {t("settings:codeIndex.clearDataDialog.title")} - - - {t("settings:codeIndex.clearDataDialog.description")} - - - - - {t("settings:codeIndex.clearDataDialog.cancelButton")} - - vscode.postMessage({ type: "clearIndexData" })}> - {t("settings:codeIndex.clearDataDialog.confirmButton")} - - - - + {/* Advanced Settings Disclosure */} +
+ + + {isAdvancedSettingsOpen && ( +
+ {/* Search Score Threshold Slider */} +
+
+ + + + +
+
+ + updateSetting("codebaseIndexSearchMinScore", values[0]) + } + className="flex-1" + data-testid="search-min-score-slider" + /> + + {( + currentSettings.codebaseIndexSearchMinScore ?? + CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE + ).toFixed(2)} + + + updateSetting( + "codebaseIndexSearchMinScore", + CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE, + ) + }> + + +
+
+ + {/* Maximum Search Results Slider */} +
+
+ + + + +
+
+ + updateSetting("codebaseIndexSearchMaxResults", values[0]) + } + className="flex-1" + data-testid="search-max-results-slider" + /> + + {currentSettings.codebaseIndexSearchMaxResults ?? + CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS} + + + updateSetting( + "codebaseIndexSearchMaxResults", + CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS, + ) + }> + + +
+
+
)}
- - {saveStatus === "saving" - ? t("settings:codeIndex.saving") - : t("settings:codeIndex.saveSettings")} - -
+ {/* Action Buttons */} +
+
+ {(indexingStatus.systemStatus === "Error" || + indexingStatus.systemStatus === "Standby") && ( + vscode.postMessage({ type: "startIndexing" })} + disabled={saveStatus === "saving" || hasUnsavedChanges}> + {t("settings:codeIndex.startIndexingButton")} + + )} - {/* Save Status Messages */} - {saveStatus === "error" && ( -
- - {saveError || t("settings:codeIndex.saveError")} - + {(indexingStatus.systemStatus === "Indexed" || + indexingStatus.systemStatus === "Error") && ( + + + + {t("settings:codeIndex.clearIndexDataButton")} + + + + + + {t("settings:codeIndex.clearDataDialog.title")} + + + {t("settings:codeIndex.clearDataDialog.description")} + + + + + {t("settings:codeIndex.clearDataDialog.cancelButton")} + + vscode.postMessage({ type: "clearIndexData" })}> + {t("settings:codeIndex.clearDataDialog.confirmButton")} + + + + + )} +
+ + + {saveStatus === "saving" + ? t("settings:codeIndex.saving") + : t("settings:codeIndex.saveSettings")} +
- )} -
- - + + {/* Save Status Messages */} + {saveStatus === "error" && ( +
+ + {saveError || t("settings:codeIndex.saveError")} + +
+ )} +
+ + + + {/* Discard Changes Dialog */} + + + + + + {t("settings:unsavedChangesDialog.title")} + + + {t("settings:unsavedChangesDialog.description")} + + + + onConfirmDialogResult(false)}> + {t("settings:unsavedChangesDialog.cancelButton")} + + onConfirmDialogResult(true)}> + {t("settings:unsavedChangesDialog.discardButton")} + + + + + ) } diff --git a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx new file mode 100644 index 0000000000..4dd89288ea --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx @@ -0,0 +1,375 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { CodeIndexPopover } from "../CodeIndexPopover" + +// Mock the vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock the extension state context +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: vi.fn(), +})) + +// Mock the translation context +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: vi.fn((key: string) => key) }), +})) + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + Trans: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +// Mock the doc links utility +vi.mock("@src/utils/docLinks", () => ({ + buildDocLink: vi.fn(() => "https://docs.roocode.com"), +})) + +// Mock the portal hook +vi.mock("@src/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => ({ portalContainer: document.body }), +})) + +// Mock Radix UI components to avoid portal issues +vi.mock("@src/components/ui", () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + Select: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder}, + AlertDialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogAction: ({ children }: { children: React.ReactNode }) => , + AlertDialogCancel: ({ children }: { children: React.ReactNode }) => , + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + Slider: ({ value, onValueChange }: { value: number[]; onValueChange: (value: number[]) => void }) => ( + onValueChange([parseInt(e.target.value)])} /> + ), + StandardTooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + cn: (...classes: string[]) => classes.join(" "), +})) + +// Mock VSCode web components to behave like regular HTML inputs +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ value, onInput, placeholder, className, ...rest }: any) => ( + onInput && onInput(e)} + placeholder={placeholder} + className={className} + aria-label="Text field" + {...rest} + /> + ), + VSCodeButton: ({ children, onClick, ...rest }: any) => ( + + ), + VSCodeDropdown: ({ value, onChange, children, className, ...rest }: any) => ( + + ), + VSCodeOption: ({ value, children, ...rest }: any) => ( + + ), + VSCodeLink: ({ href, children, ...rest }: any) => ( + + {children} + + ), +})) + +// Helper function to simulate input on form elements +const simulateInput = (element: Element, value: string) => { + // Now that we're mocking VSCode components as regular HTML inputs, + // we can use standard fireEvent.change + fireEvent.change(element, { target: { value } }) +} + +describe("CodeIndexPopover Validation", () => { + let mockUseExtensionState: any + + beforeEach(async () => { + vi.clearAllMocks() + + // Get the mocked function + const { useExtensionState } = await import("@src/context/ExtensionStateContext") + mockUseExtensionState = vi.mocked(useExtensionState) + + // Setup default extension state + mockUseExtensionState.mockReturnValue({ + codebaseIndexConfig: { + codebaseIndexEnabled: false, + codebaseIndexQdrantUrl: "", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "", + codebaseIndexSearchMaxResults: 10, + codebaseIndexSearchMinScore: 0.7, + codebaseIndexOpenAiCompatibleBaseUrl: "", + codebaseIndexEmbedderModelDimension: undefined, + }, + codebaseIndexModels: { + openai: [{ dimension: 1536 }], + }, + }) + }) + + const renderComponent = () => { + return render( + + + , + ) + } + + const openPopover = async () => { + const trigger = screen.getByText("Test Trigger") + fireEvent.click(trigger) + + // Wait for popover to open + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument() + }) + } + + const expandSetupSection = async () => { + const setupButton = screen.getByText("settings:codeIndex.setupConfigLabel") + fireEvent.click(setupButton) + + // Wait for section to expand - look for vscode-text-field elements + await waitFor(() => { + const textFields = screen.getAllByLabelText("Text field") + expect(textFields.length).toBeGreaterThan(0) + }) + } + + describe("OpenAI Provider Validation", () => { + it("should show validation error when OpenAI API key is empty", async () => { + renderComponent() + await openPopover() + await expandSetupSection() + + // First, make a change to enable the save button by modifying the Qdrant URL + const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i) + fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } }) + + // Wait for the save button to become enabled + await waitFor(() => { + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + expect(saveButton).not.toBeDisabled() + }) + + // Now clear the OpenAI API key to create a validation error + const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i) + fireEvent.change(apiKeyField, { target: { value: "" } }) + + // Click the save button to trigger validation + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + fireEvent.click(saveButton) + + // Should show specific field error + await waitFor(() => { + expect(screen.getByText("settings:codeIndex.validation.openaiApiKeyRequired")).toBeInTheDocument() + }) + }) + + it("should show validation error when model is not selected", async () => { + renderComponent() + await openPopover() + await expandSetupSection() + + // First, make a change to enable the save button + const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i) + fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } }) + + // Set API key but leave model empty + const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i) + fireEvent.change(apiKeyField, { target: { value: "test-api-key" } }) + + // Wait for the save button to become enabled + await waitFor(() => { + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + expect(saveButton).not.toBeDisabled() + }) + + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByText("settings:codeIndex.validation.modelSelectionRequired")).toBeInTheDocument() + }) + }) + }) + + describe("Qdrant URL Validation", () => { + it("should show validation error when Qdrant URL is empty", async () => { + renderComponent() + await openPopover() + await expandSetupSection() + + // First, make a change to enable the save button by setting API key + const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i) + fireEvent.change(apiKeyField, { target: { value: "test-api-key" } }) + + // Clear the Qdrant URL to create validation error + const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i) + fireEvent.change(qdrantUrlField, { target: { value: "" } }) + + // Wait for the save button to become enabled + await waitFor(() => { + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + expect(saveButton).not.toBeDisabled() + }) + + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByText("settings:codeIndex.validation.invalidQdrantUrl")).toBeInTheDocument() + }) + }) + + it("should show validation error when Qdrant URL is invalid", async () => { + renderComponent() + await openPopover() + await expandSetupSection() + + // First, make a change to enable the save button by setting API key + const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i) + fireEvent.change(apiKeyField, { target: { value: "test-api-key" } }) + + // Set invalid Qdrant URL + const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i) + fireEvent.change(qdrantUrlField, { target: { value: "invalid-url" } }) + + // Wait for the save button to become enabled + await waitFor(() => { + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + expect(saveButton).not.toBeDisabled() + }) + + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByText("settings:codeIndex.validation.invalidQdrantUrl")).toBeInTheDocument() + }) + }) + }) + + describe("Common Field Validation", () => { + it("should not show validation error for optional Qdrant API key", async () => { + renderComponent() + await openPopover() + await expandSetupSection() + + // Set required fields to make form valid + const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i) + fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } }) + + const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i) + fireEvent.change(apiKeyField, { target: { value: "test-api-key" } }) + + // Select a model - this is required (get the select element specifically) + const modelSelect = screen.getAllByRole("combobox").find((el) => el.tagName === "SELECT") + if (modelSelect) { + fireEvent.change(modelSelect, { target: { value: "0" } }) + } + + // Leave Qdrant API key empty (it's optional) + const qdrantApiKeyField = screen.getByPlaceholderText(/settings:codeIndex.qdrantApiKeyPlaceholder/i) + fireEvent.change(qdrantApiKeyField, { target: { value: "" } }) + + // Wait for the save button to become enabled + await waitFor(() => { + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + expect(saveButton).not.toBeDisabled() + }) + + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + fireEvent.click(saveButton) + + // Should not show validation errors since Qdrant API key is optional + }) + + it("should clear validation errors when fields are corrected", async () => { + renderComponent() + await openPopover() + await expandSetupSection() + + // First make an invalid change to enable the save button and trigger validation + const textFields = screen.getAllByLabelText("Text field") + const qdrantField = textFields.find((field) => + field.getAttribute("placeholder")?.toLowerCase().includes("qdrant"), + ) + + if (qdrantField) { + simulateInput(qdrantField, "invalid-url") // Invalid URL to trigger validation + } + + // Wait for save button to be enabled + const saveButton = screen.getByText("settings:codeIndex.saveSettings") + await waitFor(() => { + expect(saveButton).not.toBeDisabled() + }) + + // Click save to trigger validation errors + fireEvent.click(saveButton) + + // Now fix the errors with valid values + const apiKeyField = textFields.find( + (field) => + field.getAttribute("placeholder")?.toLowerCase().includes("openai") || + field.getAttribute("placeholder")?.toLowerCase().includes("key"), + ) + + // Set valid Qdrant URL + if (qdrantField) { + simulateInput(qdrantField, "http://localhost:6333") + } + + // Set API key + if (apiKeyField) { + simulateInput(apiKeyField, "test-api-key") + } + + // Select a model - this is required (get the select element specifically) + const modelSelect = screen.getAllByRole("combobox").find((el) => el.tagName === "SELECT") + if (modelSelect) { + fireEvent.change(modelSelect, { target: { value: "0" } }) + } + + // Try to save again + fireEvent.click(saveButton) + + // Validation errors should be cleared (specific field errors are checked elsewhere) + }) + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 470b0fae1d..57b5dadaae 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -98,6 +98,21 @@ "error": "Error" }, "close": "Tancar", + "validation": { + "invalidQdrantUrl": "URL de Qdrant no vàlida", + "invalidOllamaUrl": "URL d'Ollama no vàlida", + "invalidBaseUrl": "URL de base no vàlida", + "qdrantUrlRequired": "Cal una URL de Qdrant", + "openaiApiKeyRequired": "Cal una clau d'API d'OpenAI", + "modelSelectionRequired": "Cal seleccionar un model", + "apiKeyRequired": "Cal una clau d'API", + "modelIdRequired": "Cal un ID de model", + "modelDimensionRequired": "Cal una dimensió de model", + "geminiApiKeyRequired": "Cal una clau d'API de Gemini", + "ollamaBaseUrlRequired": "Cal una URL base d'Ollama", + "baseUrlRequired": "Cal una URL base", + "modelDimensionMinValue": "La dimensió del model ha de ser superior a 0" + }, "advancedConfigLabel": "Configuració avançada", "searchMinScoreLabel": "Llindar de puntuació de cerca", "searchMinScoreDescription": "Puntuació mínima de similitud (0.0-1.0) requerida per als resultats de la cerca. Valors més baixos retornen més resultats però poden ser menys rellevants. Valors més alts retornen menys resultats però més rellevants.", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index aa97677655..8d57652ba2 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -98,6 +98,21 @@ "error": "Fehler" }, "close": "Schließen", + "validation": { + "invalidQdrantUrl": "Ungültige Qdrant-URL", + "invalidOllamaUrl": "Ungültige Ollama-URL", + "invalidBaseUrl": "Ungültige Basis-URL", + "qdrantUrlRequired": "Qdrant-URL ist erforderlich", + "openaiApiKeyRequired": "OpenAI-API-Schlüssel ist erforderlich", + "modelSelectionRequired": "Modellauswahl ist erforderlich", + "apiKeyRequired": "API-Schlüssel ist erforderlich", + "modelIdRequired": "Modell-ID ist erforderlich", + "modelDimensionRequired": "Modellabmessung ist erforderlich", + "geminiApiKeyRequired": "Gemini-API-Schlüssel ist erforderlich", + "ollamaBaseUrlRequired": "Ollama-Basis-URL ist erforderlich", + "baseUrlRequired": "Basis-URL ist erforderlich", + "modelDimensionMinValue": "Modellabmessung muss größer als 0 sein" + }, "advancedConfigLabel": "Erweiterte Konfiguration", "searchMinScoreLabel": "Suchergebnis-Schwellenwert", "searchMinScoreDescription": "Mindestähnlichkeitswert (0.0-1.0), der für Suchergebnisse erforderlich ist. Niedrigere Werte liefern mehr Ergebnisse, die jedoch möglicherweise weniger relevant sind. Höhere Werte liefern weniger, aber relevantere Ergebnisse.", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7ae4e85b40..da40058b00 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -104,7 +104,22 @@ "indexed": "Indexed", "error": "Error" }, - "close": "Close" + "close": "Close", + "validation": { + "qdrantUrlRequired": "Qdrant URL is required", + "invalidQdrantUrl": "Invalid Qdrant URL", + "invalidOllamaUrl": "Invalid Ollama URL", + "invalidBaseUrl": "Invalid base URL", + "openaiApiKeyRequired": "OpenAI API key is required", + "modelSelectionRequired": "Model selection is required", + "apiKeyRequired": "API key is required", + "modelIdRequired": "Model ID is required", + "modelDimensionRequired": "Model dimension is required", + "geminiApiKeyRequired": "Gemini API key is required", + "ollamaBaseUrlRequired": "Ollama base URL is required", + "baseUrlRequired": "Base URL is required", + "modelDimensionMinValue": "Model dimension must be greater than 0" + } }, "autoApprove": { "description": "Allow Roo to automatically perform operations without requiring approval. Enable these settings only if you fully trust the AI and understand the associated security risks.", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 390a180d2c..b91d0e055f 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -98,6 +98,21 @@ "error": "Error" }, "close": "Cerrar", + "validation": { + "invalidQdrantUrl": "URL de Qdrant no válida", + "invalidOllamaUrl": "URL de Ollama no válida", + "invalidBaseUrl": "URL base no válida", + "qdrantUrlRequired": "Se requiere la URL de Qdrant", + "openaiApiKeyRequired": "Se requiere la clave API de OpenAI", + "modelSelectionRequired": "Se requiere la selección de un modelo", + "apiKeyRequired": "Se requiere la clave API", + "modelIdRequired": "Se requiere el ID del modelo", + "modelDimensionRequired": "Se requiere la dimensión del modelo", + "geminiApiKeyRequired": "Se requiere la clave API de Gemini", + "ollamaBaseUrlRequired": "Se requiere la URL base de Ollama", + "baseUrlRequired": "Se requiere la URL base", + "modelDimensionMinValue": "La dimensión del modelo debe ser mayor que 0" + }, "advancedConfigLabel": "Configuración avanzada", "searchMinScoreLabel": "Umbral de puntuación de búsqueda", "searchMinScoreDescription": "Puntuación mínima de similitud (0.0-1.0) requerida para los resultados de búsqueda. Valores más bajos devuelven más resultados pero pueden ser menos relevantes. Valores más altos devuelven menos resultados pero más relevantes.", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 21ad19f727..d4940567c6 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -98,6 +98,21 @@ "error": "Erreur" }, "close": "Fermer", + "validation": { + "invalidQdrantUrl": "URL Qdrant invalide", + "invalidOllamaUrl": "URL Ollama invalide", + "invalidBaseUrl": "URL de base invalide", + "qdrantUrlRequired": "L'URL Qdrant est requise", + "openaiApiKeyRequired": "La clé API OpenAI est requise", + "modelSelectionRequired": "La sélection du modèle est requise", + "apiKeyRequired": "La clé API est requise", + "modelIdRequired": "L'ID du modèle est requis", + "modelDimensionRequired": "La dimension du modèle est requise", + "geminiApiKeyRequired": "La clé API Gemini est requise", + "ollamaBaseUrlRequired": "L'URL de base Ollama est requise", + "baseUrlRequired": "L'URL de base est requise", + "modelDimensionMinValue": "La dimension du modèle doit être supérieure à 0" + }, "advancedConfigLabel": "Configuration avancée", "searchMinScoreLabel": "Seuil de score de recherche", "searchMinScoreDescription": "Score de similarité minimum (0.0-1.0) requis pour les résultats de recherche. Des valeurs plus faibles renvoient plus de résultats mais peuvent être moins pertinents. Des valeurs plus élevées renvoient moins de résultats mais plus pertinents.", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index a004bacaef..8665afc990 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -98,6 +98,21 @@ "error": "त्रुटि" }, "close": "बंद करें", + "validation": { + "invalidQdrantUrl": "अमान्य Qdrant URL", + "invalidOllamaUrl": "अमान्य Ollama URL", + "invalidBaseUrl": "अमान्य बेस URL", + "qdrantUrlRequired": "Qdrant URL आवश्यक है", + "openaiApiKeyRequired": "OpenAI API कुंजी आवश्यक है", + "modelSelectionRequired": "मॉडल चयन आवश्यक है", + "apiKeyRequired": "API कुंजी आवश्यक है", + "modelIdRequired": "मॉडल आईडी आवश्यक है", + "modelDimensionRequired": "मॉडल आयाम आवश्यक है", + "geminiApiKeyRequired": "Gemini API कुंजी आवश्यक है", + "ollamaBaseUrlRequired": "Ollama आधार URL आवश्यक है", + "baseUrlRequired": "आधार URL आवश्यक है", + "modelDimensionMinValue": "मॉडल आयाम 0 से बड़ा होना चाहिए" + }, "advancedConfigLabel": "उन्नत कॉन्फ़िगरेशन", "searchMinScoreLabel": "खोज स्कोर थ्रेसहोल्ड", "searchMinScoreDescription": "खोज परिणामों के लिए आवश्यक न्यूनतम समानता स्कोर (0.0-1.0)। कम मान अधिक परिणाम लौटाते हैं लेकिन कम प्रासंगिक हो सकते हैं। उच्च मान कम लेकिन अधिक प्रासंगिक परिणाम लौटाते हैं।", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 63b7950544..199751e384 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -98,6 +98,21 @@ "error": "Error" }, "close": "Tutup", + "validation": { + "invalidQdrantUrl": "URL Qdrant tidak valid", + "invalidOllamaUrl": "URL Ollama tidak valid", + "invalidBaseUrl": "URL dasar tidak valid", + "qdrantUrlRequired": "URL Qdrant diperlukan", + "openaiApiKeyRequired": "Kunci API OpenAI diperlukan", + "modelSelectionRequired": "Pemilihan model diperlukan", + "apiKeyRequired": "Kunci API diperlukan", + "modelIdRequired": "ID Model diperlukan", + "modelDimensionRequired": "Dimensi model diperlukan", + "geminiApiKeyRequired": "Kunci API Gemini diperlukan", + "ollamaBaseUrlRequired": "URL dasar Ollama diperlukan", + "baseUrlRequired": "URL dasar diperlukan", + "modelDimensionMinValue": "Dimensi model harus lebih besar dari 0" + }, "advancedConfigLabel": "Konfigurasi Lanjutan", "searchMinScoreLabel": "Ambang Batas Skor Pencarian", "searchMinScoreDescription": "Skor kesamaan minimum (0.0-1.0) yang diperlukan untuk hasil pencarian. Nilai yang lebih rendah mengembalikan lebih banyak hasil tetapi mungkin kurang relevan. Nilai yang lebih tinggi mengembalikan lebih sedikit hasil tetapi lebih relevan.", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 46d4c82f3a..48a4c8e4db 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -98,6 +98,21 @@ "error": "Errore" }, "close": "Chiudi", + "validation": { + "invalidQdrantUrl": "URL Qdrant non valido", + "invalidOllamaUrl": "URL Ollama non valido", + "invalidBaseUrl": "URL di base non valido", + "qdrantUrlRequired": "È richiesto l'URL di Qdrant", + "openaiApiKeyRequired": "È richiesta la chiave API di OpenAI", + "modelSelectionRequired": "È richiesta la selezione del modello", + "apiKeyRequired": "È richiesta la chiave API", + "modelIdRequired": "È richiesto l'ID del modello", + "modelDimensionRequired": "È richiesta la dimensione del modello", + "geminiApiKeyRequired": "È richiesta la chiave API Gemini", + "ollamaBaseUrlRequired": "È richiesto l'URL di base di Ollama", + "baseUrlRequired": "È richiesto l'URL di base", + "modelDimensionMinValue": "La dimensione del modello deve essere maggiore di 0" + }, "advancedConfigLabel": "Configurazione avanzata", "searchMinScoreLabel": "Soglia punteggio di ricerca", "searchMinScoreDescription": "Punteggio minimo di somiglianza (0.0-1.0) richiesto per i risultati della ricerca. Valori più bassi restituiscono più risultati ma potrebbero essere meno pertinenti. Valori più alti restituiscono meno risultati ma più pertinenti.", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 75656a1d81..397ce67f62 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -98,6 +98,21 @@ "error": "エラー" }, "close": "閉じる", + "validation": { + "invalidQdrantUrl": "無効なQdrant URL", + "invalidOllamaUrl": "無効なOllama URL", + "invalidBaseUrl": "無効なベースURL", + "qdrantUrlRequired": "Qdrant URL が必要です", + "openaiApiKeyRequired": "OpenAI APIキーが必要です", + "modelSelectionRequired": "モデルの選択が必要です", + "apiKeyRequired": "APIキーが必要です", + "modelIdRequired": "モデルIDが必要です", + "modelDimensionRequired": "モデルの次元が必要です", + "geminiApiKeyRequired": "Gemini APIキーが必要です", + "ollamaBaseUrlRequired": "OllamaのベースURLが必要です", + "baseUrlRequired": "ベースURLが必要です", + "modelDimensionMinValue": "モデルの次元は0より大きくなければなりません" + }, "advancedConfigLabel": "詳細設定", "searchMinScoreLabel": "検索スコアのしきい値", "searchMinScoreDescription": "検索結果に必要な最小類似度スコア(0.0-1.0)。値を低くするとより多くの結果が返されますが、関連性が低くなる可能性があります。値を高くすると返される結果は少なくなりますが、より関連性が高くなります。", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index c90a44f27d..746cea65ad 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -98,6 +98,21 @@ "error": "오류" }, "close": "닫기", + "validation": { + "invalidQdrantUrl": "잘못된 Qdrant URL", + "invalidOllamaUrl": "잘못된 Ollama URL", + "invalidBaseUrl": "잘못된 기본 URL", + "qdrantUrlRequired": "Qdrant URL이 필요합니다", + "openaiApiKeyRequired": "OpenAI API 키가 필요합니다", + "modelSelectionRequired": "모델 선택이 필요합니다", + "apiKeyRequired": "API 키가 필요합니다", + "modelIdRequired": "모델 ID가 필요합니다", + "modelDimensionRequired": "모델 차원이 필요합니다", + "geminiApiKeyRequired": "Gemini API 키가 필요합니다", + "ollamaBaseUrlRequired": "Ollama 기본 URL이 필요합니다", + "baseUrlRequired": "기본 URL이 필요합니다", + "modelDimensionMinValue": "모델 차원은 0보다 커야 합니다" + }, "advancedConfigLabel": "고급 구성", "searchMinScoreLabel": "검색 점수 임계값", "searchMinScoreDescription": "검색 결과에 필요한 최소 유사도 점수(0.0-1.0). 값이 낮을수록 더 많은 결과가 반환되지만 관련성이 떨어질 수 있습니다. 값이 높을수록 결과는 적지만 관련성이 높은 결과가 반환됩니다.", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index b57985512b..c5315205ca 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -98,6 +98,21 @@ "error": "Fout" }, "close": "Sluiten", + "validation": { + "invalidQdrantUrl": "Ongeldige Qdrant URL", + "invalidOllamaUrl": "Ongeldige Ollama URL", + "invalidBaseUrl": "Ongeldige basis-URL", + "qdrantUrlRequired": "Qdrant URL is vereist", + "openaiApiKeyRequired": "OpenAI API-sleutel is vereist", + "modelSelectionRequired": "Modelselectie is vereist", + "apiKeyRequired": "API-sleutel is vereist", + "modelIdRequired": "Model-ID is vereist", + "modelDimensionRequired": "Modelafmeting is vereist", + "geminiApiKeyRequired": "Gemini API-sleutel is vereist", + "ollamaBaseUrlRequired": "Ollama basis-URL is vereist", + "baseUrlRequired": "Basis-URL is vereist", + "modelDimensionMinValue": "Modelafmeting moet groter zijn dan 0" + }, "advancedConfigLabel": "Geavanceerde configuratie", "searchMinScoreLabel": "Zoekscore drempel", "searchMinScoreDescription": "Minimale overeenkomstscore (0.0-1.0) vereist voor zoekresultaten. Lagere waarden leveren meer resultaten op, maar zijn mogelijk minder relevant. Hogere waarden leveren minder, maar relevantere resultaten op.", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index c9ab001cac..1829c23ba4 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -98,6 +98,21 @@ "error": "Błąd" }, "close": "Zamknij", + "validation": { + "invalidQdrantUrl": "Nieprawidłowy URL Qdrant", + "invalidOllamaUrl": "Nieprawidłowy URL Ollama", + "invalidBaseUrl": "Nieprawidłowy podstawowy URL", + "qdrantUrlRequired": "Wymagany jest URL Qdrant", + "openaiApiKeyRequired": "Wymagany jest klucz API OpenAI", + "modelSelectionRequired": "Wymagany jest wybór modelu", + "apiKeyRequired": "Wymagany jest klucz API", + "modelIdRequired": "Wymagane jest ID modelu", + "modelDimensionRequired": "Wymagany jest wymiar modelu", + "geminiApiKeyRequired": "Wymagany jest klucz API Gemini", + "ollamaBaseUrlRequired": "Wymagany jest bazowy adres URL Ollama", + "baseUrlRequired": "Wymagany jest bazowy adres URL", + "modelDimensionMinValue": "Wymiar modelu musi być większy niż 0" + }, "advancedConfigLabel": "Konfiguracja zaawansowana", "searchMinScoreLabel": "Próg wyniku wyszukiwania", "searchMinScoreDescription": "Minimalny wynik podobieństwa (0.0-1.0) wymagany dla wyników wyszukiwania. Niższe wartości zwracają więcej wyników, ale mogą być mniej trafne. Wyższe wartości zwracają mniej wyników, ale bardziej trafnych.", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 6147369471..6e46cc8c3e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -98,6 +98,21 @@ "error": "Erro" }, "close": "Fechar", + "validation": { + "invalidQdrantUrl": "URL do Qdrant inválida", + "invalidOllamaUrl": "URL do Ollama inválida", + "invalidBaseUrl": "URL base inválida", + "qdrantUrlRequired": "A URL do Qdrant é obrigatória", + "openaiApiKeyRequired": "A chave de API da OpenAI é obrigatória", + "modelSelectionRequired": "A seleção do modelo é obrigatória", + "apiKeyRequired": "A chave de API é obrigatória", + "modelIdRequired": "O ID do modelo é obrigatório", + "modelDimensionRequired": "A dimensão do modelo é obrigatória", + "geminiApiKeyRequired": "A chave de API do Gemini é obrigatória", + "ollamaBaseUrlRequired": "A URL base do Ollama é obrigatória", + "baseUrlRequired": "A URL base é obrigatória", + "modelDimensionMinValue": "A dimensão do modelo deve ser maior que 0" + }, "advancedConfigLabel": "Configuração Avançada", "searchMinScoreLabel": "Limite de pontuação de busca", "searchMinScoreDescription": "Pontuação mínima de similaridade (0.0-1.0) necessária para os resultados da busca. Valores mais baixos retornam mais resultados, mas podem ser menos relevantes. Valores mais altos retornam menos resultados, mas mais relevantes.", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index f4dfcf55ad..e0a897c2e5 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -98,6 +98,21 @@ "error": "Ошибка" }, "close": "Закрыть", + "validation": { + "invalidQdrantUrl": "Неверный URL Qdrant", + "invalidOllamaUrl": "Неверный URL Ollama", + "invalidBaseUrl": "Неверный базовый URL", + "qdrantUrlRequired": "Требуется URL Qdrant", + "openaiApiKeyRequired": "Требуется ключ API OpenAI", + "modelSelectionRequired": "Требуется выбор модели", + "apiKeyRequired": "Требуется ключ API", + "modelIdRequired": "Требуется идентификатор модели", + "modelDimensionRequired": "Требуется размерность модели", + "geminiApiKeyRequired": "Требуется ключ API Gemini", + "ollamaBaseUrlRequired": "Требуется базовый URL Ollama", + "baseUrlRequired": "Требуется базовый URL", + "modelDimensionMinValue": "Размерность модели должна быть больше 0" + }, "advancedConfigLabel": "Расширенная конфигурация", "searchMinScoreLabel": "Порог оценки поиска", "searchMinScoreDescription": "Минимальный балл сходства (0.0-1.0), необходимый для результатов поиска. Более низкие значения возвращают больше результатов, но они могут быть менее релевантными. Более высокие значения возвращают меньше результатов, но более релевантных.", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index bdc5d020c4..486991ec0d 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -98,6 +98,21 @@ "error": "Hata" }, "close": "Kapat", + "validation": { + "invalidQdrantUrl": "Geçersiz Qdrant URL'si", + "invalidOllamaUrl": "Geçersiz Ollama URL'si", + "invalidBaseUrl": "Geçersiz temel URL'si", + "qdrantUrlRequired": "Qdrant URL'si gereklidir", + "openaiApiKeyRequired": "OpenAI API anahtarı gereklidir", + "modelSelectionRequired": "Model seçimi gereklidir", + "apiKeyRequired": "API anahtarı gereklidir", + "modelIdRequired": "Model kimliği gereklidir", + "modelDimensionRequired": "Model boyutu gereklidir", + "geminiApiKeyRequired": "Gemini API anahtarı gereklidir", + "ollamaBaseUrlRequired": "Ollama temel URL'si gereklidir", + "baseUrlRequired": "Temel URL'si gereklidir", + "modelDimensionMinValue": "Model boyutu 0'dan büyük olmalıdır" + }, "advancedConfigLabel": "Gelişmiş Yapılandırma", "searchMinScoreLabel": "Arama Skoru Eşiği", "searchMinScoreDescription": "Arama sonuçları için gereken minimum benzerlik puanı (0.0-1.0). Düşük değerler daha fazla sonuç döndürür ancak daha az alakalı olabilir. Yüksek değerler daha az ancak daha alakalı sonuçlar döndürür.", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 915130b1e5..e31355b403 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -98,6 +98,21 @@ "error": "Lỗi" }, "close": "Đóng", + "validation": { + "invalidQdrantUrl": "URL Qdrant không hợp lệ", + "invalidOllamaUrl": "URL Ollama không hợp lệ", + "invalidBaseUrl": "URL cơ sở không hợp lệ", + "qdrantUrlRequired": "Yêu cầu URL Qdrant", + "openaiApiKeyRequired": "Yêu cầu khóa API OpenAI", + "modelSelectionRequired": "Yêu cầu chọn mô hình", + "apiKeyRequired": "Yêu cầu khóa API", + "modelIdRequired": "Yêu cầu ID mô hình", + "modelDimensionRequired": "Yêu cầu kích thước mô hình", + "geminiApiKeyRequired": "Yêu cầu khóa API Gemini", + "ollamaBaseUrlRequired": "Yêu cầu URL cơ sở Ollama", + "baseUrlRequired": "Yêu cầu URL cơ sở", + "modelDimensionMinValue": "Kích thước mô hình phải lớn hơn 0" + }, "advancedConfigLabel": "Cấu hình nâng cao", "searchMinScoreLabel": "Ngưỡng điểm tìm kiếm", "searchMinScoreDescription": "Điểm tương đồng tối thiểu (0.0-1.0) cần thiết cho kết quả tìm kiếm. Giá trị thấp hơn trả về nhiều kết quả hơn nhưng có thể kém liên quan hơn. Giá trị cao hơn trả về ít kết quả hơn nhưng có liên quan hơn.", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index eed0d80c78..4b46b9af0a 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -98,6 +98,21 @@ "error": "错误" }, "close": "关闭", + "validation": { + "invalidQdrantUrl": "无效的 Qdrant URL", + "invalidOllamaUrl": "无效的 Ollama URL", + "invalidBaseUrl": "无效的基础 URL", + "qdrantUrlRequired": "需要 Qdrant URL", + "openaiApiKeyRequired": "需要 OpenAI API 密钥", + "modelSelectionRequired": "需要选择模型", + "apiKeyRequired": "需要 API 密钥", + "modelIdRequired": "需要模型 ID", + "modelDimensionRequired": "需要模型维度", + "geminiApiKeyRequired": "需要 Gemini API 密钥", + "ollamaBaseUrlRequired": "需要 Ollama 基础 URL", + "baseUrlRequired": "需要基础 URL", + "modelDimensionMinValue": "模型维度必须大于 0" + }, "advancedConfigLabel": "高级配置", "searchMinScoreLabel": "搜索分数阈值", "searchMinScoreDescription": "搜索结果所需的最低相似度分数(0.0-1.0)。较低的值返回更多结果,但可能不太相关。较高的值返回较少但更相关的结果。", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 6871695616..3e35097b1e 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -98,6 +98,21 @@ "error": "錯誤" }, "close": "關閉", + "validation": { + "invalidQdrantUrl": "無效的 Qdrant URL", + "invalidOllamaUrl": "無效的 Ollama URL", + "invalidBaseUrl": "無效的基礎 URL", + "qdrantUrlRequired": "需要 Qdrant URL", + "openaiApiKeyRequired": "需要 OpenAI API 金鑰", + "modelSelectionRequired": "需要選擇模型", + "apiKeyRequired": "需要 API 金鑰", + "modelIdRequired": "需要模型 ID", + "modelDimensionRequired": "需要模型維度", + "geminiApiKeyRequired": "需要 Gemini API 金鑰", + "ollamaBaseUrlRequired": "需要 Ollama 基礎 URL", + "baseUrlRequired": "需要基礎 URL", + "modelDimensionMinValue": "模型維度必須大於 0" + }, "advancedConfigLabel": "進階設定", "searchMinScoreLabel": "搜尋分數閾值", "searchMinScoreDescription": "搜尋結果所需的最低相似度分數(0.0-1.0)。較低的值會傳回更多結果,但可能較不相關。較高的值會傳回較少但更相關的結果。",