From 66774d94f97d5b3f06b48ce8d7e2cd7337cb389c Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Tue, 19 May 2026 15:43:24 -0400 Subject: [PATCH] search input and backend error fixes --- .../src/components/ChatSearchResults.tsx | 51 ++++++-- .../frontend/src/components/SearchInput.tsx | 43 +++---- gadget-code/src/controllers/api/v1/search.ts | 5 +- gadget-code/src/services/vector-store.ts | 120 +++++++++++++++++- 4 files changed, 174 insertions(+), 45 deletions(-) diff --git a/gadget-code/frontend/src/components/ChatSearchResults.tsx b/gadget-code/frontend/src/components/ChatSearchResults.tsx index e9f2625..252e15b 100644 --- a/gadget-code/frontend/src/components/ChatSearchResults.tsx +++ b/gadget-code/frontend/src/components/ChatSearchResults.tsx @@ -3,7 +3,7 @@ // All Rights Reserved import { useState, useEffect, useCallback } from "react"; -import { X, Loader2 } from "lucide-react"; +import { Search, X, Loader2 } from "lucide-react"; import { searchApi, ISearchResult } from "../lib/api"; interface ChatSearchResultsProps { @@ -19,26 +19,34 @@ export default function ChatSearchResults({ onSelect, onClose, }: ChatSearchResultsProps) { + const [editQuery, setEditQuery] = useState(query); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const performSearch = useCallback(async () => { - if (!query.trim()) return; + if (!editQuery.trim()) return; setLoading(true); setError(null); try { - const data = await searchApi.search(query, filters, 20); + const data = await searchApi.search(editQuery, filters, 20); setResults(data); } catch (err) { setError((err as Error).message || "Search failed"); } finally { setLoading(false); } - }, [query, filters]); + }, [editQuery, filters]); useEffect(() => { performSearch(); }, [performSearch]); + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + performSearch(); + } + }; + const formatScore = (score: number): string => `${Math.round(score * 100)}%`; const formatTimestamp = (iso: string): string => { @@ -53,14 +61,37 @@ export default function ChatSearchResults({ onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} >
-
-
-

Search Results

- {!loading && {results.length} result{results.length !== 1 ? "s" : ""} for "{query}"} -
- + {/* Header with editable search input */} +
+ + setEditQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="flex-1 bg-transparent text-text-primary text-sm focus:outline-none placeholder:text-text-tertiary" + placeholder="Edit search query…" + autoFocus + /> + +
+ {/* Result count bar */} + {!loading && ( +
+ {results.length} result{results.length !== 1 ? "s" : ""} for "{query === editQuery ? query : editQuery}" +
+ )} +
{loading && (
diff --git a/gadget-code/frontend/src/components/SearchInput.tsx b/gadget-code/frontend/src/components/SearchInput.tsx index 370bee6..e6aee49 100644 --- a/gadget-code/frontend/src/components/SearchInput.tsx +++ b/gadget-code/frontend/src/components/SearchInput.tsx @@ -2,7 +2,7 @@ // Copyright (C) 2026 Robert Colbert // All Rights Reserved -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState } from "react"; import { Search, X } from "lucide-react"; interface SearchInputProps { @@ -19,42 +19,26 @@ export default function SearchInput({ className = "", }: SearchInputProps) { const [value, setValue] = useState(""); - const debounceRef = useRef | null>(null); - - const debouncedSearch = useCallback( - (query: string) => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - if (query.trim()) onSearch(query.trim()); - }, 300); - }, - [onSearch], - ); - - useEffect(() => { - return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, []); const handleChange = (e: React.ChangeEvent) => { const v = e.target.value; setValue(v); - if (v.trim()) { debouncedSearch(v); } else { - if (debounceRef.current) clearTimeout(debounceRef.current); + if (!v.trim()) { onClear?.(); } }; const handleClear = () => { setValue(""); - if (debounceRef.current) clearTimeout(debounceRef.current); onClear?.(); }; + const handleSubmit = () => { + if (value.trim()) onSearch(value.trim()); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && value.trim()) { - if (debounceRef.current) clearTimeout(debounceRef.current); - onSearch(value.trim()); - } + if (e.key === "Enter") handleSubmit(); if (e.key === "Escape") handleClear(); }; @@ -67,12 +51,17 @@ export default function SearchInput({ onChange={handleChange} onKeyDown={handleKeyDown} placeholder={placeholder} - className="w-full bg-bg-secondary border border-border-default rounded pl-8 pr-8 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-brand transition-colors" + className="w-full bg-bg-secondary border border-border-default rounded pl-8 pr-16 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-brand transition-colors" /> {value && ( - + <> + + + )}
); diff --git a/gadget-code/src/controllers/api/v1/search.ts b/gadget-code/src/controllers/api/v1/search.ts index 8ea5e1e..84ca73d 100644 --- a/gadget-code/src/controllers/api/v1/search.ts +++ b/gadget-code/src/controllers/api/v1/search.ts @@ -104,11 +104,10 @@ class SearchController extends DtpController { data: hydratedResults, }); } catch (error) { - const message = (error as Error).message; - this.log.error("search failed", { error: message }); + this.log.error("search failed", { error }); res.status(500).json({ success: false, - message: "Search failed", + message: (error as Error).message || "Search failed", }); } } diff --git a/gadget-code/src/services/vector-store.ts b/gadget-code/src/services/vector-store.ts index 9485b8a..74a2771 100644 --- a/gadget-code/src/services/vector-store.ts +++ b/gadget-code/src/services/vector-store.ts @@ -49,6 +49,7 @@ class VectorStoreService extends DtpService { private aiApi!: AiApi; private splitter!: RecursiveCharacterTextSplitter; private _initialized = false; + private _dimensionMismatch = false; get name(): string { return "VectorStoreService"; @@ -97,9 +98,19 @@ class VectorStoreService extends DtpService { chunkOverlap: 200, }); - // Ensure the Qdrant collection exists + // Ensure the Qdrant collection exists and validate dimensions await this.ensureCollection(); + // Validate that the embedding model produces vectors of the configured size + await this.validateEmbeddingDimensions(); + + if (this._dimensionMismatch) { + this.log.error( + "VectorStoreService started with DIMENSION MISMATCH — searches and ingestion will fail. " + + "See previous error logs for fix instructions." + ); + } + this._initialized = true; this.log.info("started", { host: env.qdrant.host, @@ -108,6 +119,7 @@ class VectorStoreService extends DtpService { providerId: env.qdrant.providerId, embeddingModel: env.qdrant.embeddingModel, vectorSize: env.qdrant.vectorSize, + dimensionMismatch: this._dimensionMismatch, }); } @@ -125,6 +137,7 @@ class VectorStoreService extends DtpService { /** * Create the Qdrant collection if it doesn't already exist. + * Validates existing collection dimensions against the configured vectorSize. */ private async ensureCollection(): Promise { const collections = await this.client.getCollections(); @@ -144,7 +157,67 @@ class VectorStoreService extends DtpService { distance: "Cosine", }); } else { - this.log.info(`Qdrant collection "${env.qdrant.collection}" already exists`); + // Validate existing collection dimensions against config + try { + const collectionInfo = await this.client.getCollection(env.qdrant.collection); + const vectorsConfig = collectionInfo.config?.params?.vectors; + // Handle both named and unnamed vector configurations + // Unnamed: { size: N, distance: "Cosine" } — Named: { "default": { size: N, distance: "Cosine" } } + const actualSize = (vectorsConfig as Record)?.size + ?? (Object.values(vectorsConfig as Record || {})[0] as Record)?.size; + + if (actualSize && actualSize !== env.qdrant.vectorSize) { + this._dimensionMismatch = true; + this.log.error( + "QDRANT COLLECTION DIMENSION MISMATCH — searches and ingestion will fail!", + { + collectionName: env.qdrant.collection, + configuredVectorSize: env.qdrant.vectorSize, + actualCollectionVectorSize: actualSize, + fix: `Either (1) delete the collection and restart to recreate it, or (2) update qdrant.vectorSize in your config to ${actualSize}`, + } + ); + } else { + this.log.info(`Qdrant collection "${env.qdrant.collection}" already exists`, { + vectorSize: actualSize || env.qdrant.vectorSize, + }); + } + } catch (err) { + this.log.warn("Could not validate Qdrant collection dimensions", { + error: (err as Error).message, + }); + } + } + } + + /** + * Validate that the embedding model produces vectors matching the configured vectorSize. + * Sends a test embedding and compares its length against env.qdrant.vectorSize. + */ + private async validateEmbeddingDimensions(): Promise { + try { + const testEmbedding = await this.getEmbedding("test"); + if (testEmbedding.length !== env.qdrant.vectorSize) { + this._dimensionMismatch = true; + this.log.error( + "EMBEDDING MODEL DIMENSION MISMATCH — the configured vectorSize does not match the model output!", + { + configuredVectorSize: env.qdrant.vectorSize, + actualModelDimensions: testEmbedding.length, + embeddingModel: env.qdrant.embeddingModel, + fix: `Update qdrant.vectorSize in your config to ${testEmbedding.length}`, + } + ); + } else { + this.log.info("embedding dimension validation passed", { + vectorSize: testEmbedding.length, + model: env.qdrant.embeddingModel, + }); + } + } catch (err) { + this.log.warn("Could not validate embedding dimensions at startup", { + error: (err as Error).message, + }); } } @@ -167,6 +240,14 @@ class VectorStoreService extends DtpService { return; } + if (this._dimensionMismatch) { + this.log.error("ingestTurn skipped — vector dimension mismatch detected at startup. Fix config and restart.", { + turnId, + configuredVectorSize: env.qdrant.vectorSize, + }); + return; + } + try { // Fetch and populate the turn let turn = await ChatTurn.findById(turnId); @@ -227,6 +308,18 @@ class VectorStoreService extends DtpService { const embedding = await this.getEmbedding(chunk); + // Validate embedding dimensions before upsert + if (embedding.length !== env.qdrant.vectorSize) { + this.log.error("embedding dimension mismatch during ingest — skipping chunk", { + expected: env.qdrant.vectorSize, + actual: embedding.length, + turnId, + chunkIndex: i, + fix: `Update qdrant.vectorSize in your config to ${embedding.length}`, + }); + continue; + } + points.push({ id: `${turnId}:${i}`, vector: embedding, @@ -250,12 +343,12 @@ class VectorStoreService extends DtpService { this.log.info("ingested turn to vector store", { turnId, - chunkCount: chunks.length, + chunkCount: points.length, }); } catch (error) { this.log.error("ingestTurn failed", { turnId, - error: (error as Error).message, + error, }); // Do not rethrow — this is designed to be fire-and-forget } @@ -273,8 +366,25 @@ class VectorStoreService extends DtpService { throw new Error("VectorStoreService is not initialized"); } + if (this._dimensionMismatch) { + throw new Error( + `Vector dimension mismatch: the Qdrant collection dimensions do not match the ` + + `configured vectorSize (${env.qdrant.vectorSize}). Either delete the collection ` + + `and restart, or update qdrant.vectorSize in your config.` + ); + } + const queryVector = await this.getEmbedding(query); + // Validate embedding dimensions before searching + if (queryVector.length !== env.qdrant.vectorSize) { + throw new Error( + `Embedding dimension mismatch: model produced ${queryVector.length} dimensions, ` + + `but collection expects ${env.qdrant.vectorSize}. ` + + `Update qdrant.vectorSize in your config to ${queryVector.length}.` + ); + } + // Build Qdrant filter from provided filters const must: Array<{ key: string; @@ -334,7 +444,7 @@ class VectorStoreService extends DtpService { } catch (error) { this.log.error("removeTurnPoints failed", { turnId, - error: (error as Error).message, + error, }); } }