Merge branch 'develop' of git.digitaltelepresence.com:rob/gadget into develop
This commit is contained in:
commit
3c076fc01a
@ -3,7 +3,7 @@
|
|||||||
// All Rights Reserved
|
// All Rights Reserved
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
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";
|
import { searchApi, ISearchResult } from "../lib/api";
|
||||||
|
|
||||||
interface ChatSearchResultsProps {
|
interface ChatSearchResultsProps {
|
||||||
@ -19,26 +19,34 @@ export default function ChatSearchResults({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: ChatSearchResultsProps) {
|
}: ChatSearchResultsProps) {
|
||||||
|
const [editQuery, setEditQuery] = useState(query);
|
||||||
const [results, setResults] = useState<ISearchResult[]>([]);
|
const [results, setResults] = useState<ISearchResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const performSearch = useCallback(async () => {
|
const performSearch = useCallback(async () => {
|
||||||
if (!query.trim()) return;
|
if (!editQuery.trim()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await searchApi.search(query, filters, 20);
|
const data = await searchApi.search(editQuery, filters, 20);
|
||||||
setResults(data);
|
setResults(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message || "Search failed");
|
setError((err as Error).message || "Search failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [query, filters]);
|
}, [editQuery, filters]);
|
||||||
|
|
||||||
useEffect(() => { performSearch(); }, [performSearch]);
|
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 formatScore = (score: number): string => `${Math.round(score * 100)}%`;
|
||||||
|
|
||||||
const formatTimestamp = (iso: string): string => {
|
const formatTimestamp = (iso: string): string => {
|
||||||
@ -53,13 +61,36 @@ export default function ChatSearchResults({
|
|||||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
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="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">
|
{/* Header with editable search input */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 px-6 py-4 border-b border-border-default">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">Search Results</h2>
|
<Search className="text-text-tertiary shrink-0" size={16} />
|
||||||
{!loading && <span className="text-sm text-text-tertiary">{results.length} result{results.length !== 1 ? "s" : ""} for "{query}"</span>}
|
<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>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 p-4">
|
<div className="overflow-y-auto flex-1 p-4">
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
// All Rights Reserved
|
// All Rights Reserved
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
|
|
||||||
interface SearchInputProps {
|
interface SearchInputProps {
|
||||||
@ -19,42 +19,26 @@ export default function SearchInput({
|
|||||||
className = "",
|
className = "",
|
||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
const [value, setValue] = useState("");
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setValue(v);
|
setValue(v);
|
||||||
if (v.trim()) { debouncedSearch(v); } else {
|
if (!v.trim()) {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
onClear?.();
|
onClear?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setValue("");
|
setValue("");
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
onClear?.();
|
onClear?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (value.trim()) onSearch(value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === "Enter" && value.trim()) {
|
if (e.key === "Enter") handleSubmit();
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
onSearch(value.trim());
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") handleClear();
|
if (e.key === "Escape") handleClear();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,12 +51,17 @@ export default function SearchInput({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
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 && (
|
{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} />
|
<X size={14} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -104,11 +104,10 @@ class SearchController extends DtpController {
|
|||||||
data: hydratedResults,
|
data: hydratedResults,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = (error as Error).message;
|
this.log.error("search failed", { error });
|
||||||
this.log.error("search failed", { error: message });
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Search failed",
|
message: (error as Error).message || "Search failed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,7 @@ class VectorStoreService extends DtpService {
|
|||||||
private aiApi!: AiApi;
|
private aiApi!: AiApi;
|
||||||
private splitter!: RecursiveCharacterTextSplitter;
|
private splitter!: RecursiveCharacterTextSplitter;
|
||||||
private _initialized = false;
|
private _initialized = false;
|
||||||
|
private _dimensionMismatch = false;
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "VectorStoreService";
|
return "VectorStoreService";
|
||||||
@ -111,9 +112,19 @@ class VectorStoreService extends DtpService {
|
|||||||
chunkOverlap: 200,
|
chunkOverlap: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure the Qdrant collection exists
|
// Ensure the Qdrant collection exists and validate dimensions
|
||||||
await this.ensureCollection();
|
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._initialized = true;
|
||||||
this.log.info("started", {
|
this.log.info("started", {
|
||||||
host: env.qdrant.host,
|
host: env.qdrant.host,
|
||||||
@ -122,6 +133,7 @@ class VectorStoreService extends DtpService {
|
|||||||
providerId: env.qdrant.providerId,
|
providerId: env.qdrant.providerId,
|
||||||
embeddingModel: env.qdrant.embeddingModel,
|
embeddingModel: env.qdrant.embeddingModel,
|
||||||
vectorSize: env.qdrant.vectorSize,
|
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.
|
* Create the Qdrant collection if it doesn't already exist.
|
||||||
|
* Validates existing collection dimensions against the configured vectorSize.
|
||||||
*/
|
*/
|
||||||
private async ensureCollection(): Promise<void> {
|
private async ensureCollection(): Promise<void> {
|
||||||
const collections = await this.client.getCollections();
|
const collections = await this.client.getCollections();
|
||||||
@ -157,11 +170,79 @@ class VectorStoreService extends DtpService {
|
|||||||
vectorSize: env.qdrant.vectorSize,
|
vectorSize: env.qdrant.vectorSize,
|
||||||
distance: "Cosine",
|
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 {
|
} else {
|
||||||
this.log.info(
|
this.log.info(
|
||||||
`Qdrant collection "${env.qdrant.collection}" already exists`,
|
`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;
|
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 {
|
try {
|
||||||
// Fetch and populate the turn
|
// Fetch and populate the turn
|
||||||
let turn = await ChatTurn.findById(turnId);
|
let turn = await ChatTurn.findById(turnId);
|
||||||
@ -251,6 +343,21 @@ class VectorStoreService extends DtpService {
|
|||||||
|
|
||||||
const embedding = await this.getEmbedding(chunk);
|
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({
|
points.push({
|
||||||
id: `${turnId}:${i}`,
|
id: `${turnId}:${i}`,
|
||||||
vector: embedding,
|
vector: embedding,
|
||||||
@ -274,12 +381,12 @@ class VectorStoreService extends DtpService {
|
|||||||
|
|
||||||
this.log.info("ingested turn to vector store", {
|
this.log.info("ingested turn to vector store", {
|
||||||
turnId,
|
turnId,
|
||||||
chunkCount: chunks.length,
|
chunkCount: points.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.error("ingestTurn failed", {
|
this.log.error("ingestTurn failed", {
|
||||||
turnId,
|
turnId,
|
||||||
error: (error as Error).message,
|
error,
|
||||||
});
|
});
|
||||||
// Do not rethrow — this is designed to be fire-and-forget
|
// 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");
|
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);
|
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
|
// Build Qdrant filter from provided filters
|
||||||
const must: Array<{
|
const must: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
@ -361,7 +485,7 @@ class VectorStoreService extends DtpService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.error("removeTurnPoints failed", {
|
this.log.error("removeTurnPoints failed", {
|
||||||
turnId,
|
turnId,
|
||||||
error: (error as Error).message,
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user