search input and backend error fixes
This commit is contained in:
parent
56f35a2fdf
commit
66774d94f9
@ -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<ISearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
performSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const formatScore = (score: number): string => `${Math.round(score * 100)}%`;
|
||||
|
||||
const formatTimestamp = (iso: string): string => {
|
||||
@ -53,13 +61,36 @@ export default function ChatSearchResults({
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="bg-bg-secondary border border-border-default rounded-lg w-full max-w-3xl max-h-[80vh] flex flex-col mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Search Results</h2>
|
||||
{!loading && <span className="text-sm text-text-tertiary">{results.length} result{results.length !== 1 ? "s" : ""} for "{query}"</span>}
|
||||
{/* Header with editable search input */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-border-default">
|
||||
<Search className="text-text-tertiary shrink-0" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={editQuery}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
onClick={performSearch}
|
||||
className="text-text-tertiary hover:text-brand transition-colors shrink-0"
|
||||
aria-label="Submit search"
|
||||
>
|
||||
<Search size={16} />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary transition-colors p-1 shrink-0" aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary transition-colors p-1" aria-label="Close"><X size={18} /></button>
|
||||
|
||||
{/* Result count bar */}
|
||||
{!loading && (
|
||||
<div className="px-6 py-2 text-sm text-text-tertiary border-b border-border-default">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""} for "{query === editQuery ? query : editQuery}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-4">
|
||||
{loading && (
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||
// 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<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 && (
|
||||
<button onClick={handleClear} className="absolute right-2 text-text-tertiary hover:text-text-primary transition-colors" aria-label="Clear search">
|
||||
<>
|
||||
<button onClick={handleClear} className="absolute right-8 text-text-tertiary hover:text-text-primary transition-colors" aria-label="Clear search">
|
||||
<X size={14} />
|
||||
</button>
|
||||
<button onClick={handleSubmit} className="absolute right-2 text-text-tertiary hover:text-brand transition-colors" aria-label="Submit search">
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<void> {
|
||||
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<string, any>)?.size
|
||||
?? (Object.values(vectorsConfig as Record<string, any> || {})[0] as Record<string, any>)?.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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user