From 56f35a2fdf68883f2c4bc8f206c583f86aaff765 Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Tue, 19 May 2026 14:28:30 -0400 Subject: [PATCH] feat: Qdrant semantic search over chat history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds vector-based semantic search across all chat sessions using Qdrant. When a ChatTurn finishes, its content is chunked, embedded, and upserted to a Qdrant collection. A search API and UI components enable searching at user, project, and session scope. Phase 1 — Configuration & Dependencies - Add port/apiKey to GadgetCodeConfig.qdrant type - Uncomment and update qdrant section in YAML config example - Add qdrant config passthrough in env.ts - Add @qdrant/js-client-rest dependency Phase 2 — AI Embedding API (@gadget/ai) - Add IAiEmbeddingResponse interface and abstract embeddings() to AiApi - Implement embeddings() in OllamaAiApi (client.embeddings) - Implement embeddings() in OpenAiApi (client.embeddings.create) - Export IAiEmbeddingResponse from package index Phase 3 — Backend Vector Store Service - Create VectorStoreService (ingestTurn, search, removeTurnPoints) - Hook fire-and-forget ingest after turn.save() in drone-session - Register VectorStoreService in service startup/shutdown Phase 4 — Backend Search API - Create POST /api/v1/search controller with userId enforcement - Batch-hydrate results from MongoDB (user, project, session, turn) - Register search route in v1 API router Phase 5 — Frontend Search Components - SearchInput: debounced input with lucide-react icons - ChatSearchResults: modal with score badges, metadata, loading states - DroneSelectionModal: drone picker for sessions without a drone - Add searchApi and ISearchResult to API client - Add search to Home (global), ProjectManager (project), ChatSessionView (session) - Add id=turn-{turnId} to ChatTurn for scroll targeting - Scroll-to-turn from search result selection and router state - Show DroneSelectionModal when no drone available - Add Select Drone button in ChatSessionView sidebar --- docs/install/config-examples/gadget-code.yaml | 16 +- .../src/components/ChatSearchResults.tsx | 98 +++++ .../frontend/src/components/ChatTurn.tsx | 2 +- .../src/components/DroneSelectionModal.tsx | 84 +++++ .../frontend/src/components/SearchInput.tsx | 79 ++++ gadget-code/frontend/src/lib/api.ts | 29 ++ .../frontend/src/pages/ChatSessionView.tsx | 120 +++++- gadget-code/frontend/src/pages/Home.tsx | 41 ++- .../frontend/src/pages/ProjectManager.tsx | 53 ++- gadget-code/package.json | 1 + gadget-code/src/config/env.ts | 9 + gadget-code/src/controllers/api/v1.ts | 1 + gadget-code/src/controllers/api/v1/search.ts | 171 +++++++++ gadget-code/src/lib/drone-session.ts | 11 + gadget-code/src/services/index.ts | 4 + gadget-code/src/services/vector-store.ts | 343 ++++++++++++++++++ packages/ai/src/api.ts | 10 + packages/ai/src/index.ts | 1 + packages/ai/src/ollama.ts | 6 + packages/ai/src/openai.ts | 9 + packages/config/src/types.ts | 4 +- pnpm-lock.yaml | 27 ++ 22 files changed, 1096 insertions(+), 23 deletions(-) create mode 100644 gadget-code/frontend/src/components/ChatSearchResults.tsx create mode 100644 gadget-code/frontend/src/components/DroneSelectionModal.tsx create mode 100644 gadget-code/frontend/src/components/SearchInput.tsx create mode 100644 gadget-code/src/controllers/api/v1/search.ts create mode 100644 gadget-code/src/services/vector-store.ts diff --git a/docs/install/config-examples/gadget-code.yaml b/docs/install/config-examples/gadget-code.yaml index c00c9f8..073faa1 100644 --- a/docs/install/config-examples/gadget-code.yaml +++ b/docs/install/config-examples/gadget-code.yaml @@ -132,12 +132,14 @@ minio: videos: "gadget-videos" audios: "gadget-audios" -# ── Qdrant Vector Database (COMING SOON) ──────────────────────────────────── +# ── Qdrant Vector Database ────────────────────────────────────────────────────── # Used for semantic search over chat history and conversation context. -# Enable when the Qdrant integration is released. # -# qdrant: -# host: "localhost" -# port: 6333 -# apiKey: "${QDRANT_API_KEY}" -# collection: "gadget-chat-embeddings" +qdrant: + host: "localhost" + port: 6333 + apiKey: "${QDRANT_API_KEY}" + collection: "gadget-chat-embeddings" + providerId: "${QDRANT_PROVIDER_ID}" # AiProvider._id from the database + embeddingModel: "nomic-embed-text" # model name on the provider + vectorSize: 768 # must match the embedding model output diff --git a/gadget-code/frontend/src/components/ChatSearchResults.tsx b/gadget-code/frontend/src/components/ChatSearchResults.tsx new file mode 100644 index 0000000..e9f2625 --- /dev/null +++ b/gadget-code/frontend/src/components/ChatSearchResults.tsx @@ -0,0 +1,98 @@ +// src/components/ChatSearchResults.tsx +// Copyright (C) 2026 Robert Colbert +// All Rights Reserved + +import { useState, useEffect, useCallback } from "react"; +import { X, Loader2 } from "lucide-react"; +import { searchApi, ISearchResult } from "../lib/api"; + +interface ChatSearchResultsProps { + query: string; + filters?: { projectId?: string; sessionId?: string; turnId?: string }; + onSelect: (result: ISearchResult) => void; + onClose: () => void; +} + +export default function ChatSearchResults({ + query, + filters, + onSelect, + onClose, +}: ChatSearchResultsProps) { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const performSearch = useCallback(async () => { + if (!query.trim()) return; + setLoading(true); + setError(null); + try { + const data = await searchApi.search(query, filters, 20); + setResults(data); + } catch (err) { + setError((err as Error).message || "Search failed"); + } finally { + setLoading(false); + } + }, [query, filters]); + + useEffect(() => { performSearch(); }, [performSearch]); + + const formatScore = (score: number): string => `${Math.round(score * 100)}%`; + + const formatTimestamp = (iso: string): string => { + if (!iso) return ""; + try { return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }); } + catch { return iso; } + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} + > +
+
+
+

Search Results

+ {!loading && {results.length} result{results.length !== 1 ? "s" : ""} for "{query}"} +
+ +
+ +
+ {loading && ( +
+ + Searching… +
+ )} + {error &&

{error}

} + {!loading && !error && results.length === 0 &&

No results found

} + {!loading && !error && results.length > 0 && ( +
+ {results.map((result) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/gadget-code/frontend/src/components/ChatTurn.tsx b/gadget-code/frontend/src/components/ChatTurn.tsx index 79cfcfd..3ca2e17 100644 --- a/gadget-code/frontend/src/components/ChatTurn.tsx +++ b/gadget-code/frontend/src/components/ChatTurn.tsx @@ -34,7 +34,7 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) { const startedAt = new Date(turn.createdAt); return ( -
+
{/* Turn Header */}
{startedAt.toLocaleTimeString()}
diff --git a/gadget-code/frontend/src/components/DroneSelectionModal.tsx b/gadget-code/frontend/src/components/DroneSelectionModal.tsx new file mode 100644 index 0000000..2aba94d --- /dev/null +++ b/gadget-code/frontend/src/components/DroneSelectionModal.tsx @@ -0,0 +1,84 @@ +// src/components/DroneSelectionModal.tsx +// Copyright (C) 2026 Robert Colbert +// All Rights Reserved + +import { useState, useEffect } from "react"; +import { X, Loader2 } from "lucide-react"; +import { droneApi, type DroneRegistration } from "../lib/api"; + +interface DroneSelectionModalProps { + onSelect: (drone: DroneRegistration) => void; + onClose: () => void; +} + +export default function DroneSelectionModal({ onSelect, onClose }: DroneSelectionModalProps) { + const [drones, setDrones] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadDrones = async () => { + try { setDrones(await droneApi.getAll()); } + catch (err) { setError((err as Error).message || "Failed to load drones"); } + finally { setLoading(false); } + }; + loadDrones(); + }, []); + + const statusColor = (status: string) => { + switch (status) { + case "available": return "bg-green-500"; + case "busy": return "bg-yellow-500"; + default: return "bg-gray-500"; + } + }; + + const availableDrones = drones.filter(d => d.status === "available" || d.status === "busy"); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} + > +
+
+

Select a Drone

+ +
+
+ {loading && ( +
+ + Loading drones… +
+ )} + {error &&

{error}

} + {!loading && !error && availableDrones.length === 0 && ( +
+

No drones are currently available.

+

Start a drone agent to begin working.

+
+ )} + {!loading && !error && availableDrones.length > 0 && ( +
+ {availableDrones.map((drone) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/gadget-code/frontend/src/components/SearchInput.tsx b/gadget-code/frontend/src/components/SearchInput.tsx new file mode 100644 index 0000000..370bee6 --- /dev/null +++ b/gadget-code/frontend/src/components/SearchInput.tsx @@ -0,0 +1,79 @@ +// src/components/SearchInput.tsx +// Copyright (C) 2026 Robert Colbert +// All Rights Reserved + +import { useState, useEffect, useRef, useCallback } from "react"; +import { Search, X } from "lucide-react"; + +interface SearchInputProps { + placeholder?: string; + onSearch: (query: string) => void; + onClear?: () => void; + className?: string; +} + +export default function SearchInput({ + placeholder = "Search…", + onSearch, + onClear, + 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); + onClear?.(); + } + }; + + const handleClear = () => { + setValue(""); + if (debounceRef.current) clearTimeout(debounceRef.current); + onClear?.(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && value.trim()) { + if (debounceRef.current) clearTimeout(debounceRef.current); + onSearch(value.trim()); + } + if (e.key === "Escape") handleClear(); + }; + + return ( +
+ + + {value && ( + + )} +
+ ); +} diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts index e8849b1..fac6baf 100644 --- a/gadget-code/frontend/src/lib/api.ts +++ b/gadget-code/frontend/src/lib/api.ts @@ -500,3 +500,32 @@ export const chatSessionApi = { getTurns: (id: string) => api.get(`/api/v1/chat-sessions/${id}/turns`), }; + +export interface ISearchResult { + id: string; + content: string; + score: number; + userId: string; + projectId: string; + sessionId: string; + turnId: string; + role: string; + createdAt: string; + user?: { _id: string; displayName: string; username: string }; + project?: { _id: string; name: string; slug: string }; + session?: { _id: string; name: string }; + turn?: { _id: string; status: string; mode: string; llm: string }; +} + +export const searchApi = { + search: ( + query: string, + filters?: { projectId?: string; sessionId?: string; turnId?: string }, + topK?: number, + ) => + api.post("/api/v1/search", { + query, + ...filters, + topK, + }), +}; diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx index af9c28c..2cf11cc 100644 --- a/gadget-code/frontend/src/pages/ChatSessionView.tsx +++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useContext, useCallback } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { BrushCleaning, Maximize2 } from 'lucide-react'; import { socketClient } from '../lib/socket'; -import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project, type IWorkOrderCompleteStats } from '../lib/api'; +import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project, type IWorkOrderCompleteStats, type ISearchResult } from '../lib/api'; import { WorkspaceMode } from '../lib/types'; import SessionPanel from '../components/SessionPanel'; import ProjectPanel from '../components/ProjectPanel'; @@ -12,6 +12,9 @@ import ChatTurnComponent from '../components/ChatTurn'; import EditorPanel from '../components/EditorPanel'; import ErrorBoundary from '../components/ErrorBoundary'; import ExpandedPromptEditor from '../components/ExpandedPromptEditor'; +import SearchInput from '../components/SearchInput'; +import ChatSearchResults from '../components/ChatSearchResults'; +import DroneSelectionModal from '../components/DroneSelectionModal'; import { AppContext } from '../App'; interface LogEntry { @@ -56,6 +59,7 @@ enum SessionStartupState { interface RouterState { drone?: any; // DroneRegistration passed via router state project?: Project; // Project passed via router state (optional — can be fetched) + scrollToTurnId?: string; // Turn to scroll to after search result selection } export default function ChatSessionView() { @@ -104,6 +108,9 @@ export default function ChatSessionView() { const [reconnectAttempts, setReconnectAttempts] = useState(0); const [editorFilePath, setEditorFilePath] = useState(undefined); const [isExpandedEditor, setIsExpandedEditor] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showSearchResults, setShowSearchResults] = useState(false); + const [showDroneModal, setShowDroneModal] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -123,6 +130,23 @@ export default function ChatSessionView() { loadSessionData(); }, [projectId, sessionId]); + // Scroll to turn if navigated from search results + useEffect(() => { + const routerState = location.state as RouterState | null; + const targetTurnId = routerState?.scrollToTurnId; + if (targetTurnId && turns.length > 0) { + // Clear the router state so we don't re-scroll on re-renders + window.history.replaceState({}, ''); + // Use requestAnimationFrame to ensure DOM is painted + requestAnimationFrame(() => { + const el = document.getElementById(`turn-${targetTurnId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }); + } + }, [turns, location.state]); + useEffect(() => { setupSocketListeners(); return () => cleanupSocketListeners(); @@ -310,9 +334,9 @@ export default function ChatSessionView() { return; // Don't proceed to ready state } } else { - // No drone provided — skip lock step, go directly to Ready - // (This handles the case where the view is loaded directly via URL without a drone, - // e.g., for viewing completed sessions or task runs) + // No drone provided — show drone selection modal + // The user must select a drone to interact with the session + setShowDroneModal(true); setStartupState(SessionStartupState.Ready); } @@ -1223,6 +1247,54 @@ export default function ChatSessionView() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; + // ── Search handlers ── + const handleSearch = useCallback((query: string) => { + setSearchQuery(query); + setShowSearchResults(true); + }, []); + + const handleSearchSelect = useCallback((result: ISearchResult) => { + setShowSearchResults(false); + setSearchQuery(""); + // Scroll to the turn within this session view + const el = document.getElementById(`turn-${result.turnId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, []); + + const handleSearchClear = useCallback(() => { + setSearchQuery(""); + setShowSearchResults(false); + }, []); + + // ── Drone selection handler ── + const handleDroneSelect = useCallback(async (drone: any) => { + setDroneRegistration(drone); + droneRegistrationRef.current = drone; + setShowDroneModal(false); + + // Persist and request lock + localStorage.setItem('dtp_drone_registration', JSON.stringify(drone)); + try { + const success = await socketClient.requestSessionLock( + drone, + projectRef.current, + sessionRef.current, + ); + if (success) { + setStartupState(SessionStartupState.Ready); + appContext?.setStatusMessage('Session ready'); + } else { + setStartupState(SessionStartupState.LockFailed); + appContext?.setStatusMessage('Failed to lock drone — drone may be busy'); + } + } catch (err) { + setStartupState(SessionStartupState.LockFailed); + appContext?.setStatusMessage('Failed to lock drone'); + } + }, [session, appContext]); + const sessionLocked = startupState === SessionStartupState.Ready; const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider; @@ -1366,7 +1438,16 @@ export default function ChatSessionView() {
{/* Prompt Input */} -
+
+ {/* Search bar */} +
+ +
+
)} @@ -1436,6 +1518,20 @@ export default function ChatSessionView() { {/* Sidebar */}
+ {/* Drone Selection Button (when no drone is registered) */} + {!droneRegistration && ( +
+ +

+ A drone is required to send prompts and interact with the session. +

+
+ )}
+ {showSearchResults && searchQuery && session && ( + + )} + {showDroneModal && ( + setShowDroneModal(false)} + /> + )}
); } diff --git a/gadget-code/frontend/src/pages/Home.tsx b/gadget-code/frontend/src/pages/Home.tsx index 8980b86..c04f21f 100644 --- a/gadget-code/frontend/src/pages/Home.tsx +++ b/gadget-code/frontend/src/pages/Home.tsx @@ -1,12 +1,14 @@ import { useState, useEffect, useRef } from "react"; import { Link, useNavigate } from "react-router-dom"; -import type { User, Project, DroneRegistration, ChatSession, SubProcessStat } from "../lib/api"; +import type { User, Project, DroneRegistration, ChatSession, SubProcessStat, ISearchResult } from "../lib/api"; import { projectApi, droneApi, chatSessionApi } from "../lib/api"; import { socketClient } from "../lib/socket"; import Clock from "../components/Clock"; import GadgetGrid from "../components/GadgetGrid"; import LogRenderer from "../components/LogRenderer"; import type { LogRendererEntry } from "../components/LogRenderer"; +import SearchInput from "../components/SearchInput"; +import ChatSearchResults from "../components/ChatSearchResults"; function SystemReady() { return ( @@ -443,6 +445,27 @@ export default function Home({ user }: HomeProps) { const [selectedDrone, setSelectedDrone] = useState( null, ); + const [searchQuery, setSearchQuery] = useState(""); + const [showSearchResults, setShowSearchResults] = useState(false); + + const handleSearch = (query: string) => { + setSearchQuery(query); + setShowSearchResults(true); + }; + + const handleSearchSelect = (result: ISearchResult) => { + setShowSearchResults(false); + setSearchQuery(""); + // Navigate to the chat session — the turn will be scrolled to via id attr + navigate(`/projects/${result.projectId}/chat-session/${result.sessionId}`, { + state: { scrollToTurnId: result.turnId }, + }); + }; + + const handleSearchClear = () => { + setSearchQuery(""); + setShowSearchResults(false); + }; if (!user) { return ( @@ -477,7 +500,14 @@ export default function Home({ user }: HomeProps) {
-
+
+
+ +

Welcome, {user.displayName}!

@@ -516,6 +546,13 @@ export default function Home({ user }: HomeProps) {
{mainContent} + {showSearchResults && searchQuery && ( + + )}
); } diff --git a/gadget-code/frontend/src/pages/ProjectManager.tsx b/gadget-code/frontend/src/pages/ProjectManager.tsx index baa86ce..ee4dca1 100644 --- a/gadget-code/frontend/src/pages/ProjectManager.tsx +++ b/gadget-code/frontend/src/pages/ProjectManager.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import slug from "slug"; -import type { User, Project, ProjectSkill, ProjectTask } from "../lib/api"; +import type { User, Project, ProjectSkill, ProjectTask, ISearchResult } from "../lib/api"; import { projectApi, droneApi, @@ -11,6 +11,8 @@ import { type AiProvider, providerApi, } from "../lib/api"; +import SearchInput from "../components/SearchInput"; +import ChatSearchResults from "../components/ChatSearchResults"; import gabAiImage from "./assets/gab-ai.jpeg"; interface ProjectManagerProps { @@ -1501,6 +1503,26 @@ export default function ProjectManager({ user }: ProjectManagerProps) { const [loading, setLoading] = useState(true); const [showNewForm, setShowNewForm] = useState(false); const [showPullForm, setShowPullForm] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showSearchResults, setShowSearchResults] = useState(false); + + const handleSearch = (query: string) => { + setSearchQuery(query); + setShowSearchResults(true); + }; + + const handleSearchSelect = (result: ISearchResult) => { + setShowSearchResults(false); + setSearchQuery(""); + navigate(`/projects/${result.projectId}/chat-session/${result.sessionId}`, { + state: { scrollToTurnId: result.turnId }, + }); + }; + + const handleSearchClear = () => { + setSearchQuery(""); + setShowSearchResults(false); + }; useEffect(() => { loadProjects(); @@ -1688,12 +1710,21 @@ export default function ProjectManager({ user }: ProjectManagerProps) { ) : selectedProject ? ( <> {/* Center - Project Inspector */} - +
+
+ +
+ +
{/* Right Sidebar - Drones & Chat Sessions */} )}
+ {showSearchResults && searchQuery && ( + + )}
); } diff --git a/gadget-code/package.json b/gadget-code/package.json index 433e118..efd3eb1 100644 --- a/gadget-code/package.json +++ b/gadget-code/package.json @@ -26,6 +26,7 @@ "@gadget/api": "workspace:*", "@gadget/config": "workspace:*", "@langchain/textsplitters": "^1.0.1", + "@qdrant/js-client-rest": "^1.12.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "ansicolor": "^2.0.3", diff --git a/gadget-code/src/config/env.ts b/gadget-code/src/config/env.ts index 404c1aa..05e5a4a 100644 --- a/gadget-code/src/config/env.ts +++ b/gadget-code/src/config/env.ts @@ -105,6 +105,15 @@ export default { host: yamlConfig.mongodb?.host || "localhost", database: yamlConfig.mongodb?.database || "", }, + qdrant: { + host: yamlConfig.qdrant?.host || "localhost", + port: yamlConfig.qdrant?.port || 6333, + apiKey: yamlConfig.qdrant?.apiKey, + collection: yamlConfig.qdrant?.collection || "gadget-chat-embeddings", + providerId: yamlConfig.qdrant?.providerId || "", + embeddingModel: yamlConfig.qdrant?.embeddingModel || "nomic-embed-text", + vectorSize: yamlConfig.qdrant?.vectorSize || 768, + }, redis: { host: yamlConfig.redis?.host || "localhost", port: yamlConfig.redis?.port || 6379, diff --git a/gadget-code/src/controllers/api/v1.ts b/gadget-code/src/controllers/api/v1.ts index eaf9c90..ccc54b2 100644 --- a/gadget-code/src/controllers/api/v1.ts +++ b/gadget-code/src/controllers/api/v1.ts @@ -35,6 +35,7 @@ export class ApiControllerV1 extends DtpController { await this.loadChild(path.join(basePath, "drone.js")); await this.loadChild(path.join(basePath, "project.js")); await this.loadChild(path.join(basePath, "provider.js")); + await this.loadChild(path.join(basePath, "search.js")); await this.loadChild(path.join(basePath, "user.js")); } } diff --git a/gadget-code/src/controllers/api/v1/search.ts b/gadget-code/src/controllers/api/v1/search.ts new file mode 100644 index 0000000..8ea5e1e --- /dev/null +++ b/gadget-code/src/controllers/api/v1/search.ts @@ -0,0 +1,171 @@ +// src/controllers/api/v1/search.ts +// Copyright (C) 2026 Robert Colbert +// All Rights Reserved + +import { Request, Response } from "express"; + +import { DtpController } from "../../../lib/controller.js"; +import VectorStoreService, { ISearchFilters, ISearchResult } from "../../../services/vector-store.js"; +import User from "../../../models/user.js"; +import Project from "../../../models/project.js"; +import ChatSession from "../../../models/chat-session.js"; +import ChatTurn from "../../../models/chat-turn.js"; + +interface IHydratedSearchResult extends ISearchResult { + user?: { _id: string; displayName: string; username: string }; + project?: { _id: string; name: string; slug: string }; + session?: { _id: string; name: string }; + turn?: { _id: string; status: string; mode: string; llm: string }; +} + +interface ISearchRequestBody { + query: string; + userId?: string; + projectId?: string; + sessionId?: string; + turnId?: string; + topK?: number; +} + +class SearchController extends DtpController { + get name(): string { + return "SearchController"; + } + get slug(): string { + return "ctrl:api:v1:search"; + } + get route(): string { + return "/search"; + } + + async start(): Promise { + this.router.post("/", this.requireUser(), this.search.bind(this)); + } + + /** + * POST /api/v1/search + * Semantic search across chat history. + * + * Enforces userId filter: users can only search their own corpus. + */ + private async search(req: Request, res: Response): Promise { + try { + const body = req.body as ISearchRequestBody; + + if (!body.query?.trim()) { + res.status(400).json({ + success: false, + message: "query is required", + }); + return; + } + + // Enforce that the user can only search their own corpus + const userId = (req.user as any)?._id; + if (!userId) { + res.status(403).json({ + success: false, + message: "Authentication required", + }); + return; + } + + const filters: ISearchFilters = { + userId, + projectId: body.projectId, + sessionId: body.sessionId, + turnId: body.turnId, + }; + + const topK = body.topK && body.topK > 0 && body.topK <= 50 + ? body.topK + : 10; + + // Check if vector store is initialized + if (!VectorStoreService.isReady) { + res.status(503).json({ + success: false, + message: "Vector store is not configured. Set qdrant.providerId in gadget-code.yaml to enable semantic search.", + }); + return; + } + + const results = await VectorStoreService.search( + body.query.trim(), + filters, + topK, + ); + + // Hydrate results with MongoDB documents + const hydratedResults = await this.hydrateResults(results); + + res.json({ + success: true, + data: hydratedResults, + }); + } catch (error) { + const message = (error as Error).message; + this.log.error("search failed", { error: message }); + res.status(500).json({ + success: false, + message: "Search failed", + }); + } + } + + /** + * Hydrate search results with user, project, session, and turn data from MongoDB. + * Collects unique IDs to batch-load documents efficiently. + */ + private async hydrateResults( + results: ISearchResult[], + ): Promise { + if (results.length === 0) return []; + + // Collect unique IDs for batch loading + const userIds = [...new Set(results.map((r) => r.userId).filter(Boolean))]; + const projectIds = [...new Set(results.map((r) => r.projectId).filter(Boolean))]; + const sessionIds = [...new Set(results.map((r) => r.sessionId).filter(Boolean))]; + const turnIds = [...new Set(results.map((r) => r.turnId).filter(Boolean))]; + + // Batch load from MongoDB + const [users, projects, sessions, turns] = await Promise.all([ + userIds.length > 0 + ? User.find({ _id: { $in: userIds } }) + .select("_id displayName username") + .lean() + : [], + projectIds.length > 0 + ? Project.find({ _id: { $in: projectIds } }) + .select("_id name slug") + .lean() + : [], + sessionIds.length > 0 + ? ChatSession.find({ _id: { $in: sessionIds } }) + .select("_id name") + .lean() + : [], + turnIds.length > 0 + ? ChatTurn.find({ _id: { $in: turnIds } }) + .select("_id status mode llm") + .lean() + : [], + ]); + + // Build lookup maps + const userMap = new Map(users.map((u) => [u._id, u])); + const projectMap = new Map(projects.map((p) => [p._id, p])); + const sessionMap = new Map(sessions.map((s) => [s._id, s])); + const turnMap = new Map(turns.map((t) => [t._id, t])); + + return results.map((result) => ({ + ...result, + user: userMap.get(result.userId) as any || undefined, + project: projectMap.get(result.projectId) as any || undefined, + session: sessionMap.get(result.sessionId) as any || undefined, + turn: turnMap.get(result.turnId) as any || undefined, + })); + } +} + +export default SearchController; diff --git a/gadget-code/src/lib/drone-session.ts b/gadget-code/src/lib/drone-session.ts index f27daa0..fb5a491 100644 --- a/gadget-code/src/lib/drone-session.ts +++ b/gadget-code/src/lib/drone-session.ts @@ -20,6 +20,7 @@ import { SocketSessionType, } from "./socket-session.js"; import { SocketService } from "../services/index.js"; +import { VectorStoreService } from "../services/index.js"; import { ChatTurn } from "../models/chat-turn.js"; import ChatSession from "../models/chat-session.js"; import MessageQueue, { type QueuedMessage } from "./message-queue.js"; @@ -332,6 +333,16 @@ export class DroneSession extends SocketSession { await turn.save(); + // Ingest to vector store (fire-and-forget for finished turns) + if (turn.status === ChatTurnStatus.Finished) { + VectorStoreService.ingestTurn(turnId).catch((err) => { + this.log.error("Failed to ingest turn to vector store", { + turnId, + error: err.message, + }); + }); + } + // COMPUTE AGGREGATE: walk the turn's subagent records for aggregate totals let totalInputTokens = stats?.masterInputTokens ?? 0; let totalOutputTokens = stats?.masterOutputTokens ?? 0; diff --git a/gadget-code/src/services/index.ts b/gadget-code/src/services/index.ts index 5f2eed0..2172045 100644 --- a/gadget-code/src/services/index.ts +++ b/gadget-code/src/services/index.ts @@ -13,6 +13,7 @@ import SessionService from "./session.js"; import SocketService from "./socket.js"; import StorageService from "./storage.js"; import UserService from "./user.js"; +import VectorStoreService from "./vector-store.js"; export async function startServices(): Promise { await ApiClientService.start(); @@ -26,6 +27,7 @@ export async function startServices(): Promise { await SocketService.start(); await StorageService.start(); await UserService.start(); + await VectorStoreService.start(); } export async function stopServices(): Promise { @@ -40,6 +42,7 @@ export async function stopServices(): Promise { await SocketService.stop(); await StorageService.stop(); await UserService.stop(); + await VectorStoreService.stop(); } export { @@ -54,4 +57,5 @@ export { SocketService, StorageService, UserService, + VectorStoreService, }; diff --git a/gadget-code/src/services/vector-store.ts b/gadget-code/src/services/vector-store.ts new file mode 100644 index 0000000..9485b8a --- /dev/null +++ b/gadget-code/src/services/vector-store.ts @@ -0,0 +1,343 @@ +// src/services/vector-store.ts +// Copyright (C) 2026 Robert Colbert +// All Rights Reserved + +import { QdrantClient } from "@qdrant/js-client-rest"; +import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters"; + +import env from "../config/env.js"; +import { DtpService } from "../lib/service.js"; +import { ChatTurnStatus } from "@gadget/api"; +import { createAiApi, IAiEnvironment, IAiProvider as IAiApiProvider, AiApi } from "@gadget/ai"; +import ChatTurn from "../models/chat-turn.js"; +import AiProvider from "../models/ai-provider.js"; +import { ChatSessionService } from "./index.js"; + +export interface ISearchFilters { + userId?: string; + projectId?: string; + sessionId?: string; + turnId?: string; +} + +export interface ISearchResult { + id: string; + content: string; + score: number; + userId: string; + projectId: string; + sessionId: string; + turnId: string; + role: string; + createdAt: string; +} + +const aiEnv: IAiEnvironment = { + NODE_ENV: env.NODE_ENV || "develop", + services: { + google: { + cse: { + apiKey: env.google.cse.apiKey, + engineId: env.google.cse.engineId, + }, + }, + }, +}; + +class VectorStoreService extends DtpService { + private client!: QdrantClient; + private aiApi!: AiApi; + private splitter!: RecursiveCharacterTextSplitter; + private _initialized = false; + + get name(): string { + return "VectorStoreService"; + } + get slug(): string { + return "svc:vector-store"; + } + + async start(): Promise { + if (!env.qdrant.providerId) { + this.log.warn( + "qdrant.providerId is not configured — vector store service will not start. " + + "Set providerId in gadget-code.yaml under qdrant to enable semantic search." + ); + return; + } + + // Initialize Qdrant client + this.client = new QdrantClient({ + url: `http://${env.qdrant.host}:${env.qdrant.port}`, + apiKey: env.qdrant.apiKey || undefined, + }); + + // Load the configured AI provider for embeddings + const providerDoc = await AiProvider.findById(env.qdrant.providerId); + if (!providerDoc) { + this.log.error( + `Qdrant providerId "${env.qdrant.providerId}" not found in database — ` + + "vector store service will not start." + ); + return; + } + + const aiProvider: IAiApiProvider = { + _id: providerDoc._id, + name: providerDoc.name, + sdk: providerDoc.apiType, // map apiType → sdk + baseUrl: providerDoc.baseUrl, + apiKey: providerDoc.apiKey, + }; + this.aiApi = createAiApi(aiEnv, aiProvider, this.log); + + // Initialize text splitter + this.splitter = new RecursiveCharacterTextSplitter({ + chunkSize: 1000, + chunkOverlap: 200, + }); + + // Ensure the Qdrant collection exists + await this.ensureCollection(); + + this._initialized = true; + this.log.info("started", { + host: env.qdrant.host, + port: env.qdrant.port, + collection: env.qdrant.collection, + providerId: env.qdrant.providerId, + embeddingModel: env.qdrant.embeddingModel, + vectorSize: env.qdrant.vectorSize, + }); + } + + async stop(): Promise { + this._initialized = false; + this.log.info("stopped"); + } + + /** + * Check if the service is initialized and ready. + */ + get isReady(): boolean { + return this._initialized; + } + + /** + * Create the Qdrant collection if it doesn't already exist. + */ + private async ensureCollection(): Promise { + const collections = await this.client.getCollections(); + const exists = collections.collections.some( + (c) => c.name === env.qdrant.collection, + ); + + if (!exists) { + await this.client.createCollection(env.qdrant.collection, { + vectors: { + size: env.qdrant.vectorSize, + distance: "Cosine" as const, + }, + }); + this.log.info(`created Qdrant collection "${env.qdrant.collection}"`, { + vectorSize: env.qdrant.vectorSize, + distance: "Cosine", + }); + } else { + this.log.info(`Qdrant collection "${env.qdrant.collection}" already exists`); + } + } + + /** + * Generate an embedding vector for the given text. + */ + private async getEmbedding(text: string): Promise { + const response = await this.aiApi.embeddings(env.qdrant.embeddingModel, text); + return response.embedding; + } + + /** + * Ingest a chat turn into the vector store. + * Fetches the turn by ID, extracts content, chunks, embeds, and upserts to Qdrant. + * Fire-and-forget safe — logs errors but does not throw. + */ + async ingestTurn(turnId: string): Promise { + if (!this._initialized) { + this.log.warn("ingestTurn called but service is not initialized — skipping", { turnId }); + return; + } + + try { + // Fetch and populate the turn + let turn = await ChatTurn.findById(turnId); + if (!turn) { + this.log.warn("ingestTurn: turn not found", { turnId }); + return; + } + + // Only ingest finished turns + if (turn.status !== ChatTurnStatus.Finished) { + this.log.debug("ingestTurn: skipping non-finished turn", { + turnId, + status: turn.status, + }); + return; + } + + // Populate references for metadata + turn = await ChatTurn.populate(turn, ChatSessionService.populateChatTurn); + + // Extract content + const userPrompt = turn.prompts.user || ""; + + // Extract agent response: concatenate all 'responding' mode blocks + let agentResponse = ""; + for (const block of turn.blocks) { + if (block.mode === "responding" && typeof block.content === "string") { + agentResponse += block.content + "\n"; + } + } + + // Combine user + agent text for chunking + const combinedText = [ + userPrompt ? `User: ${userPrompt}` : "", + agentResponse ? `Agent: ${agentResponse.trim()}` : "", + ].filter(Boolean).join("\n\n"); + + if (!combinedText.trim()) { + this.log.debug("ingestTurn: no content to ingest", { turnId }); + return; + } + + // Chunk the text + const chunks = await this.splitter.splitText(combinedText); + + // Determine role for each chunk based on content + const points = []; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]!; + let role: string; + if (chunk.startsWith("User:") && !chunk.includes("Agent:")) { + role = "user"; + } else if (chunk.startsWith("Agent:") && !chunk.includes("User:")) { + role = "agent"; + } else { + role = "both"; + } + + const embedding = await this.getEmbedding(chunk); + + points.push({ + id: `${turnId}:${i}`, + vector: embedding, + payload: { + content: chunk, + userId: String(turn.user), + projectId: turn.project ? String(turn.project) : "", + sessionId: String(turn.session), + turnId: turn._id, + role, + createdAt: turn.createdAt.toISOString(), + }, + }); + } + + // Upsert all points + await this.client.upsert(env.qdrant.collection, { + wait: false, + points, + }); + + this.log.info("ingested turn to vector store", { + turnId, + chunkCount: chunks.length, + }); + } catch (error) { + this.log.error("ingestTurn failed", { + turnId, + error: (error as Error).message, + }); + // Do not rethrow — this is designed to be fire-and-forget + } + } + + /** + * Search the vector store for relevant chunks. + */ + async search( + query: string, + filters?: ISearchFilters, + topK: number = 10, + ): Promise { + if (!this._initialized) { + throw new Error("VectorStoreService is not initialized"); + } + + const queryVector = await this.getEmbedding(query); + + // Build Qdrant filter from provided filters + const must: Array<{ + key: string; + match: { value: string }; + }> = []; + + if (filters?.userId) { + must.push({ key: "userId", match: { value: filters.userId } }); + } + if (filters?.projectId) { + must.push({ key: "projectId", match: { value: filters.projectId } }); + } + if (filters?.sessionId) { + must.push({ key: "sessionId", match: { value: filters.sessionId } }); + } + if (filters?.turnId) { + must.push({ key: "turnId", match: { value: filters.turnId } }); + } + + const searchResults = await this.client.search(env.qdrant.collection, { + vector: queryVector, + limit: topK, + filter: must.length > 0 ? { must } : undefined, + with_payload: true, + }); + + return searchResults.map((point) => ({ + id: point.id as string, + content: (point.payload as any)?.content || "", + score: point.score ?? 0, + userId: (point.payload as any)?.userId || "", + projectId: (point.payload as any)?.projectId || "", + sessionId: (point.payload as any)?.sessionId || "", + turnId: (point.payload as any)?.turnId || "", + role: (point.payload as any)?.role || "", + createdAt: (point.payload as any)?.createdAt || "", + })); + } + + /** + * Remove all vector points for a given turn. + * Used for cleanup when a turn is deleted. + */ + async removeTurnPoints(turnId: string): Promise { + if (!this._initialized) { + this.log.warn("removeTurnPoints called but service is not initialized — skipping", { turnId }); + return; + } + + try { + await this.client.delete(env.qdrant.collection, { + filter: { + must: [{ key: "turnId", match: { value: turnId } }], + }, + }); + this.log.info("removed vector points for turn", { turnId }); + } catch (error) { + this.log.error("removeTurnPoints failed", { + turnId, + error: (error as Error).message, + }); + } + } +} + +export default new VectorStoreService(); diff --git a/packages/ai/src/api.ts b/packages/ai/src/api.ts index a0726a0..5a0d917 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -170,6 +170,11 @@ export interface IAiModelProbeResult { }; } +export interface IAiEmbeddingResponse { + embedding: number[]; + model: string; +} + export abstract class AiApi { protected env: IAiEnvironment; protected provider: IAiProvider; @@ -196,6 +201,11 @@ export abstract class AiApi { streamCallback?: IAiResponseStreamFn, ): Promise; + /** + * Generate an embedding vector for the given text. + */ + abstract embeddings(modelId: string, text: string): Promise; + /** * Forcefully abort any in-progress API request. * Provider-specific implementations (e.g., Ollama) should terminate diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 1c6c57d..d2ae6e9 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -21,6 +21,7 @@ export { AiApi, type IAiModelListResult, type IAiModelProbeResult, + type IAiEmbeddingResponse, } from "./api.js"; export type { diff --git a/packages/ai/src/ollama.ts b/packages/ai/src/ollama.ts index 2bea71e..614c491 100644 --- a/packages/ai/src/ollama.ts +++ b/packages/ai/src/ollama.ts @@ -19,6 +19,7 @@ import { IAiModelProbeResult, IAiProvider, IAiResponseStreamFn, + IAiEmbeddingResponse, } from "./api.js"; import { IAiEnvironment } from "./config/env.ts"; import type { Message as OllamaMessage } from "ollama"; @@ -367,4 +368,9 @@ export class OllamaAiApi extends AiApi { this.assertNonEmptyChatResponse(chatResponse); return chatResponse; } + + async embeddings(modelId: string, text: string): Promise { + const response = await this.client.embeddings({ model: modelId, prompt: text }); + return { embedding: response.embedding, model: modelId }; + } } diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts index ee3e09f..eb1e471 100644 --- a/packages/ai/src/openai.ts +++ b/packages/ai/src/openai.ts @@ -17,6 +17,7 @@ import { IAiModelProbeResult, IAiProvider, IAiResponseStreamFn, + IAiEmbeddingResponse, } from "./api.js"; import { ChatCompletionFunctionTool, @@ -646,4 +647,12 @@ export class OpenAiApi extends AiApi { }, }; } + + async embeddings(modelId: string, text: string): Promise { + const response = await this.client.embeddings.create({ + model: modelId, + input: text, + }); + return { embedding: response.data[0].embedding, model: modelId }; + } } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index a647e22..d5b0e0e 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -42,8 +42,10 @@ export interface GadgetCodeConfig { }; qdrant: { host: string; + port?: number; // default: 6333 + apiKey?: string; // optional API key for secured Qdrant collection: string; - providerId: string; // AiProvider._id (GadgetId type not available here) + providerId: string; // AiProvider._id (GadgetId type not available here) embeddingModel: string; vectorSize: number; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4b06ee..49d67ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@langchain/textsplitters': specifier: ^1.0.1 version: 1.0.1(@langchain/core@1.1.47(openai@6.34.0(ws@8.18.3)(zod@4.4.3))(ws@8.18.3)) + '@qdrant/js-client-rest': + specifier: ^1.12.0 + version: 1.18.0(typescript@5.9.3) '@react-three/drei': specifier: ^10.7.7 version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.184.0) @@ -1390,6 +1393,16 @@ packages: engines: {node: '>=18'} hasBin: true + '@qdrant/js-client-rest@1.18.0': + resolution: {integrity: sha512-/0dqX5uV9chC1DnYSnU4gNMrDqse/pt6hHg3Rqqpl5isH7xl1xSNvffjzBoxycDD79luWn7Ho6Rh/61sOs5DNw==} + engines: {node: '>=18.17.0', pnpm: '>=8'} + peerDependencies: + typescript: '>=4.7' + + '@qdrant/openapi-typescript-fetch@1.2.6': + resolution: {integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==} + engines: {node: '>=18.0.0', pnpm: '>=8'} + '@react-three/drei@10.7.7': resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} peerDependencies: @@ -4073,6 +4086,10 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + undici@7.25.0: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} @@ -5120,6 +5137,14 @@ snapshots: dependencies: playwright: 1.59.1 + '@qdrant/js-client-rest@1.18.0(typescript@5.9.3)': + dependencies: + '@qdrant/openapi-typescript-fetch': 1.2.6 + typescript: 5.9.3 + undici: 6.25.0 + + '@qdrant/openapi-typescript-fetch@1.2.6': {} + '@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.184.0)': dependencies: '@babel/runtime': 7.29.2 @@ -7951,6 +7976,8 @@ snapshots: undici-types@7.19.2: {} + undici@6.25.0: {} + undici@7.25.0: {} undici@8.1.0: {}