feat(web): wire agent/chat through HTTP in backend mode

Add HTTP wrapper functions (executeQuery, textSearch) to the ingestion
worker and a new initializeBackendAgent method that creates the agent
with HTTP-backed tool closures instead of local KuzuDB. useAppState
detects backend mode and routes agent initialization accordingly.
This commit is contained in:
Paul Robello 2026-02-22 10:49:08 -08:00
parent 56f92ca1ad
commit 8dd1c19bec
4 changed files with 148 additions and 14 deletions

View file

@ -32,7 +32,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
| **Scale** | Full repos, any size | Limited by browser memory (~5k files) |
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
| **Storage** | KuzuDB native (fast, persistent) | KuzuDB WASM (in-memory, per session) |
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
@ -235,7 +235,7 @@ npm run dev
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, KuzuDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos. No need to re-upload or re-index.
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
---

View file

@ -11,7 +11,7 @@ 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 } from '../services/backend';
import { runCypherQuery, getBackendUrl } from '../services/backend';
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
export type RightPanelTab = 'code' | 'chat';
@ -571,25 +571,39 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}, []);
const initializeAgent = useCallback(async (overrideProjectName?: string): Promise<void> => {
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;
}
const api = apiRef.current;
if (!api) {
setAgentError('Worker not initialized');
return;
}
setIsAgentInitializing(true);
setAgentError(null);
try {
// Use override if provided (for fresh loads), fallback to state (for re-init)
const effectiveProjectName = overrideProjectName || projectName || 'project';
const result = await api.initializeAgent(config, effectiveProjectName);
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);
}
if (result.success) {
setIsAgentReady(true);
setAgentError(null);
@ -607,7 +621,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
} finally {
setIsAgentInitializing(false);
}
}, [projectName]);
}, [projectName, isBackendMode, backendRepo, fileContents]);
const sendChatMessage = useCallback(async (message: string): Promise<void> => {
const api = apiRef.current;

View file

@ -16,7 +16,7 @@ import { SystemMessage } from '@langchain/core/messages';
import { enrichClustersBatch, ClusterMemberInfo, ClusterEnrichment } from '../core/ingestion/cluster-enricher';
import { CommunityNode } from '../core/ingestion/community-processor';
import { PipelineResult } from '../types/pipeline';
import { buildCodebaseContext } from '../core/llm/context-builder';
import { buildCodebaseContext, type CodebaseContext } from '../core/llm/context-builder';
import {
buildBM25Index,
searchBM25,
@ -54,6 +54,65 @@ let enrichmentCancelled = false;
// Chat cancellation flag
let chatCancelled = false;
// ============================================================
// HTTP helpers for backend mode
// ============================================================
const httpFetchWithTimeout = async (
url: string,
init: RequestInit = {},
timeoutMs: number = 30_000,
): Promise<Response> => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
};
const createHttpExecuteQuery = (backendUrl: string, repo: string) => {
return async (cypher: string): Promise<any[]> => {
const response = await httpFetchWithTimeout(`${backendUrl}/api/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cypher, repo }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Backend query failed: ${response.status}`);
}
const body = await response.json();
return (body.result ?? body) as any[];
};
};
const createHttpTextSearch = (executeQuery: (cypher: string) => Promise<any[]>) => {
return async (query: string, k: number = 15): Promise<any[]> => {
const sanitized = query.replace(/'/g, "\\'");
const cypher = `
MATCH (n)
WHERE n.name IS NOT NULL AND toLower(n.name) CONTAINS toLower('${sanitized}')
RETURN n.id AS id, label(n) AS type, n.name AS name, n.filePath AS filePath, n.content AS content
LIMIT ${k}
`;
try {
const results = await executeQuery(cypher);
return results.map((r: any, i: number) => ({
id: r.id,
name: r.name,
type: r.type,
filePath: r.filePath,
content: r.content ?? '',
score: 1 - (i * 0.05),
}));
} catch {
return [];
}
};
};
/**
* Worker API exposed via Comlink
*
@ -540,6 +599,67 @@ const workerApi = {
}
},
/**
* Initialize the Graph RAG agent in backend mode (HTTP-backed tools).
* Uses HTTP wrappers instead of local KuzuDB for all tool queries.
* @param config - Provider configuration for the LLM
* @param backendUrl - Base URL of the gitnexus serve backend
* @param repoName - Repository name on the backend
* @param fileContentsEntries - File contents as [path, content][] (Comlink can't transfer Maps)
* @param projectName - Display name for the project
*/
async initializeBackendAgent(
config: ProviderConfig,
backendUrl: string,
repoName: string,
fileContentsEntries: [string, string][],
projectName?: string,
): Promise<{ success: boolean; error?: string }> {
try {
// Rebuild Map from serializable entries (Comlink can't transfer Maps)
const contents = new Map<string, string>(fileContentsEntries);
storedFileContents = contents;
// Create HTTP-based tool wrappers
const executeQuery = createHttpExecuteQuery(backendUrl, repoName);
const textSearch = createHttpTextSearch(executeQuery);
// Build codebase context (uses Cypher queries — works via HTTP)
let codebaseContext: CodebaseContext | undefined;
try {
codebaseContext = await buildCodebaseContext(executeQuery, projectName || repoName);
} catch {
// Non-fatal — agent works without context
}
// Create agent with HTTP-backed tools
currentAgent = createGraphRAGAgent(
config,
executeQuery, // Cypher via HTTP
textSearch, // semanticSearch → Cypher text search
textSearch, // semanticSearchWithContext → same
textSearch, // hybridSearch → same
() => true, // isEmbeddingReady → always true (routes to textSearch)
() => true, // isBM25Ready → always true (routes to textSearch)
contents, // fileContents Map
codebaseContext,
);
currentProviderConfig = config;
if (import.meta.env.DEV) {
console.log('🤖 Backend agent initialized with provider:', config.provider);
}
return { success: true };
} catch (err: any) {
if (import.meta.env.DEV) {
console.error('❌ Backend agent initialization failed:', err);
}
return { success: false, error: err.message || 'Failed to initialize backend agent' };
}
},
/**
* Check if the agent is initialized
*/

View file

@ -179,7 +179,7 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
GitNexus also has a browser-based UI at [gitnexus.vercel.app](https://gitnexus.vercel.app) — 100% client-side, your code never leaves the browser.
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos. No need to re-upload or re-index.
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
## License