diff --git a/.changeset/quiet-qdrant-warnings.md b/.changeset/quiet-qdrant-warnings.md new file mode 100644 index 00000000000..de8e3c200e9 --- /dev/null +++ b/.changeset/quiet-qdrant-warnings.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stabilize code indexing workers, retry Kilo model catalog downloads, reduce progress log noise, and show indexing failures as TUI notifications instead of writing over the terminal interface. diff --git a/.changeset/scoped-indexing-settings.md b/.changeset/scoped-indexing-settings.md new file mode 100644 index 00000000000..7ca7c5b2138 --- /dev/null +++ b/.changeset/scoped-indexing-settings.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Support configuring code indexing separately for global and project settings in Kilo Console, the CLI TUI, and VS Code. diff --git a/packages/kilo-console/src/client.test.ts b/packages/kilo-console/src/client.test.ts index 5932d3ad98c..4c94e5e9091 100644 --- a/packages/kilo-console/src/client.test.ts +++ b/packages/kilo-console/src/client.test.ts @@ -23,11 +23,13 @@ test("config writes include the selected directory", async () => { await client.saveConfig(query, { permission: { edit: { "*": "allow" } } }) await client.unsetConfig(query, [["permission", "edit"]]) + await client.patchConfig(query, { indexing: { provider: "ollama" } }, [["indexing", "model"]]) - expect(calls).toHaveLength(2) + expect(calls).toHaveLength(3) const save = calls[0] const unset = calls[1] + const patch = calls[2] expect(save.method).toBe("PATCH") expect(new URL(save.url).searchParams.get("directory")).toBe("/tmp/project") expect(save.body).toEqual({ scope: "project", set: { permission: { edit: { "*": "allow" } } } }) @@ -35,4 +37,12 @@ test("config writes include the selected directory", async () => { expect(unset.method).toBe("PATCH") expect(new URL(unset.url).searchParams.get("directory")).toBe("/tmp/project") expect(unset.body).toEqual({ scope: "project", unset: [["permission", "edit"]] }) + + expect(patch.method).toBe("PATCH") + expect(new URL(patch.url).searchParams.get("directory")).toBe("/tmp/project") + expect(patch.body).toEqual({ + scope: "project", + set: { indexing: { provider: "ollama" } }, + unset: [["indexing", "model"]], + }) }) diff --git a/packages/kilo-console/src/client.ts b/packages/kilo-console/src/client.ts index b401c74e11a..9a3eb364893 100644 --- a/packages/kilo-console/src/client.ts +++ b/packages/kilo-console/src/client.ts @@ -10,6 +10,7 @@ import type { FormatterStatusResponse, GlobalHealthResponse, GlobalEvent, + KiloEmbeddingModelCatalog, LspStatusResponse, McpStatusResponse, Pty as PtyInfo, @@ -400,6 +401,10 @@ export async function load(input: Query): Promise { } } +export async function loadEmbeddingModels(input: Query): Promise { + return demand("Kilo embedding models", await client(input).indexing.models()) +} + export async function loadProjects(input: ProjectQuery): Promise { const sdk = client(input) const dir = value(input.dir) @@ -611,16 +616,24 @@ export function ptyWsUrl(input: Query, pty: string, cursor = 0) { return url.toString() } -export async function saveConfig(input: Query, patch: Partial) { +export async function patchConfig(input: Query, patch: Partial, unset?: ConfigUnset) { const sdk = client(input) - const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, set: patch }) + const set = Object.keys(patch).length ? patch : undefined + const result = await sdk.config.overlayUpdate({ + directory: value(input.dir), + scope: input.scope, + set, + unset, + }) return demand("Update config", result) } +export async function saveConfig(input: Query, patch: Partial) { + return patchConfig(input, patch) +} + export async function unsetConfig(input: Query, unset: ConfigUnset) { - const sdk = client(input) - const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, unset }) - return demand("Update config", result) + return patchConfig(input, {}, unset) } export async function saveModelState(input: Query, favorite: ModelRef[]) { diff --git a/packages/kilo-console/src/context/ConfigProvider.tsx b/packages/kilo-console/src/context/ConfigProvider.tsx index 15e0438e1ee..0d7ce62a65a 100644 --- a/packages/kilo-console/src/context/ConfigProvider.tsx +++ b/packages/kilo-console/src/context/ConfigProvider.tsx @@ -6,6 +6,7 @@ import { load, loadCached, loadProjects, + patchConfig, resolveServer, saveCached, saveConfig, @@ -133,6 +134,10 @@ export function ConfigProvider(props: { children?: JSX.Element }) { run("Saving config", () => saveConfig(target(), patch)) } + function patch(update: Partial, unset?: ConfigUnset) { + run("Saving config", () => patchConfig(target(), update, unset)) + } + function unset(paths: ConfigUnset) { run("Saving config", () => unsetConfig(target(), paths)) } @@ -150,6 +155,7 @@ export function ConfigProvider(props: { children?: JSX.Element }) { fail, run, save, + patch, unset, tui, } diff --git a/packages/kilo-console/src/context/config.tsx b/packages/kilo-console/src/context/config.tsx index 5570009a97a..966812580a6 100644 --- a/packages/kilo-console/src/context/config.tsx +++ b/packages/kilo-console/src/context/config.tsx @@ -16,6 +16,7 @@ export type Ctx = { fail: (message: string) => void run: (label: string, job: () => Promise, task?: Task) => void save: (patch: Partial) => void + patch: (patch: Partial, unset?: ConfigUnset) => void unset: (paths: ConfigUnset) => void tui: (patch: TuiPatch) => void } diff --git a/packages/kilo-console/src/routes/config/AgentsRoute.tsx b/packages/kilo-console/src/routes/config/AgentsRoute.tsx index bd699e623a7..36d740df0df 100644 --- a/packages/kilo-console/src/routes/config/AgentsRoute.tsx +++ b/packages/kilo-console/src/routes/config/AgentsRoute.tsx @@ -4,13 +4,12 @@ import { Button } from "@kilocode/kilo-web-ui/button" import { Card } from "@kilocode/kilo-web-ui/card" import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console" import { IconButton } from "@kilocode/kilo-web-ui/icon-button" -import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag" import { CustomSelect, type SelectOption } from "../../components/CustomSelect" import { SearchField } from "../../components/SearchField" import { useConfig } from "../../context/config" import { settings } from "../../shared/navigation" import { toolCapabilities, toolName } from "../../shared/utils" -import { ConfigPage, SourceBadge } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" import { ActionSelect, label as actionLabel, tone as actionTone } from "./PermissionsRoute" import { agentEditable, agentTitle, snippets, useAgentBuilder, type AgentEntry, type AgentItem } from "./state/agents" import type { PermissionAction } from "./state/permissions" diff --git a/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx b/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx index 17ef755ea4d..e8621cffc9a 100644 --- a/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx +++ b/packages/kilo-console/src/routes/config/CliNotificationsRoute.tsx @@ -1,7 +1,6 @@ import { Button } from "@kilocode/kilo-web-ui/button" import { Card } from "@kilocode/kilo-web-ui/card" -import { Tag } from "@kilocode/kilo-web-ui/tag" -import { ConfigPage } from "./ConfigPage" +import { ConfigPage, ConfigTag as Tag } from "./ConfigPage" import { useTuiNotificationSettings } from "./state/ui" function Toggle(props: { diff --git a/packages/kilo-console/src/routes/config/CliUiRoute.tsx b/packages/kilo-console/src/routes/config/CliUiRoute.tsx index 8bd6d264aef..30d80f17608 100644 --- a/packages/kilo-console/src/routes/config/CliUiRoute.tsx +++ b/packages/kilo-console/src/routes/config/CliUiRoute.tsx @@ -1,10 +1,9 @@ import { For, Show } from "solid-js" import { Button } from "@kilocode/kilo-web-ui/button" import { Card } from "@kilocode/kilo-web-ui/card" -import { Tag } from "@kilocode/kilo-web-ui/tag" import { CustomSelect, type SelectOption } from "../../components/CustomSelect" import { SearchField } from "../../components/SearchField" -import { ConfigPage } from "./ConfigPage" +import { ConfigPage, ConfigTag as Tag } from "./ConfigPage" import { type Theme, themeTitle, useTuiUiSettings } from "./state/ui" const diffs = [ diff --git a/packages/kilo-console/src/routes/config/ConfigPage.tsx b/packages/kilo-console/src/routes/config/ConfigPage.tsx index 6c056bcb9a3..13446c44425 100644 --- a/packages/kilo-console/src/routes/config/ConfigPage.tsx +++ b/packages/kilo-console/src/routes/config/ConfigPage.tsx @@ -1,6 +1,8 @@ import type { JSX } from "solid-js" import { Show } from "solid-js" -import { SourceBadge as UiSourceBadge } from "@kilocode/kilo-web-ui/console" +import { ConfigCountTag, ConfigTag, SourceBadge as UiSourceBadge } from "@kilocode/kilo-web-ui/console" + +export { ConfigCountTag, ConfigTag } export function ConfigPage(props: { title: JSX.Element diff --git a/packages/kilo-console/src/routes/config/FormattersRoute.tsx b/packages/kilo-console/src/routes/config/FormattersRoute.tsx index 5037b1fdb95..e9fb45246ff 100644 --- a/packages/kilo-console/src/routes/config/FormattersRoute.tsx +++ b/packages/kilo-console/src/routes/config/FormattersRoute.tsx @@ -2,8 +2,7 @@ import { For, Show } from "solid-js" import { Button } from "@kilocode/kilo-web-ui/button" import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console" import { IconButton } from "@kilocode/kilo-web-ui/icon-button" -import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag" -import { ConfigPage, SourceBadge } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" import { useFormatterSettings, type ToolRow } from "./state/formatters" type Kind = "formatter" | "lsp" diff --git a/packages/kilo-console/src/routes/config/IndexingRoute.tsx b/packages/kilo-console/src/routes/config/IndexingRoute.tsx new file mode 100644 index 00000000000..2b5463e939e --- /dev/null +++ b/packages/kilo-console/src/routes/config/IndexingRoute.tsx @@ -0,0 +1,546 @@ +import { Button } from "@kilocode/kilo-web-ui/button" +import { Card } from "@kilocode/kilo-web-ui/card" +import type { IndexingConfig } from "@kilocode/sdk/v2/client" +import { For, Show, createEffect, createMemo, createResource, createSignal, type JSX } from "solid-js" +import { CustomSelect, type SelectOption } from "../../components/CustomSelect" +import { loadEmbeddingModels } from "../../client" +import { useConfig } from "../../context/config" +import { ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" +import { clean, clone, merge, providerPatch, removed, shouldSync, validate } from "./state/indexing" + +type Provider = NonNullable +type ProviderValue = Provider | "" +type Store = NonNullable +type Field = { key: string; label: string; placeholder: string; secret?: boolean } + +const providers = [ + { value: "", label: "Automatic" }, + { value: "kilo", label: "Kilo" }, + { value: "openai", label: "OpenAI" }, + { value: "ollama", label: "Ollama (local)" }, + { value: "openai-compatible", label: "OpenAI-compatible" }, + { value: "gemini", label: "Gemini" }, + { value: "mistral", label: "Mistral" }, + { value: "vercel-ai-gateway", label: "Vercel AI Gateway" }, + { value: "bedrock", label: "AWS Bedrock" }, + { value: "openrouter", label: "OpenRouter" }, + { value: "voyage", label: "Voyage" }, +] satisfies SelectOption[] + +const stores = [ + { value: "lancedb", label: "LanceDB (default)" }, + { value: "qdrant", label: "Qdrant" }, +] satisfies SelectOption[] + +const fields: Record = { + kilo: [], + openai: [{ key: "apiKey", label: "API key", placeholder: "sk-...", secret: true }], + ollama: [{ key: "baseUrl", label: "Base URL", placeholder: "http://localhost:11434" }], + "openai-compatible": [ + { key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" }, + { key: "apiKey", label: "API key", placeholder: "sk-...", secret: true }, + ], + gemini: [{ key: "apiKey", label: "API key", placeholder: "AI...", secret: true }], + mistral: [{ key: "apiKey", label: "API key", placeholder: "...", secret: true }], + "vercel-ai-gateway": [{ key: "apiKey", label: "API key", placeholder: "...", secret: true }], + bedrock: [ + { key: "region", label: "AWS region", placeholder: "us-east-1" }, + { key: "profile", label: "AWS profile", placeholder: "default" }, + ], + openrouter: [ + { key: "apiKey", label: "API key", placeholder: "sk-or-...", secret: true }, + { key: "specificProvider", label: "Specific provider", placeholder: "Optional routing provider" }, + ], + voyage: [{ key: "apiKey", label: "API key", placeholder: "pa-...", secret: true }], +} + +function options(input: IndexingConfig, provider: Provider) { + const value = input[provider] + if (!value || typeof value !== "object") return {} + return value as Record +} + +function FieldCard(props: { label: string; description?: string; actions?: JSX.Element; children: JSX.Element }) { + return ( +
+
+
+ {props.label} + {(description) => {description()}} +
+ {(actions) =>
{actions()}
}
+
+
{props.children}
+
+ ) +} + +function Toggle(props: { + label: string + description: string + checked: boolean + disabled?: boolean + source?: string + inherited?: boolean + overridden?: boolean + onChange: () => void +}) { + return ( + + ) +} + +export function IndexingRoute() { + const ctx = useConfig() + const [draft, setDraft] = createSignal({}) + const [source, setSource] = createSignal("") + const [dirty, setDirty] = createSignal(false) + const scope = () => ctx.query()?.scope ?? "global" + const [selected, setSelected] = createSignal(scope()) + const project = () => scope() === "project" + const global = createMemo(() => ctx.data()?.overlay.global.indexing ?? {}) + const local = createMemo(() => { + const overlay = ctx.data()?.overlay + if (!overlay) return {} + return (project() ? overlay.project.indexing : overlay.global.indexing) ?? {} + }) + const view = createMemo(() => (project() ? merge(global(), draft()) : draft())) + const provider = createMemo(() => view().provider) + const store = createMemo(() => view().vectorStore ?? "lancedb") + const [catalog] = createResource(ctx.query, loadEmbeddingModels) + const kiloModels = createMemo[]>(() => { + const models = catalog()?.models ?? [] + if (models.length === 0) return [{ value: "", label: "No Kilo embedding models available", disabled: true }] + return models.map((model) => ({ + value: model.id, + label: `${model.name} (${model.note ? `${model.note}, ` : ""}${model.dimension}d)`, + })) + }) + const kiloModel = createMemo(() => { + const data = catalog() + if (!data) return "" + const model = view().model ?? data.defaultModel + return data.aliases[model] ?? model + }) + const errors = createMemo(() => validate(clean(draft()))) + const overridden = createMemo(() => Object.keys(local()).length > 0) + + createEffect(() => { + const current = scope() + const next = local() + const key = JSON.stringify(next) + if (!shouldSync(selected(), current, dirty(), source(), key)) return + setSelected(current) + setSource(key) + setDraft(clone(next)) + setDirty(false) + }) + + function field(path: string) { + return ctx.data()?.overlay.fields[`indexing.${path}`] + } + + function update(patch: IndexingConfig) { + setDraft((current) => merge(current, patch)) + setDirty(true) + } + + function text(key: keyof IndexingConfig, value: string) { + update({ [key]: value || undefined }) + } + + function number(key: keyof IndexingConfig, value: string) { + update({ [key]: value ? Number(value) : undefined }) + } + + function providerField(group: Provider, key: string, value: string) { + update({ [group]: { ...options(draft(), group), [key]: value || undefined } }) + } + + function selectProvider(value: ProviderValue) { + update(providerPatch(value, catalog()?.defaultModel)) + } + + function save() { + const next = clean(draft()) + const unset = removed(local(), next) + ctx.patch({ indexing: next }, unset.length ? unset : undefined) + setDraft(next) + setSource(JSON.stringify(next)) + setDirty(false) + } + + function reset() { + ctx.unset([["indexing"]]) + setDraft({}) + setSource("{}") + setDirty(false) + } + + return ( + + + + + + + } + > +
+
+ +
+
+

Indexing

+

Build and maintain an embedding index used by semantic search.

+
+ {project() ? "Project" : "Global"} +
+
+ update({ enabled: !(view().enabled ?? false) })} + /> +
+
+ + +
+
+

Embeddings

+

Select the provider and model used to turn code into searchable vectors.

+
+
+
+ + } + > + + + + + } + > + text("model", event.currentTarget.value)} + /> + } + > + update({ model: value, dimension: undefined })} + /> + + + + + } + > + number("dimension", event.currentTarget.value)} + /> + + + +
+ Kilo embeddings use the account currently signed in to this Kilo server. Model dimensions are supplied + by the catalog. +
+
+ + + {(group) => ( + + {(item) => { + const meta = () => field(`${group}.${item.key}`) + return ( + + } + > + providerField(group, item.key, event.currentTarget.value)} + /> + + ) + }} + + )} + +
+
+ + +
+
+

Vector Store

+

Choose where indexed embeddings and metadata are stored.

+
+
+
+ + } + > + update({ vectorStore: value })} + /> + + + + } + > + + update({ lancedb: { ...draft().lancedb, directory: event.currentTarget.value || undefined } }) + } + /> + + } + > + + } + > + + update({ qdrant: { ...draft().qdrant, url: event.currentTarget.value || undefined } }) + } + /> + + + } + > + + update({ qdrant: { ...draft().qdrant, apiKey: event.currentTarget.value || undefined } }) + } + /> + + +
+
+ + +
+
+

Search and Scanning

+

Tune result filtering, batching, and retries.

+
+
+
+ + number("searchMinScore", event.currentTarget.value)} + /> + + + number("searchMaxResults", event.currentTarget.value)} + /> + + + number("embeddingBatchSize", event.currentTarget.value)} + /> + + + number("scannerMaxBatchRetries", event.currentTarget.value)} + /> + +
+ 0}> +
+ {(error) => {error}} +
+
+
+
+
+
+ ) +} diff --git a/packages/kilo-console/src/routes/config/KeybindsRoute.tsx b/packages/kilo-console/src/routes/config/KeybindsRoute.tsx index 44c391e34f7..58bce6af5ba 100644 --- a/packages/kilo-console/src/routes/config/KeybindsRoute.tsx +++ b/packages/kilo-console/src/routes/config/KeybindsRoute.tsx @@ -2,9 +2,8 @@ import { For, Show } from "solid-js" import { Button } from "@kilocode/kilo-web-ui/button" import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console" import { IconButton } from "@kilocode/kilo-web-ui/icon-button" -import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag" import { SearchField } from "../../components/SearchField" -import { ConfigPage, SourceBadge } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" import { useKeybindSettings } from "./state/keybinds" export function KeybindsRoute() { diff --git a/packages/kilo-console/src/routes/config/McpRoute.tsx b/packages/kilo-console/src/routes/config/McpRoute.tsx index 0d43aef7799..21bbe96887d 100644 --- a/packages/kilo-console/src/routes/config/McpRoute.tsx +++ b/packages/kilo-console/src/routes/config/McpRoute.tsx @@ -2,12 +2,11 @@ import { For, Show } from "solid-js" import { Button } from "@kilocode/kilo-web-ui/button" import { Card } from "@kilocode/kilo-web-ui/card" import { IconButton } from "@kilocode/kilo-web-ui/icon-button" -import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag" import { StatusTag } from "@kilocode/kilo-web-ui/status-tag" import { ConfirmDialog } from "../../components/ConfirmDialog" import { CustomSelect, type SelectOption } from "../../components/CustomSelect" import { SearchField } from "../../components/SearchField" -import { ConfigPage, SourceBadge } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" import { useMcpSettings } from "./state/mcp" type StatusFilter = "all" | "installed" | "notInstalled" diff --git a/packages/kilo-console/src/routes/config/ModelsRoute.tsx b/packages/kilo-console/src/routes/config/ModelsRoute.tsx index c1ec64c1dd6..f0b4cb194ec 100644 --- a/packages/kilo-console/src/routes/config/ModelsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ModelsRoute.tsx @@ -1,11 +1,10 @@ import { For, Show } from "solid-js" import { Button } from "@kilocode/kilo-web-ui/button" import { IconButton } from "@kilocode/kilo-web-ui/icon-button" -import { Tag } from "@kilocode/kilo-web-ui/tag" import type { Model } from "@kilocode/sdk/v2/client" import { SearchField } from "../../components/SearchField" import { text } from "../../shared/utils" -import { ConfigPage, SourceBadge } from "./ConfigPage" +import { ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" import { type Capability, type ModelField, useModelSettings } from "./state/models" function money(n: number) { diff --git a/packages/kilo-console/src/routes/config/PermissionsRoute.tsx b/packages/kilo-console/src/routes/config/PermissionsRoute.tsx index f88c6d3a53b..daf02aef1e5 100644 --- a/packages/kilo-console/src/routes/config/PermissionsRoute.tsx +++ b/packages/kilo-console/src/routes/config/PermissionsRoute.tsx @@ -2,9 +2,8 @@ import { For, Show } from "solid-js" import { Button } from "@kilocode/kilo-web-ui/button" import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console" import { IconButton } from "@kilocode/kilo-web-ui/icon-button" -import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag" import { toolName } from "../../shared/utils" -import { ConfigPage, SourceBadge } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage" import { actions, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions" export function tone(action: PermissionAction) { diff --git a/packages/kilo-console/src/routes/config/ProvidersRoute.tsx b/packages/kilo-console/src/routes/config/ProvidersRoute.tsx index 72be498c989..6187500baa2 100644 --- a/packages/kilo-console/src/routes/config/ProvidersRoute.tsx +++ b/packages/kilo-console/src/routes/config/ProvidersRoute.tsx @@ -3,12 +3,11 @@ import { Button } from "@kilocode/kilo-web-ui/button" import { Card } from "@kilocode/kilo-web-ui/card" import { IconButton } from "@kilocode/kilo-web-ui/icon-button" import { ProviderIcon } from "@kilocode/kilo-web-ui/provider-icon" -import { CountTag } from "@kilocode/kilo-web-ui/tag" import { StatusTag } from "@kilocode/kilo-web-ui/status-tag" import { ConfirmDialog } from "../../components/ConfirmDialog" import { CustomSelect } from "../../components/CustomSelect" import { SearchField } from "../../components/SearchField" -import { ConfigPage, SourceBadge } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage, SourceBadge } from "./ConfigPage" import { useProviderSettings } from "./state/providers" export function ProvidersRoute() { diff --git a/packages/kilo-console/src/routes/config/ServersRoute.tsx b/packages/kilo-console/src/routes/config/ServersRoute.tsx index f880f35e167..f7be8913f81 100644 --- a/packages/kilo-console/src/routes/config/ServersRoute.tsx +++ b/packages/kilo-console/src/routes/config/ServersRoute.tsx @@ -1,9 +1,8 @@ import { Button } from "@kilocode/kilo-web-ui/button" import { Card } from "@kilocode/kilo-web-ui/card" -import { Tag } from "@kilocode/kilo-web-ui/tag" import { For, Show, createMemo, createSignal } from "solid-js" import { useConfig } from "../../context/config" -import { ConfigPage, ConfigToolbar } from "./ConfigPage" +import { ConfigPage, ConfigTag as Tag, ConfigToolbar } from "./ConfigPage" type Server = { id: string diff --git a/packages/kilo-console/src/routes/config/SourcesRoute.tsx b/packages/kilo-console/src/routes/config/SourcesRoute.tsx index aa27453570b..3fe405b8f0e 100644 --- a/packages/kilo-console/src/routes/config/SourcesRoute.tsx +++ b/packages/kilo-console/src/routes/config/SourcesRoute.tsx @@ -1,7 +1,6 @@ import { For, Show } from "solid-js" -import { Tag } from "@kilocode/kilo-web-ui/tag" import { useConfig } from "../../context/config" -import { ConfigPage, ConfigToolbar } from "./ConfigPage" +import { ConfigPage, ConfigTag as Tag, ConfigToolbar } from "./ConfigPage" export function SourcesRoute() { const ctx = useConfig() diff --git a/packages/kilo-console/src/routes/config/ToolsRoute.tsx b/packages/kilo-console/src/routes/config/ToolsRoute.tsx index 6b2ec67f5a8..1aca8a16b84 100644 --- a/packages/kilo-console/src/routes/config/ToolsRoute.tsx +++ b/packages/kilo-console/src/routes/config/ToolsRoute.tsx @@ -1,10 +1,9 @@ import { createMemo, createSignal, For, Show } from "solid-js" import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console" -import { CountTag } from "@kilocode/kilo-web-ui/tag" import { SearchField } from "../../components/SearchField" import { useConfig } from "../../context/config" import { toolCapabilities, toolName } from "../../shared/utils" -import { ConfigPage } from "./ConfigPage" +import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage" export function ToolsRoute() { const ctx = useConfig() diff --git a/packages/kilo-console/src/routes/config/sections.tsx b/packages/kilo-console/src/routes/config/sections.tsx index 8f2ff5291d3..4d7ce6547b3 100644 --- a/packages/kilo-console/src/routes/config/sections.tsx +++ b/packages/kilo-console/src/routes/config/sections.tsx @@ -4,6 +4,7 @@ import { AgentBuilderRoute, AgentsRoute } from "./AgentsRoute" import { CliNotificationsRoute } from "./CliNotificationsRoute" import { CliUiRoute } from "./CliUiRoute" import { FormattersRoute, LspRoute } from "./FormattersRoute" +import { IndexingRoute } from "./IndexingRoute" import { KeybindsRoute } from "./KeybindsRoute" import { McpRoute } from "./McpRoute" import { ModelsAvailableRoute, ModelsDefaultRoute, ModelsRoute } from "./ModelsRoute" @@ -92,7 +93,21 @@ export const configNav: ConfigNode[] = [ { id: "behaviour", label: "Behaviour", - items: [agents, tools, permissions, mcp, formatters, lsp], + items: [ + agents, + tools, + permissions, + mcp, + formatters, + lsp, + { + path: "/indexing", + href: "/settings/indexing", + icon: "circuit-board", + label: "Code Indexing", + component: IndexingRoute, + }, + ], }, { id: "cli", diff --git a/packages/kilo-console/src/routes/config/state/indexing.test.ts b/packages/kilo-console/src/routes/config/state/indexing.test.ts new file mode 100644 index 00000000000..dd5ded7245f --- /dev/null +++ b/packages/kilo-console/src/routes/config/state/indexing.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { clean, merge, providerPatch, removed, shouldSync, validate } from "./indexing" + +describe("indexing config state", () => { + test("merges project settings over nested global settings", () => { + expect( + merge( + { enabled: true, provider: "openai", openai: { apiKey: "global" }, qdrant: { url: "http://global" } }, + { enabled: false, provider: "ollama", qdrant: { apiKey: "project" } }, + ), + ).toEqual({ + enabled: false, + provider: "ollama", + openai: { apiKey: "global" }, + qdrant: { url: "http://global", apiKey: "project" }, + }) + }) + + test("cleans empty fields and returns unset paths", () => { + const before = { + provider: "openai" as const, + model: "text-embedding-3-small", + openai: { apiKey: "secret" }, + } + const after = clean({ provider: "openai", model: "", openai: { apiKey: "" } }) + + expect(after).toEqual({ provider: "openai" }) + expect(removed(before, after)).toEqual([ + ["indexing", "model"], + ["indexing", "openai"], + ]) + }) + + test("resyncs dirty drafts when the scope changes", () => { + expect(shouldSync("global", "global", true, "global", "updated")).toBe(false) + expect(shouldSync("global", "project", true, "global", "project")).toBe(true) + expect(shouldSync("global", "project", true, "shared", "shared")).toBe(true) + }) + + test("builds provider patches for custom selector changes", () => { + expect(providerPatch("kilo", "default-embedding")).toEqual({ + provider: "kilo", + model: "default-embedding", + dimension: undefined, + }) + expect(providerPatch("ollama", "ignored")).toEqual({ + provider: "ollama", + model: undefined, + dimension: undefined, + }) + expect(providerPatch("")).toEqual({ provider: undefined, model: undefined, dimension: undefined }) + }) + + test("validates numeric settings", () => { + expect(validate({ dimension: 0, searchMinScore: 2, searchMaxResults: 0, embeddingBatchSize: 1.5 })).toEqual([ + "Vector dimension must be a positive integer.", + "Search minimum score must be between 0 and 1.", + "Search maximum results must be a positive integer.", + "Embedding batch size must be a positive integer.", + ]) + }) +}) diff --git a/packages/kilo-console/src/routes/config/state/indexing.ts b/packages/kilo-console/src/routes/config/state/indexing.ts new file mode 100644 index 00000000000..1882b16df11 --- /dev/null +++ b/packages/kilo-console/src/routes/config/state/indexing.ts @@ -0,0 +1,84 @@ +import type { IndexingConfig } from "@kilocode/sdk/v2/client" + +function record(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} + +export function clone(input: IndexingConfig | undefined): IndexingConfig { + return structuredClone(input ?? {}) +} + +export function shouldSync(selected: string, current: string, dirty: boolean, source: string, next: string) { + return selected !== current || (!dirty && source !== next) +} + +export function merge(base: IndexingConfig | undefined, patch: IndexingConfig | undefined): IndexingConfig { + const result: Record = { ...(base ?? {}) } + for (const [key, value] of Object.entries(patch ?? {})) { + if (record(value) && record(result[key])) { + result[key] = { ...result[key], ...value } + continue + } + result[key] = value + } + return result as IndexingConfig +} + +function prune(input: unknown): unknown { + if (typeof input === "string") return input.trim() || undefined + if (!record(input)) return input ?? undefined + const entries = Object.entries(input).flatMap(([key, value]) => { + const next = prune(value) + return next === undefined ? [] : [[key, next] as const] + }) + if (entries.length === 0) return undefined + return Object.fromEntries(entries) +} + +export function clean(input: IndexingConfig): IndexingConfig { + return (prune(input) ?? {}) as IndexingConfig +} + +export function providerPatch(provider: IndexingConfig["provider"] | "", model?: string): IndexingConfig { + return { + provider: provider || undefined, + model: provider === "kilo" ? model || undefined : undefined, + dimension: undefined, + } +} + +function paths(before: unknown, after: unknown, prefix: string[]): string[][] { + if (!record(before)) return [] + const next = record(after) ? after : {} + return Object.entries(before).flatMap(([key, value]) => { + const path = [...prefix, key] + if (!(key in next)) return [path] + if (record(value) && record(next[key])) return paths(value, next[key], path) + return [] + }) +} + +export function removed(before: IndexingConfig, after: IndexingConfig): string[][] { + return paths(before, after, ["indexing"]) +} + +export function validate(input: IndexingConfig): string[] { + const errors: string[] = [] + if (input.dimension !== undefined && input.dimension !== null) { + if (!Number.isInteger(input.dimension) || input.dimension <= 0) + errors.push("Vector dimension must be a positive integer.") + } + if (input.searchMinScore !== undefined && (input.searchMinScore < 0 || input.searchMinScore > 1)) { + errors.push("Search minimum score must be between 0 and 1.") + } + const integers = [ + ["Search maximum results", input.searchMaxResults], + ["Embedding batch size", input.embeddingBatchSize], + ["Scanner maximum retries", input.scannerMaxBatchRetries], + ] as const + for (const [label, value] of integers) { + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) + errors.push(`${label} must be a positive integer.`) + } + return errors +} diff --git a/packages/kilo-console/src/styles.css b/packages/kilo-console/src/styles.css index fc05a3316a2..5ab4a61da3f 100644 --- a/packages/kilo-console/src/styles.css +++ b/packages/kilo-console/src/styles.css @@ -10,6 +10,7 @@ @import "./styles/formatters.css"; @import "./styles/keybinds.css"; @import "./styles/models.css"; +@import "./styles/indexing.css"; @import "./styles/agents-tools.css"; @import "./styles/projects.css"; @import "./styles/project-console.css"; diff --git a/packages/kilo-console/src/styles/agents-tools.css b/packages/kilo-console/src/styles/agents-tools.css index 866b61f3bed..07065fdbf13 100644 --- a/packages/kilo-console/src/styles/agents-tools.css +++ b/packages/kilo-console/src/styles/agents-tools.css @@ -180,8 +180,8 @@ min-width: 0; } -.kilo-console .agent-builder-field-head span, -.kilo-console .agent-builder-field > span { +.kilo-console .agent-builder-field-head span:not([data-component="tag"]), +.kilo-console .agent-builder-field > span:not([data-component="tag"]) { color: var(--foreground); font-size: 0.75rem; font-weight: 500; diff --git a/packages/kilo-console/src/styles/indexing.css b/packages/kilo-console/src/styles/indexing.css new file mode 100644 index 00000000000..1a129dcef35 --- /dev/null +++ b/packages/kilo-console/src/styles/indexing.css @@ -0,0 +1,50 @@ +.kilo-console .indexing-builder .agent-builder-card:has(.indexing-select[open]) { + position: relative; + z-index: 40; + overflow: visible; +} + +.kilo-console .indexing-select[open] { + z-index: 41; +} + +.kilo-console .indexing-toggle-tags { + display: inline-flex; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; +} + +.kilo-console .indexing-field-title { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + min-width: 0; +} + +.kilo-console .indexing-field-title > :first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kilo-console .indexing-note { + grid-column: 1 / -1; + border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--border)); + border-radius: var(--radius-md); + background: color-mix(in oklab, var(--primary) 8%, transparent); + color: var(--muted-foreground); + font-size: 0.6875rem; + line-height: 1.5; + padding: 0.625rem; +} + +.kilo-console .indexing-errors { + display: grid; + gap: 0.25rem; + border-top: 1px solid var(--border); + color: var(--destructive); + font-size: 0.6875rem; + padding: 0.75rem; +} diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-catalog-loading-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-catalog-loading-chromium-linux.png index 5203e5d55fc..aac2e629fe3 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-catalog-loading-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-catalog-loading-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:810d109acde3456d250df74029ac031929cb4b2fffe13a4c765cda9d2a62f175 -size 46116 +oid sha256:add00c319d54f53c7c9859428b18182fa91da1b17e4c03b1ee8015ed08f73d19 +size 48653 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-model-preset-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-model-preset-chromium-linux.png index 7c064e2b69e..714c7562e78 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-model-preset-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-kilo-model-preset-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7ad739b2099b5b35abf3989bca8e8f63c2926906decfddf7530d6b97fb0e6b1 -size 50711 +oid sha256:d9fbce155629b97a30ee9f95f226ef883b6febf8002a657516ff439e3fae0f6c +size 51829 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png index 18c542093c0..13e53ad7ee2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a67058f86869ec7457058feec03f880b7a3ac31c00b87ae6db5a55d977295856 -size 57421 +oid sha256:3330276aded477430e615ababb2caa7eeb2fa7f7406974b9b829e6092d5a619a +size 51792 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-scope-switch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-scope-switch-chromium-linux.png new file mode 100644 index 00000000000..49e6bf669af --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-scope-switch-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb78008c0fcbc47cb4b73fcea0739ce975c64b31d58852d74fd31078f3774694 +size 54958 diff --git a/packages/kilo-gateway/src/api/embedding-models.ts b/packages/kilo-gateway/src/api/embedding-models.ts index 475d8d25249..9809ddf2a07 100644 --- a/packages/kilo-gateway/src/api/embedding-models.ts +++ b/packages/kilo-gateway/src/api/embedding-models.ts @@ -15,6 +15,12 @@ export type KiloEmbeddingModelCatalog = { aliases: Record } +export type KiloEmbeddingModelCatalogIssue = { + code: "http" | "invalid-response" | "network" + message: string + status?: number +} + export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = { defaultModel: "", models: [], @@ -39,25 +45,66 @@ type Options = { baseURL?: string token?: string signal?: AbortSignal + attempts?: number + onError?: (issue: KiloEmbeddingModelCatalogIssue) => void +} + +const retryable = (status: number) => status === 408 || status === 425 || status === 429 || status >= 500 + +function wait(ms: number, signal?: AbortSignal) { + if (signal?.aborted) return Promise.reject(signal.reason) + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer) + reject(signal?.reason) + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", abort) + resolve() + }, ms) + signal?.addEventListener("abort", abort, { once: true }) + }) } export async function fetchKiloEmbeddingModelCatalog(options: Options = {}): Promise { const url = new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: options.baseURL, token: options.token })) + const requested = options.attempts ?? 3 + const attempts = Number.isFinite(requested) ? Math.min(3, Math.max(1, Math.floor(requested))) : 3 + const issue = { current: undefined as KiloEmbeddingModelCatalogIssue | undefined } - try { - const response = await fetch(url, { signal: options.signal }) - if (!response.ok) { - console.warn(`[Kilo Gateway] Failed to fetch embedding model catalog: ${response.status}`) - return EMPTY_KILO_EMBEDDING_MODEL_CATALOG + for (const attempt of Array.from({ length: attempts }, (_, index) => index)) { + if (options.signal?.aborted) throw options.signal.reason + try { + const response = await fetch(url, { signal: options.signal, redirect: "error" }) + if (!response.ok) { + issue.current = { + code: "http", + message: `Unable to load Kilo embedding models (HTTP ${response.status}).`, + status: response.status, + } + if (!retryable(response.status) || attempt === attempts - 1) break + await wait(200 * 2 ** attempt, options.signal) + continue + } + const body = await response.json().catch(() => undefined) + const parsed = catalog.safeParse(body) + if (parsed.success) return parsed.data + issue.current = { + code: "invalid-response", + message: "Kilo returned an invalid embedding model catalog.", + } + break + } catch (err) { + if (options.signal?.aborted) throw options.signal.reason + issue.current = { + code: "network", + message: "Unable to connect to Kilo to load embedding models. Check your network connection and try again.", + } + if (attempt === attempts - 1) break + await wait(200 * 2 ** attempt, options.signal) } - const parsed = catalog.safeParse(await response.json()) - if (!parsed.success) { - console.warn("[Kilo Gateway] Embedding model catalog response validation failed:", parsed.error.format()) - return EMPTY_KILO_EMBEDDING_MODEL_CATALOG - } - return parsed.data - } catch (err) { - console.warn("[Kilo Gateway] Error fetching embedding model catalog:", err) - return EMPTY_KILO_EMBEDDING_MODEL_CATALOG } + + if (issue.current) options.onError?.(issue.current) + return EMPTY_KILO_EMBEDDING_MODEL_CATALOG } diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index 6a1449d9c19..bc8d21df72e 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -39,6 +39,7 @@ export { fetchKiloEmbeddingModelCatalog, type KiloEmbeddingModel, type KiloEmbeddingModelCatalog, + type KiloEmbeddingModelCatalogIssue, } from "./api/embedding-models.js" export { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "./api/url.js" export { diff --git a/packages/kilo-gateway/test/api/embedding-models.test.ts b/packages/kilo-gateway/test/api/embedding-models.test.ts index f2e15a9e250..408f048786c 100644 --- a/packages/kilo-gateway/test/api/embedding-models.test.ts +++ b/packages/kilo-gateway/test/api/embedding-models.test.ts @@ -1,43 +1,81 @@ -import { describe, expect, mock, test } from "bun:test" +import { describe, expect, mock, spyOn, test } from "bun:test" import { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog } from "../../src/api/embedding-models" +const response = () => + new Response( + JSON.stringify({ + defaultModel: "provider/model", + models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }], + aliases: { model: "provider/model" }, + }), + ) + describe("fetchKiloEmbeddingModelCatalog", () => { test("fetches catalog from Kilo Gateway", async () => { const prev = global.fetch - const fn = mock(() => - Promise.resolve( - new Response( - JSON.stringify({ - defaultModel: "provider/model", - models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }], - aliases: { model: "provider/model" }, - }), - ), - ), - ) as unknown as typeof fetch + const fn = mock(() => Promise.resolve(response())) as unknown as typeof fetch global.fetch = fn try { const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" }) expect(catalog.defaultModel).toBe("provider/model") - expect((fn as unknown as { mock: { calls: Array<[URL]> } }).mock.calls[0]?.[0].toString()).toBe( - "https://example.test/api/gateway/embedding-models", - ) + const call = (fn as unknown as { mock: { calls: Array<[URL, RequestInit]> } }).mock.calls[0] + expect(call?.[0].toString()).toBe("https://example.test/api/gateway/embedding-models") + expect(call?.[1].redirect).toBe("error") + } finally { + global.fetch = prev + } + }) + + test("retries transient transport failures", async () => { + const prev = global.fetch + const fn = mock(() => Promise.reject(new TypeError("fetch failed"))) + fn.mockImplementationOnce(() => Promise.reject(new TypeError("fetch failed"))) + fn.mockImplementationOnce(() => Promise.resolve(response())) + global.fetch = fn as unknown as typeof fetch + + try { + const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" }) + + expect(catalog.models).toHaveLength(1) + expect(fn).toHaveBeenCalledTimes(2) + } finally { + global.fetch = prev + } + }) + + test("bounds caller-controlled retry attempts", async () => { + const prev = global.fetch + const fn = mock(() => Promise.resolve(new Response("nope", { status: 500 }))) + global.fetch = fn as unknown as typeof fetch + + try { + await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: Number.POSITIVE_INFINITY }) + expect(fn).toHaveBeenCalledTimes(3) } finally { global.fetch = prev } }) - test("falls back when the request fails", async () => { + test("reports a final failure without writing to the console", async () => { const prev = global.fetch + const warn = spyOn(console, "warn").mockImplementation(() => undefined) + const issue = mock(() => undefined) global.fetch = mock(() => Promise.resolve(new Response("nope", { status: 500 }))) as unknown as typeof fetch try { - await expect(fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })).resolves.toEqual( - EMPTY_KILO_EMBEDDING_MODEL_CATALOG, - ) + await expect( + fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: 1, onError: issue }), + ).resolves.toEqual(EMPTY_KILO_EMBEDDING_MODEL_CATALOG) + expect(issue).toHaveBeenCalledWith({ + code: "http", + message: "Unable to load Kilo embedding models (HTTP 500).", + status: 500, + }) + expect(warn).not.toHaveBeenCalled() } finally { + warn.mockRestore() global.fetch = prev } }) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1e227f90038..c393d27912a 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -993,7 +993,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) break case "updateConfig": - await this.handleUpdateConfig(message.config, message.projectConfig) + await this.handleUpdateConfig( + message.config, + message.projectConfig, + message.globalUnset, + message.projectUnset, + ) break case "openSettingsTab": if (message.tab === "indexing") { @@ -2062,16 +2067,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: config } = await retry(() => - this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }), - ) - const { data: global } = await this.client.global.config.get({ throwOnError: true }) + const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })), + this.client.global.config.get({ throwOnError: true }), + this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }), + ]) this.cachedGlobalConfig = global ?? null const message = { type: "configLoaded", config, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(config), } this.cachedConfigMessage = message @@ -2156,16 +2163,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client || this.connectionState !== "connected") return try { const dir = this.getWorkspaceDirectory() - const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - const { data: global } = await this.client.global.config.get({ throwOnError: true }) + const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), + this.client.global.config.get({ throwOnError: true }), + this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + ]) this.cachedGlobalConfig = global ?? null this.cachedConfigMessage = { type: "configLoaded", config, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(config), } - this.postMessage({ type: "configUpdated", config, globalConfig: global, features: configFeatures(config) }) + this.postMessage({ + type: "configUpdated", + config, + globalConfig: global, + projectConfig: overlay?.project, + features: configFeatures(config), + }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -2321,7 +2338,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return getBusySessionCount(this.sessionStatusMap) } - private async handleUpdateConfig(partial: Partial, project: Partial = {}): Promise { + private async handleUpdateConfig( + partial: Partial, + project: Partial = {}, + globalUnset: string[][] = [], + projectUnset: string[][] = [], + ): Promise { if (!this.client || this.connectionState !== "connected") { this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" }) return @@ -2336,16 +2358,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper partial.agent !== undefined || project.default_agent !== undefined || project.agent !== undefined - const hasGlobal = Object.keys(partial).length > 0 - const hasProject = Object.keys(project).length > 0 + const hasGlobal = Object.keys(partial).length > 0 || globalUnset.length > 0 + const hasProject = Object.keys(project).length > 0 || projectUnset.length > 0 this.pending++ const dir = this.getWorkspaceDirectory() try { await this.connectionService.drainPendingPrompts() - if (hasGlobal) await this.client.global.config.update({ config: partial }, { throwOnError: true }) - if (hasProject) await this.client.config.update({ config: project, directory: dir }, { throwOnError: true }) + if (hasGlobal) { + await this.client.config.overlayUpdate( + { scope: "global", set: partial, unset: globalUnset, directory: dir }, + { throwOnError: true }, + ) + } + if (hasProject) { + await this.client.config.overlayUpdate( + { scope: "project", set: project, unset: projectUnset, directory: dir }, + { throwOnError: true }, + ) + } } catch (error) { this.postConfigFailure(error) this.pending-- @@ -2353,19 +2385,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - const { data: global } = await this.client.global.config.get({ throwOnError: true }) + const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([ + retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })), + this.client.global.config.get({ throwOnError: true }), + this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + ]) this.cachedGlobalConfig = global ?? null this.cachedConfigMessage = { type: "configLoaded", config: merged, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(merged), } this.postMessage({ type: "configUpdated", config: merged, globalConfig: global, + projectConfig: overlay?.project, features: configFeatures(merged), }) await Promise.all([ diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index eb28b773e81..b862887edf6 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -718,5 +718,5 @@ export function isEventFromForeignProject(event: StreamEvent, expectedProjectID: } if (event.name !== "session.updated.1") return false const project = event.data.info.projectID - return project != null && project !== expectedProjectID + return project !== undefined && project !== expectedProjectID } diff --git a/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts index c8f362f5705..eb4830ec6c0 100644 --- a/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts +++ b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts @@ -12,6 +12,7 @@ const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" const STORY_ID = "settings--indexing-provider-blur-race" const KILO_STORY_ID = "settings--indexing-kilo-model-preset" const KILO_LOADING_STORY_ID = "settings--indexing-kilo-catalog-loading" +const SCOPE_STORY_ID = "settings--indexing-scope-switch" type Saved = { provider?: string @@ -19,6 +20,7 @@ type Saved = { dimension?: number | null openai?: { apiKey?: string } gemini?: { apiKey?: string } + qdrant?: { url?: string; apiKey?: string } } function storyUrl(id = STORY_ID) { @@ -76,6 +78,41 @@ test("provider switch writes to selected provider bucket", async ({ page }) => { await expect(model).toHaveAttribute("placeholder", "Enter model ID") }) +test("scope switching preserves raw overrides and commits blur to the original scope", async ({ page }) => { + await page.setViewportSize({ width: 420, height: 720 }) + await page.goto(storyUrl(SCOPE_STORY_ID), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + + await expect(page.locator('[data-slot="settings-row"] [data-component="tag"]')).toHaveCount(0) + + const url = field(page, "Qdrant URL").first() + await url.fill("http://edited-global:6333") + await page.getByRole("button", { name: "Local", exact: true }).click() + + const global = page.getByTestId("indexing-global-save") + await expect + .poll(async () => { + const cfg = JSON.parse(((await global.textContent()) ?? "{}").trim()) as Saved + return cfg.qdrant?.url + }) + .toBe("http://edited-global:6333") + + const project = JSON.parse(((await page.getByTestId("indexing-project-save").textContent()) ?? "{}").trim()) as Saved + expect(project.qdrant?.url).toBeUndefined() + + await expect(field(page, "Embedding model").first()).toHaveValue("") + await expect(url).toHaveValue("http://edited-global:6333") + const urlRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant URL" }) + const keyRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant API key" }) + const modelRow = page.locator('[data-slot="settings-row"]', { hasText: "Embedding model" }) + const tuningRow = page.locator('[data-slot="settings-row"]', { hasText: "Search max results" }) + await expect(urlRow.locator('[data-component="tag"]')).toHaveText("Global") + await expect(keyRow.locator('[data-component="tag"]')).toHaveText("Local") + await expect(modelRow.locator('[data-component="tag"]')).toHaveText("Local") + await expect(tuningRow.locator('[data-component="tag"]')).toHaveText("Default") +}) + test("Kilo exposes only supported embedding model presets", async ({ page }) => { await page.setViewportSize({ width: 420, height: 720 }) await page.goto(storyUrl(KILO_STORY_ID), { waitUntil: "load" }) @@ -110,11 +147,18 @@ test("enabling Kilo before its catalog loads does not store an empty model", asy await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" }) await disableAnimations(page) await page.waitForSelector("#storybook-root *", { state: "attached" }) - await page.locator('[data-component="switch"] [data-slot="switch-control"]').nth(1).click() + await page.getByRole("button", { name: "Local", exact: true }).click() + await page + .locator('[data-slot="settings-row"]', { hasText: "Enable for this project" }) + .locator('[data-slot="switch-control"]') + .click() await verify() await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" }) await page.waitForSelector("#storybook-root *", { state: "attached" }) - await page.locator('[data-component="switch"] [data-slot="switch-control"]').first().click() + await page + .locator('[data-slot="settings-row"]', { hasText: "Enable globally" }) + .locator('[data-slot="switch-control"]') + .click() await verify() }) diff --git a/packages/kilo-vscode/tests/unit/config-utils.test.ts b/packages/kilo-vscode/tests/unit/config-utils.test.ts index 374a023c6f5..5ca90cb714b 100644 --- a/packages/kilo-vscode/tests/unit/config-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/config-utils.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "bun:test" -import { deepMerge, stripNulls, ConfigState } from "../../webview-ui/src/utils/config-utils" +import { + configUnsetPaths, + ConfigState, + deepMerge, + mergeScopedConfig, + pruneConfigSet, + stripNulls, +} from "../../webview-ui/src/utils/config-utils" import type { Config } from "../../webview-ui/src/types/messages" // --------------------------------------------------------------------------- @@ -42,6 +49,38 @@ describe("deepMerge", () => { }) }) +describe("scoped config normalization", () => { + it("preserves indexing null overrides while stripping unrelated nulls", () => { + const target = { username: "alice", indexing: { model: "global", dimension: 1024 } } as Config + const source = { username: null, indexing: { model: null, dimension: null } } as unknown as Partial + + expect(mergeScopedConfig(target, source)).toEqual({ indexing: { model: null, dimension: null } }) + }) + + it("builds clean set and unset payloads while preserving indexing null overrides", () => { + const patch = { + formatter: {}, + username: null, + indexing: { + model: null, + dimension: null, + searchMinScore: undefined, + qdrant: { apiKey: undefined }, + }, + } + + expect(pruneConfigSet(patch)).toEqual({ + formatter: {}, + indexing: { model: null, dimension: null }, + }) + expect(configUnsetPaths(patch)).toEqual([ + ["username"], + ["indexing", "searchMinScore"], + ["indexing", "qdrant", "apiKey"], + ]) + }) +}) + describe("stripNulls", () => { it("removes null values", () => { const cfg = { snapshot: true, username: null } as unknown as Config diff --git a/packages/kilo-vscode/tests/unit/indexing-tab-state.test.ts b/packages/kilo-vscode/tests/unit/indexing-tab-state.test.ts new file mode 100644 index 00000000000..35f0193cd33 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/indexing-tab-state.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test" +import { + indexingConfig, + indexingDescription, + indexingEnabled, + indexingEnabledInherited, + indexingInheritance, + indexingSource, + indexingUpdate, +} from "../../webview-ui/src/components/settings/indexing-tab-state" + +describe("indexing tab scope state", () => { + it("uses the global value when project enablement is inherited", () => { + expect(indexingEnabled("project", { enabled: true }, {})).toBe(true) + expect(indexingEnabled("project", { enabled: false }, {})).toBe(false) + expect(indexingEnabledInherited("project", { enabled: true }, {})).toBe(true) + expect(indexingEnabledInherited("project", { enabled: false }, {})).toBe(true) + }) + + it("uses explicit project overrides", () => { + expect(indexingEnabled("project", { enabled: true }, { enabled: false })).toBe(false) + expect(indexingEnabled("project", { enabled: false }, { enabled: true })).toBe(true) + expect(indexingEnabledInherited("project", { enabled: true }, { enabled: false })).toBe(false) + }) + + it("ignores project values in global scope", () => { + const global = { enabled: false, provider: "openai" as const, openai: { apiKey: "global" } } + const project = { enabled: true, provider: "ollama" as const, ollama: { baseUrl: "http://project" } } + + expect(indexingEnabled("global", global, project)).toBe(false) + expect(indexingEnabledInherited("global", global, {})).toBe(false) + expect(indexingConfig("global", global, project)).toEqual(global) + }) + + it("keeps inherited values out of project updates", () => { + expect( + indexingUpdate( + "project", + { enabled: true, provider: "openai", openai: { apiKey: "global" } }, + { qdrant: { url: "http://project" } }, + { enabled: false }, + ), + ).toEqual({ enabled: false, qdrant: { url: "http://project" } }) + }) + + it("preserves explicit null overrides and recursively inherits undefined leaves", () => { + expect( + indexingConfig( + "project", + { + model: "global-model", + dimension: 1024, + qdrant: { url: "http://global", apiKey: "global-secret" }, + }, + { + model: null, + dimension: null, + qdrant: { url: "http://project", apiKey: undefined }, + }, + ), + ).toEqual({ + model: null, + dimension: null, + qdrant: { url: "http://project", apiKey: "global-secret" }, + }) + }) + + it("classifies inherited and partially inherited fields", () => { + const global = { + provider: "openai-compatible" as const, + model: "global-model", + dimension: 1024, + "openai-compatible": { baseUrl: "https://global.test", apiKey: "secret" }, + } + const project = { + model: null, + "openai-compatible": { baseUrl: "https://project.test" }, + } + + expect(indexingInheritance("project", global, project, [["provider"]])).toBe("inherited") + expect(indexingInheritance("project", global, project, [["model"]])).toBe("none") + expect(indexingInheritance("project", global, project, [["dimension"]])).toBe("inherited") + expect( + indexingInheritance("project", global, project, [ + ["openai-compatible", "baseUrl"], + ["openai-compatible", "apiKey"], + ]), + ).toBe("partial") + expect(indexingInheritance("global", global, project, [["provider"]])).toBe("none") + expect(indexingInheritance("project", {}, {}, [["vectorStore"]])).toBe("none") + expect(indexingSource("project", global, project, [["provider"]])).toBe("global") + expect(indexingSource("project", global, project, [["model"]])).toBe("local") + expect( + indexingSource("project", global, project, [ + ["openai-compatible", "baseUrl"], + ["openai-compatible", "apiKey"], + ]), + ).toBe("mixed") + expect(indexingSource("project", {}, {}, [["vectorStore"]])).toBe("default") + expect(indexingSource("global", global, project, [["provider"]])).toBe("none") + expect(indexingDescription("Configure this value.", "inherited")).toBe( + "Configure this value. Inherited from global config.", + ) + }) + + it("merges inherited values with project overrides", () => { + expect( + indexingConfig( + "project", + { + enabled: true, + provider: "openai", + model: "global-model", + vectorStore: "qdrant", + openai: { apiKey: "global" }, + qdrant: { url: "http://global", apiKey: "global-secret" }, + }, + { provider: "ollama", qdrant: { url: "http://project" } }, + ), + ).toEqual({ + enabled: true, + provider: "ollama", + model: "global-model", + vectorStore: "qdrant", + openai: { apiKey: "global" }, + qdrant: { url: "http://project", apiKey: "global-secret" }, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index e3854e53015..6b3b63388b6 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -10,7 +10,12 @@ type Internals = { cachedIndexingStatusMessage: unknown handleEvent: (event: unknown, directory?: string) => void reloadAfterAuthChange: () => Promise - handleUpdateConfig: (partial: Partial) => Promise + handleUpdateConfig: ( + partial: Partial, + project?: Partial, + globalUnset?: string[][], + projectUnset?: string[][], + ) => Promise fetchAndSendConfig: () => Promise fetchAndSendProviders: () => Promise fetchAndSendAgents: () => Promise @@ -22,6 +27,7 @@ type Internals = { function createConnection() { let drains = 0 + const patches: unknown[] = [] const client = { global: { config: { @@ -32,11 +38,17 @@ function createConnection() { config: { get: async () => ({ data: {} }), update: async () => ({ data: {} }), + overlay: async () => ({ data: { project: {} } }), + overlayUpdate: async (patch: unknown) => { + patches.push(patch) + return { data: {} } + }, }, } return { drains: () => drains, + patches: () => patches, service: { drainPendingPrompts: async () => { drains += 1 @@ -97,6 +109,33 @@ describe("KiloProvider indexing refresh", () => { expect(indexing).toBe(0) }) + it("passes scoped unset paths to the config overlay endpoint", async () => { + const conn = createConnection() + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + internal.connectionState = "connected" + + await internal.handleUpdateConfig( + { indexing: { qdrant: { apiKey: undefined } } }, + { indexing: { searchMinScore: undefined } }, + [["indexing", "qdrant", "apiKey"]], + [["indexing", "searchMinScore"]], + ) + + expect(conn.patches()).toEqual([ + expect.objectContaining({ + scope: "global", + set: { indexing: { qdrant: { apiKey: undefined } } }, + unset: [["indexing", "qdrant", "apiKey"]], + }), + expect.objectContaining({ + scope: "project", + set: { indexing: { searchMinScore: undefined } }, + unset: [["indexing", "searchMinScore"]], + }), + ]) + }) + it("fetchAndSendIndexingStatus uses current session directory header", async () => { const worktree = "/repo/.kilo/.kilocode/worktrees/feature" const calls: { input: RequestInfo | URL; init?: RequestInit }[] = [] diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx index 02e3c7870cf..2b29b3fde19 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/IndexingTab.tsx @@ -1,11 +1,11 @@ import { Component, For, Show, createMemo, createSignal } from "solid-js" +import { Button } from "@kilocode/kilo-ui/button" import { Card } from "@kilocode/kilo-ui/card" import { DEFAULT_VECTOR_STORE } from "@kilocode/kilo-indexing/config" import { formatKiloEmbeddingModelLabel, getKiloEmbeddingModel } from "@kilocode/kilo-indexing/embedding-models" import { Select } from "@kilocode/kilo-ui/select" import { Switch } from "@kilocode/kilo-ui/switch" import { TextField } from "@kilocode/kilo-ui/text-field" -import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useConfig } from "../../context/config" import { formatIndexingLabel, useIndexing } from "../../context/indexing" import { useKiloEmbeddingModels } from "../../context/kilo-embedding-models" @@ -15,6 +15,17 @@ import { useServer } from "../../context/server" import type { IndexingConfig, IndexingProvider as ProviderId } from "../../types/messages" import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model" import SettingsRow from "./SettingsRow" +import { + indexingConfig, + indexingDescription, + indexingEnabled, + indexingEnabledInherited, + indexingInheritance, + indexingSource, + indexingUpdate, + type IndexingScope, + type IndexingSource, +} from "./indexing-tab-state" type Option = { value: string; label: string } type TuningKey = "searchMinScore" | "searchMaxResults" | "embeddingBatchSize" | "scannerMaxBatchRetries" @@ -44,6 +55,14 @@ const tuning: Array<{ key: TuningKey; label: string; placeholder: string }> = [ { key: "scannerMaxBatchRetries", label: "Scanner Max Batch Retries", placeholder: "3" }, ] +function sourceLabel(source: IndexingSource) { + if (source === "global") return "Global" + if (source === "local") return "Local" + if (source === "mixed") return "Global + Local" + if (source === "default") return "Default" + return "" +} + function providerFields(provider: ProviderId | undefined): Array<{ key: string; label: string; placeholder: string }> { if (provider === "kilo") return [] if (provider === "openai") return [{ key: "apiKey", label: "API Key", placeholder: "sk-..." }] @@ -74,7 +93,7 @@ function providerFields(provider: ProviderId | undefined): Array<{ key: string; } const IndexingTab: Component = () => { - const { config, globalConfig, updateConfig, updateGlobalConfig } = useConfig() + const { globalConfig, projectConfig, updateGlobalConfig, updateProjectConfig } = useConfig() const indexing = useIndexing() const embeds = useKiloEmbeddingModels() const language = useLanguage() @@ -83,13 +102,33 @@ const IndexingTab: Component = () => { const [providerDrafts, setProviderDrafts] = createSignal>({}) const [storeDrafts, setStoreDrafts] = createSignal>({}) const [tuningDrafts, setTuningDrafts] = createSignal>({}) + const [scope, setScope] = createSignal("global") - const cfg = createMemo(() => config().indexing ?? {}) const globalCfg = createMemo(() => globalConfig().indexing ?? {}) - const globalOn = createMemo(() => globalCfg().enabled === true) + const projectCfg = createMemo(() => projectConfig().indexing ?? {}) + const raw = createMemo(() => (scope() === "global" ? globalCfg() : projectCfg())) + const cfg = createMemo(() => indexingConfig(scope(), globalCfg(), projectCfg())) + const enabled = createMemo(() => indexingEnabled(scope(), globalCfg(), projectCfg())) + const inherited = createMemo(() => indexingEnabledInherited(scope(), globalCfg(), projectCfg())) + const inheritance = (paths: readonly (readonly string[])[]) => + indexingInheritance(scope(), globalCfg(), projectCfg(), paths) + const tag = (current: IndexingScope, paths: readonly (readonly string[])[]) => + sourceLabel(indexingSource(current, globalCfg(), projectCfg(), paths)) || undefined + const description = (value: string, paths: readonly (readonly string[])[]) => + indexingDescription(value, inheritance(paths)) + const changeScope = (next: IndexingScope) => { + const active = document.activeElement + if (active instanceof HTMLElement) active.blur() + setScope(next) + } const updateIndexing = (partial: IndexingConfig) => { - updateConfig({ indexing: { ...cfg(), ...partial } }) + const patch = { indexing: indexingUpdate(scope(), globalCfg(), projectCfg(), partial) } + if (scope() === "global") { + updateGlobalConfig(patch) + return + } + updateProjectConfig(patch) } const vectorStore = () => cfg().vectorStore ?? DEFAULT_VECTOR_STORE @@ -138,21 +177,6 @@ const IndexingTab: Component = () => { updateIndexing({ enabled }) } - const saveGlobalEnabled = (enabled: boolean) => { - if (enabled && !globalCfg().provider && !cfg().provider && kiloAvailable()) { - updateGlobalConfig({ - indexing: { - enabled, - provider: "kilo", - model: knownKiloModel(cfg().model) ?? (kiloDefault() || null), - dimension: null, - }, - }) - return - } - updateGlobalConfig({ indexing: { enabled } }) - } - const saveModel = (value: string) => { if (selectedProvider() === "kilo") return const trimmed = value.trim() @@ -160,7 +184,7 @@ const IndexingTab: Component = () => { } const providerValue = (group: string, key: string) => { - const draftKey = `${group}.${key}` + const draftKey = `${scope()}.${group}.${key}` const draft = providerDrafts()[draftKey] if (draft !== undefined) return draft const value = (cfg()[group as keyof IndexingConfig] as Record | undefined)?.[key] @@ -168,7 +192,7 @@ const IndexingTab: Component = () => { } const storeValue = (group: "qdrant" | "lancedb", key: string) => { - const draftKey = `${group}.${key}` + const draftKey = `${scope()}.${group}.${key}` const draft = storeDrafts()[draftKey] if (draft !== undefined) return draft const value = (cfg()[group] as Record | undefined)?.[key] @@ -176,13 +200,17 @@ const IndexingTab: Component = () => { } const saveProviderField = (group: ProviderId, key: string, value: string) => { - const current = (cfg()[group] as Record | undefined) ?? {} + const current = (raw()[group] as Record | undefined) ?? {} updateIndexing({ [group]: { ...current, [key]: value.trim() || undefined } }) + const draftKey = `${scope()}.${group}.${key}` + setProviderDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey))) } const saveStoreField = (group: "qdrant" | "lancedb", key: string, value: string) => { - const current = (cfg()[group] as Record | undefined) ?? {} + const current = (raw()[group] as Record | undefined) ?? {} updateIndexing({ [group]: { ...current, [key]: value.trim() || undefined } }) + const draftKey = `${scope()}.${group}.${key}` + setStoreDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey))) } const saveNumber = ( @@ -193,6 +221,10 @@ const IndexingTab: Component = () => { const trimmed = value.trim() if (!trimmed) { updateIndexing({ [key]: key === "dimension" ? null : undefined }) + if (key !== "dimension") { + const draftKey = `${scope()}.${key}` + setTuningDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey))) + } return } @@ -202,16 +234,20 @@ const IndexingTab: Component = () => { if (options?.min !== undefined && num < options.min) return if (options?.max !== undefined && num > options.max) return updateIndexing({ [key]: num }) + if (key !== "dimension") { + const draftKey = `${scope()}.${key}` + setTuningDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey))) + } } const tuningValue = (key: TuningKey) => { - const draft = tuningDrafts()[key] + const draft = tuningDrafts()[`${scope()}.${key}`] if (draft !== undefined) return draft const value = cfg()[key] return value === undefined ? "" : String(value) } - return ( + const content = (_scope: IndexingScope) => (
@@ -220,34 +256,55 @@ const IndexingTab: Component = () => { - - {language.t("settings.indexing.globalEnable.title")} - +
+ + +
tag(scope(), [["enabled"]])} last > - - - {language.t("settings.indexing.projectEnable.title")} - - + + {language.t("settings.indexing.enable.title")} +
tag(scope(), [["provider"]])} > { tag(scope(), [["model"]])} > (selectedProvider() === "kilo" ? undefined : tag(scope(), [["dimension"]]))} last={!selectedProvider() || (fields().length === 0 && !(selectedProvider() === "kilo" && !kiloAvailable()))} > { ? "" : String(cfg().dimension) } - placeholder={language.t("settings.indexing.dimension.placeholder")} + placeholder={ + selectedProvider() === "kilo" ? "Provided by Kilo" : language.t("settings.indexing.dimension.placeholder") + } + disabled={selectedProvider() === "kilo"} onChange={(value) => saveNumber("dimension", value, { integer: true, min: 1 })} /> @@ -316,13 +383,16 @@ const IndexingTab: Component = () => { 0 ? selectedProvider() : undefined} keyed> {(group) => { const fields = providerFields(group) - const label = allProviders.find((item) => item.value === group)?.label ?? group + const name = allProviders.find((item) => item.value === group)?.label ?? group return ( {(field, index) => ( tag(scope(), [[group, field.key]])} last={index() === fields.length - 1} > { placeholder={field.placeholder} onInput={(e: InputEvent) => { const target = e.currentTarget as HTMLInputElement - setProviderDrafts((prev) => ({ ...prev, [`${group}.${field.key}`]: target.value })) + setProviderDrafts((prev) => ({ ...prev, [`${scope()}.${group}.${field.key}`]: target.value })) }} onBlur={(e: FocusEvent) => { const target = e.currentTarget as HTMLInputElement @@ -349,7 +419,8 @@ const IndexingTab: Component = () => { tag(scope(), [["vectorStore"]])} >