feat: Qdrant semantic search over chat history
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
This commit is contained in:
parent
d47ef96916
commit
56f35a2fdf
@ -132,12 +132,14 @@ minio:
|
|||||||
videos: "gadget-videos"
|
videos: "gadget-videos"
|
||||||
audios: "gadget-audios"
|
audios: "gadget-audios"
|
||||||
|
|
||||||
# ── Qdrant Vector Database (COMING SOON) ────────────────────────────────────
|
# ── Qdrant Vector Database ──────────────────────────────────────────────────────
|
||||||
# Used for semantic search over chat history and conversation context.
|
# Used for semantic search over chat history and conversation context.
|
||||||
# Enable when the Qdrant integration is released.
|
|
||||||
#
|
#
|
||||||
# qdrant:
|
qdrant:
|
||||||
# host: "localhost"
|
host: "localhost"
|
||||||
# port: 6333
|
port: 6333
|
||||||
# apiKey: "${QDRANT_API_KEY}"
|
apiKey: "${QDRANT_API_KEY}"
|
||||||
# collection: "gadget-chat-embeddings"
|
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
|
||||||
|
|||||||
98
gadget-code/frontend/src/components/ChatSearchResults.tsx
Normal file
98
gadget-code/frontend/src/components/ChatSearchResults.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -34,7 +34,7 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
|
|||||||
const startedAt = new Date(turn.createdAt);
|
const startedAt = new Date(turn.createdAt);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Turn Header */}
|
||||||
<div className="flex items-center gap-4 text-xs text-text-muted mb-3 px-4">
|
<div className="flex items-center gap-4 text-xs text-text-muted mb-3 px-4">
|
||||||
<div>{startedAt.toLocaleTimeString()}</div>
|
<div>{startedAt.toLocaleTimeString()}</div>
|
||||||
|
|||||||
84
gadget-code/frontend/src/components/DroneSelectionModal.tsx
Normal file
84
gadget-code/frontend/src/components/DroneSelectionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
gadget-code/frontend/src/components/SearchInput.tsx
Normal file
79
gadget-code/frontend/src/components/SearchInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -500,3 +500,32 @@ export const chatSessionApi = {
|
|||||||
getTurns: (id: string) =>
|
getTurns: (id: string) =>
|
||||||
api.get<ChatTurn[]>(`/api/v1/chat-sessions/${id}/turns`),
|
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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useContext, useCallback } from 'react';
|
|||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { BrushCleaning, Maximize2 } from 'lucide-react';
|
import { BrushCleaning, Maximize2 } from 'lucide-react';
|
||||||
import { socketClient } from '../lib/socket';
|
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 { WorkspaceMode } from '../lib/types';
|
||||||
import SessionPanel from '../components/SessionPanel';
|
import SessionPanel from '../components/SessionPanel';
|
||||||
import ProjectPanel from '../components/ProjectPanel';
|
import ProjectPanel from '../components/ProjectPanel';
|
||||||
@ -12,6 +12,9 @@ import ChatTurnComponent from '../components/ChatTurn';
|
|||||||
import EditorPanel from '../components/EditorPanel';
|
import EditorPanel from '../components/EditorPanel';
|
||||||
import ErrorBoundary from '../components/ErrorBoundary';
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import ExpandedPromptEditor from '../components/ExpandedPromptEditor';
|
import ExpandedPromptEditor from '../components/ExpandedPromptEditor';
|
||||||
|
import SearchInput from '../components/SearchInput';
|
||||||
|
import ChatSearchResults from '../components/ChatSearchResults';
|
||||||
|
import DroneSelectionModal from '../components/DroneSelectionModal';
|
||||||
import { AppContext } from '../App';
|
import { AppContext } from '../App';
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
@ -56,6 +59,7 @@ enum SessionStartupState {
|
|||||||
interface RouterState {
|
interface RouterState {
|
||||||
drone?: any; // DroneRegistration passed via router state
|
drone?: any; // DroneRegistration passed via router state
|
||||||
project?: Project; // Project passed via router state (optional — can be fetched)
|
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() {
|
export default function ChatSessionView() {
|
||||||
@ -104,6 +108,9 @@ export default function ChatSessionView() {
|
|||||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||||
const [editorFilePath, setEditorFilePath] = useState<string | undefined>(undefined);
|
const [editorFilePath, setEditorFilePath] = useState<string | undefined>(undefined);
|
||||||
const [isExpandedEditor, setIsExpandedEditor] = useState(false);
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@ -123,6 +130,23 @@ export default function ChatSessionView() {
|
|||||||
loadSessionData();
|
loadSessionData();
|
||||||
}, [projectId, sessionId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setupSocketListeners();
|
setupSocketListeners();
|
||||||
return () => cleanupSocketListeners();
|
return () => cleanupSocketListeners();
|
||||||
@ -310,9 +334,9 @@ export default function ChatSessionView() {
|
|||||||
return; // Don't proceed to ready state
|
return; // Don't proceed to ready state
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No drone provided — skip lock step, go directly to Ready
|
// No drone provided — show drone selection modal
|
||||||
// (This handles the case where the view is loaded directly via URL without a drone,
|
// The user must select a drone to interact with the session
|
||||||
// e.g., for viewing completed sessions or task runs)
|
setShowDroneModal(true);
|
||||||
setStartupState(SessionStartupState.Ready);
|
setStartupState(SessionStartupState.Ready);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1223,6 +1247,54 @@ export default function ChatSessionView() {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
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 sessionLocked = startupState === SessionStartupState.Ready;
|
||||||
|
|
||||||
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider;
|
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider;
|
||||||
@ -1366,7 +1438,16 @@ export default function ChatSessionView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt Input */}
|
{/* 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">
|
<form onSubmit={handleSubmitPrompt} className="flex gap-2">
|
||||||
<div className="flex flex-col self-end">
|
<div className="flex flex-col self-end">
|
||||||
<button
|
<button
|
||||||
@ -1424,6 +1505,7 @@ export default function ChatSessionView() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Log Panel (25%) */}
|
{/* Log Panel (25%) */}
|
||||||
@ -1436,6 +1518,20 @@ export default function ChatSessionView() {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col">
|
<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
|
<SessionPanel
|
||||||
session={session}
|
session={session}
|
||||||
workspaceMode={workspaceMode}
|
workspaceMode={workspaceMode}
|
||||||
@ -1490,6 +1586,20 @@ export default function ChatSessionView() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{showSearchResults && searchQuery && session && (
|
||||||
|
<ChatSearchResults
|
||||||
|
query={searchQuery}
|
||||||
|
filters={{ sessionId: session._id }}
|
||||||
|
onSelect={handleSearchSelect}
|
||||||
|
onClose={handleSearchClear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDroneModal && (
|
||||||
|
<DroneSelectionModal
|
||||||
|
onSelect={handleDroneSelect}
|
||||||
|
onClose={() => setShowDroneModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
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 { projectApi, droneApi, chatSessionApi } from "../lib/api";
|
||||||
import { socketClient } from "../lib/socket";
|
import { socketClient } from "../lib/socket";
|
||||||
import Clock from "../components/Clock";
|
import Clock from "../components/Clock";
|
||||||
import GadgetGrid from "../components/GadgetGrid";
|
import GadgetGrid from "../components/GadgetGrid";
|
||||||
import LogRenderer from "../components/LogRenderer";
|
import LogRenderer from "../components/LogRenderer";
|
||||||
import type { LogRendererEntry } from "../components/LogRenderer";
|
import type { LogRendererEntry } from "../components/LogRenderer";
|
||||||
|
import SearchInput from "../components/SearchInput";
|
||||||
|
import ChatSearchResults from "../components/ChatSearchResults";
|
||||||
|
|
||||||
function SystemReady() {
|
function SystemReady() {
|
||||||
return (
|
return (
|
||||||
@ -443,6 +445,27 @@ export default function Home({ user }: HomeProps) {
|
|||||||
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
|
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
|
||||||
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) {
|
if (!user) {
|
||||||
return (
|
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="relative z-10 flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex-1 flex min-h-0">
|
<div className="flex-1 flex min-h-0">
|
||||||
<div className="flex-1 flex items-center justify-center p-8">
|
<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">
|
<h1 className="text-2xl font-semibold mb-4">
|
||||||
Welcome, {user.displayName}!
|
Welcome, {user.displayName}!
|
||||||
</h1>
|
</h1>
|
||||||
@ -516,6 +546,13 @@ export default function Home({ user }: HomeProps) {
|
|||||||
<GadgetGrid />
|
<GadgetGrid />
|
||||||
</div>
|
</div>
|
||||||
{mainContent}
|
{mainContent}
|
||||||
|
{showSearchResults && searchQuery && (
|
||||||
|
<ChatSearchResults
|
||||||
|
query={searchQuery}
|
||||||
|
onSelect={handleSearchSelect}
|
||||||
|
onClose={handleSearchClear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import slug from "slug";
|
import slug from "slug";
|
||||||
import type { User, Project, ProjectSkill, ProjectTask } from "../lib/api";
|
import type { User, Project, ProjectSkill, ProjectTask, ISearchResult } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
projectApi,
|
projectApi,
|
||||||
droneApi,
|
droneApi,
|
||||||
@ -11,6 +11,8 @@ import {
|
|||||||
type AiProvider,
|
type AiProvider,
|
||||||
providerApi,
|
providerApi,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
import SearchInput from "../components/SearchInput";
|
||||||
|
import ChatSearchResults from "../components/ChatSearchResults";
|
||||||
import gabAiImage from "./assets/gab-ai.jpeg";
|
import gabAiImage from "./assets/gab-ai.jpeg";
|
||||||
|
|
||||||
interface ProjectManagerProps {
|
interface ProjectManagerProps {
|
||||||
@ -1501,6 +1503,26 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showNewForm, setShowNewForm] = useState(false);
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
const [showPullForm, setShowPullForm] = 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(() => {
|
useEffect(() => {
|
||||||
loadProjects();
|
loadProjects();
|
||||||
@ -1688,12 +1710,21 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
) : selectedProject ? (
|
) : selectedProject ? (
|
||||||
<>
|
<>
|
||||||
{/* Center - Project Inspector */}
|
{/* Center - Project Inspector */}
|
||||||
|
<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
|
<ProjectInspector
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
onDelete={handleProjectDeleted}
|
onDelete={handleProjectDeleted}
|
||||||
onUpdate={handleProjectUpdated}
|
onUpdate={handleProjectUpdated}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar - Drones & Chat Sessions */}
|
{/* Right Sidebar - Drones & Chat Sessions */}
|
||||||
<RightSidebar
|
<RightSidebar
|
||||||
@ -1712,6 +1743,14 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showSearchResults && searchQuery && (
|
||||||
|
<ChatSearchResults
|
||||||
|
query={searchQuery}
|
||||||
|
filters={selectedProject ? { projectId: selectedProject._id } : undefined}
|
||||||
|
onSelect={handleSearchSelect}
|
||||||
|
onClose={handleSearchClear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
"@gadget/api": "workspace:*",
|
"@gadget/api": "workspace:*",
|
||||||
"@gadget/config": "workspace:*",
|
"@gadget/config": "workspace:*",
|
||||||
"@langchain/textsplitters": "^1.0.1",
|
"@langchain/textsplitters": "^1.0.1",
|
||||||
|
"@qdrant/js-client-rest": "^1.12.0",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
"ansicolor": "^2.0.3",
|
"ansicolor": "^2.0.3",
|
||||||
|
|||||||
@ -105,6 +105,15 @@ export default {
|
|||||||
host: yamlConfig.mongodb?.host || "localhost",
|
host: yamlConfig.mongodb?.host || "localhost",
|
||||||
database: yamlConfig.mongodb?.database || "",
|
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: {
|
redis: {
|
||||||
host: yamlConfig.redis?.host || "localhost",
|
host: yamlConfig.redis?.host || "localhost",
|
||||||
port: yamlConfig.redis?.port || 6379,
|
port: yamlConfig.redis?.port || 6379,
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export class ApiControllerV1 extends DtpController {
|
|||||||
await this.loadChild(path.join(basePath, "drone.js"));
|
await this.loadChild(path.join(basePath, "drone.js"));
|
||||||
await this.loadChild(path.join(basePath, "project.js"));
|
await this.loadChild(path.join(basePath, "project.js"));
|
||||||
await this.loadChild(path.join(basePath, "provider.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"));
|
await this.loadChild(path.join(basePath, "user.js"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
171
gadget-code/src/controllers/api/v1/search.ts
Normal file
171
gadget-code/src/controllers/api/v1/search.ts
Normal 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;
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
SocketSessionType,
|
SocketSessionType,
|
||||||
} from "./socket-session.js";
|
} from "./socket-session.js";
|
||||||
import { SocketService } from "../services/index.js";
|
import { SocketService } from "../services/index.js";
|
||||||
|
import { VectorStoreService } from "../services/index.js";
|
||||||
import { ChatTurn } from "../models/chat-turn.js";
|
import { ChatTurn } from "../models/chat-turn.js";
|
||||||
import ChatSession from "../models/chat-session.js";
|
import ChatSession from "../models/chat-session.js";
|
||||||
import MessageQueue, { type QueuedMessage } from "./message-queue.js";
|
import MessageQueue, { type QueuedMessage } from "./message-queue.js";
|
||||||
@ -332,6 +333,16 @@ export class DroneSession extends SocketSession {
|
|||||||
|
|
||||||
await turn.save();
|
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
|
// COMPUTE AGGREGATE: walk the turn's subagent records for aggregate totals
|
||||||
let totalInputTokens = stats?.masterInputTokens ?? 0;
|
let totalInputTokens = stats?.masterInputTokens ?? 0;
|
||||||
let totalOutputTokens = stats?.masterOutputTokens ?? 0;
|
let totalOutputTokens = stats?.masterOutputTokens ?? 0;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import SessionService from "./session.js";
|
|||||||
import SocketService from "./socket.js";
|
import SocketService from "./socket.js";
|
||||||
import StorageService from "./storage.js";
|
import StorageService from "./storage.js";
|
||||||
import UserService from "./user.js";
|
import UserService from "./user.js";
|
||||||
|
import VectorStoreService from "./vector-store.js";
|
||||||
|
|
||||||
export async function startServices(): Promise<void> {
|
export async function startServices(): Promise<void> {
|
||||||
await ApiClientService.start();
|
await ApiClientService.start();
|
||||||
@ -26,6 +27,7 @@ export async function startServices(): Promise<void> {
|
|||||||
await SocketService.start();
|
await SocketService.start();
|
||||||
await StorageService.start();
|
await StorageService.start();
|
||||||
await UserService.start();
|
await UserService.start();
|
||||||
|
await VectorStoreService.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopServices(): Promise<void> {
|
export async function stopServices(): Promise<void> {
|
||||||
@ -40,6 +42,7 @@ export async function stopServices(): Promise<void> {
|
|||||||
await SocketService.stop();
|
await SocketService.stop();
|
||||||
await StorageService.stop();
|
await StorageService.stop();
|
||||||
await UserService.stop();
|
await UserService.stop();
|
||||||
|
await VectorStoreService.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -54,4 +57,5 @@ export {
|
|||||||
SocketService,
|
SocketService,
|
||||||
StorageService,
|
StorageService,
|
||||||
UserService,
|
UserService,
|
||||||
|
VectorStoreService,
|
||||||
};
|
};
|
||||||
|
|||||||
343
gadget-code/src/services/vector-store.ts
Normal file
343
gadget-code/src/services/vector-store.ts
Normal 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();
|
||||||
@ -170,6 +170,11 @@ export interface IAiModelProbeResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAiEmbeddingResponse {
|
||||||
|
embedding: number[];
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class AiApi {
|
export abstract class AiApi {
|
||||||
protected env: IAiEnvironment;
|
protected env: IAiEnvironment;
|
||||||
protected provider: IAiProvider;
|
protected provider: IAiProvider;
|
||||||
@ -196,6 +201,11 @@ export abstract class AiApi {
|
|||||||
streamCallback?: IAiResponseStreamFn,
|
streamCallback?: IAiResponseStreamFn,
|
||||||
): Promise<IAiChatResponse>;
|
): 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.
|
* Forcefully abort any in-progress API request.
|
||||||
* Provider-specific implementations (e.g., Ollama) should terminate
|
* Provider-specific implementations (e.g., Ollama) should terminate
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export {
|
|||||||
AiApi,
|
AiApi,
|
||||||
type IAiModelListResult,
|
type IAiModelListResult,
|
||||||
type IAiModelProbeResult,
|
type IAiModelProbeResult,
|
||||||
|
type IAiEmbeddingResponse,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
IAiModelProbeResult,
|
IAiModelProbeResult,
|
||||||
IAiProvider,
|
IAiProvider,
|
||||||
IAiResponseStreamFn,
|
IAiResponseStreamFn,
|
||||||
|
IAiEmbeddingResponse,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { IAiEnvironment } from "./config/env.ts";
|
import { IAiEnvironment } from "./config/env.ts";
|
||||||
import type { Message as OllamaMessage } from "ollama";
|
import type { Message as OllamaMessage } from "ollama";
|
||||||
@ -367,4 +368,9 @@ export class OllamaAiApi extends AiApi {
|
|||||||
this.assertNonEmptyChatResponse(chatResponse);
|
this.assertNonEmptyChatResponse(chatResponse);
|
||||||
return 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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
IAiModelProbeResult,
|
IAiModelProbeResult,
|
||||||
IAiProvider,
|
IAiProvider,
|
||||||
IAiResponseStreamFn,
|
IAiResponseStreamFn,
|
||||||
|
IAiEmbeddingResponse,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
ChatCompletionFunctionTool,
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export interface GadgetCodeConfig {
|
|||||||
};
|
};
|
||||||
qdrant: {
|
qdrant: {
|
||||||
host: string;
|
host: string;
|
||||||
|
port?: number; // default: 6333
|
||||||
|
apiKey?: string; // optional API key for secured Qdrant
|
||||||
collection: string;
|
collection: string;
|
||||||
providerId: string; // AiProvider._id (GadgetId type not available here)
|
providerId: string; // AiProvider._id (GadgetId type not available here)
|
||||||
embeddingModel: string;
|
embeddingModel: string;
|
||||||
|
|||||||
@ -28,6 +28,9 @@ importers:
|
|||||||
'@langchain/textsplitters':
|
'@langchain/textsplitters':
|
||||||
specifier: ^1.0.1
|
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))
|
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':
|
'@react-three/drei':
|
||||||
specifier: ^10.7.7
|
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)
|
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'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
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':
|
'@react-three/drei@10.7.7':
|
||||||
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
|
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4073,6 +4086,10 @@ packages:
|
|||||||
undici-types@7.19.2:
|
undici-types@7.19.2:
|
||||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||||
|
|
||||||
|
undici@6.25.0:
|
||||||
|
resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
|
||||||
|
engines: {node: '>=18.17'}
|
||||||
|
|
||||||
undici@7.25.0:
|
undici@7.25.0:
|
||||||
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
|
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@ -5120,6 +5137,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.59.1
|
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)':
|
'@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:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.2
|
||||||
@ -7951,6 +7976,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.19.2: {}
|
undici-types@7.19.2: {}
|
||||||
|
|
||||||
|
undici@6.25.0: {}
|
||||||
|
|
||||||
undici@7.25.0: {}
|
undici@7.25.0: {}
|
||||||
|
|
||||||
undici@8.1.0: {}
|
undici@8.1.0: {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user