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

This commit is contained in:
Rob Colbert 2026-05-19 14:30:04 -04:00
commit 70ad578425
22 changed files with 1096 additions and 23 deletions

View File

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

View File

@ -0,0 +1,98 @@
// src/components/ChatSearchResults.tsx
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// 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<ISearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={(e) => { if (e.target === e.currentTarget) 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="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>}
</div>
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary transition-colors p-1" aria-label="Close"><X size={18} /></button>
</div>
<div className="overflow-y-auto flex-1 p-4">
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-brand text-2xl animate-spin" />
<span className="ml-3 text-text-secondary">Searching</span>
</div>
)}
{error && <div className="text-center py-12"><p className="text-red-400">{error}</p></div>}
{!loading && !error && results.length === 0 && <div className="text-center py-12"><p className="text-text-tertiary">No results found</p></div>}
{!loading && !error && results.length > 0 && (
<div className="space-y-2">
{results.map((result) => (
<button key={result.id} onClick={() => onSelect(result)}
className="w-full text-left p-4 rounded-lg bg-bg-primary hover:bg-bg-tertiary border border-border-default hover:border-brand transition-colors group"
>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-mono bg-brand/20 text-brand">{formatScore(result.score)}</span>
<div className="flex-1 min-w-0"><p className="text-sm text-text-primary line-clamp-3 whitespace-pre-wrap">{result.content}</p></div>
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-text-tertiary">
{result.project?.name && <span>{result.project.name}</span>}
{result.session?.name && <><span></span><span>{result.session.name}</span></>}
<span></span>
<span className="font-mono">{result.turnId.slice(0, 8)}</span>
{result.createdAt && <><span className="mx-1">·</span><span>{formatTimestamp(result.createdAt)}</span></>}
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -34,7 +34,7 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
const startedAt = new Date(turn.createdAt);
return (
<div className="border-b border-border-subtle pb-4 last:border-b-0">
<div id={`turn-${turn._id}`} className="border-b border-border-subtle pb-4 last:border-b-0">
{/* Turn Header */}
<div className="flex items-center gap-4 text-xs text-text-muted mb-3 px-4">
<div>{startedAt.toLocaleTimeString()}</div>

View File

@ -0,0 +1,84 @@
// src/components/DroneSelectionModal.tsx
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// 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<DroneRegistration[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="bg-bg-secondary border border-border-default rounded-lg w-full max-w-xl mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default">
<h2 className="text-lg font-semibold text-text-primary">Select a Drone</h2>
<button onClick={onClose} className="text-text-tertiary hover:text-text-primary transition-colors p-1" aria-label="Close"><X size={18} /></button>
</div>
<div className="p-4 max-h-[60vh] overflow-y-auto">
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-brand text-xl animate-spin" />
<span className="ml-3 text-text-secondary">Loading drones</span>
</div>
)}
{error && <div className="text-center py-8"><p className="text-red-400">{error}</p></div>}
{!loading && !error && availableDrones.length === 0 && (
<div className="text-center py-8">
<p className="text-text-tertiary">No drones are currently available.</p>
<p className="text-text-muted text-sm mt-2">Start a drone agent to begin working.</p>
</div>
)}
{!loading && !error && availableDrones.length > 0 && (
<div className="space-y-2">
{availableDrones.map((drone) => (
<button key={drone._id} onClick={() => onSelect(drone)}
className="w-full text-left p-3 rounded-lg bg-bg-primary hover:bg-bg-tertiary border border-border-default hover:border-brand transition-colors group"
>
<div className="flex items-center gap-3">
<span className={`shrink-0 w-2.5 h-2.5 rounded-full ${statusColor(drone.status)}`} />
<div className="flex-1 min-w-0">
<div className="font-mono text-sm text-text-primary group-hover:text-brand transition-colors">{drone.hostname}</div>
<div className="text-xs text-text-tertiary truncate">{drone.workspaceDir}</div>
</div>
<span className="text-xs text-text-muted capitalize">{drone.status}</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,79 @@
// src/components/SearchInput.tsx
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// 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<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);
onClear?.();
}
};
const handleClear = () => {
setValue("");
if (debounceRef.current) clearTimeout(debounceRef.current);
onClear?.();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && value.trim()) {
if (debounceRef.current) clearTimeout(debounceRef.current);
onSearch(value.trim());
}
if (e.key === "Escape") handleClear();
};
return (
<div className={`relative flex items-center ${className}`}>
<Search className="absolute left-3 text-text-tertiary pointer-events-none" size={14} />
<input
type="text"
value={value}
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"
/>
{value && (
<button onClick={handleClear} className="absolute right-2 text-text-tertiary hover:text-text-primary transition-colors" aria-label="Clear search">
<X size={14} />
</button>
)}
</div>
);
}

View File

@ -500,3 +500,32 @@ export const chatSessionApi = {
getTurns: (id: string) =>
api.get<ChatTurn[]>(`/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<ISearchResult[]>("/api/v1/search", {
query,
...filters,
topK,
}),
};

View File

@ -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<string | undefined>(undefined);
const [isExpandedEditor, setIsExpandedEditor] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showSearchResults, setShowSearchResults] = useState(false);
const [showDroneModal, setShowDroneModal] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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: 'instant' });
};
// ── 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() {
</div>
{/* Prompt Input */}
<div className="border-t border-border-subtle p-4 bg-bg-secondary shrink-0">
<div className="border-t border-border-subtle bg-bg-secondary shrink-0">
{/* Search bar */}
<div className="px-4 pt-3 pb-1">
<SearchInput
placeholder="Search in session"
onSearch={handleSearch}
onClear={handleSearchClear}
/>
</div>
<div className="p-4 pt-2">
<form onSubmit={handleSubmitPrompt} className="flex gap-2">
<div className="flex flex-col self-end">
<button
@ -1422,6 +1503,7 @@ export default function ChatSessionView() {
</button>
)}
</form>
</div>
</div>
</div>
)}
@ -1436,6 +1518,20 @@ export default function ChatSessionView() {
{/* Sidebar */}
<div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col">
{/* Drone Selection Button (when no drone is registered) */}
{!droneRegistration && (
<div className="px-4 py-3 border-b border-border-subtle">
<button
onClick={() => setShowDroneModal(true)}
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
>
Select Drone
</button>
<p className="text-xs text-text-muted mt-1.5">
A drone is required to send prompts and interact with the session.
</p>
</div>
)}
<SessionPanel
session={session}
workspaceMode={workspaceMode}
@ -1490,6 +1586,20 @@ export default function ChatSessionView() {
}}
/>
</div>
{showSearchResults && searchQuery && session && (
<ChatSearchResults
query={searchQuery}
filters={{ sessionId: session._id }}
onSelect={handleSearchSelect}
onClose={handleSearchClear}
/>
)}
{showDroneModal && (
<DroneSelectionModal
onSelect={handleDroneSelect}
onClose={() => setShowDroneModal(false)}
/>
)}
</div>
);
}

View File

@ -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<DroneRegistration | null>(
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) {
<div className="relative z-10 flex-1 flex flex-col min-h-0">
<div className="flex-1 flex min-h-0">
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<div className="text-center w-full max-w-lg">
<div className="mb-6">
<SearchInput
placeholder="Search your work"
onSearch={handleSearch}
onClear={handleSearchClear}
/>
</div>
<h1 className="text-2xl font-semibold mb-4">
Welcome, {user.displayName}!
</h1>
@ -516,6 +546,13 @@ export default function Home({ user }: HomeProps) {
<GadgetGrid />
</div>
{mainContent}
{showSearchResults && searchQuery && (
<ChatSearchResults
query={searchQuery}
onSelect={handleSearchSelect}
onClose={handleSearchClear}
/>
)}
</div>
);
}

View File

@ -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 */}
<ProjectInspector
project={selectedProject}
providers={providers}
onDelete={handleProjectDeleted}
onUpdate={handleProjectUpdated}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-6 pt-4 pb-2 border-b border-border-subtle">
<SearchInput
placeholder="Search in project"
onSearch={handleSearch}
onClear={handleSearchClear}
/>
</div>
<ProjectInspector
project={selectedProject}
providers={providers}
onDelete={handleProjectDeleted}
onUpdate={handleProjectUpdated}
/>
</div>
{/* Right Sidebar - Drones & Chat Sessions */}
<RightSidebar
@ -1712,6 +1743,14 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
</div>
)}
</div>
{showSearchResults && searchQuery && (
<ChatSearchResults
query={searchQuery}
filters={selectedProject ? { projectId: selectedProject._id } : undefined}
onSelect={handleSearchSelect}
onClose={handleSearchClear}
/>
)}
</div>
);
}

View File

@ -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",

View File

@ -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,

View File

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

View File

@ -0,0 +1,171 @@
// src/controllers/api/v1/search.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// 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<void> {
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<void> {
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<IHydratedSearchResult[]> {
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;

View File

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

View File

@ -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<void> {
await ApiClientService.start();
@ -26,6 +27,7 @@ export async function startServices(): Promise<void> {
await SocketService.start();
await StorageService.start();
await UserService.start();
await VectorStoreService.start();
}
export async function stopServices(): Promise<void> {
@ -40,6 +42,7 @@ export async function stopServices(): Promise<void> {
await SocketService.stop();
await StorageService.stop();
await UserService.stop();
await VectorStoreService.stop();
}
export {
@ -54,4 +57,5 @@ export {
SocketService,
StorageService,
UserService,
VectorStoreService,
};

View File

@ -0,0 +1,343 @@
// src/services/vector-store.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// 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<void> {
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<void> {
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<void> {
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<number[]> {
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<void> {
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<ISearchResult[]> {
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<void> {
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();

View File

@ -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<IAiChatResponse>;
/**
* Generate an embedding vector for the given text.
*/
abstract embeddings(modelId: string, text: string): Promise<IAiEmbeddingResponse>;
/**
* Forcefully abort any in-progress API request.
* Provider-specific implementations (e.g., Ollama) should terminate

View File

@ -21,6 +21,7 @@ export {
AiApi,
type IAiModelListResult,
type IAiModelProbeResult,
type IAiEmbeddingResponse,
} from "./api.js";
export type {

View File

@ -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<IAiEmbeddingResponse> {
const response = await this.client.embeddings({ model: modelId, prompt: text });
return { embedding: response.embedding, model: modelId };
}
}

View File

@ -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<IAiEmbeddingResponse> {
const response = await this.client.embeddings.create({
model: modelId,
input: text,
});
return { embedding: response.data[0].embedding, model: modelId };
}
}

View File

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

View File

@ -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: {}