diff --git a/src/agent/semanticIndex.ts b/src/agent/semanticIndex.ts index 5fe5dac..da8ec76 100644 --- a/src/agent/semanticIndex.ts +++ b/src/agent/semanticIndex.ts @@ -8,8 +8,9 @@ */ // Real local semantic codebase index. -// - Embeddings: @huggingface/transformers (Xenova/all-MiniLM-L6-v2, q8 ONNX/WASM) -// → 100% local, no server, no API key. Model downloads once to globalStorage. +// - Embeddings: @huggingface/transformers + onnxruntime-node +// (Xenova/all-MiniLM-L6-v2, q8). GPU when available (DML/CUDA/CoreML/WebGPU), +// else CPU. 100% local; model downloads once to globalStorage. // - Chunking: sliding line-window per file (simple, language-agnostic). // ponytail: line-window chunking; upgrade to tree-sitter AST chunks when // ranking quality on large funcs matters. @@ -59,6 +60,7 @@ export function setEmbedModel(id: string): void { if (m.id !== activeModel.id) { activeModel = m; extractorP = null; // force reload with new repo/dtype + embedDevice = null; } if (changed) { memIndex = null; // index built with old model is stale @@ -136,7 +138,33 @@ export function setIndexStorageDir(dir: string): void { } // ---- Embedder (lazy, singleton) ---- +// ONNX Runtime Node EPs: Win→dml, Linux x64→cuda, macOS→coreml, then webgpu, then cpu. +// transformers.js defaults to CPU only; we try GPU when available and fall back. +function preferredEmbedDevices(): string[] { + const order: string[] = []; + switch (process.platform) { + case "win32": + order.push("dml"); // DirectML (any DX12 GPU) + break; + case "linux": + if (process.arch === "x64") order.push("cuda"); // needs CUDA 12 + cuDNN + break; + case "darwin": + order.push("coreml"); + break; + } + order.push("webgpu", "cpu"); + return order; +} + let extractorP: Promise | null = null; +let embedDevice: string | null = null; + +/** Device the live embedder is using (null until first load). */ +export function getEmbedDevice(): string | null { + return embedDevice; +} + async function getExtractor(): Promise { if (!storageDir) return null; if (!extractorP) { @@ -145,10 +173,31 @@ async function getExtractor(): Promise { const t = await importRuntimeDep("@huggingface/transformers"); t.env.allowRemoteModels = true; t.env.cacheDir = path.join(storageDir!, "models"); - return t.pipeline("feature-extraction", m.repo, { dtype: m.dtype }); + // Try GPU EPs first; ORT often fails hard if a provider's libs are missing, + // so probe one device at a time instead of device:"auto". + const devices = preferredEmbedDevices(); + let lastErr: unknown; + for (const device of devices) { + try { + const pipe = await t.pipeline("feature-extraction", m.repo, { + dtype: m.dtype, + device, + }); + embedDevice = device; + console.log(`[semanticIndex] embedder on ${device} (${m.id})`); + // UI may already be open; push device once the pipeline is ready. + if (memRoot) emitStatus(memRoot); + return pipe; + } catch (e) { + lastErr = e; + console.warn(`[semanticIndex] embedder device "${device}" unavailable, trying next…`); + } + } + throw lastErr ?? new Error("no embedder device available"); })().catch((e) => { console.error("[semanticIndex] embedder load failed:", e); extractorP = null; + embedDevice = null; return null; }); } @@ -282,7 +331,24 @@ export interface IndexStatus { total: number; files: number; // indexed files in store chunks: number; - model: string; // active EmbedModel.id + model: string; // active EmbedModel.id or remote model id + /** "local" = onnxruntime-node; "remote" = provider /embeddings API. */ + backend: "local" | "remote"; + /** ONNX EP in use: dml | cuda | coreml | webgpu | cpu. Null until first load / remote. */ + device: string | null; + /** GPU-class EP vs CPU (remote counts as neither — uses provider). */ + accelerator: "gpu" | "cpu" | "remote" | "pending"; + /** Human-readable device label for the UI. */ + deviceLabel: string; + /** Active local model technical fields (undefined when remote). */ + modelRepo?: string; + modelDtype?: string; + modelPooling?: string; + modelDim?: number; + /** Remote endpoint host when using a provider embedding model. */ + remoteBaseUrl?: string; + runtime: string; + platform: string; } let progress = { done: 0, total: 0 }; const statusSubs = new Set<(s: IndexStatus) => void>(); @@ -290,8 +356,36 @@ export function onIndexStatus(fn: (s: IndexStatus) => void): () => void { statusSubs.add(fn); return () => statusSubs.delete(fn); } + +function deviceLabelOf(device: string | null, backend: "local" | "remote"): string { + if (backend === "remote") return "Remote API"; + if (!device) return "Not loaded yet"; + switch (device) { + case "dml": + return "GPU · DirectML"; + case "cuda": + return "GPU · CUDA"; + case "coreml": + return "GPU · CoreML"; + case "webgpu": + return "GPU · WebGPU"; + case "cpu": + return "CPU"; + default: + return device; + } +} + +function acceleratorOf(device: string | null, backend: "local" | "remote"): IndexStatus["accelerator"] { + if (backend === "remote") return "remote"; + if (!device) return "pending"; + return device === "cpu" ? "cpu" : "gpu"; +} + export function getStatus(root: string): IndexStatus { const idx = memRoot === normRoot(root) ? memIndex : null; + const backend: "local" | "remote" = remoteCfg ? "remote" : "local"; + const device = backend === "remote" ? null : embedDevice; return { indexing, done: progress.done, @@ -299,6 +393,17 @@ export function getStatus(root: string): IndexStatus { files: idx ? Object.keys(idx.files).length : 0, chunks: idx ? idx.chunks.length : 0, model: getEmbedModelId(), + backend, + device, + accelerator: acceleratorOf(device, backend), + deviceLabel: deviceLabelOf(device, backend), + modelRepo: backend === "local" ? activeModel.repo : undefined, + modelDtype: backend === "local" ? activeModel.dtype : undefined, + modelPooling: backend === "local" ? activeModel.pooling : undefined, + modelDim: backend === "local" ? activeModel.dim : undefined, + remoteBaseUrl: remoteCfg?.baseUrl, + runtime: backend === "local" ? "onnxruntime-node + @huggingface/transformers" : "OpenAI-compatible /embeddings", + platform: `${process.platform}-${process.arch}`, }; } function emitStatus(root: string): void { diff --git a/webview-ui/settings/App.tsx b/webview-ui/settings/App.tsx index 7c1bb0b..8e74441 100644 --- a/webview-ui/settings/App.tsx +++ b/webview-ui/settings/App.tsx @@ -41,6 +41,17 @@ interface IndexStatus { files: number; chunks: number; model: string; + backend?: "local" | "remote"; + device?: string | null; + accelerator?: "gpu" | "cpu" | "remote" | "pending"; + deviceLabel?: string; + modelRepo?: string; + modelDtype?: string; + modelPooling?: string; + modelDim?: number; + remoteBaseUrl?: string; + runtime?: string; + platform?: string; } interface EmbedModel { id: string; @@ -535,6 +546,11 @@ function IndexingPanel({ }) { const pct = status.total > 0 ? Math.round((status.done / status.total) * 100) : status.files > 0 ? 100 : 0; const remoteEmbed = modelList.filter((m) => isEmbeddingModel(m.id)); + const accel = status.accelerator || "pending"; + const accelClass = + accel === "gpu" ? "gpu" : accel === "cpu" ? "cpu" : accel === "remote" ? "remote" : "pending"; + const accelText = + accel === "gpu" ? "GPU" : accel === "cpu" ? "CPU" : accel === "remote" ? "Remote" : "Loading..."; return ( <>

Indexing & Docs

@@ -543,7 +559,7 @@ function IndexingPanel({
Codebase Indexing

Embed codebase for improved contextual understanding and knowledge. Embeddings and metadata are - stored locally on your machine — your code never leaves your computer. The index persists across + stored locally on your machine - your code never leaves your computer. The index persists across restarts; only new or changed files are re-embedded.

@@ -563,17 +579,98 @@ function IndexingPanel({ ? `Disabled - ${status.files} files on disk` : status.indexing ? `Indexing ${status.done} / ${status.total} files...` - : `${status.files} files`} + : `${status.files} files / ${status.chunks || 0} chunks`}
+
+
+ {accelText} + {status.deviceLabel || "-"} +
+
+
+
Model
+
+ {status.model} +
+
+ {status.modelRepo && ( +
+
Repo
+
+ {status.modelRepo} +
+
+ )} + {status.modelDtype && ( +
+
Dtype
+
+ {status.modelDtype} +
+
+ )} + {status.modelPooling && ( +
+
Pooling
+
+ {status.modelPooling} +
+
+ )} + {status.modelDim != null && ( +
+
Dimensions
+
+ {status.modelDim} +
+
+ )} + {status.device != null && status.device !== "" && ( +
+
ONNX EP
+
+ {status.device} +
+
+ )} +
+
Backend
+
+ {status.backend || "local"} +
+
+ {status.remoteBaseUrl && ( +
+
Endpoint
+
+ {status.remoteBaseUrl} +
+
+ )} +
+
Runtime
+
+ {status.runtime || "—"} +
+
+
+
Platform
+
+ {status.platform || "—"} +
+
+
+
+
Embedding model !status.indexing && features.indexingEnabled !== false && vscode.postMessage({ type: "setEmbedModel", modelId: id })} - customItems={models.map((m) => ({ value: m.id, label: m.name, desc: "local — runs on your machine" }))} + customItems={models.map((m) => ({ value: m.id, label: m.name, desc: "local - runs on your machine" }))} style={{ maxWidth: 260 }} />
diff --git a/webview-ui/settings/settings.css b/webview-ui/settings/settings.css index b44bffa..b3d412d 100644 --- a/webview-ui/settings/settings.css +++ b/webview-ui/settings/settings.css @@ -285,6 +285,52 @@ select:focus { outline: none; border-color: var(--vscode-focusBorder); } .index-progress-meta { font-size: 11.5px; color: var(--fg-dim); margin-top: 6px; } .index-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; } +.index-runtime { margin: 14px 0 4px; } +.index-runtime-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } +.index-runtime-device { font-size: 12.5px; font-weight: 600; color: var(--fg); } +.index-accel-badge { + display: inline-flex; align-items: center; justify-content: center; + font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; + padding: 2px 8px; border-radius: 999px; border: 1px solid transparent; +} +.index-accel-badge.gpu { + color: var(--green); + background: color-mix(in srgb, var(--green) 14%, transparent); + border-color: color-mix(in srgb, var(--green) 35%, transparent); +} +.index-accel-badge.cpu { + color: var(--fg-dim); + background: color-mix(in srgb, var(--fg) 8%, transparent); + border-color: color-mix(in srgb, var(--fg) 18%, transparent); +} +.index-accel-badge.remote { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.index-accel-badge.pending { + color: var(--fg-faint); + background: color-mix(in srgb, var(--fg) 6%, transparent); + border-color: color-mix(in srgb, var(--fg) 14%, transparent); +} +.index-tech { + display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; margin: 0; + padding: 10px 12px; border-radius: 8px; + background: color-mix(in srgb, var(--fg) 4%, transparent); + border: 1px solid var(--border-soft); +} +.index-tech > div { display: grid; grid-template-columns: 88px 1fr; gap: 6px; align-items: baseline; min-width: 0; } +.index-tech dt { margin: 0; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--fg-dim); } +.index-tech dd { margin: 0; min-width: 0; font-size: 11.5px; color: var(--fg); } +.index-tech code { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 11px; word-break: break-all; + color: var(--fg); +} +@media (max-width: 720px) { + .index-tech { grid-template-columns: 1fr; } +} + /* Indexing & Docs page (Cursor layout) */ .index-divider { height: 1px; background: var(--border-soft); margin: 14px -16px; } .index-model-row { display: flex; align-items: center; gap: 10px; }