mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-30 06:42:43 +00:00
feat: add remote server connection mode and multi-repo switching
Replace the old local-only backend mode with a new server connection flow that lets the web UI connect to any running GitNexus server, download the pre-built knowledge graph, and explore it without WASM. - Add server-connection service with streaming download and progress - Replace DropZone backend tab with server connect UI (URL input, progress bar, cancel) - Add repo switcher dropdown in Header when multiple repos are indexed - Mount MCP server over StreamableHTTP at /api/mcp for remote AI tool access - Refactor api.ts to query KuzuDB directly instead of routing through LocalBackend - Fix KuzuDB adapter to support switching between databases (close old before opening new) - Extract createMCPServer() from startMCPServer() for transport-agnostic reuse - Support ?server= query param for bookmarkable auto-connect Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dc66bb83ef
commit
df2674cb1a
11 changed files with 868 additions and 471 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { AppStateProvider, useAppState } from './hooks/useAppState';
|
||||
import { DropZone } from './components/DropZone';
|
||||
import { LoadingOverlay } from './components/LoadingOverlay';
|
||||
|
|
@ -11,9 +11,8 @@ import { FileTreePanel } from './components/FileTreePanel';
|
|||
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
|
||||
import { FileEntry } from './services/zip';
|
||||
import { getActiveProviderConfig } from './core/llm/settings-service';
|
||||
import { useBackend } from './hooks/useBackend';
|
||||
import { fetchGraph } from './services/backend';
|
||||
import { createKnowledgeGraph } from './core/graph/graph';
|
||||
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
|
||||
|
||||
const AppContent = () => {
|
||||
const {
|
||||
|
|
@ -36,12 +35,13 @@ const AppContent = () => {
|
|||
codeReferences,
|
||||
selectedNode,
|
||||
isCodePanelOpen,
|
||||
setBackendMode,
|
||||
setBackendRepo,
|
||||
serverBaseUrl,
|
||||
setServerBaseUrl,
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
} = useAppState();
|
||||
|
||||
const backend = useBackend();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
||||
const handleFileSelect = useCallback(async (file: File) => {
|
||||
|
|
@ -132,63 +132,105 @@ const AppContent = () => {
|
|||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]);
|
||||
|
||||
const handleFocusNode = useCallback((nodeId: string) => {
|
||||
graphCanvasRef.current?.focusNode(nodeId);
|
||||
}, []);
|
||||
const handleServerConnect = useCallback((result: ConnectToServerResult) => {
|
||||
// Extract project name from repoPath
|
||||
const repoPath = result.repoInfo.repoPath;
|
||||
const projectName = repoPath.split('/').pop() || 'server-project';
|
||||
setProjectName(projectName);
|
||||
|
||||
const handleSelectBackendRepo = useCallback(async (repoName: string) => {
|
||||
// Build KnowledgeGraph from server data (bypasses WASM pipeline entirely)
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) {
|
||||
graph.addNode(node);
|
||||
}
|
||||
for (const rel of result.relationships) {
|
||||
graph.addRelationship(rel);
|
||||
}
|
||||
setGraph(graph);
|
||||
|
||||
// Set file contents from extracted File node content
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const [path, content] of Object.entries(result.fileContents)) {
|
||||
fileMap.set(path, content);
|
||||
}
|
||||
setFileContents(fileMap);
|
||||
|
||||
// Transition directly to exploring view
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent if LLM is configured
|
||||
if (getActiveProviderConfig()) {
|
||||
initializeAgent(projectName);
|
||||
}
|
||||
|
||||
// Auto-start embeddings
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
}, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoConnectRan.current) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.has('server')) return;
|
||||
autoConnectRan.current = true;
|
||||
|
||||
// Clean the URL so a refresh won't re-trigger
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Connecting to server...', detail: 'Validating server' });
|
||||
setViewMode('loading');
|
||||
setProjectName(repoName);
|
||||
setProgress({ phase: 'extracting', percent: 50, message: 'Loading from server...', detail: 'Fetching graph data' });
|
||||
|
||||
try {
|
||||
const graphData = await fetchGraph(repoName);
|
||||
const serverUrl = params.get('server') || window.location.origin;
|
||||
|
||||
// Build KnowledgeGraph from server data
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of graphData.nodes) {
|
||||
graph.addNode(node as any);
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
|
||||
connectToServer(serverUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' });
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
|
||||
}
|
||||
for (const rel of graphData.relationships) {
|
||||
graph.addRelationship(rel as any);
|
||||
}
|
||||
setGraph(graph);
|
||||
}).then(async (result) => {
|
||||
handleServerConnect(result);
|
||||
|
||||
// Extract file contents from File nodes (content is in node properties)
|
||||
const contents = new Map<string, string>();
|
||||
for (const node of graphData.nodes) {
|
||||
const n = node as any;
|
||||
if (n.label === 'File' && n.properties?.content && n.properties?.filePath) {
|
||||
contents.set(n.properties.filePath, n.properties.content);
|
||||
}
|
||||
// Store server URL and fetch available repos for the repo switcher
|
||||
setServerBaseUrl(baseUrl);
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
setFileContents(contents);
|
||||
|
||||
// Enter backend mode
|
||||
setBackendMode(true);
|
||||
setBackendRepo(repoName);
|
||||
backend.selectRepo(repoName);
|
||||
setProgress(null);
|
||||
setViewMode('exploring');
|
||||
|
||||
// Initialize agent if LLM configured
|
||||
if (getActiveProviderConfig()) {
|
||||
initializeAgent(repoName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Backend load error:', error);
|
||||
}).catch((err) => {
|
||||
console.error('Auto-connect failed:', err);
|
||||
setProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
message: 'Error loading from server',
|
||||
detail: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Failed to connect to server',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
setProgress(null);
|
||||
}, 3000);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, setBackendMode, setBackendRepo, backend, initializeAgent]);
|
||||
});
|
||||
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
|
||||
|
||||
const handleFocusNode = useCallback((nodeId: string) => {
|
||||
graphCanvasRef.current?.focusNode(nodeId);
|
||||
}, []);
|
||||
|
||||
// Handle settings saved - refresh and reinitialize agent
|
||||
// NOTE: Must be defined BEFORE any conditional returns (React hooks rule)
|
||||
|
|
@ -203,10 +245,19 @@ const AppContent = () => {
|
|||
<DropZone
|
||||
onFileSelect={handleFileSelect}
|
||||
onGitClone={handleGitClone}
|
||||
backendRepos={backend.repos}
|
||||
isBackendConnected={backend.isConnected}
|
||||
backendUrl={backend.backendUrl}
|
||||
onSelectBackendRepo={handleSelectBackendRepo}
|
||||
onServerConnect={async (result, serverUrl) => {
|
||||
handleServerConnect(result);
|
||||
if (serverUrl) {
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(baseUrl);
|
||||
try {
|
||||
const repos = await fetchRepos(baseUrl);
|
||||
setAvailableRepos(repos);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo list:', e);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -218,7 +269,7 @@ const AppContent = () => {
|
|||
// Exploring view
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-void overflow-hidden">
|
||||
<Header onFocusNode={handleFocusNode} />
|
||||
<Header onFocusNode={handleFocusNode} availableRepos={availableRepos} onSwitchRepo={switchRepo} />
|
||||
|
||||
<main className="flex-1 flex min-h-0">
|
||||
{/* Left Panel - File Tree */}
|
||||
|
|
@ -247,9 +298,6 @@ const AppContent = () => {
|
|||
isOpen={isSettingsPanelOpen}
|
||||
onClose={() => setSettingsPanelOpen(false)}
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
backendUrl={backend.backendUrl}
|
||||
isBackendConnected={backend.isConnected}
|
||||
onBackendUrlChange={backend.setBackendUrl}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import { useState, useCallback, useEffect, useRef, DragEvent } from 'react';
|
||||
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Server } from 'lucide-react';
|
||||
import { useState, useCallback, useRef, DragEvent } from 'react';
|
||||
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from 'lucide-react';
|
||||
import { cloneRepository, parseGitHubUrl } from '../services/git-clone';
|
||||
import { connectToServer, type ConnectToServerResult } from '../services/server-connection';
|
||||
import { FileEntry } from '../services/zip';
|
||||
import { BackendRepo } from '../services/backend';
|
||||
import { BackendRepoSelector } from './BackendRepoSelector';
|
||||
|
||||
interface DropZoneProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
onGitClone?: (files: FileEntry[]) => void;
|
||||
backendRepos?: BackendRepo[];
|
||||
isBackendConnected?: boolean;
|
||||
backendUrl?: string;
|
||||
onSelectBackendRepo?: (repoName: string) => void;
|
||||
onServerConnect?: (result: ConnectToServerResult, serverUrl?: string) => void;
|
||||
}
|
||||
|
||||
export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConnected, backendUrl, onSelectBackendRepo }: DropZoneProps) => {
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZoneProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'local'>('zip');
|
||||
const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'server'>('zip');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
|
@ -24,13 +26,17 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
const [cloneProgress, setCloneProgress] = useState({ phase: '', percent: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const hasAutoSwitched = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasAutoSwitched.current && isBackendConnected && backendRepos && backendRepos.length > 0) {
|
||||
setActiveTab('local');
|
||||
hasAutoSwitched.current = true;
|
||||
}
|
||||
}, [isBackendConnected, backendRepos]);
|
||||
// Server tab state
|
||||
const [serverUrl, setServerUrl] = useState(() =>
|
||||
localStorage.getItem('gitnexus-server-url') || ''
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [serverProgress, setServerProgress] = useState<{
|
||||
phase: string;
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
}>({ phase: '', downloaded: 0, total: null });
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -92,10 +98,9 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
const files = await cloneRepository(
|
||||
githubUrl,
|
||||
(phase, percent) => setCloneProgress({ phase, percent }),
|
||||
githubToken || undefined // Pass token if provided
|
||||
githubToken || undefined
|
||||
);
|
||||
|
||||
// Clear token from memory after successful clone
|
||||
setGithubToken('');
|
||||
|
||||
if (onGitClone) {
|
||||
|
|
@ -104,12 +109,11 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
} catch (err) {
|
||||
console.error('Clone failed:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to clone repository';
|
||||
// Provide helpful error for auth failures
|
||||
if (message.includes('401') || message.includes('403') || message.includes('Authentication')) {
|
||||
if (!githubToken) {
|
||||
setError('🔒 This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
|
||||
setError('This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
|
||||
} else {
|
||||
setError('🔑 Authentication failed. Check your token permissions (needs repo access).');
|
||||
setError('Authentication failed. Check your token permissions (needs repo access).');
|
||||
}
|
||||
} else if (message.includes('404') || message.includes('not found')) {
|
||||
setError('Repository not found. Check the URL or it might be private (needs PAT).');
|
||||
|
|
@ -121,6 +125,62 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
}
|
||||
};
|
||||
|
||||
const handleServerConnect = async () => {
|
||||
const urlToUse = serverUrl.trim() || window.location.origin;
|
||||
if (!urlToUse) {
|
||||
setError('Please enter a server URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist URL to localStorage
|
||||
localStorage.setItem('gitnexus-server-url', serverUrl);
|
||||
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
setServerProgress({ phase: 'validating', downloaded: 0, total: null });
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const result = await connectToServer(
|
||||
urlToUse,
|
||||
(phase, downloaded, total) => {
|
||||
setServerProgress({ phase, downloaded, total });
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
if (onServerConnect) {
|
||||
onServerConnect(result, urlToUse);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
console.error('Server connect failed:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect to server';
|
||||
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
||||
setError('Cannot reach server. Check the URL and ensure the server is running.');
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsConnecting(false);
|
||||
};
|
||||
|
||||
const serverProgressPercent = serverProgress.total
|
||||
? Math.round((serverProgress.downloaded / serverProgress.total) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
|
||||
{/* Background gradient effects */}
|
||||
|
|
@ -161,25 +221,18 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
GitHub URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('local'); setError(null); }}
|
||||
onClick={() => { setActiveTab('server'); setError(null); }}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg
|
||||
text-sm font-medium transition-all duration-200
|
||||
${activeTab === 'local'
|
||||
${activeTab === 'server'
|
||||
? 'bg-accent text-white shadow-md'
|
||||
: isBackendConnected
|
||||
? 'text-text-secondary hover:text-text-primary hover:bg-elevated'
|
||||
: 'text-text-muted cursor-not-allowed opacity-50'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
|
||||
}
|
||||
`}
|
||||
disabled={!isBackendConnected}
|
||||
title={!isBackendConnected ? 'Start gitnexus serve to connect' : undefined}
|
||||
>
|
||||
<Server className="w-4 h-4" />
|
||||
Local Server
|
||||
{isBackendConnected && (
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />
|
||||
)}
|
||||
<Globe className="w-4 h-4" />
|
||||
Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -195,7 +248,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
<>
|
||||
<div
|
||||
className={`
|
||||
relative p-16
|
||||
relative p-16
|
||||
bg-surface border-2 border-dashed rounded-3xl
|
||||
transition-all duration-300 cursor-pointer
|
||||
${isDragging
|
||||
|
|
@ -282,7 +335,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full px-4 py-3
|
||||
w-full px-4 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
|
|
@ -308,7 +361,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full pl-10 pr-10 py-3
|
||||
w-full pl-10 pr-10 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
|
|
@ -329,9 +382,9 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
onClick={handleGitClone}
|
||||
disabled={isCloning || !githubUrl.trim()}
|
||||
className="
|
||||
w-full flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
w-full flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
text-white font-medium rounded-xl
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
|
|
@ -371,7 +424,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
{/* Security note */}
|
||||
{githubToken && (
|
||||
<p className="mt-3 text-xs text-text-muted text-center">
|
||||
🔒 Token stays in your browser only, never sent to any server
|
||||
Token stays in your browser only, never sent to any server
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -387,14 +440,131 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Server Tab */}
|
||||
{activeTab === 'local' && isBackendConnected && backendRepos && onSelectBackendRepo && (
|
||||
<BackendRepoSelector
|
||||
repos={backendRepos}
|
||||
onSelectRepo={onSelectBackendRepo}
|
||||
backendUrl={backendUrl ?? 'http://localhost:4747'}
|
||||
isConnected={isBackendConnected}
|
||||
/>
|
||||
{/* Server Tab */}
|
||||
{activeTab === 'server' && (
|
||||
<div className="p-8 bg-surface border border-border-default rounded-3xl">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-emerald-600 rounded-2xl shadow-lg">
|
||||
<Globe className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
|
||||
Connect to Server
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary text-center mb-6">
|
||||
Load a pre-built knowledge graph from a running GitNexus server
|
||||
</p>
|
||||
|
||||
{/* Inputs */}
|
||||
<div className="space-y-3" data-form-type="other">
|
||||
<input
|
||||
type="url"
|
||||
name="server-url-input"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleServerConnect()}
|
||||
placeholder={window.location.origin}
|
||||
disabled={isConnecting}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
className="
|
||||
w-full px-4 py-3
|
||||
bg-elevated border border-border-default rounded-xl
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleServerConnect}
|
||||
disabled={isConnecting}
|
||||
className="
|
||||
flex-1 flex items-center justify-center gap-2
|
||||
px-4 py-3
|
||||
bg-accent hover:bg-accent/90
|
||||
text-white font-medium rounded-xl
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{serverProgress.phase === 'validating'
|
||||
? 'Validating...'
|
||||
: serverProgress.phase === 'downloading'
|
||||
? serverProgressPercent !== null
|
||||
? `Downloading... ${serverProgressPercent}%`
|
||||
: `Downloading... ${formatBytes(serverProgress.downloaded)}`
|
||||
: serverProgress.phase === 'extracting'
|
||||
? 'Processing...'
|
||||
: 'Connecting...'
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Connect
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isConnecting && (
|
||||
<button
|
||||
onClick={handleCancelConnect}
|
||||
className="
|
||||
flex items-center justify-center
|
||||
px-4 py-3
|
||||
bg-red-500/20 hover:bg-red-500/30
|
||||
text-red-400 font-medium rounded-xl
|
||||
transition-all duration-200
|
||||
"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isConnecting && serverProgress.phase === 'downloading' && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-elevated rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-accent transition-all duration-300 ease-out ${
|
||||
serverProgressPercent === null ? 'animate-pulse' : ''
|
||||
}`}
|
||||
style={{
|
||||
width: serverProgressPercent !== null
|
||||
? `${serverProgressPercent}%`
|
||||
: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{serverProgress.total && (
|
||||
<p className="mt-1 text-xs text-text-muted text-center">
|
||||
{formatBytes(serverProgress.downloaded)} / {formatBytes(serverProgress.total)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hints */}
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
Pre-indexed
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
No WASM needed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react';
|
||||
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from 'lucide-react';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import type { RepoSummary } from '../services/server-connection';
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { GraphNode } from '../core/graph/types';
|
||||
import { EmbeddingStatus } from './EmbeddingStatus';
|
||||
|
|
@ -19,9 +20,11 @@ const NODE_TYPE_COLORS: Record<string, string> = {
|
|||
|
||||
interface HeaderProps {
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
availableRepos?: RepoSummary[];
|
||||
onSwitchRepo?: (repoName: string) => void;
|
||||
}
|
||||
|
||||
export const Header = ({ onFocusNode }: HeaderProps) => {
|
||||
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: HeaderProps) => {
|
||||
const {
|
||||
projectName,
|
||||
graph,
|
||||
|
|
@ -29,8 +32,9 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
|
|||
isRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setSettingsPanelOpen,
|
||||
isBackendMode,
|
||||
} = useAppState();
|
||||
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
|
||||
const repoDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
|
@ -50,12 +54,15 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
|
|||
.slice(0, 10); // Limit to 10 results
|
||||
}, [graph, searchQuery]);
|
||||
|
||||
// Handle clicking outside to close dropdown
|
||||
// Handle clicking outside to close dropdowns
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) {
|
||||
setIsRepoDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
|
|
@ -117,18 +124,50 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
|
|||
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
|
||||
</div>
|
||||
|
||||
{/* Project badge */}
|
||||
{/* Project badge / Repo selector dropdown */}
|
||||
{projectName && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary">
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
|
||||
<span className="truncate max-w-[200px]">{projectName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative" ref={repoDropdownRef}>
|
||||
<button
|
||||
onClick={() => availableRepos.length >= 2 && setIsRepoDropdownOpen(prev => !prev)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary transition-colors ${availableRepos.length >= 2 ? 'hover:bg-hover cursor-pointer' : ''}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
|
||||
<span className="truncate max-w-[200px]">{projectName}</span>
|
||||
{availableRepos.length >= 2 && (
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-text-muted transition-transform ${isRepoDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isBackendMode && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-green-500/10 border border-green-500/30 rounded-lg text-xs text-green-400">
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
|
||||
Local
|
||||
{/* Repo dropdown */}
|
||||
{isRepoDropdownOpen && availableRepos.length >= 2 && (
|
||||
<div className="absolute top-full left-0 mt-1 w-72 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
|
||||
{availableRepos.map((repo) => {
|
||||
const isCurrent = repo.name === projectName;
|
||||
return (
|
||||
<button
|
||||
key={repo.name}
|
||||
onClick={() => {
|
||||
if (!isCurrent && onSwitchRepo) {
|
||||
onSwitchRepo(repo.name);
|
||||
}
|
||||
setIsRepoDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-3 flex items-center gap-3 text-left transition-colors ${isCurrent ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-hover border-l-2 border-transparent'}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isCurrent ? 'bg-node-function animate-pulse' : 'bg-text-muted'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isCurrent ? 'text-accent' : 'text-text-primary'}`}>
|
||||
{repo.name}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{repo.stats?.nodes ?? '?'} nodes · {repo.stats?.files ?? '?'} files
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCa
|
|||
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
|
||||
import type { AgentMessage } from '../core/llm/agent';
|
||||
import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants';
|
||||
import { runCypherQuery, getBackendUrl } from '../services/backend';
|
||||
import type { RepoSummary, ConnectToServerResult } from '../services/server-connection';
|
||||
import { fetchRepos, connectToServer } from '../services/server-connection';
|
||||
|
||||
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
|
||||
export type RightPanelTab = 'code' | 'chat';
|
||||
|
|
@ -112,6 +113,13 @@ interface AppState {
|
|||
projectName: string;
|
||||
setProjectName: (name: string) => void;
|
||||
|
||||
// Multi-repo switching
|
||||
serverBaseUrl: string | null;
|
||||
setServerBaseUrl: (url: string | null) => void;
|
||||
availableRepos: RepoSummary[];
|
||||
setAvailableRepos: (repos: RepoSummary[]) => void;
|
||||
switchRepo: (repoName: string) => Promise<void>;
|
||||
|
||||
// Worker API (shared across app)
|
||||
runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
|
||||
runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
|
||||
|
|
@ -161,12 +169,6 @@ interface AppState {
|
|||
clearAICodeReferences: () => void;
|
||||
clearCodeReferences: () => void;
|
||||
codeReferenceFocus: CodeReferenceFocus | null;
|
||||
|
||||
// Backend mode
|
||||
isBackendMode: boolean;
|
||||
backendRepo: string | null;
|
||||
setBackendMode: (mode: boolean) => void;
|
||||
setBackendRepo: (repo: string | null) => void;
|
||||
}
|
||||
|
||||
const AppStateContext = createContext<AppState | null>(null);
|
||||
|
|
@ -277,6 +279,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
// Project info
|
||||
const [projectName, setProjectName] = useState<string>('');
|
||||
|
||||
// Multi-repo switching
|
||||
const [serverBaseUrl, setServerBaseUrl] = useState<string | null>(null);
|
||||
const [availableRepos, setAvailableRepos] = useState<RepoSummary[]>([]);
|
||||
|
||||
// Embedding state
|
||||
const [embeddingStatus, setEmbeddingStatus] = useState<EmbeddingStatus>('idle');
|
||||
const [embeddingProgress, setEmbeddingProgress] = useState<EmbeddingProgress | null>(null);
|
||||
|
|
@ -298,11 +304,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const [isCodePanelOpen, setCodePanelOpen] = useState(false);
|
||||
const [codeReferenceFocus, setCodeReferenceFocus] = useState<CodeReferenceFocus | null>(null);
|
||||
|
||||
// Backend mode
|
||||
const [isBackendMode, setIsBackendMode] = useState(false);
|
||||
const [backendRepo, setBackendRepo] = useState<string | null>(null);
|
||||
|
||||
const normalizePath = useCallback((p: string) => {
|
||||
const normalizePath = useCallback((p: string) => {
|
||||
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
|
||||
}, []);
|
||||
|
||||
|
|
@ -465,16 +467,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
}, []);
|
||||
|
||||
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
|
||||
if (isBackendMode && backendRepo) {
|
||||
return runCypherQuery(backendRepo, cypher);
|
||||
}
|
||||
const api = apiRef.current;
|
||||
if (!api) throw new Error('Worker not initialized');
|
||||
return api.runQuery(cypher);
|
||||
}, [isBackendMode, backendRepo]);
|
||||
}, []);
|
||||
|
||||
const isDatabaseReady = useCallback(async (): Promise<boolean> => {
|
||||
if (isBackendMode) return true; // backend handles DB
|
||||
const api = apiRef.current;
|
||||
if (!api) return false;
|
||||
try {
|
||||
|
|
@ -482,13 +480,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [isBackendMode]);
|
||||
}, []);
|
||||
|
||||
// Embedding methods
|
||||
const startEmbeddings = useCallback(async (forceDevice?: 'webgpu' | 'wasm'): Promise<void> => {
|
||||
// Embeddings require the WASM worker DB — skip in backend mode
|
||||
if (isBackendMode) return;
|
||||
|
||||
const api = apiRef.current;
|
||||
if (!api) throw new Error('Worker not initialized');
|
||||
|
||||
|
|
@ -530,7 +525,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
throw error;
|
||||
}
|
||||
}, [isBackendMode]);
|
||||
}, []);
|
||||
|
||||
const semanticSearch = useCallback(async (
|
||||
query: string,
|
||||
|
|
@ -571,39 +566,25 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
}, []);
|
||||
|
||||
const initializeAgent = useCallback(async (overrideProjectName?: string): Promise<void> => {
|
||||
const config = getActiveProviderConfig();
|
||||
if (!config) {
|
||||
setAgentError('Please configure an LLM provider in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
const api = apiRef.current;
|
||||
if (!api) {
|
||||
setAgentError('Worker not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getActiveProviderConfig();
|
||||
if (!config) {
|
||||
setAgentError('Please configure an LLM provider in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAgentInitializing(true);
|
||||
setAgentError(null);
|
||||
|
||||
try {
|
||||
// Use override if provided (for fresh loads), fallback to state (for re-init)
|
||||
const effectiveProjectName = overrideProjectName || projectName || 'project';
|
||||
let result: { success: boolean; error?: string };
|
||||
|
||||
if (isBackendMode && backendRepo) {
|
||||
// Backend mode: pass HTTP config + file contents to worker
|
||||
const entries = Array.from(fileContents.entries());
|
||||
result = await api.initializeBackendAgent(
|
||||
config,
|
||||
getBackendUrl(),
|
||||
backendRepo,
|
||||
entries,
|
||||
effectiveProjectName,
|
||||
);
|
||||
} else {
|
||||
result = await api.initializeAgent(config, effectiveProjectName);
|
||||
}
|
||||
|
||||
const result = await api.initializeAgent(config, effectiveProjectName);
|
||||
if (result.success) {
|
||||
setIsAgentReady(true);
|
||||
setAgentError(null);
|
||||
|
|
@ -621,7 +602,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
} finally {
|
||||
setIsAgentInitializing(false);
|
||||
}
|
||||
}, [projectName, isBackendMode, backendRepo, fileContents]);
|
||||
}, [projectName]);
|
||||
|
||||
const sendChatMessage = useCallback(async (message: string): Promise<void> => {
|
||||
const api = apiRef.current;
|
||||
|
|
@ -991,6 +972,73 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
setAgentError(null);
|
||||
}, []);
|
||||
|
||||
// Switch to a different repo on the connected server
|
||||
const switchRepo = useCallback(async (repoName: string) => {
|
||||
if (!serverBaseUrl) return;
|
||||
|
||||
setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` });
|
||||
setViewMode('loading');
|
||||
|
||||
// Clear stale graph state from previous repo (highlights, selections, blast radius)
|
||||
// Without this, sigma reducers dim ALL nodes/edges because old node IDs don't match
|
||||
setHighlightedNodeIds(new Set());
|
||||
clearAIToolHighlights();
|
||||
clearBlastRadius();
|
||||
setSelectedNode(null);
|
||||
setQueryResult(null);
|
||||
setCodeReferences([]);
|
||||
setCodePanelOpen(false);
|
||||
setCodeReferenceFocus(null);
|
||||
|
||||
try {
|
||||
const result: ConnectToServerResult = await connectToServer(serverBaseUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({ phase: 'extracting', percent: 5, message: 'Switching repository...', detail: 'Validating' });
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
|
||||
}
|
||||
}, undefined, repoName);
|
||||
|
||||
// Reuse the same handleServerConnect logic inline
|
||||
const repoPath = result.repoInfo.repoPath;
|
||||
const pName = result.repoInfo.name || repoPath.split('/').pop() || 'server-project';
|
||||
setProjectName(pName);
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) graph.addNode(node);
|
||||
for (const rel of result.relationships) graph.addRelationship(rel);
|
||||
setGraph(graph);
|
||||
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const [p, c] of Object.entries(result.fileContents)) fileMap.set(p, c);
|
||||
setFileContents(fileMap);
|
||||
|
||||
setViewMode('exploring');
|
||||
|
||||
if (getActiveProviderConfig()) initializeAgent(pName);
|
||||
|
||||
startEmbeddings().catch((err) => {
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Repo switch failed:', err);
|
||||
setProgress({
|
||||
phase: 'error', percent: 0,
|
||||
message: 'Failed to switch repository',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000);
|
||||
}
|
||||
}, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]);
|
||||
|
||||
const removeCodeReference = useCallback((id: string) => {
|
||||
setCodeReferences(prev => {
|
||||
const ref = prev.find(r => r.id === id);
|
||||
|
|
@ -1084,6 +1132,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
setProgress,
|
||||
projectName,
|
||||
setProjectName,
|
||||
// Multi-repo switching
|
||||
serverBaseUrl,
|
||||
setServerBaseUrl,
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
runPipeline,
|
||||
runPipelineFromFiles,
|
||||
runQuery,
|
||||
|
|
@ -1124,11 +1178,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
clearAICodeReferences,
|
||||
clearCodeReferences,
|
||||
codeReferenceFocus,
|
||||
// Backend mode
|
||||
isBackendMode,
|
||||
backendRepo,
|
||||
setBackendMode: setIsBackendMode,
|
||||
setBackendRepo,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
155
gitnexus-web/src/services/server-connection.ts
Normal file
155
gitnexus-web/src/services/server-connection.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { GraphNode, GraphRelationship } from '../core/graph/types';
|
||||
|
||||
export interface RepoSummary {
|
||||
name: string;
|
||||
path: string;
|
||||
indexedAt: string;
|
||||
lastCommit: string;
|
||||
stats: {
|
||||
files: number;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
processes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerRepoInfo {
|
||||
name: string;
|
||||
repoPath: string;
|
||||
indexedAt: string;
|
||||
stats: {
|
||||
files: number;
|
||||
nodes: number;
|
||||
edges: number;
|
||||
communities: number;
|
||||
processes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectToServerResult {
|
||||
nodes: GraphNode[];
|
||||
relationships: GraphRelationship[];
|
||||
fileContents: Record<string, string>;
|
||||
repoInfo: ServerRepoInfo;
|
||||
}
|
||||
|
||||
export function normalizeServerUrl(input: string): string {
|
||||
let url = input.trim();
|
||||
|
||||
// Strip trailing slashes
|
||||
url = url.replace(/\/+$/, '');
|
||||
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
if (url.startsWith('localhost') || url.startsWith('127.0.0.1')) {
|
||||
url = `http://${url}`;
|
||||
} else {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add /api if not already present
|
||||
if (!url.endsWith('/api')) {
|
||||
url = `${url}/api`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function fetchRepos(baseUrl: string): Promise<RepoSummary[]> {
|
||||
const response = await fetch(`${baseUrl}/repos`);
|
||||
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise<ServerRepoInfo> {
|
||||
const url = repoName ? `${baseUrl}/repo?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/repo`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchGraph(
|
||||
baseUrl: string,
|
||||
onProgress?: (downloaded: number, total: number | null) => void,
|
||||
signal?: AbortSignal,
|
||||
repoName?: string
|
||||
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> {
|
||||
const url = repoName ? `${baseUrl}/graph?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/graph`;
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const total = contentLength ? parseInt(contentLength, 10) : null;
|
||||
|
||||
if (!response.body) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let downloaded = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
downloaded += value.length;
|
||||
onProgress?.(downloaded, total);
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(downloaded);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const text = new TextDecoder().decode(combined);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export function extractFileContents(nodes: GraphNode[]): Record<string, string> {
|
||||
const contents: Record<string, string> = {};
|
||||
for (const node of nodes) {
|
||||
if (node.label === 'File' && (node.properties as any).content) {
|
||||
contents[node.properties.filePath] = (node.properties as any).content;
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
export async function connectToServer(
|
||||
url: string,
|
||||
onProgress?: (phase: string, downloaded: number, total: number | null) => void,
|
||||
signal?: AbortSignal,
|
||||
repoName?: string
|
||||
): Promise<ConnectToServerResult> {
|
||||
const baseUrl = normalizeServerUrl(url);
|
||||
|
||||
// Phase 1: Validate server
|
||||
onProgress?.('validating', 0, null);
|
||||
const repoInfo = await fetchRepoInfo(baseUrl, repoName);
|
||||
|
||||
// Phase 2: Download graph
|
||||
onProgress?.('downloading', 0, null);
|
||||
const { nodes, relationships } = await fetchGraph(
|
||||
baseUrl,
|
||||
(downloaded, total) => onProgress?.('downloading', downloaded, total),
|
||||
signal,
|
||||
repoName
|
||||
);
|
||||
|
||||
// Phase 3: Extract file contents
|
||||
onProgress?.('extracting', 0, null);
|
||||
const fileContents = extractFileContents(nodes);
|
||||
|
||||
return { nodes, relationships, fileContents, repoInfo };
|
||||
}
|
||||
4
gitnexus/package-lock.json
generated
4
gitnexus/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.1.9",
|
||||
"version": "1.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.1.9",
|
||||
"version": "1.2.8",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export const analyzeCommand = async (
|
|||
const origLog = console.log.bind(console);
|
||||
const origWarn = console.warn.bind(console);
|
||||
const origError = console.error.bind(console);
|
||||
const barLog = (...args: any[]) => origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
const barLog = (...args: any[]) => { const msg = args.map(a => (typeof a === 'string' ? a : String(a))).join(' '); if (typeof (bar as any).log === 'function') { (bar as any).log(msg + '\n'); } };
|
||||
console.log = barLog;
|
||||
console.warn = barLog;
|
||||
console.error = barLog;
|
||||
|
|
|
|||
|
|
@ -13,11 +13,22 @@ import { generateAllCSVs } from './csv-generator.js';
|
|||
|
||||
let db: kuzu.Database | null = null;
|
||||
let conn: kuzu.Connection | null = null;
|
||||
let currentDbPath: string | null = null;
|
||||
|
||||
const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/');
|
||||
|
||||
export const initKuzu = async (dbPath: string) => {
|
||||
if (conn) return { db, conn };
|
||||
// If already connected to the SAME database, reuse
|
||||
if (conn && currentDbPath === dbPath) return { db, conn };
|
||||
|
||||
// Different database requested — close the old one first
|
||||
if (conn || db) {
|
||||
try { if (conn) await conn.close(); } catch {}
|
||||
try { if (db) await db.close(); } catch {}
|
||||
conn = null;
|
||||
db = null;
|
||||
currentDbPath = null;
|
||||
}
|
||||
|
||||
// kuzu v0.11 stores the database as a single file (not a directory).
|
||||
// If the path already exists, it must be a valid kuzu database file.
|
||||
|
|
@ -58,6 +69,7 @@ export const initKuzu = async (dbPath: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
currentDbPath = dbPath;
|
||||
return { db, conn };
|
||||
};
|
||||
|
||||
|
|
@ -525,6 +537,7 @@ export const closeKuzu = async (): Promise<void> => {
|
|||
} catch {}
|
||||
db = null;
|
||||
}
|
||||
currentDbPath = null;
|
||||
};
|
||||
|
||||
export const isKuzuReady = (): boolean => conn !== null && db !== null;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* MCP Server (Multi-Repo)
|
||||
*
|
||||
*
|
||||
* Model Context Protocol server that runs on stdio.
|
||||
* External AI tools (Cursor, Claude) spawn this process and
|
||||
* communicate via stdin/stdout using the MCP protocol.
|
||||
*
|
||||
*
|
||||
* Supports multiple indexed repositories via the global registry.
|
||||
*
|
||||
*
|
||||
* Tools: list_repos, query, cypher, context, impact, detect_changes, rename
|
||||
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
|
||||
*/
|
||||
|
|
@ -28,10 +28,10 @@ import { getResourceDefinitions, getResourceTemplates, readResource } from './re
|
|||
|
||||
/**
|
||||
* Next-step hints appended to tool responses.
|
||||
*
|
||||
*
|
||||
* Agents often stop after one tool call. These hints guide them to the
|
||||
* logical next action, creating a self-guiding workflow without hooks.
|
||||
*
|
||||
*
|
||||
* Design: Each hint is a short, actionable instruction (not a suggestion).
|
||||
* The hint references the specific tool/resource to use next.
|
||||
*/
|
||||
|
|
@ -75,7 +75,11 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
|
|||
}
|
||||
}
|
||||
|
||||
export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
||||
/**
|
||||
* Create a configured MCP Server with all handlers registered.
|
||||
* Transport-agnostic — caller connects the desired transport.
|
||||
*/
|
||||
export function createMCPServer(backend: LocalBackend): Server {
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'gitnexus',
|
||||
|
|
@ -119,7 +123,7 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
|||
// Handle read resource request
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
const { uri } = request.params;
|
||||
|
||||
|
||||
try {
|
||||
const content = await readResource(uri, backend);
|
||||
return {
|
||||
|
|
@ -209,7 +213,7 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
|||
// Handle get prompt request
|
||||
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
|
||||
if (name === 'detect_impact') {
|
||||
const scope = args?.scope || 'all';
|
||||
const baseRef = args?.base_ref || '';
|
||||
|
|
@ -233,7 +237,7 @@ Present the analysis as a clear risk report.`,
|
|||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (name === 'generate_map') {
|
||||
const repo = args?.repo || '';
|
||||
return {
|
||||
|
|
@ -247,7 +251,7 @@ Present the analysis as a clear risk report.`,
|
|||
Follow these steps:
|
||||
1. READ \`gitnexus://repo/${repo || '{name}'}/context\` for codebase stats
|
||||
2. READ \`gitnexus://repo/${repo || '{name}'}/clusters\` to see all functional areas
|
||||
3. READ \`gitnexus://repo/${repo || '{name}'}/processes\` to see all execution flows
|
||||
3. READ \`gitnexus://repo/${repo || '{name}'}/processes\` to see all execution flows
|
||||
4. For the top 5 most important processes, READ \`gitnexus://repo/${repo || '{name}'}/process/{name}\` for step-by-step traces
|
||||
5. Generate a mermaid architecture diagram showing the major areas and their connections
|
||||
6. Write an ARCHITECTURE.md file with: overview, functional areas, key execution flows, and the mermaid diagram`,
|
||||
|
|
@ -256,10 +260,19 @@ Follow these steps:
|
|||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`Unknown prompt: ${name}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the MCP server on stdio transport (for CLI use).
|
||||
*/
|
||||
export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
||||
const server = createMCPServer(backend);
|
||||
|
||||
// Connect to stdio transport
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
|
|
|||
|
|
@ -1,29 +1,27 @@
|
|||
/**
|
||||
* HTTP API Server (Multi-Repo)
|
||||
* HTTP API Server
|
||||
*
|
||||
* REST API for browser-based clients to query indexed repositories.
|
||||
* Uses LocalBackend for multi-repo support via the global registry —
|
||||
* the same backend the MCP server uses.
|
||||
* REST API for browser-based clients to query the local .gitnexus/ index.
|
||||
* Also hosts the MCP server over StreamableHTTP for remote AI tool access.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { findRepo, loadMeta, listRegisteredRepos } from '../storage/repo-manager.js';
|
||||
import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js';
|
||||
import { NODE_TABLES } from '../core/kuzu/schema.js';
|
||||
import { GraphNode, GraphRelationship } from '../core/graph/types.js';
|
||||
import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
|
||||
import { hybridSearch } from '../core/search/hybrid-search.js';
|
||||
import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
|
||||
import { isEmbedderReady } from '../core/embeddings/embedder.js';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { mountMCPEndpoints } from './mcp-http.js';
|
||||
|
||||
/**
|
||||
* Build the full knowledge graph for a repo by querying each node table
|
||||
* and all relationships via the backend's cypher tool.
|
||||
*/
|
||||
const buildGraph = async (
|
||||
backend: LocalBackend,
|
||||
repoName: string,
|
||||
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
|
||||
const nodes: GraphNode[] = [];
|
||||
|
||||
for (const table of NODE_TABLES) {
|
||||
try {
|
||||
let query = '';
|
||||
|
|
@ -39,10 +37,7 @@ const buildGraph = async (
|
|||
query = `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`;
|
||||
}
|
||||
|
||||
const result = await backend.executeCypher(repoName, query);
|
||||
// cypher returns the rows directly (array), or { error } on failure
|
||||
const rows = Array.isArray(result) ? result : [];
|
||||
|
||||
const rows = await executeQuery(query);
|
||||
for (const row of rows) {
|
||||
nodes.push({
|
||||
id: row.id ?? row[0],
|
||||
|
|
@ -70,283 +65,135 @@ const buildGraph = async (
|
|||
}
|
||||
|
||||
const relationships: GraphRelationship[] = [];
|
||||
try {
|
||||
const relResult = await backend.executeCypher(
|
||||
repoName,
|
||||
`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`,
|
||||
);
|
||||
const relRows = Array.isArray(relResult) ? relResult : [];
|
||||
|
||||
for (const row of relRows) {
|
||||
relationships.push({
|
||||
id: `${row.sourceId}_${row.type}_${row.targetId}`,
|
||||
type: row.type,
|
||||
sourceId: row.sourceId,
|
||||
targetId: row.targetId,
|
||||
confidence: row.confidence,
|
||||
reason: row.reason,
|
||||
step: row.step,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('GitNexus: relationship query failed:', err?.message);
|
||||
const relRows = await executeQuery(
|
||||
`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`
|
||||
);
|
||||
for (const row of relRows) {
|
||||
relationships.push({
|
||||
id: `${row.sourceId}_${row.type}_${row.targetId}`,
|
||||
type: row.type,
|
||||
sourceId: row.sourceId,
|
||||
targetId: row.targetId,
|
||||
confidence: row.confidence,
|
||||
reason: row.reason,
|
||||
step: row.step,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, relationships };
|
||||
};
|
||||
|
||||
const httpStatus = (err: any): number => {
|
||||
const msg = err?.message ?? '';
|
||||
if (msg.includes('not found') || msg.includes('No indexed')) return 404;
|
||||
if (msg.includes('Multiple repositories')) return 400;
|
||||
return 500;
|
||||
};
|
||||
|
||||
export const createServer = async (port: number) => {
|
||||
const backend = new LocalBackend();
|
||||
const hasRepos = await backend.init();
|
||||
|
||||
if (!hasRepos) {
|
||||
console.warn('GitNexus: No indexed repositories found. The server will start but most endpoints will return errors.');
|
||||
console.warn('Run "gitnexus analyze" in a repository to index it first.');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (curl, server-to-server), localhost, and the deployed site.
|
||||
// The server binds to 127.0.0.1 so only the local machine can reach it — CORS just gates
|
||||
// which browser-tab origins may issue the request.
|
||||
if (
|
||||
!origin
|
||||
|| origin.startsWith('http://localhost:')
|
||||
|| origin.startsWith('http://127.0.0.1:')
|
||||
|| origin === 'https://gitnexus.vercel.app'
|
||||
) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// ─── GET /api/repos ─────────────────────────────────────────────
|
||||
// List all indexed repositories
|
||||
app.get('/api/repos', async (_req, res) => {
|
||||
try {
|
||||
const repos = await backend.listRepos();
|
||||
res.json(repos);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to list repos' });
|
||||
}
|
||||
});
|
||||
// Initialize MCP backend (multi-repo, shared across all MCP sessions)
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
mountMCPEndpoints(app, backend);
|
||||
|
||||
// ─── GET /api/repo?repo=X ──────────────────────────────────────
|
||||
// Get metadata for a specific repo
|
||||
app.get('/api/repo', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const repo = await backend.resolveRepo(repoName);
|
||||
res.json({
|
||||
name: repo.name,
|
||||
path: repo.repoPath,
|
||||
indexedAt: repo.indexedAt,
|
||||
lastCommit: repo.lastCommit,
|
||||
stats: repo.stats || {},
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Repository not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/graph?repo=X ─────────────────────────────────────
|
||||
// Full knowledge graph (all nodes + relationships)
|
||||
app.get('/api/graph', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
// Resolve repo to validate it exists and get the name
|
||||
const repo = await backend.resolveRepo(repoName);
|
||||
const graph = await buildGraph(backend, repo.name);
|
||||
res.json(graph);
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Failed to build graph' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/query ───────────────────────────────────────────
|
||||
// Execute a raw Cypher query.
|
||||
// This endpoint is intentionally unrestricted (no query validation) because
|
||||
// the server binds to 127.0.0.1 only — it exposes full graph query
|
||||
// capabilities to local clients by design.
|
||||
app.post('/api/query', async (req, res) => {
|
||||
try {
|
||||
const repoName = (req.body.repo ?? req.query.repo) as string | undefined;
|
||||
const cypher = req.body.cypher as string;
|
||||
|
||||
if (!cypher) {
|
||||
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await backend.callTool('cypher', { repo: repoName, query: cypher });
|
||||
if (result && !Array.isArray(result) && result.error) {
|
||||
res.status(500).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json({ result });
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Query failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/search ──────────────────────────────────────────
|
||||
// Process-grouped semantic search
|
||||
app.post('/api/search', async (req, res) => {
|
||||
try {
|
||||
const repoName = (req.body.repo ?? req.query.repo) as string | undefined;
|
||||
const query = (req.body.query ?? '').trim();
|
||||
const limit = req.body.limit as number | undefined;
|
||||
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Missing "query" in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await backend.callTool('query', {
|
||||
repo: repoName,
|
||||
query,
|
||||
limit,
|
||||
});
|
||||
res.json({ results });
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/file?repo=X&path=Y ──────────────────────────────
|
||||
// Read a file from a resolved repo path on disk
|
||||
app.get('/api/file', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const filePath = req.query.path as string;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing "path" query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = await backend.resolveRepo(repoName);
|
||||
|
||||
// Resolve the full path and validate it stays within the repo root
|
||||
const repoRoot = path.resolve(repo.repoPath);
|
||||
const fullPath = path.resolve(repoRoot, filePath);
|
||||
|
||||
if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
|
||||
res.status(403).json({ error: 'Path traversal denied: path escapes repo root' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
res.json({ content });
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
} else {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Failed to read file' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/processes?repo=X ─────────────────────────────────
|
||||
// List all processes for a repo
|
||||
app.get('/api/processes', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const result = await backend.queryProcesses(repoName);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Failed to query processes' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/process?repo=X&name=Y ───────────────────────────
|
||||
// Get detailed process info including steps
|
||||
app.get('/api/process', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const name = req.query.name as string;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing "name" query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await backend.queryProcessDetail(name, repoName);
|
||||
if (result.error) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Failed to query process detail' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/clusters?repo=X ─────────────────────────────────
|
||||
// List all clusters for a repo
|
||||
app.get('/api/clusters', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const result = await backend.queryClusters(repoName);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Failed to query clusters' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/cluster?repo=X&name=Y ───────────────────────────
|
||||
// Get detailed cluster info including members
|
||||
app.get('/api/cluster', async (req, res) => {
|
||||
try {
|
||||
const repoName = req.query.repo as string | undefined;
|
||||
const name = req.query.name as string;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing "name" query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await backend.queryClusterDetail(name, repoName);
|
||||
if (result.error) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(httpStatus(err))
|
||||
.json({ error: err.message || 'Failed to query cluster detail' });
|
||||
}
|
||||
});
|
||||
|
||||
const server = app.listen(port, '127.0.0.1', () => {
|
||||
console.log(`GitNexus server running on http://localhost:${port}`);
|
||||
console.log(`Serving ${hasRepos ? 'all indexed repositories' : 'no repositories (run gitnexus analyze first)'}`);
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
server.close();
|
||||
await backend.disconnect();
|
||||
process.exit(0);
|
||||
// Helper: resolve a repo by name from the global registry, or default to first
|
||||
const resolveRepo = async (repoName?: string) => {
|
||||
const repos = await listRegisteredRepos();
|
||||
if (repos.length === 0) return null;
|
||||
if (repoName) return repos.find(r => r.name === repoName) || null;
|
||||
return repos[0]; // default to first
|
||||
};
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
|
||||
// List all registered repos
|
||||
app.get('/api/repos', async (_req, res) => {
|
||||
const repos = await listRegisteredRepos();
|
||||
res.json(repos.map(r => ({
|
||||
name: r.name, path: r.path, indexedAt: r.indexedAt,
|
||||
lastCommit: r.lastCommit, stats: r.stats,
|
||||
})));
|
||||
});
|
||||
|
||||
// Get repo info
|
||||
app.get('/api/repo', async (req, res) => {
|
||||
const entry = await resolveRepo(req.query.repo as string | undefined);
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' });
|
||||
return;
|
||||
}
|
||||
const meta = await loadMeta(entry.storagePath);
|
||||
res.json({
|
||||
name: entry.name,
|
||||
repoPath: entry.path,
|
||||
indexedAt: meta?.indexedAt ?? entry.indexedAt,
|
||||
stats: meta?.stats ?? entry.stats ?? {},
|
||||
});
|
||||
});
|
||||
|
||||
// Get full graph
|
||||
app.get('/api/graph', async (req, res) => {
|
||||
const entry = await resolveRepo(req.query.repo as string | undefined);
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const kuzuPath = path.join(entry.storagePath, 'kuzu');
|
||||
await initKuzu(kuzuPath);
|
||||
const graph = await buildGraph();
|
||||
res.json(graph);
|
||||
});
|
||||
|
||||
// Execute Cypher query
|
||||
app.post('/api/query', async (req, res) => {
|
||||
const entry = await resolveRepo(req.query.repo as string | undefined);
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const kuzuPath = path.join(entry.storagePath, 'kuzu');
|
||||
await initKuzu(kuzuPath);
|
||||
const result = await executeQuery(req.body.cypher);
|
||||
res.json({ result });
|
||||
});
|
||||
|
||||
// Search
|
||||
app.post('/api/search', async (req, res) => {
|
||||
const entry = await resolveRepo(req.query.repo as string | undefined);
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const kuzuPath = path.join(entry.storagePath, 'kuzu');
|
||||
await initKuzu(kuzuPath);
|
||||
|
||||
const query = req.body.query ?? '';
|
||||
const limit = req.body.limit ?? 10;
|
||||
|
||||
if (isEmbedderReady()) {
|
||||
const results = await hybridSearch(query, limit, executeQuery, semanticSearch);
|
||||
res.json({ results });
|
||||
return;
|
||||
}
|
||||
|
||||
// FTS-only fallback when embeddings aren't loaded
|
||||
const results = await searchFTSFromKuzu(query, limit);
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
// Read file
|
||||
app.get('/api/file', async (req, res) => {
|
||||
const entry = await resolveRepo(req.query.repo as string | undefined);
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const filePath = req.query.path as string;
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing path' });
|
||||
return;
|
||||
}
|
||||
const fullPath = path.join(entry.path, filePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
res.json({ content });
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`GitNexus server running on http://localhost:${port}`);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
63
gitnexus/src/server/mcp-http.ts
Normal file
63
gitnexus/src/server/mcp-http.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* MCP over HTTP
|
||||
*
|
||||
* Mounts the GitNexus MCP server on Express using StreamableHTTP transport.
|
||||
* Each connecting client gets its own stateful session; the LocalBackend
|
||||
* is shared across all sessions (thread-safe — lazy KuzuDB per repo).
|
||||
*/
|
||||
|
||||
import type { Express, Request, Response } from 'express';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { createMCPServer } from '../mcp/server.js';
|
||||
import type { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
interface MCPSession {
|
||||
server: Server;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
}
|
||||
|
||||
export function mountMCPEndpoints(app: Express, backend: LocalBackend): void {
|
||||
const sessions = new Map<string, MCPSession>();
|
||||
|
||||
app.all('/api/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
// Existing session — delegate to its transport
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(req, res, req.body);
|
||||
} else if (sessionId) {
|
||||
// Unknown/expired session ID — tell client to re-initialize (per MCP spec)
|
||||
res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32001, message: 'Session not found. Re-initialize.' },
|
||||
id: null,
|
||||
});
|
||||
} else if (req.method === 'POST') {
|
||||
// No session ID — new client initializing
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
});
|
||||
const server = createMCPServer(backend);
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
|
||||
if (transport.sessionId) {
|
||||
sessions.set(transport.sessionId, { server, transport });
|
||||
transport.onclose = () => {
|
||||
sessions.delete(transport.sessionId!);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'No valid session. Send a POST to initialize.' },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('MCP HTTP endpoints mounted at /api/mcp');
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue