Merge branch 'develop' of git.digitaltelepresence.com:rob/gadget into develop

This commit is contained in:
Rob Colbert 2026-05-19 15:46:16 -04:00
commit 3c076fc01a
4 changed files with 190 additions and 47 deletions

View File

@ -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 && (

View File

@ -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>
);

View File

@ -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",
});
}
}

View File

@ -54,6 +54,7 @@ class VectorStoreService extends DtpService {
private aiApi!: AiApi;
private splitter!: RecursiveCharacterTextSplitter;
private _initialized = false;
private _dimensionMismatch = false;
get name(): string {
return "VectorStoreService";
@ -111,9 +112,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,
@ -122,6 +133,7 @@ class VectorStoreService extends DtpService {
providerId: env.qdrant.providerId,
embeddingModel: env.qdrant.embeddingModel,
vectorSize: env.qdrant.vectorSize,
dimensionMismatch: this._dimensionMismatch,
});
}
@ -139,6 +151,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();
@ -157,11 +170,79 @@ class VectorStoreService extends DtpService {
vectorSize: env.qdrant.vectorSize,
distance: "Cosine",
});
} else {
// 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,
});
}
}
/**
@ -189,6 +270,17 @@ 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);
@ -251,6 +343,21 @@ 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,
@ -274,12 +381,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
}
@ -297,8 +404,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;
@ -361,7 +485,7 @@ class VectorStoreService extends DtpService {
} catch (error) {
this.log.error("removeTurnPoints failed", {
turnId,
error: (error as Error).message,
error,
});
}
}