mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-30 06:42:43 +00:00
Merge pull request #5 from abhigyanpatwari/embeddings_pipeline
Embeddings pipeline
This commit is contained in:
commit
de39fb341f
16 changed files with 3451 additions and 71 deletions
785
package-lock.json
generated
785
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,10 @@
|
|||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
"@isomorphic-git/lightning-fs": "^4.6.2",
|
||||
"@langchain/core": "^0.3.0",
|
||||
"@langchain/google-genai": "^0.1.0",
|
||||
"@langchain/langgraph": "^0.2.74",
|
||||
"@langchain/openai": "^0.3.0",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
|
|
@ -24,6 +28,7 @@
|
|||
"isomorphic-git": "^1.36.1",
|
||||
"jszip": "^3.10.1",
|
||||
"kuzu-wasm": "^0.11.1",
|
||||
"langchain": "^0.3.37",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
@ -36,7 +41,7 @@
|
|||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"web-tree-sitter": "^0.20.8",
|
||||
"zod": "^4.1.13"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.28.5",
|
||||
|
|
|
|||
47
src/App.tsx
47
src/App.tsx
|
|
@ -5,6 +5,7 @@ import { LoadingOverlay } from './components/LoadingOverlay';
|
|||
import { Header } from './components/Header';
|
||||
import { GraphCanvas, GraphCanvasHandle } from './components/GraphCanvas';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
import { SettingsPanel } from './components/SettingsPanel';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
import { FileTreePanel } from './components/FileTreePanel';
|
||||
import { FileEntry } from './services/zip';
|
||||
|
|
@ -21,6 +22,12 @@ const AppContent = () => {
|
|||
isRightPanelOpen,
|
||||
runPipeline,
|
||||
runPipelineFromFiles,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
refreshLLMSettings,
|
||||
initializeAgent,
|
||||
startEmbeddings,
|
||||
embeddingStatus,
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
|
@ -38,6 +45,17 @@ const AppContent = () => {
|
|||
setGraph(result.graph);
|
||||
setFileContents(result.fileContents);
|
||||
setViewMode('exploring');
|
||||
|
||||
// Auto-start embeddings pipeline in background
|
||||
// Uses WebGPU if available, falls back to WASM
|
||||
startEmbeddings().catch((err) => {
|
||||
// WebGPU not available - try WASM fallback silently
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Pipeline error:', error);
|
||||
setProgress({
|
||||
|
|
@ -51,7 +69,7 @@ const AppContent = () => {
|
|||
setProgress(null);
|
||||
}, 3000);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline]);
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddings]);
|
||||
|
||||
const handleGitClone = useCallback(async (files: FileEntry[]) => {
|
||||
// Extract project name from first file path (e.g., "owner-repo-123/src/..." -> "owner-repo")
|
||||
|
|
@ -69,6 +87,17 @@ const AppContent = () => {
|
|||
setGraph(result.graph);
|
||||
setFileContents(result.fileContents);
|
||||
setViewMode('exploring');
|
||||
|
||||
// Auto-start embeddings pipeline in background
|
||||
// Uses WebGPU if available, falls back to WASM
|
||||
startEmbeddings().catch((err) => {
|
||||
// WebGPU not available - try WASM fallback silently
|
||||
if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) {
|
||||
startEmbeddings('wasm').catch(console.warn);
|
||||
} else {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Pipeline error:', error);
|
||||
setProgress({
|
||||
|
|
@ -82,12 +111,19 @@ const AppContent = () => {
|
|||
setProgress(null);
|
||||
}, 3000);
|
||||
}
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles]);
|
||||
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings]);
|
||||
|
||||
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)
|
||||
const handleSettingsSaved = useCallback(() => {
|
||||
refreshLLMSettings();
|
||||
initializeAgent();
|
||||
}, [refreshLLMSettings, initializeAgent]);
|
||||
|
||||
// Render based on view mode
|
||||
if (viewMode === 'onboarding') {
|
||||
return <DropZone onFileSelect={handleFileSelect} onGitClone={handleGitClone} />;
|
||||
|
|
@ -116,6 +152,13 @@ const AppContent = () => {
|
|||
</main>
|
||||
|
||||
<StatusBar />
|
||||
|
||||
{/* Settings Panel (modal) */}
|
||||
<SettingsPanel
|
||||
isOpen={isSettingsPanelOpen}
|
||||
onClose={() => setSettingsPanelOpen(false)}
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -209,9 +209,6 @@ export const DropZone = ({ onFileSelect, onGitClone }: DropZoneProps) => {
|
|||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
.zip
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
|
||||
up to 50MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Search, Settings, HelpCircle, Sparkles } from 'lucide-react';
|
||||
import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { GraphNode } from '../core/graph/types';
|
||||
|
|
@ -183,6 +183,19 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
|
|||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* GitHub Star Button */}
|
||||
<a
|
||||
href="https://github.com/abhigyanpatwari/GitNexus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3.5 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 rounded-lg text-white text-sm font-medium shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 group"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Star if cool</span>
|
||||
<Star className="w-3.5 h-3.5 group-hover:fill-yellow-300 group-hover:text-yellow-300 transition-all" />
|
||||
<span className="hidden sm:inline">✨</span>
|
||||
</a>
|
||||
|
||||
{/* Stats */}
|
||||
{graph && (
|
||||
<div className="flex items-center gap-4 mr-2 text-xs text-text-muted">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { X, Send, Sparkles, User, FileCode, Hash, GitBranch, Code, MessageSquare, PanelRightClose } from 'lucide-react';
|
||||
import {
|
||||
X, Send, Sparkles, User, FileCode, Hash, GitBranch, Code, MessageSquare,
|
||||
PanelRightClose, Loader2, Settings, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { NODE_COLORS } from '../lib/constants';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
|
||||
// Custom syntax theme
|
||||
const customTheme = {
|
||||
|
|
@ -23,13 +28,6 @@ const customTheme = {
|
|||
},
|
||||
};
|
||||
|
||||
// Chat message interface
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const RightPanel = () => {
|
||||
const {
|
||||
selectedNode,
|
||||
|
|
@ -40,10 +38,18 @@ export const RightPanel = () => {
|
|||
setRightPanelOpen,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
// LLM / chat state
|
||||
chatMessages,
|
||||
isChatLoading,
|
||||
currentToolCalls,
|
||||
agentError,
|
||||
isAgentReady,
|
||||
isAgentInitializing,
|
||||
setSettingsPanelOpen,
|
||||
sendChatMessage,
|
||||
clearChat,
|
||||
} = useAppState();
|
||||
|
||||
// Chat state
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
|
||||
// Get source code for selected node
|
||||
|
|
@ -90,27 +96,11 @@ export const RightPanel = () => {
|
|||
}, [selectedNode, graph]);
|
||||
|
||||
// Chat handlers
|
||||
const handleSendMessage = () => {
|
||||
const handleSendMessage = async () => {
|
||||
if (!chatInput.trim()) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: chatInput.trim(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
const text = chatInput.trim();
|
||||
setChatInput('');
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'This is a placeholder response. AI integration coming soon! I will be able to help you understand the codebase, find specific functions, and explain how different parts connect.',
|
||||
};
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
}, 500);
|
||||
await sendChatMessage(text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
|
|
@ -289,11 +279,51 @@ export const RightPanel = () => {
|
|||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="font-medium text-sm">Nexus AI</span>
|
||||
<span className="text-xs text-text-muted">• Ask about the codebase</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!isAgentReady && (
|
||||
<span className="text-[11px] px-2 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
Configure AI
|
||||
</span>
|
||||
)}
|
||||
{isAgentInitializing && (
|
||||
<span className="text-[11px] px-2 py-1 rounded-full bg-surface border border-border-subtle flex items-center gap-1 text-text-muted">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> Connecting
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSettingsPanelOpen(true)}
|
||||
className="p-1.5 rounded-md text-text-muted hover:text-text-primary hover:bg-hover transition-colors"
|
||||
title="AI Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status / errors */}
|
||||
{agentError && (
|
||||
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>{agentError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active tool calls - shown at top during execution */}
|
||||
{currentToolCalls.length > 0 && (
|
||||
<div className="px-4 py-3 bg-elevated/60 border-b border-border-subtle space-y-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Working...</span>
|
||||
</div>
|
||||
{currentToolCalls.map(tc => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
|
||||
{messages.length === 0 ? (
|
||||
{chatMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
|
||||
🧠
|
||||
|
|
@ -318,7 +348,7 @@ export const RightPanel = () => {
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => (
|
||||
{chatMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''} animate-fade-in`}
|
||||
|
|
@ -339,11 +369,41 @@ export const RightPanel = () => {
|
|||
<div className={`
|
||||
max-w-[85%] px-3.5 py-2.5 rounded-xl text-sm leading-relaxed
|
||||
${message.role === 'assistant'
|
||||
? 'bg-elevated border border-border-subtle text-text-primary'
|
||||
? 'bg-elevated border border-border-subtle text-text-primary prose prose-sm prose-invert max-w-none'
|
||||
: 'bg-accent text-white'
|
||||
}
|
||||
`}>
|
||||
{message.content}
|
||||
{message.role === 'assistant' ? (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isInline = !className;
|
||||
return isInline ? (
|
||||
<code className="px-1 py-0.5 bg-surface rounded text-accent font-mono text-xs" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="block p-2 bg-surface rounded text-xs font-mono overflow-x-auto" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
{/* Tool calls shown as expandable cards */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{message.toolCalls.map(tc => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -363,13 +423,26 @@ export const RightPanel = () => {
|
|||
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none max-h-24"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput.trim()}
|
||||
className="w-7 h-7 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={clearChat}
|
||||
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
title="Clear chat"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput.trim() || isChatLoading || isAgentInitializing}
|
||||
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isChatLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
{!isAgentReady && !isAgentInitializing && (
|
||||
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span>Configure an LLM provider to enable chat.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
490
src/components/SettingsPanel.tsx
Normal file
490
src/components/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
getProviderDisplayName,
|
||||
getAvailableModels,
|
||||
} from '../core/llm/settings-service';
|
||||
import type { LLMSettings, LLMProvider } from '../core/llm/types';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSettingsSaved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available Gemini models from the API
|
||||
*/
|
||||
const fetchGeminiModels = async (apiKey: string): Promise<string[]> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Filter for chat-capable models and extract model names
|
||||
const models = (data.models || [])
|
||||
.filter((model: any) => {
|
||||
const methods = model.supportedGenerationMethods || [];
|
||||
return methods.includes('generateContent');
|
||||
})
|
||||
.map((model: any) => {
|
||||
const name = model.name || '';
|
||||
return name.replace('models/', '');
|
||||
})
|
||||
.filter((name: string) => name.length > 0)
|
||||
.sort((a: string, b: string) => {
|
||||
const score = (n: string) => {
|
||||
if (n.includes('gemini-2')) return 0;
|
||||
if (n.includes('gemini-1.5')) return 1;
|
||||
if (n.includes('gemini-1')) return 2;
|
||||
return 3;
|
||||
};
|
||||
return score(a) - score(b) || a.localeCompare(b);
|
||||
});
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch Gemini models:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPanelProps) => {
|
||||
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
|
||||
// Gemini model fetching state
|
||||
const [geminiModels, setGeminiModels] = useState<string[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [modelFetchError, setModelFetchError] = useState<string | null>(null);
|
||||
const [useCustomModel, setUseCustomModel] = useState(false);
|
||||
|
||||
// Load settings when panel opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSettings(loadSettings());
|
||||
setSaveStatus('idle');
|
||||
setGeminiModels([]);
|
||||
setModelFetchError(null);
|
||||
setUseCustomModel(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-fetch models when Gemini API key changes
|
||||
const fetchModels = useCallback(async (apiKey: string) => {
|
||||
if (!apiKey || apiKey.length < 10) {
|
||||
setGeminiModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingModels(true);
|
||||
setModelFetchError(null);
|
||||
|
||||
const models = await fetchGeminiModels(apiKey);
|
||||
|
||||
setIsLoadingModels(false);
|
||||
|
||||
if (models.length > 0) {
|
||||
setGeminiModels(models);
|
||||
setModelFetchError(null);
|
||||
} else {
|
||||
setGeminiModels([]);
|
||||
setModelFetchError('Could not fetch models. Check your API key or enter model manually.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch models when API key is entered (debounced)
|
||||
useEffect(() => {
|
||||
if (settings.activeProvider === 'gemini' && settings.gemini?.apiKey) {
|
||||
const apiKey = settings.gemini.apiKey ?? '';
|
||||
const timer = setTimeout(() => {
|
||||
fetchModels(apiKey);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [settings.gemini?.apiKey, settings.activeProvider, fetchModels]);
|
||||
|
||||
const handleProviderChange = (provider: LLMProvider) => {
|
||||
setSettings(prev => ({ ...prev, activeProvider: provider }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
saveSettings(settings);
|
||||
setSaveStatus('saved');
|
||||
onSettingsSaved?.();
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
} catch {
|
||||
setSaveStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApiKeyVisibility = (key: string) => {
|
||||
setShowApiKey(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const providers: LLMProvider[] = ['gemini', 'azure-openai'];
|
||||
|
||||
const availableGeminiModels = geminiModels.length > 0
|
||||
? geminiModels
|
||||
: getAvailableModels('gemini');
|
||||
const currentGeminiModel = settings.gemini?.model ?? 'gemini-2.0-flash';
|
||||
const isCustomModelSelected = !availableGeminiModels.includes(currentGeminiModel);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-subtle bg-elevated/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-accent/20 rounded-xl">
|
||||
<Brain className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">AI Settings</h2>
|
||||
<p className="text-xs text-text-muted">Configure your LLM provider</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-text-muted hover:text-text-primary hover:bg-hover rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
Provider
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{providers.map(provider => (
|
||||
<button
|
||||
key={provider}
|
||||
onClick={() => handleProviderChange(provider)}
|
||||
className={`
|
||||
flex items-center gap-3 p-4 rounded-xl border-2 transition-all
|
||||
${settings.activeProvider === provider
|
||||
? 'border-accent bg-accent/10 text-text-primary'
|
||||
: 'border-border-subtle bg-elevated hover:border-accent/50 text-text-secondary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-lg flex items-center justify-center text-lg
|
||||
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
|
||||
`}>
|
||||
{provider === 'gemini' ? '💎' : '☁️'}
|
||||
</div>
|
||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gemini Settings */}
|
||||
{settings.activeProvider === 'gemini' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['gemini'] ? 'text' : 'password'}
|
||||
value={settings.gemini?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your Google AI API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('gemini')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['gemini'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://aistudio.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Google AI Studio
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingModels && (
|
||||
<span className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Fetching models...
|
||||
</span>
|
||||
)}
|
||||
{geminiModels.length > 0 && (
|
||||
<span className="text-xs text-green-400">
|
||||
{geminiModels.length} models available
|
||||
</span>
|
||||
)}
|
||||
{settings.gemini?.apiKey && !isLoadingModels && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchModels(settings.gemini?.apiKey ?? '')}
|
||||
className="p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
title="Refresh models"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model selector or manual input */}
|
||||
{(useCustomModel || isCustomModelSelected) ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gemini?.model ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, model: e.target.value }
|
||||
}))}
|
||||
placeholder="e.g., gemini-2.0-flash-exp"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseCustomModel(false);
|
||||
if (availableGeminiModels.length > 0) {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, model: availableGeminiModels[0] }
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
← Back to model list
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={settings.gemini?.model ?? 'gemini-2.0-flash'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
gemini: { ...prev.gemini!, model: e.target.value }
|
||||
}))}
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
{availableGeminiModels.map(model => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCustomModel(true)}
|
||||
className="text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
Enter model name manually →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modelFetchError && !useCustomModel && (
|
||||
<p className="text-xs text-amber-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{modelFetchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Azure OpenAI Settings */}
|
||||
{settings.activeProvider === 'azure-openai' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="w-4 h-4" />
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey['azure'] ? 'text' : 'password'}
|
||||
value={settings.azureOpenAI?.apiKey ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value }
|
||||
}))}
|
||||
placeholder="Enter your Azure OpenAI API key"
|
||||
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleApiKeyVisibility('azure')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showApiKey['azure'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="w-4 h-4" />
|
||||
Endpoint
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.azureOpenAI?.endpoint ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value }
|
||||
}))}
|
||||
placeholder="https://your-resource.openai.azure.com"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Deployment Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.deploymentName ?? ''}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value }
|
||||
}))}
|
||||
placeholder="e.g., gpt-4o-deployment"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.model ?? 'gpt-4o'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value }
|
||||
}))}
|
||||
placeholder="gpt-4o"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">API Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.apiVersion ?? '2024-08-01-preview'}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value }
|
||||
}))}
|
||||
placeholder="2024-08-01-preview"
|
||||
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
Configure your Azure OpenAI service in the{' '}
|
||||
<a
|
||||
href="https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Azure Portal
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Privacy Note */}
|
||||
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-green-500/20 rounded-lg text-green-400 flex-shrink-0">
|
||||
🔒
|
||||
</div>
|
||||
<div className="text-xs text-text-muted leading-relaxed">
|
||||
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's local storage.
|
||||
They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border-subtle bg-elevated/30">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{saveStatus === 'saved' && (
|
||||
<span className="flex items-center gap-1.5 text-green-400 animate-fade-in">
|
||||
<Check className="w-4 h-4" />
|
||||
Settings saved
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="flex items-center gap-1.5 text-red-400 animate-fade-in">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Failed to save
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-5 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
169
src/components/ToolCallCard.tsx
Normal file
169
src/components/ToolCallCard.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* ToolCallCard Component
|
||||
*
|
||||
* Displays a tool call with expand/collapse functionality.
|
||||
* Shows the tool name, status, and when expanded, the query/args and result.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '../core/llm/types';
|
||||
|
||||
interface ToolCallCardProps {
|
||||
toolCall: ToolCallInfo;
|
||||
/** Start expanded (useful for in-progress calls) */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool arguments for display
|
||||
*/
|
||||
const formatArgs = (args: Record<string, unknown>): string => {
|
||||
if (!args || Object.keys(args).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Special handling for Cypher queries
|
||||
if ('query' in args && typeof args.query === 'string') {
|
||||
return args.query;
|
||||
}
|
||||
if ('cypher' in args && typeof args.cypher === 'string') {
|
||||
// For execute_vector_cypher, show both the natural language query and cypher
|
||||
let result = '';
|
||||
if ('query' in args) {
|
||||
result += `Search: "${args.query}"\n\n`;
|
||||
}
|
||||
result += args.cypher;
|
||||
return result;
|
||||
}
|
||||
|
||||
// For other tools, show as formatted JSON
|
||||
return JSON.stringify(args, null, 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status icon and color
|
||||
*/
|
||||
const getStatusDisplay = (status: ToolCallInfo['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <Loader2 className="w-3.5 h-3.5 animate-spin" />,
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'border-amber-500/30',
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
icon: <Check className="w-3.5 h-3.5" />,
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
icon: <AlertCircle className="w-3.5 h-3.5" />,
|
||||
color: 'text-rose-400',
|
||||
bgColor: 'bg-rose-500/10',
|
||||
borderColor: 'border-rose-500/30',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <Sparkles className="w-3.5 h-3.5" />,
|
||||
color: 'text-text-muted',
|
||||
bgColor: 'bg-surface',
|
||||
borderColor: 'border-border-subtle',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a friendly display name for the tool
|
||||
*/
|
||||
const getToolDisplayName = (name: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
'execute_cypher': '🔍 Cypher Query',
|
||||
'execute_vector_cypher': '🧠 Semantic + Graph Query',
|
||||
'semantic_search': '🔎 Semantic Search',
|
||||
'semantic_search_with_context': '🔎 Semantic Search + Context',
|
||||
'get_code_content': '📄 Read Code',
|
||||
'get_codebase_stats': '📊 Get Stats',
|
||||
'get_graph_schema': '📋 Get Schema',
|
||||
};
|
||||
return names[name] || name;
|
||||
};
|
||||
|
||||
export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCardProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const status = getStatusDisplay(toolCall.status);
|
||||
const formattedArgs = formatArgs(toolCall.args);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${status.borderColor} ${status.bgColor} overflow-hidden transition-all`}>
|
||||
{/* Header - always visible */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{/* Expand/collapse icon */}
|
||||
<span className="text-text-muted">
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</span>
|
||||
|
||||
{/* Tool name */}
|
||||
<span className="flex-1 text-sm font-medium text-text-primary">
|
||||
{getToolDisplayName(toolCall.name)}
|
||||
</span>
|
||||
|
||||
{/* Status indicator */}
|
||||
<span className={`flex items-center gap-1 text-xs ${status.color}`}>
|
||||
{status.icon}
|
||||
<span className="capitalize">{toolCall.status}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border-subtle/50">
|
||||
{/* Arguments/Query */}
|
||||
{formattedArgs && (
|
||||
<div className="px-3 py-2 border-b border-border-subtle/50">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
|
||||
{toolCall.name.includes('cypher') ? 'Query' : 'Input'}
|
||||
</div>
|
||||
<pre className="text-xs text-text-secondary bg-surface/50 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{toolCall.result && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
|
||||
Result
|
||||
</div>
|
||||
<pre className="text-xs text-text-secondary bg-surface/50 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono max-h-64 overflow-y-auto">
|
||||
{toolCall.result.length > 2000
|
||||
? toolCall.result.slice(0, 2000) + '\n\n... (truncated)'
|
||||
: toolCall.result
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state for in-progress */}
|
||||
{toolCall.status === 'running' && !toolCall.result && (
|
||||
<div className="px-3 py-3 flex items-center gap-2 text-xs text-text-muted">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolCallCard;
|
||||
|
||||
|
|
@ -275,10 +275,12 @@ export const semanticSearch = async (
|
|||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||||
|
||||
// Query the vector index on CodeEmbedding, then JOIN with CodeNode for metadata
|
||||
// Note: KuzuDB requires WITH after YIELD before using WHERE
|
||||
const cypher = `
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
|
||||
CAST(${queryVecStr} AS FLOAT[384]), ${k})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < ${maxDistance}
|
||||
MATCH (n:CodeNode {id: emb.nodeId})
|
||||
RETURN n.id AS nodeId, n.name AS name, n.label AS label,
|
||||
|
|
@ -301,22 +303,24 @@ export const semanticSearch = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Semantic search with graph expansion
|
||||
* Finds similar nodes AND their connections
|
||||
* Semantic search with graph expansion (flattened results)
|
||||
* Finds similar nodes AND their direct connections with relationship types
|
||||
*
|
||||
* Uses separate CodeEmbedding table and JOINs with CodeNode
|
||||
* Uses separate CodeEmbedding table and JOINs with CodeNode.
|
||||
* Returns flattened results: one row per (match, connected) pair.
|
||||
* This format works with KuzuDB and preserves relationship type information.
|
||||
*
|
||||
* @param executeQuery - Function to execute Cypher queries
|
||||
* @param query - Search query text
|
||||
* @param k - Number of initial results
|
||||
* @param hops - Number of hops to expand (default: 2)
|
||||
* @returns Search results with connected nodes
|
||||
* @param k - Number of initial semantic matches (default: 5)
|
||||
* @param _hops - Unused (kept for API compatibility). Use execute_vector_cypher for multi-hop.
|
||||
* @returns Flattened results: each row is a (match → connected) pair with relationship type
|
||||
*/
|
||||
export const semanticSearchWithContext = async (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
query: string,
|
||||
k: number = 5,
|
||||
hops: number = 2
|
||||
_hops: number = 1 // Currently only single-hop supported; multi-hop via execute_vector_cypher
|
||||
): Promise<any[]> => {
|
||||
if (!isEmbedderReady()) {
|
||||
throw new Error('Embedding model not initialized. Run embedding pipeline first.');
|
||||
|
|
@ -327,23 +331,22 @@ export const semanticSearchWithContext = async (
|
|||
const queryVec = embeddingToArray(queryEmbedding);
|
||||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||||
|
||||
// Query embedding table, JOIN with CodeNode, then expand graph
|
||||
// Query embedding table, JOIN with CodeNode, then expand to direct connections
|
||||
// Using single-hop so we can access r.type (variable-length paths don't support this in KuzuDB)
|
||||
// Note: KuzuDB requires WITH after YIELD before using WHERE
|
||||
const cypher = `
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
|
||||
CAST(${queryVecStr} AS FLOAT[384]), ${k})
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < 0.5
|
||||
MATCH (match:CodeNode {id: emb.nodeId})
|
||||
MATCH (match)-[r:CodeRelation*1..${hops}]-(connected:CodeNode)
|
||||
MATCH (match)-[r:CodeRelation]-(connected:CodeNode)
|
||||
RETURN match.id AS matchId, match.name AS matchName, match.label AS matchLabel,
|
||||
match.filePath AS matchPath, distance,
|
||||
collect(DISTINCT {
|
||||
id: connected.id,
|
||||
name: connected.name,
|
||||
label: connected.label,
|
||||
relationType: [rel IN r | rel.type]
|
||||
}) AS connections
|
||||
ORDER BY distance
|
||||
connected.id AS connectedId, connected.name AS connectedName,
|
||||
connected.label AS connectedLabel, r.type AS relationType
|
||||
ORDER BY distance, matchId
|
||||
`;
|
||||
|
||||
return executeQuery(cypher);
|
||||
|
|
|
|||
300
src/core/llm/agent.ts
Normal file
300
src/core/llm/agent.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
/**
|
||||
* Graph RAG Agent Factory
|
||||
*
|
||||
* Creates a LangChain agent configured for code graph analysis.
|
||||
* Supports Azure OpenAI and Google Gemini providers.
|
||||
*/
|
||||
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { AzureChatOpenAI } from '@langchain/openai';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { createGraphRAGTools } from './tools';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
AzureOpenAIConfig,
|
||||
GeminiConfig,
|
||||
AgentStreamChunk,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* System prompt for the Graph RAG agent
|
||||
*/
|
||||
const SYSTEM_PROMPT = `You are Nexus AI, an intelligent code analysis assistant. You help developers understand codebases by querying a knowledge graph (KuzuDB) that contains code structure, relationships, and semantic embeddings.
|
||||
|
||||
CAPABILITIES:
|
||||
- Execute Cypher queries to explore code structure (functions, classes, files, imports, call graphs)
|
||||
- Perform semantic search to find code by meaning (when embeddings are available)
|
||||
- Combine semantic search + graph traversal in a SINGLE Cypher query via the vector index (when embeddings are available)
|
||||
- Search for exact text patterns using grep (regex) across all files
|
||||
- Read full file contents directly
|
||||
- Trace dependencies and relationships between code elements
|
||||
- Explain code architecture and patterns
|
||||
|
||||
APPROACH:
|
||||
1. Start by understanding what the user wants to know
|
||||
2. Choose the right tool(s) for the task:
|
||||
- Use 'get_codebase_stats' first if you need an overview
|
||||
- Use 'grep_code' to find EXACT text patterns (strings, error messages, TODOs, variable names)
|
||||
- Use 'read_file' to see the FULL content of any file
|
||||
- Use 'semantic_search' for CONCEPT-based lookup (find code by meaning, not exact text)
|
||||
- Use 'semantic_search_with_context' for semantic + neighborhood expansion
|
||||
- Use 'execute_vector_cypher' when you need semantic search + CUSTOM traversal in ONE query
|
||||
- Use 'execute_cypher' for pure structural queries (relationships, call graphs)
|
||||
- Use 'get_code_content' to show source code for a specific node ID
|
||||
3. Interpret results and explain them clearly
|
||||
4. Suggest follow-up explorations when relevant
|
||||
|
||||
TOOL SELECTION GUIDE:
|
||||
| Task | Best Tool |
|
||||
|------|-----------|
|
||||
| Find exact string "API_KEY" | grep_code |
|
||||
| Find all TODO comments | grep_code |
|
||||
| Find code related to "authentication" | semantic_search |
|
||||
| What functions call X? | execute_cypher |
|
||||
| Show me file utils.ts | read_file |
|
||||
| Show code for a found node | get_code_content |
|
||||
| Find auth code AND its callers | semantic_search_with_context |
|
||||
|
||||
IMPORTANT NOTES ABOUT THE DATABASE:
|
||||
- Nodes are stored in CodeNode(id, label, name, filePath, startLine, endLine, content)
|
||||
- Edges are stored in CodeRelation(FROM CodeNode TO CodeNode, type) where type ∈ {CALLS, IMPORTS, CONTAINS, DEFINES}
|
||||
- Embeddings are stored separately in CodeEmbedding(nodeId, embedding) for memory efficiency
|
||||
- Vector index: code_embedding_idx on CodeEmbedding.embedding (cosine distance; smaller distance = more similar)
|
||||
- Full file contents are available via grep_code and read_file (not truncated)
|
||||
|
||||
UNIFIED VECTOR + GRAPH QUERY PATTERN (ONE QUERY):
|
||||
1) Vector search to get closest embeddings
|
||||
2) JOIN to CodeNode
|
||||
3) Traverse relationships / collect context
|
||||
|
||||
Example skeleton (note: WITH after YIELD is required in KuzuDB before WHERE):
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10)
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < 0.5
|
||||
MATCH (match:CodeNode {id: emb.nodeId})
|
||||
MATCH (match)-[r:CodeRelation*1..2]-(ctx:CodeNode)
|
||||
RETURN match.name, match.label, match.filePath, distance, collect(DISTINCT ctx.name) AS context
|
||||
ORDER BY distance
|
||||
|
||||
STYLE:
|
||||
- Be concise but thorough
|
||||
- Use code formatting when showing results
|
||||
- Explain technical concepts when helpful
|
||||
- If a query fails, explain why and suggest alternatives
|
||||
|
||||
LIMITATIONS:
|
||||
- Semantic search requires embeddings to be generated first (but grep_code always works)
|
||||
- Large codebases may require more specific queries
|
||||
|
||||
When showing code or query results, format them nicely using markdown.`;
|
||||
|
||||
/**
|
||||
* Create a chat model instance from provider configuration
|
||||
*/
|
||||
export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
||||
switch (config.provider) {
|
||||
case 'azure-openai': {
|
||||
const azureConfig = config as AzureOpenAIConfig;
|
||||
return new AzureChatOpenAI({
|
||||
azureOpenAIApiKey: azureConfig.apiKey,
|
||||
azureOpenAIApiInstanceName: extractInstanceName(azureConfig.endpoint),
|
||||
azureOpenAIApiDeploymentName: azureConfig.deploymentName,
|
||||
azureOpenAIApiVersion: azureConfig.apiVersion ?? '2024-08-01-preview',
|
||||
temperature: azureConfig.temperature ?? 0.1,
|
||||
maxTokens: azureConfig.maxTokens,
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
case 'gemini': {
|
||||
const geminiConfig = config as GeminiConfig;
|
||||
return new ChatGoogleGenerativeAI({
|
||||
apiKey: geminiConfig.apiKey,
|
||||
model: geminiConfig.model,
|
||||
temperature: geminiConfig.temperature ?? 0.1,
|
||||
maxOutputTokens: geminiConfig.maxTokens,
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${(config as any).provider}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract instance name from Azure endpoint URL
|
||||
* e.g., "https://my-resource.openai.azure.com" -> "my-resource"
|
||||
*/
|
||||
const extractInstanceName = (endpoint: string): string => {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
const hostname = url.hostname;
|
||||
// Extract the first part before .openai.azure.com
|
||||
const match = hostname.match(/^([^.]+)\.openai\.azure\.com/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
// Fallback: just use the first part of hostname
|
||||
return hostname.split('.')[0];
|
||||
} catch {
|
||||
return endpoint;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Graph RAG agent
|
||||
*/
|
||||
export const createGraphRAGAgent = (
|
||||
config: ProviderConfig,
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
semanticSearch: (query: string, k?: number, maxDistance?: number) => Promise<any[]>,
|
||||
semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise<any[]>,
|
||||
isEmbeddingReady: () => boolean,
|
||||
fileContents: Map<string, string>
|
||||
) => {
|
||||
const model = createChatModel(config);
|
||||
const tools = createGraphRAGTools(
|
||||
executeQuery,
|
||||
semanticSearch,
|
||||
semanticSearchWithContext,
|
||||
isEmbeddingReady,
|
||||
fileContents
|
||||
);
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: model as any,
|
||||
tools: tools as any,
|
||||
messageModifier: new SystemMessage(SYSTEM_PROMPT) as any,
|
||||
});
|
||||
|
||||
return agent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message type for agent conversation
|
||||
*/
|
||||
export interface AgentMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a response from the agent
|
||||
* Uses streamMode: "values" to get step-by-step updates including reasoning
|
||||
*
|
||||
* Each step shows:
|
||||
* - AI reasoning/thinking (content before tool calls)
|
||||
* - Tool calls with arguments
|
||||
* - Tool results
|
||||
* - Final answer
|
||||
*/
|
||||
export async function* streamAgentResponse(
|
||||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[]
|
||||
): AsyncGenerator<AgentStreamChunk> {
|
||||
try {
|
||||
const formattedMessages = messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// Use stream with "values" mode to get each step as a complete state
|
||||
// This lets us see reasoning, tool calls, and results separately
|
||||
const stream = await agent.stream(
|
||||
{ messages: formattedMessages },
|
||||
{ streamMode: 'values' }
|
||||
);
|
||||
|
||||
let lastMessageCount = formattedMessages.length;
|
||||
|
||||
for await (const step of stream) {
|
||||
const stepMessages = step.messages || [];
|
||||
|
||||
// Process only new messages since last step
|
||||
for (let i = lastMessageCount; i < stepMessages.length; i++) {
|
||||
const msg = stepMessages[i];
|
||||
const msgType = msg._getType?.() || msg.type || 'unknown';
|
||||
|
||||
// AI message with content (reasoning or final answer)
|
||||
if (msgType === 'ai' || msgType === 'AIMessage') {
|
||||
const content = msg.content;
|
||||
const toolCalls = msg.tool_calls || msg.additional_kwargs?.tool_calls || [];
|
||||
|
||||
// If has content, yield it (reasoning or answer)
|
||||
if (content && typeof content === 'string' && content.trim()) {
|
||||
yield {
|
||||
type: toolCalls.length > 0 ? 'reasoning' : 'content',
|
||||
reasoning: toolCalls.length > 0 ? content : undefined,
|
||||
content: toolCalls.length === 0 ? content : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// If has tool calls, yield each one
|
||||
for (const tc of toolCalls) {
|
||||
yield {
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: tc.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: tc.name || tc.function?.name || 'unknown',
|
||||
args: tc.args || (tc.function?.arguments ? JSON.parse(tc.function.arguments) : {}),
|
||||
status: 'running',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tool message (result from a tool)
|
||||
if (msgType === 'tool' || msgType === 'ToolMessage') {
|
||||
const toolCallId = msg.tool_call_id || msg.additional_kwargs?.tool_call_id || '';
|
||||
const result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||||
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
toolCall: {
|
||||
id: toolCallId,
|
||||
name: msg.name || 'tool',
|
||||
args: {},
|
||||
result: result,
|
||||
status: 'completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
lastMessageCount = stepMessages.length;
|
||||
}
|
||||
|
||||
yield { type: 'done' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
yield {
|
||||
type: 'error',
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a non-streaming response from the agent
|
||||
* Simpler for cases where streaming isn't needed
|
||||
*/
|
||||
export const invokeAgent = async (
|
||||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[]
|
||||
): Promise<string> => {
|
||||
const formattedMessages = messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const result = await agent.invoke({ messages: formattedMessages });
|
||||
|
||||
// result.messages is the full conversation state
|
||||
const lastMessage = result.messages[result.messages.length - 1];
|
||||
return lastMessage?.content?.toString() ?? 'No response generated.';
|
||||
};
|
||||
|
||||
34
src/core/llm/index.ts
Normal file
34
src/core/llm/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* LLM Module Exports
|
||||
*
|
||||
* Provides Graph RAG agent capabilities for code analysis.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Settings management
|
||||
export {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
updateProviderSettings,
|
||||
setActiveProvider,
|
||||
getActiveProviderConfig,
|
||||
isProviderConfigured,
|
||||
clearSettings,
|
||||
getProviderDisplayName,
|
||||
getAvailableModels,
|
||||
} from './settings-service';
|
||||
|
||||
// Tools
|
||||
export { createGraphRAGTools } from './tools';
|
||||
|
||||
// Agent
|
||||
export {
|
||||
createChatModel,
|
||||
createGraphRAGAgent,
|
||||
streamAgentResponse,
|
||||
invokeAgent,
|
||||
type AgentMessage,
|
||||
} from './agent';
|
||||
|
||||
219
src/core/llm/settings-service.ts
Normal file
219
src/core/llm/settings-service.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Settings Service
|
||||
*
|
||||
* Handles localStorage persistence for LLM provider settings.
|
||||
* All API keys are stored locally - never sent to any server except the LLM provider.
|
||||
*/
|
||||
|
||||
import {
|
||||
LLMSettings,
|
||||
DEFAULT_LLM_SETTINGS,
|
||||
LLMProvider,
|
||||
AzureOpenAIConfig,
|
||||
GeminiConfig,
|
||||
OllamaConfig,
|
||||
ProviderConfig,
|
||||
} from './types';
|
||||
|
||||
const STORAGE_KEY = 'gitnexus-llm-settings';
|
||||
|
||||
/**
|
||||
* Load settings from localStorage
|
||||
*/
|
||||
export const loadSettings = (): LLMSettings => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return DEFAULT_LLM_SETTINGS;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored) as Partial<LLMSettings>;
|
||||
|
||||
// Merge with defaults to handle new fields
|
||||
return {
|
||||
...DEFAULT_LLM_SETTINGS,
|
||||
...parsed,
|
||||
azureOpenAI: {
|
||||
...DEFAULT_LLM_SETTINGS.azureOpenAI,
|
||||
...parsed.azureOpenAI,
|
||||
},
|
||||
gemini: {
|
||||
...DEFAULT_LLM_SETTINGS.gemini,
|
||||
...parsed.gemini,
|
||||
},
|
||||
ollama: {
|
||||
...DEFAULT_LLM_SETTINGS.ollama,
|
||||
...parsed.ollama,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to load LLM settings:', error);
|
||||
return DEFAULT_LLM_SETTINGS;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save settings to localStorage
|
||||
*/
|
||||
export const saveSettings = (settings: LLMSettings): void => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Failed to save LLM settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a specific provider's settings
|
||||
*/
|
||||
export const updateProviderSettings = <T extends LLMProvider>(
|
||||
provider: T,
|
||||
updates: Partial<
|
||||
T extends 'azure-openai' ? Partial<Omit<AzureOpenAIConfig, 'provider'>> :
|
||||
T extends 'gemini' ? Partial<Omit<GeminiConfig, 'provider'>> :
|
||||
T extends 'ollama' ? Partial<Omit<OllamaConfig, 'provider'>> :
|
||||
never
|
||||
>
|
||||
): LLMSettings => {
|
||||
const current = loadSettings();
|
||||
|
||||
// Avoid spreading unions like LLMSettings[keyof LLMSettings] (can be string/undefined)
|
||||
switch (provider) {
|
||||
case 'azure-openai': {
|
||||
const updated: LLMSettings = {
|
||||
...current,
|
||||
azureOpenAI: {
|
||||
...(current.azureOpenAI ?? {}),
|
||||
...(updates as Partial<Omit<AzureOpenAIConfig, 'provider'>>),
|
||||
},
|
||||
};
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
case 'gemini': {
|
||||
const updated: LLMSettings = {
|
||||
...current,
|
||||
gemini: {
|
||||
...(current.gemini ?? {}),
|
||||
...(updates as Partial<Omit<GeminiConfig, 'provider'>>),
|
||||
},
|
||||
};
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
case 'ollama': {
|
||||
const updated: LLMSettings = {
|
||||
...current,
|
||||
ollama: {
|
||||
...(current.ollama ?? {}),
|
||||
...(updates as Partial<Omit<OllamaConfig, 'provider'>>),
|
||||
},
|
||||
};
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
default: {
|
||||
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
|
||||
const updated: LLMSettings = { ...current };
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the active provider
|
||||
*/
|
||||
export const setActiveProvider = (provider: LLMProvider): LLMSettings => {
|
||||
const current = loadSettings();
|
||||
const updated: LLMSettings = {
|
||||
...current,
|
||||
activeProvider: provider,
|
||||
};
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current provider configuration
|
||||
*/
|
||||
export const getActiveProviderConfig = (): ProviderConfig | null => {
|
||||
const settings = loadSettings();
|
||||
|
||||
switch (settings.activeProvider) {
|
||||
case 'azure-openai':
|
||||
if (!settings.azureOpenAI?.apiKey || !settings.azureOpenAI?.endpoint) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'azure-openai',
|
||||
...settings.azureOpenAI,
|
||||
} as AzureOpenAIConfig;
|
||||
|
||||
case 'gemini':
|
||||
if (!settings.gemini?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: 'gemini',
|
||||
...settings.gemini,
|
||||
} as GeminiConfig;
|
||||
|
||||
case 'ollama':
|
||||
return {
|
||||
provider: 'ollama',
|
||||
...settings.ollama,
|
||||
} as OllamaConfig;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the active provider is properly configured
|
||||
*/
|
||||
export const isProviderConfigured = (): boolean => {
|
||||
return getActiveProviderConfig() !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all settings (reset to defaults)
|
||||
*/
|
||||
export const clearSettings = (): void => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get display name for a provider
|
||||
*/
|
||||
export const getProviderDisplayName = (provider: LLMProvider): string => {
|
||||
switch (provider) {
|
||||
case 'azure-openai':
|
||||
return 'Azure OpenAI';
|
||||
case 'gemini':
|
||||
return 'Google Gemini';
|
||||
case 'ollama':
|
||||
return 'Ollama (Local)';
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available models for a provider
|
||||
*/
|
||||
export const getAvailableModels = (provider: LLMProvider): string[] => {
|
||||
switch (provider) {
|
||||
case 'azure-openai':
|
||||
// Azure models depend on deployment, so we show common ones
|
||||
return ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-35-turbo'];
|
||||
case 'gemini':
|
||||
return ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-1.0-pro'];
|
||||
case 'ollama':
|
||||
return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
594
src/core/llm/tools.ts
Normal file
594
src/core/llm/tools.ts
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
/**
|
||||
* Graph RAG Tools for LangChain Agent
|
||||
*
|
||||
* Custom tools that allow the agent to interact with the KuzuDB graph database
|
||||
* for code analysis, semantic search, and graph traversal.
|
||||
*/
|
||||
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import { GRAPH_SCHEMA_DESCRIPTION } from './types';
|
||||
import { WebGPUNotAvailableError, embedText, embeddingToArray, initEmbedder, isEmbedderReady } from '../embeddings/embedder';
|
||||
|
||||
/**
|
||||
* Tool factory - creates tools bound to the KuzuDB query functions
|
||||
* This is needed because the tools run in the worker and need access to the adapter
|
||||
*/
|
||||
export const createGraphRAGTools = (
|
||||
executeQuery: (cypher: string) => Promise<any[]>,
|
||||
semanticSearch: (query: string, k?: number, maxDistance?: number) => Promise<any[]>,
|
||||
semanticSearchWithContext: (query: string, k?: number, hops?: number) => Promise<any[]>,
|
||||
isEmbeddingReady: () => boolean,
|
||||
fileContents: Map<string, string>
|
||||
) => {
|
||||
/**
|
||||
* Tool: Execute Cypher Query
|
||||
* Allows the agent to run arbitrary Cypher queries against the graph
|
||||
*/
|
||||
const executeCypherTool = tool(
|
||||
async ({ query }: { query: string }) => {
|
||||
try {
|
||||
const results = await executeQuery(query);
|
||||
|
||||
if (results.length === 0) {
|
||||
return 'Query returned no results.';
|
||||
}
|
||||
|
||||
// Format results nicely for the LLM
|
||||
const formatted = results.slice(0, 50).map((row, i) => {
|
||||
// Handle both object and array results
|
||||
if (Array.isArray(row)) {
|
||||
return `[${i + 1}] ${row.join(', ')}`;
|
||||
}
|
||||
return `[${i + 1}] ${JSON.stringify(row)}`;
|
||||
});
|
||||
|
||||
const resultText = formatted.join('\n');
|
||||
const truncated = results.length > 50 ? `\n... (${results.length - 50} more results truncated)` : '';
|
||||
|
||||
return `Query returned ${results.length} results:\n${resultText}${truncated}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Cypher query error: ${message}\n\nPlease check your query syntax and try again.`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'execute_cypher',
|
||||
description: 'Execute a Cypher query against the code knowledge graph. Use this for structural queries like finding functions, tracing call graphs, or analyzing imports. Call get_graph_schema first if you need to see the database schema.',
|
||||
schema: z.object({
|
||||
query: z.string().describe('The Cypher query to execute. Must be valid KuzuDB Cypher syntax.'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Execute Vector Cypher Query (Unified Vector + Graph in ONE query)
|
||||
*
|
||||
* Lets the LLM write a Cypher query that includes a vector index call,
|
||||
* while this tool handles embedding the natural-language query and injecting
|
||||
* the vector into the Cypher safely.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - The provided Cypher MUST include the placeholder {{QUERY_VECTOR}}
|
||||
* - The placeholder will be replaced with: CAST([..384 floats..] AS FLOAT[384])
|
||||
* - KuzuDB requires WITH after YIELD before using WHERE
|
||||
*
|
||||
* Example:
|
||||
* CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10)
|
||||
* YIELD node AS emb, distance
|
||||
* WITH emb, distance
|
||||
* WHERE distance < 0.5
|
||||
* MATCH (match:CodeNode {id: emb.nodeId}) ...
|
||||
*/
|
||||
const executeVectorCypherTool = tool(
|
||||
async ({ query, cypher }: { query: string; cypher: string }) => {
|
||||
if (!isEmbeddingReady()) {
|
||||
return 'Vector Cypher is not available. Embeddings have not been generated yet.';
|
||||
}
|
||||
|
||||
if (!cypher.includes('{{QUERY_VECTOR}}')) {
|
||||
return "Invalid input: your Cypher must include the placeholder '{{QUERY_VECTOR}}' where a FLOAT[384] vector should go.";
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure embedder is loaded. If WebGPU isn't available, fall back to WASM.
|
||||
if (!isEmbedderReady()) {
|
||||
try {
|
||||
await initEmbedder();
|
||||
} catch (err) {
|
||||
if (err instanceof WebGPUNotAvailableError) {
|
||||
await initEmbedder(undefined, {}, 'wasm');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Embed the natural language query and inject into Cypher
|
||||
const queryEmbedding = await embedText(query);
|
||||
const queryVec = embeddingToArray(queryEmbedding);
|
||||
const queryVecStr = `CAST([${queryVec.join(',')}] AS FLOAT[384])`;
|
||||
|
||||
const finalCypher = cypher.replace(/\{\{\s*QUERY_VECTOR\s*\}\}/g, queryVecStr);
|
||||
const results = await executeQuery(finalCypher);
|
||||
|
||||
if (results.length === 0) {
|
||||
return 'Query returned no results.';
|
||||
}
|
||||
|
||||
const formatted = results.slice(0, 50).map((row, i) => {
|
||||
if (Array.isArray(row)) {
|
||||
return `[${i + 1}] ${row.join(', ')}`;
|
||||
}
|
||||
return `[${i + 1}] ${JSON.stringify(row)}`;
|
||||
});
|
||||
|
||||
const resultText = formatted.join('\n');
|
||||
const truncated = results.length > 50 ? `\n... (${results.length - 50} more results truncated)` : '';
|
||||
|
||||
return `Query returned ${results.length} results:\n${resultText}${truncated}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Vector Cypher error: ${message}\n\nTip: Ensure you're querying the vector index on CodeEmbedding and JOINing back to CodeNode via emb.nodeId.`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'execute_vector_cypher',
|
||||
description:
|
||||
"Execute a single Cypher query that combines vector similarity search and graph traversal. Provide a natural-language 'query' to embed, and a 'cypher' string containing the placeholder {{QUERY_VECTOR}}. Use this to do semantic search + traversal in ONE Cypher query. Remember: KuzuDB requires 'WITH emb, distance' after 'YIELD node AS emb, distance' before you can use WHERE.",
|
||||
schema: z.object({
|
||||
query: z.string().describe('Natural language query to embed (used to produce a FLOAT[384] vector)'),
|
||||
cypher: z
|
||||
.string()
|
||||
.describe(
|
||||
"Cypher query to execute. MUST contain {{QUERY_VECTOR}}. Pattern: CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10) YIELD node AS emb, distance WITH emb, distance WHERE distance < 0.5 MATCH (n:CodeNode {id: emb.nodeId}) ..."
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Semantic Code Search
|
||||
* Find code by meaning using vector embeddings
|
||||
*/
|
||||
const semanticSearchTool = tool(
|
||||
async ({ query, limit }: { query: string; limit?: number }) => {
|
||||
if (!isEmbeddingReady()) {
|
||||
return 'Semantic search is not available. Embeddings have not been generated yet. Please use execute_cypher tool for structured queries instead.';
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await semanticSearch(query, limit ?? 10, 0.5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return `No code found matching "${query}". Try a different search term or use execute_cypher for structured queries.`;
|
||||
}
|
||||
|
||||
const formatted = results.map((r, i) => {
|
||||
const location = r.startLine ? ` (lines ${r.startLine}-${r.endLine})` : '';
|
||||
return `[${i + 1}] ${r.label}: ${r.name}\n ID: ${r.nodeId}\n File: ${r.filePath}${location}\n Relevance: ${(1 - r.distance).toFixed(2)}`;
|
||||
});
|
||||
|
||||
return `Found ${results.length} semantically similar code elements (use ID with get_code_content to see source):\n\n${formatted.join('\n\n')}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Semantic search error: ${message}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'semantic_search',
|
||||
description: 'Search for code by meaning using semantic similarity. Good for finding code related to a concept even if exact terms are not used.',
|
||||
schema: z.object({
|
||||
query: z.string().describe('Natural language description of what you are looking for'),
|
||||
limit: z.number().optional().nullable().describe('Maximum number of results to return (default: 10)'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Semantic Search with Graph Context
|
||||
* Find similar code AND expand to connected nodes (flattened format with relationship types)
|
||||
*/
|
||||
const semanticSearchWithContextTool = tool(
|
||||
async ({ query, limit }: { query: string; limit?: number }) => {
|
||||
if (!isEmbeddingReady()) {
|
||||
return 'Semantic search is not available. Embeddings have not been generated yet. Please use execute_cypher tool for structured queries instead.';
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await semanticSearchWithContext(query, limit ?? 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return `No code found matching "${query}". Try a different search term.`;
|
||||
}
|
||||
|
||||
// Results are flattened: one row per (match → connected) pair
|
||||
// Group by match for cleaner output
|
||||
const grouped = new Map<string, {
|
||||
matchId: string;
|
||||
matchName: string;
|
||||
matchLabel: string;
|
||||
matchPath: string;
|
||||
distance: number;
|
||||
connections: Array<{ name: string; label: string; relType: string }>;
|
||||
}>();
|
||||
|
||||
for (const r of results) {
|
||||
const matchId = r.matchId ?? r[0];
|
||||
const matchName = r.matchName ?? r[1];
|
||||
const matchLabel = r.matchLabel ?? r[2];
|
||||
const matchPath = r.matchPath ?? r[3];
|
||||
const distance = r.distance ?? r[4];
|
||||
const connectedName = r.connectedName ?? r[6];
|
||||
const connectedLabel = r.connectedLabel ?? r[7];
|
||||
const relationType = r.relationType ?? r[8];
|
||||
|
||||
if (!grouped.has(matchId)) {
|
||||
grouped.set(matchId, {
|
||||
matchId,
|
||||
matchName,
|
||||
matchLabel,
|
||||
matchPath,
|
||||
distance,
|
||||
connections: [],
|
||||
});
|
||||
}
|
||||
|
||||
grouped.get(matchId)!.connections.push({
|
||||
name: connectedName,
|
||||
label: connectedLabel,
|
||||
relType: relationType,
|
||||
});
|
||||
}
|
||||
|
||||
// Format grouped results
|
||||
const formatted = Array.from(grouped.values()).map((g, i) => {
|
||||
const connectionsList = g.connections
|
||||
.slice(0, 15)
|
||||
.map(c => `${c.name} (${c.label}) via ${c.relType}`)
|
||||
.join('\n ');
|
||||
const more = g.connections.length > 15 ? `\n ... and ${g.connections.length - 15} more` : '';
|
||||
|
||||
return `[${i + 1}] ${g.matchLabel}: ${g.matchName}\n ID: ${g.matchId}\n File: ${g.matchPath}\n Relevance: ${(1 - g.distance).toFixed(2)}\n Connections:\n ${connectionsList}${more}`;
|
||||
});
|
||||
|
||||
return `Found ${grouped.size} code elements with ${results.length} total connections (use ID with get_code_content to see source):\n\n${formatted.join('\n\n')}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Search with context error: ${message}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'semantic_search_with_context',
|
||||
description: 'Search for code semantically AND show directly connected code elements with relationship types (CALLS, IMPORTS, DEFINES, CONTAINS). Shows what each match is connected to and how.',
|
||||
schema: z.object({
|
||||
query: z.string().describe('Natural language description of what you are looking for'),
|
||||
limit: z.number().optional().nullable().describe('Number of semantic matches to find (default: 5)'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Get Graph Schema
|
||||
* Returns the schema for reference - LLM should call this before writing Cypher queries
|
||||
*/
|
||||
const getSchemaTool = tool(
|
||||
async ({ includeExamples }: { includeExamples?: boolean }) => {
|
||||
return GRAPH_SCHEMA_DESCRIPTION;
|
||||
},
|
||||
{
|
||||
name: 'get_graph_schema',
|
||||
description: 'Get the graph database schema including node types, relationships, and Cypher query patterns. Call this before writing Cypher queries.',
|
||||
schema: z.object({
|
||||
includeExamples: z.boolean().optional().nullable().describe('Whether to include query examples (default: true)'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Get Code Content
|
||||
* Retrieve the source code for a specific node
|
||||
* Uses fileContents Map for full content (not truncated DB content)
|
||||
*/
|
||||
const getCodeContentTool = tool(
|
||||
async ({ nodeId }: { nodeId: string }) => {
|
||||
try {
|
||||
// Query graph for node metadata (fast, small data)
|
||||
const results = await executeQuery(
|
||||
`MATCH (n:CodeNode {id: '${nodeId.replace(/'/g, "''")}'})
|
||||
RETURN n.name AS name, n.label AS label, n.filePath AS filePath,
|
||||
n.startLine AS startLine, n.endLine AS endLine`
|
||||
);
|
||||
|
||||
if (results.length === 0) {
|
||||
return `No node found with ID: ${nodeId}`;
|
||||
}
|
||||
|
||||
const node = results[0];
|
||||
const name = node.name ?? node[0];
|
||||
const label = node.label ?? node[1];
|
||||
const filePath = node.filePath ?? node[2];
|
||||
const startLine = node.startLine ?? node[3];
|
||||
const endLine = node.endLine ?? node[4];
|
||||
|
||||
// Get FULL content from fileContents Map (not truncated DB)
|
||||
const fileContent = fileContents.get(filePath);
|
||||
|
||||
if (!fileContent) {
|
||||
return `${label}: ${name}\nFile: ${filePath}\n(File content not available in memory)`;
|
||||
}
|
||||
|
||||
// For File nodes, return full content (limited for very large files)
|
||||
if (label === 'File' || label === 'Folder') {
|
||||
const MAX_FILE_CONTENT = 30000;
|
||||
if (fileContent.length > MAX_FILE_CONTENT) {
|
||||
return `${label}: ${name}\nFile: ${filePath}\nTotal size: ${fileContent.length} characters\n\n\`\`\`\n${fileContent.slice(0, MAX_FILE_CONTENT)}\n\`\`\`\n\n... [truncated, use read_file for full content]`;
|
||||
}
|
||||
return `${label}: ${name}\nFile: ${filePath}\n\n\`\`\`\n${fileContent}\n\`\`\``;
|
||||
}
|
||||
|
||||
// For Function/Class/Method nodes, extract specific lines with context
|
||||
const lines = fileContent.split('\n');
|
||||
const contextBefore = 3;
|
||||
const contextAfter = 20; // Show more after to capture full function body
|
||||
|
||||
const start = Math.max(0, (startLine ?? 0) - contextBefore);
|
||||
const end = Math.min(lines.length - 1, (endLine ?? startLine ?? 0) + contextAfter);
|
||||
|
||||
const snippet = lines.slice(start, end + 1).join('\n');
|
||||
|
||||
return `${label}: ${name}\nFile: ${filePath}\nLines: ${startLine + 1}-${endLine + 1}\n\n\`\`\`\n${snippet}\n\`\`\``;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Error retrieving code: ${message}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_code_content',
|
||||
description: 'Retrieve the source code content for a specific node by its ID. Use this after finding relevant nodes to see the actual implementation.',
|
||||
schema: z.object({
|
||||
nodeId: z.string().describe('The ID of the node to retrieve code for'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Get Codebase Statistics
|
||||
* Quick overview of what's in the graph
|
||||
*/
|
||||
const getStatsTool = tool(
|
||||
async ({ verbose }: { verbose?: boolean }) => {
|
||||
try {
|
||||
const labelCounts = await executeQuery(`
|
||||
MATCH (n:CodeNode)
|
||||
RETURN n.label AS label, count(*) AS count
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
|
||||
const relCounts = await executeQuery(`
|
||||
MATCH ()-[r:CodeRelation]->()
|
||||
RETURN r.type AS type, count(*) AS count
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
|
||||
const nodeStats = labelCounts.map(r => {
|
||||
const label = r.label ?? r[0];
|
||||
const count = r.count ?? r[1];
|
||||
return ` ${label}: ${count}`;
|
||||
}).join('\n');
|
||||
|
||||
const relStats = relCounts.map(r => {
|
||||
const type = r.type ?? r[0];
|
||||
const count = r.count ?? r[1];
|
||||
return ` ${type}: ${count}`;
|
||||
}).join('\n');
|
||||
|
||||
const embeddingStatus = isEmbeddingReady()
|
||||
? 'Ready (semantic search available)'
|
||||
: 'Not generated (use execute_cypher for queries)';
|
||||
|
||||
return `Codebase Statistics:\n\nNodes by type:\n${nodeStats}\n\nRelationships by type:\n${relStats}\n\nEmbeddings: ${embeddingStatus}\n\nFiles in memory: ${fileContents.size}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Error getting stats: ${message}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_codebase_stats',
|
||||
description: 'Get an overview of the codebase including counts of different element types (files, functions, classes) and relationship types.',
|
||||
schema: z.object({
|
||||
verbose: z.boolean().optional().nullable().describe('Include detailed breakdown (default: false)'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Grep Code
|
||||
* Search for patterns across all file contents using regex
|
||||
*/
|
||||
const grepCodeTool = tool(
|
||||
async ({ pattern, filePattern, caseSensitive, maxResults }: {
|
||||
pattern: string;
|
||||
filePattern?: string;
|
||||
caseSensitive?: boolean;
|
||||
maxResults?: number;
|
||||
}) => {
|
||||
try {
|
||||
const flags = caseSensitive ? 'g' : 'gi';
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(pattern, flags);
|
||||
} catch (e) {
|
||||
return `Invalid regex pattern: ${pattern}. Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
file: string;
|
||||
line: number;
|
||||
content: string;
|
||||
}> = [];
|
||||
|
||||
const limit = maxResults ?? 100;
|
||||
|
||||
for (const [filePath, content] of fileContents.entries()) {
|
||||
// Optional file pattern filter
|
||||
if (filePattern && !filePath.toLowerCase().includes(filePattern.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
results.push({
|
||||
file: filePath,
|
||||
line: i + 1,
|
||||
content: lines[i].trim().slice(0, 200), // Limit line length
|
||||
});
|
||||
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
// Reset regex lastIndex for global flag
|
||||
regex.lastIndex = 0;
|
||||
}
|
||||
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return `No matches found for pattern: "${pattern}"${filePattern ? ` in files matching "${filePattern}"` : ''}`;
|
||||
}
|
||||
|
||||
const formatted = results.map(r =>
|
||||
`${r.file}:${r.line}: ${r.content}`
|
||||
).join('\n');
|
||||
|
||||
const truncatedMsg = results.length >= limit
|
||||
? `\n\n(Showing first ${limit} results. Use filePattern to narrow search.)`
|
||||
: '';
|
||||
|
||||
return `Found ${results.length} matches for "${pattern}":\n\n${formatted}${truncatedMsg}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Grep error: ${message}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'grep_code',
|
||||
description: 'Search for text patterns across all files in the codebase using regex. Use this to find exact strings, error messages, TODOs, specific variable names, or any text pattern. Returns file paths and line numbers of matches.',
|
||||
schema: z.object({
|
||||
pattern: z.string().describe('Regex pattern to search for (e.g., "TODO", "console\\.log", "API_KEY")'),
|
||||
filePattern: z.string().optional().nullable().describe('Optional filter - only search files whose path contains this string (e.g., ".ts", "src/api")'),
|
||||
caseSensitive: z.boolean().optional().nullable().describe('Whether search is case-sensitive (default: false)'),
|
||||
maxResults: z.number().optional().nullable().describe('Maximum number of results to return (default: 100)'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tool: Read File
|
||||
* Read the full content of a file by its path
|
||||
*/
|
||||
const readFileTool = tool(
|
||||
async ({ filePath }: { filePath: string }) => {
|
||||
// Normalize the requested path (handle Windows-style paths)
|
||||
const normalizedRequest = filePath.replace(/\\/g, '/').toLowerCase();
|
||||
|
||||
// Try exact match first
|
||||
let content = fileContents.get(filePath);
|
||||
let actualPath = filePath;
|
||||
|
||||
// If not found, try smarter matching
|
||||
if (!content) {
|
||||
// Score each file path by how well it matches the request
|
||||
const candidates: Array<{ path: string; score: number }> = [];
|
||||
|
||||
for (const [path] of fileContents.entries()) {
|
||||
const normalizedPath = path.toLowerCase();
|
||||
|
||||
// Exact match (case-insensitive)
|
||||
if (normalizedPath === normalizedRequest) {
|
||||
candidates.push({ path, score: 1000 });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ends with the requested path (e.g., "README.md" matches "src/agent_service/README.md")
|
||||
if (normalizedPath.endsWith(normalizedRequest)) {
|
||||
// Score higher for shorter paths (more specific match)
|
||||
candidates.push({ path, score: 100 + (200 - path.length) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Path contains all segments of the request in order
|
||||
const requestSegments = normalizedRequest.split('/').filter(Boolean);
|
||||
const pathSegments = normalizedPath.split('/');
|
||||
let matchScore = 0;
|
||||
let lastMatchIdx = -1;
|
||||
|
||||
for (const seg of requestSegments) {
|
||||
const idx = pathSegments.findIndex((s, i) => i > lastMatchIdx && s.includes(seg));
|
||||
if (idx > lastMatchIdx) {
|
||||
matchScore += 10;
|
||||
lastMatchIdx = idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Only include if we matched more than half the segments
|
||||
if (matchScore >= requestSegments.length * 5) {
|
||||
candidates.push({ path, score: matchScore });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending, pick best match
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
actualPath = candidates[0].path;
|
||||
content = fileContents.get(actualPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
// List similar files to help the user
|
||||
const fileName = filePath.split('/').pop()?.toLowerCase() || '';
|
||||
const similarFiles = Array.from(fileContents.keys())
|
||||
.filter(p => p.toLowerCase().includes(fileName))
|
||||
.slice(0, 5);
|
||||
|
||||
if (similarFiles.length > 0) {
|
||||
return `File not found: "${filePath}"\n\nDid you mean one of these?\n${similarFiles.map(f => ` - ${f}`).join('\n')}`;
|
||||
}
|
||||
return `File not found: "${filePath}"`;
|
||||
}
|
||||
|
||||
// For very large files, truncate with a warning
|
||||
const MAX_CONTENT = 50000; // ~50KB
|
||||
if (content.length > MAX_CONTENT) {
|
||||
const truncated = content.slice(0, MAX_CONTENT);
|
||||
const lines = content.split('\n').length;
|
||||
return `File: ${actualPath}\nTotal lines: ${lines}\n\n(Showing first ${MAX_CONTENT} characters, file is ${content.length} characters total)\n\n${truncated}\n\n... [truncated]`;
|
||||
}
|
||||
|
||||
const lines = content.split('\n').length;
|
||||
return `File: ${actualPath}\nLines: ${lines}\n\n${content}`;
|
||||
},
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Read the full content of a file by its path. Use this to see the complete source code of any file in the codebase.',
|
||||
schema: z.object({
|
||||
filePath: z.string().describe('The file path to read (can be partial path like "src/utils.ts")'),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
executeCypherTool,
|
||||
executeVectorCypherTool,
|
||||
semanticSearchTool,
|
||||
semanticSearchWithContextTool,
|
||||
getSchemaTool,
|
||||
getCodeContentTool,
|
||||
getStatsTool,
|
||||
grepCodeTool,
|
||||
readFileTool,
|
||||
];
|
||||
};
|
||||
230
src/core/llm/types.ts
Normal file
230
src/core/llm/types.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* LLM Provider Types
|
||||
*
|
||||
* Type definitions for multi-provider LLM support.
|
||||
* Supports Azure OpenAI and Google Gemini (with extensibility for others).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported LLM providers
|
||||
*/
|
||||
export type LLMProvider = 'azure-openai' | 'gemini' | 'ollama';
|
||||
|
||||
/**
|
||||
* Base configuration shared by all providers
|
||||
*/
|
||||
export interface BaseProviderConfig {
|
||||
provider: LLMProvider;
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure OpenAI specific configuration
|
||||
*/
|
||||
export interface AzureOpenAIConfig extends BaseProviderConfig {
|
||||
provider: 'azure-openai';
|
||||
apiKey: string;
|
||||
endpoint: string; // e.g., https://your-resource.openai.azure.com
|
||||
deploymentName: string;
|
||||
apiVersion?: string; // defaults to '2024-08-01-preview'
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Gemini specific configuration
|
||||
*/
|
||||
export interface GeminiConfig extends BaseProviderConfig {
|
||||
provider: 'gemini';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'gemini-2.0-flash', 'gemini-1.5-pro'
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama configuration (for future use)
|
||||
*/
|
||||
export interface OllamaConfig extends BaseProviderConfig {
|
||||
provider: 'ollama';
|
||||
baseUrl?: string; // defaults to http://localhost:11434
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all provider configurations
|
||||
*/
|
||||
export type ProviderConfig = AzureOpenAIConfig | GeminiConfig | OllamaConfig;
|
||||
|
||||
/**
|
||||
* Stored settings (what goes to localStorage)
|
||||
*/
|
||||
export interface LLMSettings {
|
||||
activeProvider: LLMProvider;
|
||||
/**
|
||||
* Provider settings are persisted to localStorage and may be partially configured.
|
||||
* We validate required fields at runtime before creating a ProviderConfig.
|
||||
*/
|
||||
azureOpenAI?: Partial<Omit<AzureOpenAIConfig, 'provider'>>;
|
||||
gemini?: Partial<Omit<GeminiConfig, 'provider'>>;
|
||||
ollama?: Partial<Omit<OllamaConfig, 'provider'>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default LLM settings
|
||||
*/
|
||||
export const DEFAULT_LLM_SETTINGS: LLMSettings = {
|
||||
activeProvider: 'gemini',
|
||||
gemini: {
|
||||
apiKey: '',
|
||||
model: 'gemini-2.0-flash',
|
||||
temperature: 0.1,
|
||||
},
|
||||
azureOpenAI: {
|
||||
apiKey: '',
|
||||
endpoint: '',
|
||||
deploymentName: '',
|
||||
model: 'gpt-4o',
|
||||
apiVersion: '2024-08-01-preview',
|
||||
temperature: 0.1,
|
||||
},
|
||||
ollama: {
|
||||
baseUrl: 'http://localhost:11434',
|
||||
model: 'llama3.2',
|
||||
temperature: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Chat message for agent interaction
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
toolCallId?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call information for UI display
|
||||
*/
|
||||
export interface ToolCallInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chunk from agent
|
||||
* Now supports step-based streaming where each step is a distinct message
|
||||
*/
|
||||
export interface AgentStreamChunk {
|
||||
type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done';
|
||||
/** LLM's reasoning/thinking text (shown as a step) */
|
||||
reasoning?: string;
|
||||
/** Final answer content (streamed token by token) */
|
||||
content?: string;
|
||||
/** Tool call information */
|
||||
toolCall?: ToolCallInfo;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single step in the agent's execution
|
||||
* Used for displaying the agent's thought process
|
||||
*/
|
||||
export interface AgentStep {
|
||||
id: string;
|
||||
type: 'reasoning' | 'tool_call' | 'answer';
|
||||
/** For reasoning steps */
|
||||
content?: string;
|
||||
/** For tool_call steps */
|
||||
toolCall?: ToolCallInfo;
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph schema information for LLM context
|
||||
*/
|
||||
export const GRAPH_SCHEMA_DESCRIPTION = `
|
||||
KUZU GRAPH DATABASE SCHEMA:
|
||||
|
||||
NODE TABLES:
|
||||
1. CodeNode - All code elements (polymorphic)
|
||||
- id: STRING (primary key)
|
||||
- label: STRING (one of: File, Folder, Function, Class, Method, Interface)
|
||||
- name: STRING (element name)
|
||||
- filePath: STRING (path in project)
|
||||
- startLine: INT64 (line number where element starts)
|
||||
- endLine: INT64 (line number where element ends)
|
||||
- content: STRING (source code snippet)
|
||||
|
||||
2. CodeEmbedding - Vector embeddings (SEPARATE TABLE for memory efficiency)
|
||||
- nodeId: STRING (primary key, references CodeNode.id)
|
||||
- embedding: FLOAT[384] (semantic vector)
|
||||
|
||||
RELATIONSHIP TABLE:
|
||||
- CodeRelation (FROM CodeNode TO CodeNode)
|
||||
- type: STRING (one of: CALLS, IMPORTS, CONTAINS, DEFINES)
|
||||
|
||||
IMPORTANT QUERY PATTERNS:
|
||||
|
||||
1. Basic node queries:
|
||||
MATCH (n:CodeNode {label: 'Function'}) RETURN n.name, n.filePath LIMIT 10
|
||||
|
||||
2. Relationship traversal:
|
||||
MATCH (f:CodeNode {label: 'File'})-[r:CodeRelation {type: 'DEFINES'}]->(fn:CodeNode {label: 'Function'})
|
||||
RETURN f.name AS file, fn.name AS function
|
||||
|
||||
3. SEMANTIC SEARCH (embeddings in separate table - MUST JOIN):
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 10)
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance -- KuzuDB requires WITH after YIELD before WHERE
|
||||
WHERE distance < 0.4
|
||||
MATCH (n:CodeNode {id: emb.nodeId}) -- JOIN required!
|
||||
RETURN n.name, n.label, n.filePath, distance
|
||||
ORDER BY distance
|
||||
|
||||
4. Semantic search + graph expansion:
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 5)
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < 0.5
|
||||
MATCH (match:CodeNode {id: emb.nodeId})
|
||||
MATCH (match)-[r:CodeRelation*1..2]-(connected:CodeNode)
|
||||
RETURN match.name, distance, collect(DISTINCT connected.name) AS related
|
||||
|
||||
5. Find callers of a function:
|
||||
MATCH (caller:CodeNode)-[r:CodeRelation {type: 'CALLS'}]->(fn:CodeNode {name: $functionName})
|
||||
RETURN caller.name, caller.label, caller.filePath
|
||||
|
||||
6. Import chain analysis:
|
||||
MATCH (f:CodeNode {name: $fileName})-[r:CodeRelation {type: 'IMPORTS'}]->(imported:CodeNode)
|
||||
RETURN imported.name AS imports
|
||||
|
||||
7. Unified vector + graph traversal in ONE query (recommended pattern):
|
||||
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 10)
|
||||
YIELD node AS emb, distance
|
||||
WITH emb, distance
|
||||
WHERE distance < 0.5
|
||||
MATCH (match:CodeNode {id: emb.nodeId})
|
||||
MATCH (match)-[r:CodeRelation*1..2]-(ctx:CodeNode)
|
||||
RETURN match.name AS found, match.label AS label, match.filePath AS path,
|
||||
distance, collect(DISTINCT ctx.name) AS context
|
||||
ORDER BY distance
|
||||
|
||||
TOOLING NOTE (for execute_vector_cypher):
|
||||
- When using the execute_vector_cypher tool, write Cypher containing {{QUERY_VECTOR}} where the vector should go.
|
||||
- The tool will replace {{QUERY_VECTOR}} with a CAST([..] AS FLOAT[384]) literal.
|
||||
|
||||
NOTES:
|
||||
- Always use WHERE clauses to filter by label when possible for performance
|
||||
- Use LIMIT to avoid returning too many results
|
||||
- For semantic search, the vector index is on CodeEmbedding table, not CodeNode
|
||||
- distance in vector search is cosine distance (0 = identical, 1 = orthogonal)
|
||||
`;
|
||||
|
||||
|
|
@ -7,6 +7,9 @@ import { DEFAULT_VISIBLE_LABELS } from '../lib/constants';
|
|||
import type { IngestionWorkerApi } from '../workers/ingestion.worker';
|
||||
import type { FileEntry } from '../services/zip';
|
||||
import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types';
|
||||
import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCallInfo } from '../core/llm/types';
|
||||
import { loadSettings, getActiveProviderConfig } from '../core/llm/settings-service';
|
||||
import type { AgentMessage } from '../core/llm/agent';
|
||||
|
||||
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
|
||||
export type RightPanelTab = 'code' | 'chat';
|
||||
|
|
@ -82,6 +85,25 @@ interface AppState {
|
|||
|
||||
// Debug/test methods
|
||||
testArrayParams: () => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// LLM/Agent state
|
||||
llmSettings: LLMSettings;
|
||||
isSettingsPanelOpen: boolean;
|
||||
setSettingsPanelOpen: (open: boolean) => void;
|
||||
isAgentReady: boolean;
|
||||
isAgentInitializing: boolean;
|
||||
agentError: string | null;
|
||||
|
||||
// Chat state
|
||||
chatMessages: ChatMessage[];
|
||||
isChatLoading: boolean;
|
||||
currentToolCalls: ToolCallInfo[];
|
||||
|
||||
// LLM methods
|
||||
refreshLLMSettings: () => void;
|
||||
initializeAgent: () => Promise<void>;
|
||||
sendChatMessage: (message: string) => Promise<void>;
|
||||
clearChat: () => void;
|
||||
}
|
||||
|
||||
const AppStateContext = createContext<AppState | null>(null);
|
||||
|
|
@ -135,6 +157,18 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
// Embedding state
|
||||
const [embeddingStatus, setEmbeddingStatus] = useState<EmbeddingStatus>('idle');
|
||||
const [embeddingProgress, setEmbeddingProgress] = useState<EmbeddingProgress | null>(null);
|
||||
|
||||
// LLM/Agent state
|
||||
const [llmSettings, setLLMSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [isSettingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||
const [isAgentReady, setIsAgentReady] = useState(false);
|
||||
const [isAgentInitializing, setIsAgentInitializing] = useState(false);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
||||
// Chat state
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [isChatLoading, setIsChatLoading] = useState(false);
|
||||
const [currentToolCalls, setCurrentToolCalls] = useState<ToolCallInfo[]>([]);
|
||||
|
||||
// Worker (single instance shared across app)
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
|
@ -266,6 +300,276 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
return api.testArrayParams();
|
||||
}, []);
|
||||
|
||||
// LLM methods
|
||||
const refreshLLMSettings = useCallback(() => {
|
||||
setLLMSettings(loadSettings());
|
||||
}, []);
|
||||
|
||||
const initializeAgent = useCallback(async (): 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;
|
||||
}
|
||||
|
||||
setIsAgentInitializing(true);
|
||||
setAgentError(null);
|
||||
|
||||
try {
|
||||
const result = await api.initializeAgent(config);
|
||||
if (result.success) {
|
||||
setIsAgentReady(true);
|
||||
setAgentError(null);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Agent initialized successfully');
|
||||
}
|
||||
} else {
|
||||
setAgentError(result.error ?? 'Failed to initialize agent');
|
||||
setIsAgentReady(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAgentError(message);
|
||||
setIsAgentReady(false);
|
||||
} finally {
|
||||
setIsAgentInitializing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendChatMessage = useCallback(async (message: string): Promise<void> => {
|
||||
const api = apiRef.current;
|
||||
if (!api) {
|
||||
setAgentError('Worker not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAgentReady) {
|
||||
// Try to initialize first
|
||||
await initializeAgent();
|
||||
if (!apiRef.current) return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setChatMessages(prev => [...prev, userMessage]);
|
||||
setIsChatLoading(true);
|
||||
setCurrentToolCalls([]);
|
||||
|
||||
// Prepare message history for agent (convert our format to AgentMessage format)
|
||||
const history: AgentMessage[] = [...chatMessages, userMessage].map(m => ({
|
||||
role: m.role === 'tool' ? 'assistant' : m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// Create placeholder for assistant response
|
||||
const assistantMessageId = `assistant-${Date.now()}`;
|
||||
let assistantContent = '';
|
||||
const toolCallsForMessage: ToolCallInfo[] = [];
|
||||
let reasoningSteps: string[] = []; // Collect reasoning steps
|
||||
|
||||
try {
|
||||
const onChunk = Comlink.proxy((chunk: AgentStreamChunk) => {
|
||||
switch (chunk.type) {
|
||||
case 'reasoning':
|
||||
// LLM's thinking/reasoning before tool calls
|
||||
if (chunk.reasoning) {
|
||||
reasoningSteps.push(chunk.reasoning);
|
||||
// Update message to show reasoning
|
||||
setChatMessages(prev => {
|
||||
const existing = prev.find(m => m.id === assistantMessageId);
|
||||
// Build content with reasoning steps
|
||||
const reasoningText = reasoningSteps.join('\n\n');
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.id === assistantMessageId
|
||||
? { ...m, content: reasoningText, toolCalls: [...toolCallsForMessage] }
|
||||
: m
|
||||
);
|
||||
} else {
|
||||
return [...prev, {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content: reasoningText,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: [...toolCallsForMessage],
|
||||
}];
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'content':
|
||||
// Final answer content
|
||||
if (chunk.content) {
|
||||
assistantContent = chunk.content; // Replace, don't append (it's a full message)
|
||||
// Update the assistant message
|
||||
setChatMessages(prev => {
|
||||
const existing = prev.find(m => m.id === assistantMessageId);
|
||||
// Combine reasoning + final answer
|
||||
const fullContent = reasoningSteps.length > 0
|
||||
? reasoningSteps.join('\n\n') + '\n\n---\n\n' + assistantContent
|
||||
: assistantContent;
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.id === assistantMessageId
|
||||
? { ...m, content: fullContent, toolCalls: [...toolCallsForMessage] }
|
||||
: m
|
||||
);
|
||||
} else {
|
||||
return [...prev, {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content: fullContent,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: [...toolCallsForMessage],
|
||||
}];
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_call':
|
||||
if (chunk.toolCall) {
|
||||
const tc = chunk.toolCall;
|
||||
toolCallsForMessage.push(tc);
|
||||
setCurrentToolCalls(prev => [...prev, tc]);
|
||||
// Update message to include this tool call
|
||||
setChatMessages(prev => {
|
||||
const existing = prev.find(m => m.id === assistantMessageId);
|
||||
const currentContent = reasoningSteps.length > 0
|
||||
? reasoningSteps.join('\n\n')
|
||||
: assistantContent || '';
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.id === assistantMessageId
|
||||
? { ...m, content: currentContent, toolCalls: [...toolCallsForMessage] }
|
||||
: m
|
||||
);
|
||||
} else {
|
||||
return [...prev, {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content: currentContent,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: [...toolCallsForMessage],
|
||||
}];
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
if (chunk.toolCall) {
|
||||
const tc = chunk.toolCall;
|
||||
// Update the tool call status - try ID first, then find first running tool with same name
|
||||
let idx = toolCallsForMessage.findIndex(t => t.id === tc.id);
|
||||
if (idx < 0) {
|
||||
// ID didn't match - find the first RUNNING tool with same name (not already completed)
|
||||
idx = toolCallsForMessage.findIndex(t => t.name === tc.name && t.status === 'running');
|
||||
}
|
||||
if (idx < 0) {
|
||||
// Still no match - find any tool with same name
|
||||
idx = toolCallsForMessage.findIndex(t => t.name === tc.name && !t.result);
|
||||
}
|
||||
if (idx >= 0) {
|
||||
toolCallsForMessage[idx] = {
|
||||
...toolCallsForMessage[idx],
|
||||
result: tc.result,
|
||||
status: 'completed'
|
||||
};
|
||||
}
|
||||
// Same logic for currentToolCalls
|
||||
setCurrentToolCalls(prev => {
|
||||
// Find by ID first
|
||||
let targetIdx = prev.findIndex(t => t.id === tc.id);
|
||||
if (targetIdx < 0) {
|
||||
// Find first running tool with same name
|
||||
targetIdx = prev.findIndex(t => t.name === tc.name && t.status === 'running');
|
||||
}
|
||||
if (targetIdx < 0) {
|
||||
// Find any incomplete tool with same name
|
||||
targetIdx = prev.findIndex(t => t.name === tc.name && !t.result);
|
||||
}
|
||||
if (targetIdx >= 0) {
|
||||
return prev.map((t, i) => i === targetIdx
|
||||
? { ...t, result: tc.result, status: 'completed' }
|
||||
: t
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
// Update message with completed tool call
|
||||
setChatMessages(prev => {
|
||||
const existing = prev.find(m => m.id === assistantMessageId);
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.id === assistantMessageId
|
||||
? { ...m, toolCalls: [...toolCallsForMessage] }
|
||||
: m
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setAgentError(chunk.error ?? 'Unknown error');
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
// Finalize the assistant message
|
||||
setChatMessages(prev => {
|
||||
const existing = prev.find(m => m.id === assistantMessageId);
|
||||
const finalContent = assistantContent || (reasoningSteps.length > 0 ? reasoningSteps.join('\n\n') : '');
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.id === assistantMessageId
|
||||
? { ...m, content: finalContent, toolCalls: [...toolCallsForMessage] }
|
||||
: m
|
||||
);
|
||||
} else if (finalContent || toolCallsForMessage.length > 0) {
|
||||
return [...prev, {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content: finalContent,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: [...toolCallsForMessage],
|
||||
}];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
await api.chatStream(history, onChunk);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAgentError(message);
|
||||
} finally {
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
}
|
||||
}, [chatMessages, isAgentReady, initializeAgent]);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setChatMessages([]);
|
||||
setCurrentToolCalls([]);
|
||||
setAgentError(null);
|
||||
}, []);
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels(prev => {
|
||||
if (prev.includes(label)) {
|
||||
|
|
@ -317,6 +621,22 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
isEmbeddingReady: embeddingStatus === 'ready',
|
||||
// Debug
|
||||
testArrayParams,
|
||||
// LLM/Agent state
|
||||
llmSettings,
|
||||
isSettingsPanelOpen,
|
||||
setSettingsPanelOpen,
|
||||
isAgentReady,
|
||||
isAgentInitializing,
|
||||
agentError,
|
||||
// Chat state
|
||||
chatMessages,
|
||||
isChatLoading,
|
||||
currentToolCalls,
|
||||
// LLM methods
|
||||
refreshLLMSettings,
|
||||
initializeAgent,
|
||||
sendChatMessage,
|
||||
clearChat,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
} from '../core/embeddings/embedding-pipeline';
|
||||
import { isEmbedderReady, disposeEmbedder } from '../core/embeddings/embedder';
|
||||
import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings/types';
|
||||
import type { ProviderConfig, AgentStreamChunk } from '../core/llm/types';
|
||||
import { createGraphRAGAgent, streamAgentResponse, type AgentMessage } from '../core/llm/agent';
|
||||
|
||||
// Lazy import for Kuzu to avoid breaking worker if SharedArrayBuffer unavailable
|
||||
let kuzuAdapter: typeof import('../core/kuzu/kuzu-adapter') | null = null;
|
||||
|
|
@ -24,6 +26,13 @@ const getKuzuAdapter = async () => {
|
|||
let embeddingProgress: EmbeddingProgress | null = null;
|
||||
let isEmbeddingComplete = false;
|
||||
|
||||
// File contents state - stores full file contents for grep/read tools
|
||||
let storedFileContents: Map<string, string> = new Map();
|
||||
|
||||
// Agent state
|
||||
let currentAgent: ReturnType<typeof createGraphRAGAgent> | null = null;
|
||||
let currentProviderConfig: ProviderConfig | null = null;
|
||||
|
||||
/**
|
||||
* Worker API exposed via Comlink
|
||||
*
|
||||
|
|
@ -44,6 +53,9 @@ const workerApi = {
|
|||
// Run the actual pipeline
|
||||
const result = await runIngestionPipeline(file, onProgress);
|
||||
|
||||
// Store file contents for grep/read tools (full content, not truncated)
|
||||
storedFileContents = result.fileContents;
|
||||
|
||||
// Load graph into KuzuDB for querying (optional - gracefully degrades)
|
||||
try {
|
||||
onProgress({
|
||||
|
|
@ -61,8 +73,9 @@ const workerApi = {
|
|||
await kuzu.loadGraphToKuzu(result.graph, result.fileContents);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const stats = await kuzu.getKuzuStats();
|
||||
console.log('KuzuDB loaded:', stats);
|
||||
const stats = await kuzu.getKuzuStats();
|
||||
console.log('KuzuDB loaded:', stats);
|
||||
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
|
||||
}
|
||||
} catch {
|
||||
// KuzuDB is optional - silently continue without it
|
||||
|
|
@ -130,6 +143,9 @@ const workerApi = {
|
|||
// Run the pipeline
|
||||
const result = await runPipelineFromFiles(files, onProgress);
|
||||
|
||||
// Store file contents for grep/read tools (full content, not truncated)
|
||||
storedFileContents = result.fileContents;
|
||||
|
||||
// Load graph into KuzuDB for querying (optional - gracefully degrades)
|
||||
try {
|
||||
onProgress({
|
||||
|
|
@ -149,6 +165,7 @@ const workerApi = {
|
|||
if (import.meta.env.DEV) {
|
||||
const stats = await kuzu.getKuzuStats();
|
||||
console.log('KuzuDB loaded:', stats);
|
||||
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
|
||||
}
|
||||
} catch {
|
||||
// KuzuDB is optional - silently continue without it
|
||||
|
|
@ -285,6 +302,112 @@ const workerApi = {
|
|||
}
|
||||
return kuzu.testArrayParams();
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Graph RAG Agent Methods
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Initialize the Graph RAG agent with a provider configuration
|
||||
* Must be called before using chat methods
|
||||
* @param config - Provider configuration (Azure OpenAI or Gemini)
|
||||
*/
|
||||
async initializeAgent(config: ProviderConfig): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const kuzu = await getKuzuAdapter();
|
||||
if (!kuzu.isKuzuReady()) {
|
||||
return { success: false, error: 'Database not ready. Please load a repository first.' };
|
||||
}
|
||||
|
||||
// Create semantic search wrappers that handle embedding state
|
||||
const semanticSearchWrapper = async (query: string, k?: number, maxDistance?: number) => {
|
||||
if (!isEmbeddingComplete) {
|
||||
throw new Error('Embeddings not ready');
|
||||
}
|
||||
return doSemanticSearch(kuzu.executeQuery, query, k, maxDistance);
|
||||
};
|
||||
|
||||
const semanticSearchWithContextWrapper = async (query: string, k?: number, hops?: number) => {
|
||||
if (!isEmbeddingComplete) {
|
||||
throw new Error('Embeddings not ready');
|
||||
}
|
||||
return doSemanticSearchWithContext(kuzu.executeQuery, query, k, hops);
|
||||
};
|
||||
|
||||
currentAgent = createGraphRAGAgent(
|
||||
config,
|
||||
kuzu.executeQuery,
|
||||
semanticSearchWrapper,
|
||||
semanticSearchWithContextWrapper,
|
||||
() => isEmbeddingComplete,
|
||||
storedFileContents
|
||||
);
|
||||
currentProviderConfig = config;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🤖 Graph RAG Agent initialized with provider:', config.provider);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('❌ Agent initialization failed:', error);
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the agent is initialized
|
||||
*/
|
||||
isAgentReady(): boolean {
|
||||
return currentAgent !== null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current provider info
|
||||
*/
|
||||
getAgentProvider(): { provider: string; model: string } | null {
|
||||
if (!currentProviderConfig) return null;
|
||||
return {
|
||||
provider: currentProviderConfig.provider,
|
||||
model: currentProviderConfig.model,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Chat with the Graph RAG agent (streaming)
|
||||
* Sends response chunks via the onChunk callback
|
||||
* @param messages - Conversation history
|
||||
* @param onChunk - Proxied callback for streaming chunks (runs on main thread)
|
||||
*/
|
||||
async chatStream(
|
||||
messages: AgentMessage[],
|
||||
onChunk: (chunk: AgentStreamChunk) => void
|
||||
): Promise<void> {
|
||||
if (!currentAgent) {
|
||||
onChunk({ type: 'error', error: 'Agent not initialized. Please configure an LLM provider first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const chunk of streamAgentResponse(currentAgent, messages)) {
|
||||
onChunk(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
onChunk({ type: 'error', error: message });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispose of the current agent
|
||||
*/
|
||||
disposeAgent(): void {
|
||||
currentAgent = null;
|
||||
currentProviderConfig = null;
|
||||
},
|
||||
};
|
||||
|
||||
// Expose the worker API to the main thread
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue