Initial commit: Complete CodeNexus application with AI-powered code analysis

This commit is contained in:
abhigyantrumio 2025-08-03 04:44:44 +05:30
commit 485e3e00a7
49 changed files with 12044 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

555
GITNEXUS_README.md Normal file
View file

@ -0,0 +1,555 @@
# 🔍 GitNexus - Code Knowledge Graph Explorer
GitNexus is a comprehensive code analysis and exploration tool that transforms repositories into interactive knowledge graphs, enabling AI-powered insights about code structure, relationships, and functionality.
## ✨ Features
### 📊 **Code Analysis & Visualization**
- **GitHub Integration**: Analyze any public GitHub repository directly from URL
- **ZIP File Support**: Upload and analyze local code archives
- **Interactive Knowledge Graph**: Visualize code structure with Cytoscape.js
- **Multi-language Support**: Currently optimized for Python with extensible architecture
- **Smart Filtering**: Directory and file pattern filters to focus analysis scope
- **Performance Optimization**: Configurable file limits with confirmation dialogs for large repositories
### 🤖 **AI-Powered Chat Interface**
- **Multiple LLM Providers**: OpenAI, Anthropic (Claude), Google Gemini
- **ReAct Agent Pattern**: Uses proper LangChain ReAct implementation for reasoning
- **Tool-Augmented Responses**: Graph queries, code retrieval, file search
- **Context-Aware**: Maintains conversation history with configurable memory
### 🔧 **Advanced Processing Pipeline**
- **3-Pass Ingestion Strategy**:
1. **Structure Analysis**: Project hierarchy and file organization
2. **Code Parsing**: AST-based extraction using Tree-sitter
3. **Call Resolution**: Function/method call relationship mapping
- **Web Worker Processing**: Non-blocking UI with progress tracking
- **Intelligent Caching**: AST and processing result optimization
- **Error Resilience**: Comprehensive error boundaries and recovery mechanisms
### 🎨 **Modern UI/UX**
- **Responsive Design**: Adaptive layout for different screen sizes
- **Real-time Progress**: Live updates during repository processing
- **Interactive Graph**: Node selection, zooming, panning
- **Split-Panel Layout**: Graph visualization + AI chat interface
- **Settings Management**: Persistent configuration for API keys and preferences
- **Export Functionality**: Download knowledge graphs as JSON with metadata
- **Performance Controls**: File limits, filtering, and optimization settings
### 🛡️ **Reliability & Performance**
- **Error Boundaries**: Graceful error handling with user-friendly recovery options
- **Performance Monitoring**: Real-time processing statistics and export size calculation
- **Memory Management**: Efficient handling of large repositories with configurable limits
- **Progress Tracking**: Detailed progress indicators with phase-specific messaging
- **Confirmation Dialogs**: Smart warnings for potentially expensive operations
## 🏗️ Architecture
### **Frontend Stack**
- **React 18** with TypeScript
- **Vite** for fast development and building
- **Cytoscape.js** for graph visualization
- **Custom CSS** with modern design patterns
- **Error Boundaries** for robust error handling
### **Processing Engine**
- **Deno Runtime** for TypeScript execution
- **Tree-sitter WASM** for syntax parsing
- **Web Workers** for background processing
- **Comlink** for worker communication
### **AI Integration**
- **LangChain.js** with proper ReAct agent implementation
- **Multiple LLM Support**: OpenAI, Anthropic, Gemini
- **Tool-based Architecture**: Graph queries, code retrieval, file search
- **Cypher Query Generation**: Natural language to graph queries
### **Services Layer**
```
src/
├── services/ # External API integrations
│ ├── github.ts # GitHub REST API client
│ └── zip.ts # ZIP file processing
├── core/ # Core processing logic
│ ├── graph/ # Knowledge graph types
│ ├── ingestion/ # 3-pass processing pipeline
│ └── tree-sitter/ # Syntax parsing infrastructure
├── ai/ # AI and RAG components
│ ├── llm-service.ts # Multi-provider LLM client
│ ├── cypher-generator.ts # NL to Cypher translation
│ ├── orchestrator.ts # Custom ReAct implementation
│ └── langchain-orchestrator.ts # Standard LangChain ReAct
├── workers/ # Web Worker implementations
├── ui/ # React components and pages
│ ├── components/ # Reusable UI components
│ │ ├── ErrorBoundary.tsx # Error handling component
│ │ ├── graph/ # Graph visualization components
│ │ └── chat/ # Chat interface components
│ └── pages/ # Application pages
├── lib/ # Shared utilities
│ └── export.ts # Graph export functionality
└── App.tsx # Main application entry point
```
## 🚀 Getting Started
### Prerequisites
- **Node.js 18+** and **npm/yarn**
- **Deno 1.40+** for development
- **API Keys** for AI features (OpenAI, Anthropic, or Gemini)
### Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd gitnexus
```
2. **Install dependencies**
```bash
npm install
```
3. **Start development server**
```bash
npm run dev
```
4. **Open in browser**
```
http://localhost:5173
```
### Configuration
1. **GitHub Token (Optional)**
- Increases rate limit from 60 to 5,000 requests/hour
- Generate at: https://github.com/settings/tokens
- Requires no special permissions for public repos
2. **AI API Keys**
- **OpenAI**: Get from https://platform.openai.com/api-keys
- **Anthropic**: Get from https://console.anthropic.com/
- **Gemini**: Get from https://makersuite.google.com/app/apikey
3. **Performance Settings**
- **File Limit**: Configure maximum files to process (default: 500)
- **Directory Filters**: Focus on specific directories (e.g., "src", "lib")
- **File Patterns**: Filter by file types (e.g., "*.py", "*.js", "*.ts")
## 💡 Usage
### Analyzing a Repository
1. **GitHub Repository**
```
1. Enter GitHub URL: https://github.com/owner/repo
2. Optional: Set directory/file filters to focus analysis
3. Click "Analyze"
4. For large repos: Confirm processing or adjust filters
5. Wait for processing (structure → parsing → call resolution)
6. Explore the interactive graph
```
2. **ZIP File Upload**
```
1. Click "Choose File" and select a .zip file
2. Optional: Configure filters before processing
3. Click "Analyze"
4. Processing will extract and analyze text files
5. Explore results in the graph visualization
```
### Performance Optimization
1. **Directory Filtering**
```
- Enter directory names: "src", "lib", "components"
- Focuses analysis on specific parts of the codebase
- Reduces processing time and memory usage
```
2. **File Pattern Filtering**
```
- Use patterns: "*.py", "*.js", "*.ts"
- Supports wildcards: "test*.py", "*util*"
- Comma-separated: "*.py,*.js,*.ts"
```
3. **File Limits**
```
- Default limit: 500 files
- Configurable in settings (50-2000 files)
- Large repositories show confirmation dialog
- Automatic truncation to limit if confirmed
```
### Using the AI Chat
1. **Configure API Key**
```
1. Click the ⚙️ settings button
2. Choose your preferred LLM provider
3. Enter your API key
4. Select model (e.g., gpt-4o-mini, claude-3-haiku)
```
2. **Ask Questions**
```
- "What functions are in the main.py file?"
- "Show me all classes that inherit from BaseClass"
- "How does the authentication system work?"
- "Find all functions that call the database"
```
### Exporting Data
1. **Export Knowledge Graph**
```
1. Click the 📥 Export button after processing
2. Downloads JSON file with graph data and metadata
3. Includes processing statistics and timestamps
4. File size shown in UI before export
```
2. **Export Format**
```json
{
"metadata": {
"exportedAt": "2024-01-01T12:00:00.000Z",
"version": "1.0.0",
"nodeCount": 150,
"relationshipCount": 200,
"fileCount": 25,
"processingDuration": 5000
},
"graph": {
"nodes": [...],
"relationships": [...]
},
"fileContents": {...}
}
```
### Graph Interaction
- **Node Selection**: Click any node to highlight and view details
- **Zoom & Pan**: Mouse wheel to zoom, drag to pan
- **Node Types**: Different colors/shapes for files, functions, classes, etc.
- **Relationships**: Arrows show CONTAINS, CALLS, INHERITS relationships
## 🔧 Development
### Project Structure
```
GitNexus/
├── src/
│ ├── services/ # External integrations
│ ├── core/ # Processing pipeline
│ ├── ai/ # AI and RAG systems
│ ├── workers/ # Web Workers
│ ├── ui/ # React components
│ │ ├── components/ # Reusable components
│ │ │ ├── ErrorBoundary.tsx
│ │ │ ├── graph/ # Graph components
│ │ │ └── chat/ # Chat components
│ │ └── pages/ # Application pages
│ ├── lib/ # Utilities
│ │ └── export.ts # Export functionality
│ └── App.tsx # Main application
├── public/
│ └── wasm/ # Tree-sitter WASM files
├── package.json
├── vite.config.ts
└── tsconfig.json
```
### Key Components
#### **Error Handling**
```typescript
// ErrorBoundary component with recovery options
<ErrorBoundary
onError={(error, errorInfo) => {
console.error('Application error:', error);
}}
>
<App />
</ErrorBoundary>
```
#### **Performance Optimization**
```typescript
// File filtering and limits
const filterFiles = (files: any[]) => {
return files
.filter(file => matchesDirectoryFilter(file))
.filter(file => matchesPatternFilter(file))
.slice(0, maxFiles);
};
```
#### **Export Functionality**
```typescript
// Export with metadata
exportAndDownloadGraph(graph, {
projectName: 'my-project',
includeMetadata: true,
prettyPrint: true
}, fileContents, { duration: 5000 });
```
#### **Processing Pipeline**
```typescript
// 3-pass ingestion strategy with progress tracking
const pipeline = new GraphPipeline();
const result = await pipeline.run({
projectRoot: '/',
projectName: 'MyProject',
filePaths: ['src/main.py', 'src/utils.py'],
fileContents: new Map([
['src/main.py', 'def main(): pass'],
['src/utils.py', 'def helper(): pass']
])
});
```
#### **AI Integration**
```typescript
// LangChain ReAct agent with error handling
const orchestrator = new LangChainRAGOrchestrator(llmService, cypherGenerator);
await orchestrator.setContext({ graph, fileContents }, llmConfig);
const response = await orchestrator.answerQuestion("How does auth work?");
```
#### **Graph Visualization**
```typescript
// Interactive graph component with error boundaries
<ErrorBoundary>
<GraphExplorer
graph={knowledgeGraph}
onNodeSelect={(nodeId) => setSelectedNode(nodeId)}
/>
</ErrorBoundary>
```
### Adding New Features
1. **New Language Support**
```typescript
// Add parser in core/tree-sitter/
export const loadJavaScriptParser = async () => {
// Load JS Tree-sitter grammar
};
```
2. **Custom AI Tools**
```typescript
// Add tools in ai/langchain-orchestrator.ts
const customTool = tool(
async (input: { query: string }) => {
// Tool implementation
},
{
name: "custom_tool",
description: "Custom functionality",
schema: z.object({ query: z.string() })
}
);
```
3. **Export Formats**
```typescript
// Add new export formats in lib/export.ts
export function exportToCSV(graph: KnowledgeGraph): string {
// CSV export implementation
}
```
## 🧪 Testing & Quality Assurance
### Error Handling
- **Error Boundaries**: Catch and display JavaScript errors gracefully
- **User Recovery**: Allow users to reset component state after errors
- **Detailed Logging**: Console logging for debugging and error reporting
- **Fallback UI**: User-friendly error messages with recovery options
### Performance Testing
1. **Large Repository Handling**
- Test with repositories containing 1000+ files
- Verify confirmation dialogs for file limits
- Monitor memory usage during processing
- Test filtering effectiveness
2. **UI Responsiveness**
- Ensure non-blocking processing with Web Workers
- Verify progress indicators update correctly
- Test error recovery mechanisms
- Validate export functionality with large graphs
3. **Error Scenarios**
- Network failures during GitHub API calls
- Corrupted ZIP files
- Invalid API keys
- Memory exhaustion scenarios
### Manual Testing Checklist
- [ ] GitHub repository analysis with various sizes
- [ ] ZIP file upload and extraction
- [ ] Directory and file pattern filtering
- [ ] Large repository confirmation dialog
- [ ] Export functionality with different options
- [ ] Error boundary activation and recovery
- [ ] API key validation for all providers
- [ ] Settings persistence across sessions
- [ ] Graph visualization interactions
- [ ] Chat interface with different LLM providers
## 🚀 Deployment
### Production Build
```bash
npm run build
npm run preview
```
### Environment Variables
```env
# Optional: Pre-configure API keys
VITE_OPENAI_API_KEY=sk-...
VITE_ANTHROPIC_API_KEY=sk-ant-...
VITE_GEMINI_API_KEY=...
# Performance settings
VITE_DEFAULT_MAX_FILES=500
VITE_ENABLE_DEBUG_LOGGING=false
```
### Docker Deployment
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0"]
```
### Performance Monitoring
```javascript
// Add performance monitoring
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'measure') {
console.log(`${entry.name}: ${entry.duration}ms`);
}
}
});
observer.observe({ entryTypes: ['measure'] });
```
## 🔒 Security & Privacy
- **Client-Side Processing**: All analysis happens in your browser
- **API Keys**: Stored locally, never transmitted to our servers
- **GitHub Access**: Uses public API, respects repository permissions
- **Data Privacy**: No code or analysis results are stored remotely
- **Error Logging**: Sensitive data excluded from error reports
- **Export Security**: User-controlled data export with no server interaction
## 🤝 Contributing
### Development Setup
1. Fork the repository
2. Create feature branch: `git checkout -b feature/amazing-feature`
3. Make changes and test thoroughly
4. Run the testing checklist above
5. Commit: `git commit -m 'Add amazing feature'`
6. Push: `git push origin feature/amazing-feature`
7. Open a Pull Request
### Code Style
- **TypeScript**: Strict mode enabled
- **ESLint**: Follow configured rules
- **Prettier**: Auto-formatting
- **Comments**: Minimal, only when necessary
- **Error Handling**: Comprehensive error boundaries and recovery
- **Performance**: Consider memory usage and processing time
### Testing Guidelines
- Test error scenarios and edge cases
- Verify performance with large datasets
- Ensure graceful degradation
- Test all export functionality
- Validate error boundary behavior
## 📚 Technical Details
### Knowledge Graph Schema
```typescript
interface KnowledgeGraph {
nodes: GraphNode[]; // Code entities
relationships: GraphRelationship[]; // Connections
}
// Node types: Project, Folder, File, Module, Class, Function, Method, Variable
// Relationship types: CONTAINS, CALLS, INHERITS, OVERRIDES, IMPORTS
```
### Export Format
```typescript
interface ExportedGraph {
metadata: {
exportedAt: string;
version: string;
nodeCount: number;
relationshipCount: number;
fileCount?: number;
processingDuration?: number;
};
graph: KnowledgeGraph;
fileContents?: Record<string, string>;
}
```
### Error Boundary Implementation
- **Component-Level**: Individual components wrapped for isolation
- **Application-Level**: Top-level boundary for catastrophic failures
- **Recovery Options**: Reset state, reload page, or continue with fallback
- **Error Reporting**: Detailed technical information for developers
### Performance Optimizations
- **Web Workers**: Non-blocking processing
- **AST Caching**: Reuse parsed syntax trees
- **Progressive Loading**: Stream results as available
- **Memory Management**: Efficient data structures
- **File Filtering**: Reduce processing scope
- **Confirmation Dialogs**: Prevent accidental expensive operations
### ReAct Agent Implementation
- **Standard LangChain**: Uses `createReactAgent` from `@langchain/langgraph/prebuilt`
- **Custom Implementation**: Manual ReAct loop for educational purposes
- **Tools**: Graph queries, code retrieval, file search
- **Memory**: Conversation persistence with thread management
- **Error Recovery**: Graceful handling of API failures
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- **Tree-sitter**: Syntax parsing infrastructure
- **LangChain.js**: AI agent framework
- **Cytoscape.js**: Graph visualization
- **React**: UI framework with error boundaries
- **Vite**: Build tool and dev server
---
**GitNexus** - Transforming code into knowledge, one repository at a time. 🚀
*Now with enhanced reliability, performance optimization, and comprehensive error handling for production-ready code analysis.*

1863
README.md Normal file

File diff suppressed because it is too large Load diff

1246
deno.lock generated Normal file

File diff suppressed because it is too large Load diff

28
eslint.config.js Normal file
View file

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "gitnexus",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
}
}

1455
project_guide.md Normal file

File diff suppressed because it is too large Load diff

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

42
src/App.css Normal file
View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

12
src/App.tsx Normal file
View file

@ -0,0 +1,12 @@
import React from 'react';
import { HomePage, ErrorBoundary } from './ui/index.ts';
const App: React.FC = () => {
return (
<ErrorBoundary>
<HomePage />
</ErrorBoundary>
);
};
export default App;

329
src/ai/cypher-generator.ts Normal file
View file

@ -0,0 +1,329 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { HumanMessage, SystemMessage } from 'npm:@langchain/core/messages';
import type { LLMService, LLMConfig } from './llm-service.ts';
import type { KnowledgeGraph } from '../core/graph/types.ts';
export interface CypherQuery {
cypher: string;
explanation: string;
confidence: number;
warnings?: string[];
}
export interface CypherGenerationOptions {
maxRetries?: number;
includeExamples?: boolean;
strictMode?: boolean;
}
export class CypherGenerator {
private llmService: LLMService;
private graphSchema: string = '';
// Common Cypher patterns and examples
private static readonly CYPHER_EXAMPLES = [
{
question: "What functions are in the main.py file?",
cypher: "MATCH (f:File {name: 'main.py'})-[:CONTAINS]->(func:Function) RETURN func.name"
},
{
question: "Which functions call the authenticate function?",
cypher: "MATCH (caller)-[:CALLS]->(target:Function {name: 'authenticate'}) RETURN caller.name"
},
{
question: "Show me all classes in the project",
cypher: "MATCH (c:Class) RETURN c.name, c.filePath"
},
{
question: "What modules import numpy?",
cypher: "MATCH (m:Module)-[:IMPORTS]->(lib) WHERE lib.name CONTAINS 'numpy' RETURN m.name"
}
];
constructor(llmService: LLMService) {
this.llmService = llmService;
}
/**
* Update the graph schema for better query generation
*/
public updateSchema(graph: KnowledgeGraph): void {
this.graphSchema = this.generateSchemaDescription(graph);
}
/**
* Generate a Cypher query from natural language
*/
public async generateQuery(
question: string,
llmConfig: LLMConfig,
options: CypherGenerationOptions = {}
): Promise<CypherQuery> {
const { maxRetries = 2, includeExamples = true, strictMode = false } = options;
let lastError: string | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const systemPrompt = this.buildSystemPrompt(includeExamples, strictMode, lastError);
const userPrompt = this.buildUserPrompt(question);
const messages = [
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt)
];
const response = await this.llmService.chat(llmConfig, messages);
const result = this.parseResponse(response.content);
// Validate the generated query
const validation = this.validateQuery(result.cypher);
if (!validation.isValid) {
lastError = validation.error!;
if (attempt < maxRetries) {
console.warn(`Query validation failed (attempt ${attempt + 1}): ${validation.error}`);
continue;
}
}
return {
...result,
warnings: validation.warnings
};
} catch (error) {
lastError = error instanceof Error ? error.message : 'Unknown error';
if (attempt < maxRetries) {
console.warn(`Query generation failed (attempt ${attempt + 1}): ${lastError}`);
continue;
}
throw new Error(`Failed to generate Cypher query after ${maxRetries + 1} attempts: ${lastError}`);
}
}
throw new Error('Unexpected error in query generation');
}
/**
* Build the system prompt with schema and examples
*/
private buildSystemPrompt(includeExamples: boolean, strictMode: boolean, lastError?: string | null): string {
let prompt = `You are a Cypher query expert for a code knowledge graph. Your task is to convert natural language questions into valid Cypher queries.
GRAPH SCHEMA:
${this.graphSchema}
NODE TYPES:
- Project: Root project node
- Folder: Directory containers
- File: Source code files
- Module: Python modules (.py files)
- Class: Class definitions
- Function: Function definitions
- Method: Class method definitions
- Variable: Variable declarations
RELATIONSHIP TYPES:
- CONTAINS: Hierarchical containment (Project->Folder, Folder->File, File->Function, etc.)
- CALLS: Function/method calls between code entities
- INHERITS: Class inheritance relationships
- IMPORTS: Module import relationships
- OVERRIDES: Method override relationships
IMPORTANT RULES:
1. Always use MATCH patterns to find nodes
2. Use WHERE clauses for filtering by properties
3. Node properties include: name, filePath, startLine, endLine, type
4. Return meaningful information, not just node IDs
5. Use case-insensitive matching when appropriate (e.g., WHERE toLower(n.name) CONTAINS toLower('search'))
6. Prefer specific node types over generic matches
7. Always return results in a readable format`;
if (includeExamples) {
prompt += `\n\nEXAMPLES:`;
for (const example of CypherGenerator.CYPHER_EXAMPLES) {
prompt += `\nQ: "${example.question}"\nA: ${example.cypher}\n`;
}
}
if (strictMode) {
prompt += `\n\nSTRICT MODE: Only generate queries that exactly match the schema. Do not make assumptions about node properties that aren't explicitly defined.`;
}
if (lastError) {
prompt += `\n\nPREVIOUS ERROR: The last query attempt failed with: "${lastError}". Please fix this issue in your new query.`;
}
prompt += `\n\nRESPONSE FORMAT:
Provide your response in this exact JSON format:
{
"cypher": "your cypher query here",
"explanation": "brief explanation of what the query does",
"confidence": 0.85
}
The confidence should be a number between 0 and 1 indicating how confident you are in the query.`;
return prompt;
}
/**
* Build the user prompt with the question
*/
private buildUserPrompt(question: string): string {
return `Please convert this question to a Cypher query: "${question}"`;
}
/**
* Parse the LLM response to extract Cypher query
*/
private parseResponse(response: string): { cypher: string; explanation: string; confidence: number } {
try {
// Try to extract JSON from the response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
return {
cypher: parsed.cypher || '',
explanation: parsed.explanation || '',
confidence: parsed.confidence || 0.5
};
}
// Fallback: try to extract Cypher from code blocks
const cypherMatch = response.match(/```(?:cypher)?\s*(.*?)\s*```/s);
if (cypherMatch) {
return {
cypher: cypherMatch[1].trim(),
explanation: 'Generated Cypher query',
confidence: 0.7
};
}
// Last resort: use the entire response as cypher
return {
cypher: response.trim(),
explanation: 'Raw LLM response',
confidence: 0.3
};
} catch (error) {
throw new Error(`Failed to parse LLM response: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Validate the generated Cypher query
*/
private validateQuery(cypher: string): { isValid: boolean; error?: string; warnings?: string[] } {
const warnings: string[] = [];
if (!cypher || cypher.trim().length === 0) {
return { isValid: false, error: 'Empty query generated' };
}
// Basic syntax checks
const upperCypher = cypher.toUpperCase();
// Must have MATCH or CREATE or other valid starting keywords
if (!upperCypher.match(/^\s*(MATCH|CREATE|MERGE|WITH|RETURN|CALL|SHOW)/)) {
return { isValid: false, error: 'Query must start with a valid Cypher keyword (MATCH, CREATE, etc.)' };
}
// Check for balanced parentheses
const openParens = (cypher.match(/\(/g) || []).length;
const closeParens = (cypher.match(/\)/g) || []).length;
if (openParens !== closeParens) {
return { isValid: false, error: 'Unbalanced parentheses in query' };
}
// Check for balanced brackets
const openBrackets = (cypher.match(/\[/g) || []).length;
const closeBrackets = (cypher.match(/\]/g) || []).length;
if (openBrackets !== closeBrackets) {
return { isValid: false, error: 'Unbalanced brackets in query' };
}
// Check for balanced braces
const openBraces = (cypher.match(/\{/g) || []).length;
const closeBraces = (cypher.match(/\}/g) || []).length;
if (openBraces !== closeBraces) {
return { isValid: false, error: 'Unbalanced braces in query' };
}
// Warn about potentially expensive operations
if (upperCypher.includes('MATCH ()') || upperCypher.includes('MATCH (*)')) {
warnings.push('Query matches all nodes - this could be expensive');
}
if (!upperCypher.includes('RETURN') && !upperCypher.includes('DELETE') && !upperCypher.includes('SET')) {
warnings.push('Query does not return results');
}
return { isValid: true, warnings };
}
/**
* Generate a schema description from the knowledge graph
*/
private generateSchemaDescription(graph: KnowledgeGraph): string {
const nodeTypes = new Set<string>();
const relationshipTypes = new Set<string>();
const nodeProperties = new Map<string, Set<string>>();
// Analyze nodes
graph.nodes.forEach(node => {
nodeTypes.add(node.label);
if (!nodeProperties.has(node.label)) {
nodeProperties.set(node.label, new Set());
}
Object.keys(node.properties).forEach(prop => {
nodeProperties.get(node.label)!.add(prop);
});
});
// Analyze relationships
graph.relationships.forEach(rel => {
relationshipTypes.add(rel.type);
});
let schema = `NODES (${graph.nodes.length} total):\n`;
for (const nodeType of Array.from(nodeTypes).sort()) {
const props = nodeProperties.get(nodeType);
const propList = props ? Array.from(props).sort().join(', ') : 'none';
const count = graph.nodes.filter(n => n.label === nodeType).length;
schema += `- ${nodeType} (${count}): ${propList}\n`;
}
schema += `\nRELATIONSHIPS (${graph.relationships.length} total):\n`;
for (const relType of Array.from(relationshipTypes).sort()) {
const count = graph.relationships.filter(r => r.type === relType).length;
schema += `- ${relType} (${count})\n`;
}
return schema;
}
/**
* Get the current schema description
*/
public getSchema(): string {
return this.graphSchema;
}
/**
* Clean and format a Cypher query
*/
public cleanQuery(cypher: string): string {
return cypher
.trim()
.replace(/\s+/g, ' ')
.replace(/\s*([(),\[\]{}])\s*/g, '$1')
.replace(/\s*([=<>!]+)\s*/g, ' $1 ')
.replace(/\s+/g, ' ')
.trim();
}
}

16
src/ai/index.ts Normal file
View file

@ -0,0 +1,16 @@
export { LLMService, type LLMProvider, type LLMConfig, type ChatResponse } from './llm-service.ts';
export { CypherGenerator, type CypherQuery, type CypherGenerationOptions } from './cypher-generator.ts';
export {
RAGOrchestrator,
type RAGContext,
type RAGResponse,
type RAGOptions,
type ToolResult,
type ReasoningStep
} from './orchestrator.ts';
export {
LangChainRAGOrchestrator,
type LangChainRAGContext,
type LangChainRAGResponse,
type LangChainRAGOptions
} from './langchain-orchestrator.ts';

View file

@ -0,0 +1,348 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { createReactAgent } from 'npm:@langchain/langgraph/prebuilt';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { MemorySaver } from 'npm:@langchain/langgraph';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { tool } from 'npm:@langchain/core/tools';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { z } from 'npm:zod';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { SystemMessage } from 'npm:@langchain/core/messages';
import type { LLMService, LLMConfig } from './llm-service.ts';
import type { CypherGenerator } from './cypher-generator.ts';
import type { KnowledgeGraph } from '../core/graph/types.ts';
export interface LangChainRAGContext {
graph: KnowledgeGraph;
fileContents: Map<string, string>;
}
export interface LangChainRAGResponse {
answer: string;
sources: string[];
confidence: number;
toolCalls: Array<{
tool: string;
input: any;
output: string;
}>;
}
export interface LangChainRAGOptions {
maxIterations?: number;
temperature?: number;
enableMemory?: boolean;
threadId?: string;
}
export class LangChainRAGOrchestrator {
private llmService: LLMService;
private cypherGenerator: CypherGenerator;
private context: LangChainRAGContext | null = null;
private agent: any = null;
private memory: any = null;
constructor(llmService: LLMService, cypherGenerator: CypherGenerator) {
this.llmService = llmService;
this.cypherGenerator = cypherGenerator;
this.memory = new MemorySaver();
}
/**
* Set the current context and initialize the agent
*/
public async setContext(context: LangChainRAGContext, llmConfig: LLMConfig): Promise<void> {
this.context = context;
this.cypherGenerator.updateSchema(context.graph);
// Create LangChain-compliant tools
const tools = this.createTools(llmConfig);
// Get the LLM from our service
const llm = this.llmService.getChatModel(llmConfig);
// Create system message for ReAct behavior
const systemMessage = this.buildSystemMessage();
// Create the ReAct agent using LangGraph
this.agent = createReactAgent({
llm,
tools,
messageModifier: systemMessage,
checkpointSaver: this.memory
});
}
/**
* Answer a question using the LangChain ReAct agent
*/
public async answerQuestion(
question: string,
options: LangChainRAGOptions = {}
): Promise<LangChainRAGResponse> {
if (!this.agent || !this.context) {
throw new Error('Agent not initialized. Call setContext() first.');
}
const {
maxIterations = 10,
enableMemory = false,
threadId = 'default'
} = options;
try {
const config = enableMemory
? {
configurable: { thread_id: threadId },
recursionLimit: maxIterations
}
: { recursionLimit: maxIterations };
// Stream the agent execution
const stream = await this.agent.stream(
{ messages: [{ role: "user", content: question }] },
config
);
let finalAnswer = '';
const toolCalls: Array<{ tool: string; input: any; output: string }> = [];
const sources: string[] = [];
// Process the stream
for await (const chunk of stream) {
if (chunk.agent) {
const lastMessage = chunk.agent.messages[chunk.agent.messages.length - 1];
if (lastMessage.content) {
finalAnswer = lastMessage.content;
}
} else if (chunk.tools) {
const toolMessage = chunk.tools.messages[chunk.tools.messages.length - 1];
if (toolMessage.tool_calls) {
toolMessage.tool_calls.forEach((toolCall: any) => {
toolCalls.push({
tool: toolCall.name,
input: toolCall.args,
output: toolMessage.content || ''
});
// Extract sources from tool calls
if (toolCall.name === 'get_code_content') {
sources.push(toolCall.args.filePath);
}
});
}
}
}
// Calculate confidence based on successful tool usage
const confidence = this.calculateConfidence(toolCalls);
return {
answer: finalAnswer || 'I was unable to find a complete answer to your question.',
sources: Array.from(new Set(sources)),
confidence,
toolCalls
};
} catch (error) {
throw new Error(`LangChain RAG orchestration failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Create LangChain-compliant tools
*/
private createTools(llmConfig: LLMConfig) {
const queryGraphTool = tool(
async (input: { question: string }) => {
try {
const cypherQuery = await this.cypherGenerator.generateQuery(input.question, llmConfig);
const mockResults = this.simulateGraphQuery(cypherQuery.cypher);
return `Query: ${cypherQuery.cypher}\n\nResults:\n${mockResults}\n\nExplanation: ${cypherQuery.explanation}`;
} catch (error) {
return `Error generating graph query: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
},
{
name: "query_graph",
description: "Query the code knowledge graph using natural language. Use this to find information about code structure, relationships, functions, classes, etc.",
schema: z.object({
question: z.string().describe("Natural language question about the codebase")
})
}
);
const getCodeContentTool = tool(
async (input: { filePath: string }) => {
if (!this.context) {
return 'No context available';
}
const content = this.context.fileContents.get(input.filePath);
if (!content) {
// Try to find similar file paths
const similarFiles = Array.from(this.context.fileContents.keys())
.filter(path => path.includes(input.filePath) || input.filePath.includes(path))
.slice(0, 3);
if (similarFiles.length > 0) {
return `File not found. Similar files available: ${similarFiles.join(', ')}`;
}
return 'File not found';
}
return `File: ${input.filePath}\n\n${content}`;
},
{
name: "get_code_content",
description: "Retrieve the source code content of a specific file. Use this when you need to examine the actual code implementation.",
schema: z.object({
filePath: z.string().describe("The path to the file whose content you want to retrieve")
})
}
);
const searchFilesTool = tool(
async (input: { pattern: string }) => {
if (!this.context) {
return 'No context available';
}
const matchingFiles: string[] = [];
const lowerPattern = input.pattern.toLowerCase();
// Search in file paths
for (const filePath of this.context.fileContents.keys()) {
if (filePath.toLowerCase().includes(lowerPattern)) {
matchingFiles.push(filePath);
}
}
// Search in file contents
for (const [filePath, content] of this.context.fileContents.entries()) {
if (!matchingFiles.includes(filePath) &&
content.toLowerCase().includes(lowerPattern)) {
matchingFiles.push(filePath);
}
}
return matchingFiles.length > 0
? `Found ${matchingFiles.length} files:\n${matchingFiles.slice(0, 10).join('\n')}${matchingFiles.length > 10 ? '\n... and more' : ''}`
: 'No files found matching the pattern';
},
{
name: "search_files",
description: "Search for files matching a pattern or containing specific text. Use this to find relevant files in the codebase.",
schema: z.object({
pattern: z.string().describe("Search pattern or text to look for in file names or content")
})
}
);
return [queryGraphTool, getCodeContentTool, searchFilesTool];
}
/**
* Build system message for ReAct behavior
*/
private buildSystemMessage(): SystemMessage {
const systemPrompt = `You are an expert code analyst that helps users understand codebases by using available tools.
You have access to the following tools:
1. query_graph: Query the code knowledge graph using natural language
2. get_code_content: Retrieve the source code content of a specific file
3. search_files: Search for files matching a pattern or containing specific text
IMPORTANT GUIDELINES:
- Always think step by step about what information you need
- Use tools to gather factual information before providing answers
- Base your responses ONLY on information retrieved from tools
- If you cannot find information, say so explicitly
- Provide helpful, accurate answers about the codebase structure and functionality
- When referencing code, always cite the specific files you examined
Your goal is to provide accurate, evidence-based answers about the codebase using the available tools.`;
return new SystemMessage(systemPrompt);
}
/**
* Simulate graph query execution (same as before)
*/
private simulateGraphQuery(cypher: string): string {
if (!this.context) return 'No context available';
const upperCypher = cypher.toUpperCase();
if (upperCypher.includes('FUNCTION') && upperCypher.includes('RETURN')) {
const functions = this.context.graph.nodes
.filter(n => n.label === 'Function')
.slice(0, 5)
.map(n => `${n.properties.name} (${n.properties.filePath})`)
.join('\n');
return functions || 'No functions found';
}
if (upperCypher.includes('CLASS') && upperCypher.includes('RETURN')) {
const classes = this.context.graph.nodes
.filter(n => n.label === 'Class')
.slice(0, 5)
.map(n => `${n.properties.name} (${n.properties.filePath})`)
.join('\n');
return classes || 'No classes found';
}
if (upperCypher.includes('FILE') && upperCypher.includes('RETURN')) {
const files = this.context.graph.nodes
.filter(n => n.label === 'File')
.slice(0, 5)
.map(n => n.properties.name)
.join('\n');
return files || 'No files found';
}
return 'Query executed successfully (simulated)';
}
/**
* Calculate confidence based on tool usage
*/
private calculateConfidence(toolCalls: Array<{ tool: string; input: any; output: string }>): number {
if (toolCalls.length === 0) return 0.3;
const successfulCalls = toolCalls.filter(call =>
!call.output.includes('Error') &&
!call.output.includes('not found') &&
call.output.length > 10
).length;
const baseConfidence = 0.5;
const toolBonus = (successfulCalls / toolCalls.length) * 0.4;
return Math.min(0.95, Math.max(0.1, baseConfidence + toolBonus));
}
/**
* Get current context information
*/
public getContextInfo(): { nodeCount: number; fileCount: number; hasContext: boolean; hasAgent: boolean } {
return {
nodeCount: this.context?.graph.nodes.length || 0,
fileCount: this.context?.fileContents.size || 0,
hasContext: !!this.context,
hasAgent: !!this.agent
};
}
/**
* Clear memory for a specific thread
*/
public async clearMemory(threadId: string): Promise<void> {
if (this.memory) {
// Note: MemorySaver doesn't have a direct clear method in the current API
// This would need to be implemented based on the specific memory backend
console.log(`Memory clearing not implemented for thread: ${threadId}`);
}
}
}

250
src/ai/llm-service.ts Normal file
View file

@ -0,0 +1,250 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { ChatOpenAI } from 'npm:@langchain/openai';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { ChatAnthropic } from 'npm:@langchain/anthropic';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { ChatGoogleGenerativeAI } from 'npm:@langchain/google-genai';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import type { BaseMessage } from 'npm:@langchain/core/messages';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import type { BaseChatModel } from 'npm:@langchain/core/language_models/chat_models';
export type LLMProvider = 'openai' | 'anthropic' | 'gemini';
export interface LLMConfig {
provider: LLMProvider;
apiKey: string;
model?: string;
temperature?: number;
maxTokens?: number;
maxRetries?: number;
}
export interface ChatResponse {
content: string;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
model?: string;
finishReason?: string;
}
export class LLMService {
private models: Map<string, BaseChatModel> = new Map();
private defaultConfig: Partial<LLMConfig> = {
temperature: 0.1,
maxTokens: 4000,
maxRetries: 3
};
// Default models for each provider
private static readonly DEFAULT_MODELS: Record<LLMProvider, string> = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-haiku-20240307',
gemini: 'gemini-1.5-flash'
};
constructor() {}
/**
* Initialize or get a chat model for the specified provider
*/
public getChatModel(config: LLMConfig): BaseChatModel {
const cacheKey = this.getCacheKey(config);
if (this.models.has(cacheKey)) {
return this.models.get(cacheKey)!;
}
const model = this.createChatModel(config);
this.models.set(cacheKey, model);
return model;
}
/**
* Send a chat message and get a response
*/
public async chat(
config: LLMConfig,
messages: BaseMessage[],
options?: { stream?: boolean }
): Promise<ChatResponse> {
try {
const model = this.getChatModel(config);
if (options?.stream) {
// For streaming, we'd need to handle this differently
// For now, we'll just use regular invoke
console.warn('Streaming not implemented yet, falling back to regular invoke');
}
const response = await model.invoke(messages);
return {
content: response.content as string,
usage: response.response_metadata?.usage ? {
promptTokens: response.response_metadata.usage.prompt_tokens || 0,
completionTokens: response.response_metadata.usage.completion_tokens || 0,
totalTokens: response.response_metadata.usage.total_tokens || 0
} : undefined,
model: config.model || LLMService.DEFAULT_MODELS[config.provider],
finishReason: response.response_metadata?.finish_reason
};
} catch (error) {
throw new Error(`LLM chat failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Validate API key format for different providers
*/
public validateApiKey(provider: LLMProvider, apiKey: string): boolean {
if (!apiKey || apiKey.trim().length === 0) {
return false;
}
switch (provider) {
case 'openai':
return apiKey.startsWith('sk-') && apiKey.length > 20;
case 'anthropic':
return apiKey.startsWith('sk-ant-') && apiKey.length > 20;
case 'gemini':
return apiKey.length > 20; // Google API keys don't have a consistent prefix
default:
return false;
}
}
/**
* Get available models for a provider
*/
public getAvailableModels(provider: LLMProvider): string[] {
switch (provider) {
case 'openai':
return [
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'gpt-4',
'gpt-3.5-turbo'
];
case 'anthropic':
return [
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307'
];
case 'gemini':
return [
'gemini-1.5-pro',
'gemini-1.5-flash',
'gemini-1.0-pro'
];
default:
return [];
}
}
/**
* Get provider display name
*/
public getProviderDisplayName(provider: LLMProvider): string {
switch (provider) {
case 'openai':
return 'OpenAI';
case 'anthropic':
return 'Anthropic';
case 'gemini':
return 'Google Gemini';
default:
return provider;
}
}
/**
* Test connection with the provider
*/
public async testConnection(config: LLMConfig): Promise<{ success: boolean; error?: string }> {
try {
const model = this.createChatModel(config);
// Send a simple test message
const testMessages = [{
role: 'user' as const,
content: 'Hello, this is a connection test. Please respond with "OK".'
}];
const response = await model.invoke(testMessages);
return {
success: true
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection test failed'
};
}
}
/**
* Clear cached models (useful for updating API keys)
*/
public clearCache(): void {
this.models.clear();
}
/**
* Create a chat model instance based on the provider
*/
private createChatModel(config: LLMConfig): BaseChatModel {
const mergedConfig = { ...this.defaultConfig, ...config };
const model = mergedConfig.model || LLMService.DEFAULT_MODELS[config.provider];
switch (config.provider) {
case 'openai':
return new ChatOpenAI({
apiKey: config.apiKey,
model,
temperature: mergedConfig.temperature,
maxTokens: mergedConfig.maxTokens,
maxRetries: mergedConfig.maxRetries,
timeout: 30000
});
case 'anthropic':
return new ChatAnthropic({
apiKey: config.apiKey,
model,
temperature: mergedConfig.temperature,
maxTokens: mergedConfig.maxTokens,
maxRetries: mergedConfig.maxRetries,
timeout: 30000
});
case 'gemini':
return new ChatGoogleGenerativeAI({
apiKey: config.apiKey,
model,
temperature: mergedConfig.temperature,
maxOutputTokens: mergedConfig.maxTokens,
maxRetries: mergedConfig.maxRetries
});
default:
throw new Error(`Unsupported provider: ${config.provider}`);
}
}
/**
* Generate cache key for model instances
*/
private getCacheKey(config: LLMConfig): string {
const model = config.model || LLMService.DEFAULT_MODELS[config.provider];
return `${config.provider}:${model}:${config.apiKey.slice(-8)}:${config.temperature}:${config.maxTokens}`;
}
}

480
src/ai/orchestrator.ts Normal file
View file

@ -0,0 +1,480 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import { HumanMessage, SystemMessage, AIMessage } from 'npm:@langchain/core/messages';
import type { LLMService, LLMConfig } from './llm-service.ts';
import type { CypherGenerator, CypherQuery } from './cypher-generator.ts';
import type { KnowledgeGraph, GraphNode } from '../core/graph/types.ts';
export interface RAGContext {
graph: KnowledgeGraph;
fileContents: Map<string, string>;
}
export interface ToolResult {
toolName: string;
input: string;
output: string;
success: boolean;
error?: string;
}
export interface ReasoningStep {
step: number;
thought: string;
action: string;
actionInput: string;
observation: string;
toolResult?: ToolResult;
}
export interface RAGResponse {
answer: string;
reasoning: ReasoningStep[];
cypherQueries: CypherQuery[];
confidence: number;
sources: string[];
}
export interface RAGOptions {
maxReasoningSteps?: number;
includeReasoning?: boolean;
strictMode?: boolean;
temperature?: number;
}
export class RAGOrchestrator {
private llmService: LLMService;
private cypherGenerator: CypherGenerator;
private context: RAGContext | null = null;
constructor(llmService: LLMService, cypherGenerator: CypherGenerator) {
this.llmService = llmService;
this.cypherGenerator = cypherGenerator;
}
/**
* Set the current context (graph and file contents)
*/
public setContext(context: RAGContext): void {
this.context = context;
this.cypherGenerator.updateSchema(context.graph);
}
/**
* Answer a question using ReAct pattern
*/
public async answerQuestion(
question: string,
llmConfig: LLMConfig,
options: RAGOptions = {}
): Promise<RAGResponse> {
if (!this.context) {
throw new Error('Context not set. Call setContext() first.');
}
const {
maxReasoningSteps = 5,
includeReasoning = true,
strictMode = false,
temperature = 0.1
} = options;
const reasoning: ReasoningStep[] = [];
const cypherQueries: CypherQuery[] = [];
const sources: string[] = [];
// Enhanced LLM config for reasoning
const reasoningConfig: LLMConfig = {
...llmConfig,
temperature: temperature
};
let currentStep = 1;
let finalAnswer = '';
let confidence = 0.5;
try {
// Initial system prompt for ReAct
const systemPrompt = this.buildReActSystemPrompt(strictMode);
const conversation = [new SystemMessage(systemPrompt)];
// Add the user question
conversation.push(new HumanMessage(`Question: ${question}`));
while (currentStep <= maxReasoningSteps) {
// Get reasoning from LLM
const response = await this.llmService.chat(reasoningConfig, conversation);
const reasoning_step = this.parseReasoningStep(response.content, currentStep);
reasoning.push(reasoning_step);
// Check if we have a final answer
if (reasoning_step.action.toLowerCase().includes('final_answer')) {
finalAnswer = reasoning_step.actionInput;
confidence = this.calculateConfidence(reasoning, cypherQueries);
break;
}
// Execute the action
let toolResult: ToolResult | null = null;
try {
if (reasoning_step.action.toLowerCase().includes('query_graph')) {
toolResult = await this.executeGraphQuery(reasoning_step.actionInput, reasoningConfig);
if (toolResult.success) {
cypherQueries.push(...this.extractCypherQueries(toolResult));
}
} else if (reasoning_step.action.toLowerCase().includes('get_code')) {
toolResult = await this.getCodeContent(reasoning_step.actionInput);
if (toolResult.success) {
sources.push(...this.extractSources(toolResult));
}
} else if (reasoning_step.action.toLowerCase().includes('search_files')) {
toolResult = await this.searchFiles(reasoning_step.actionInput);
} else {
toolResult = {
toolName: 'unknown',
input: reasoning_step.actionInput,
output: 'Unknown action type',
success: false,
error: `Unknown action: ${reasoning_step.action}`
};
}
} catch (error) {
toolResult = {
toolName: reasoning_step.action,
input: reasoning_step.actionInput,
output: '',
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
// Update the reasoning step with tool result
reasoning_step.observation = toolResult.output;
reasoning_step.toolResult = toolResult;
// Add the tool result to conversation
conversation.push(new AIMessage(response.content));
conversation.push(new HumanMessage(`Observation: ${toolResult.output}`));
currentStep++;
}
// If we didn't get a final answer, generate one based on the reasoning
if (!finalAnswer && reasoning.length > 0) {
const summaryPrompt = this.buildSummaryPrompt(question, reasoning);
conversation.push(new HumanMessage(summaryPrompt));
const summaryResponse = await this.llmService.chat(reasoningConfig, conversation);
finalAnswer = summaryResponse.content;
confidence = Math.max(0.3, confidence - 0.2); // Lower confidence for incomplete reasoning
}
return {
answer: finalAnswer || 'I was unable to find a complete answer to your question.',
reasoning: includeReasoning ? reasoning : [],
cypherQueries,
confidence,
sources: Array.from(new Set(sources)) // Remove duplicates
};
} catch (error) {
throw new Error(`RAG orchestration failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Build the ReAct system prompt
*/
private buildReActSystemPrompt(strictMode: boolean): string {
const prompt = `You are an expert code analyst using a ReAct (Reasoning + Acting) approach to answer questions about a codebase.
You have access to the following tools:
1. query_graph(question): Query the code knowledge graph using natural language
2. get_code(file_path): Retrieve the source code content of a specific file
3. search_files(pattern): Search for files matching a pattern or containing specific text
IMPORTANT INSTRUCTIONS:
- Always think step by step using the format: Thought: [your reasoning]
- Then specify an action using: Action: [tool_name]
- Provide the input using: Action Input: [input for the tool]
- After receiving an observation, continue reasoning or provide a final answer
- Use Final Answer: [your answer] when you have sufficient information
- Base your answers ONLY on the information retrieved from tools
- Do not make assumptions or hallucinate information
- If you cannot find information, say so explicitly
RESPONSE FORMAT:
Thought: [Your reasoning about what to do next]
Action: [query_graph, get_code, search_files, or Final Answer]
Action Input: [The input for the action]
After receiving an Observation, continue with:
Thought: [Your analysis of the observation]
Action: [Next action or Final Answer]
Action Input: [Input for next action or your final answer]
${strictMode ? '\nSTRICT MODE: Only use information explicitly found in the tools. Do not infer or assume anything.' : ''}
Remember: Your goal is to provide accurate, evidence-based answers about the codebase.`;
return prompt;
}
/**
* Parse a reasoning step from LLM response
*/
private parseReasoningStep(response: string, stepNumber: number): ReasoningStep {
const thoughtMatch = response.match(/Thought:\s*(.*?)(?=\n|Action:|$)/s);
const actionMatch = response.match(/Action:\s*(.*?)(?=\n|Action Input:|$)/s);
const inputMatch = response.match(/Action Input:\s*(.*?)(?=\n|$)/s);
return {
step: stepNumber,
thought: thoughtMatch?.[1]?.trim() || 'No thought provided',
action: actionMatch?.[1]?.trim() || 'unknown',
actionInput: inputMatch?.[1]?.trim() || '',
observation: '' // Will be filled after tool execution
};
}
/**
* Execute a graph query using the Cypher generator
*/
private async executeGraphQuery(question: string, llmConfig: LLMConfig): Promise<ToolResult> {
try {
const cypherQuery = await this.cypherGenerator.generateQuery(question, llmConfig);
// For now, we'll simulate query execution since we don't have a real graph database
// In a real implementation, this would execute the Cypher query against Neo4j or similar
const mockResults = this.simulateGraphQuery(cypherQuery.cypher);
return {
toolName: 'query_graph',
input: question,
output: `Query: ${cypherQuery.cypher}\n\nResults:\n${mockResults}\n\nExplanation: ${cypherQuery.explanation}`,
success: true
};
} catch (error) {
return {
toolName: 'query_graph',
input: question,
output: '',
success: false,
error: error instanceof Error ? error.message : 'Query execution failed'
};
}
}
/**
* Get code content from a file
*/
private async getCodeContent(filePath: string): Promise<ToolResult> {
if (!this.context) {
return {
toolName: 'get_code',
input: filePath,
output: '',
success: false,
error: 'No context available'
};
}
const content = this.context.fileContents.get(filePath);
if (!content) {
// Try to find similar file paths
const similarFiles = Array.from(this.context.fileContents.keys())
.filter(path => path.includes(filePath) || filePath.includes(path))
.slice(0, 3);
if (similarFiles.length > 0) {
return {
toolName: 'get_code',
input: filePath,
output: `File not found. Similar files available: ${similarFiles.join(', ')}`,
success: false,
error: 'File not found'
};
}
return {
toolName: 'get_code',
input: filePath,
output: 'File not found',
success: false,
error: 'File not found'
};
}
return {
toolName: 'get_code',
input: filePath,
output: `File: ${filePath}\n\n${content}`,
success: true
};
}
/**
* Search for files matching a pattern
*/
private async searchFiles(pattern: string): Promise<ToolResult> {
if (!this.context) {
return {
toolName: 'search_files',
input: pattern,
output: '',
success: false,
error: 'No context available'
};
}
const matchingFiles: string[] = [];
const lowerPattern = pattern.toLowerCase();
// Search in file paths
for (const filePath of this.context.fileContents.keys()) {
if (filePath.toLowerCase().includes(lowerPattern)) {
matchingFiles.push(filePath);
}
}
// Search in file contents
for (const [filePath, content] of this.context.fileContents.entries()) {
if (!matchingFiles.includes(filePath) &&
content.toLowerCase().includes(lowerPattern)) {
matchingFiles.push(filePath);
}
}
return {
toolName: 'search_files',
input: pattern,
output: matchingFiles.length > 0
? `Found ${matchingFiles.length} files:\n${matchingFiles.slice(0, 10).join('\n')}${matchingFiles.length > 10 ? '\n... and more' : ''}`
: 'No files found matching the pattern',
success: true
};
}
/**
* Simulate graph query execution (placeholder for real implementation)
*/
private simulateGraphQuery(cypher: string): string {
if (!this.context) return 'No context available';
// Simple simulation based on common patterns
const upperCypher = cypher.toUpperCase();
if (upperCypher.includes('FUNCTION') && upperCypher.includes('RETURN')) {
const functions = this.context.graph.nodes
.filter(n => n.label === 'Function')
.slice(0, 5)
.map(n => `${n.properties.name} (${n.properties.filePath})`)
.join('\n');
return functions || 'No functions found';
}
if (upperCypher.includes('CLASS') && upperCypher.includes('RETURN')) {
const classes = this.context.graph.nodes
.filter(n => n.label === 'Class')
.slice(0, 5)
.map(n => `${n.properties.name} (${n.properties.filePath})`)
.join('\n');
return classes || 'No classes found';
}
if (upperCypher.includes('FILE') && upperCypher.includes('RETURN')) {
const files = this.context.graph.nodes
.filter(n => n.label === 'File')
.slice(0, 5)
.map(n => n.properties.name)
.join('\n');
return files || 'No files found';
}
return 'Query executed successfully (simulated)';
}
/**
* Extract Cypher queries from tool results
*/
private extractCypherQueries(toolResult: ToolResult): CypherQuery[] {
const queries: CypherQuery[] = [];
const queryMatch = toolResult.output.match(/Query: (.*?)(?=\n|$)/);
if (queryMatch) {
queries.push({
cypher: queryMatch[1],
explanation: 'Generated during reasoning',
confidence: 0.8
});
}
return queries;
}
/**
* Extract sources from tool results
*/
private extractSources(toolResult: ToolResult): string[] {
const sources: string[] = [];
const fileMatch = toolResult.output.match(/File: (.*?)(?=\n|$)/);
if (fileMatch) {
sources.push(fileMatch[1]);
}
return sources;
}
/**
* Calculate confidence based on reasoning quality
*/
private calculateConfidence(reasoning: ReasoningStep[], queries: CypherQuery[]): number {
let confidence = 0.5;
// Boost confidence for successful tool usage
const successfulSteps = reasoning.filter(step => step.toolResult?.success).length;
confidence += (successfulSteps / reasoning.length) * 0.3;
// Boost confidence for high-quality queries
const avgQueryConfidence = queries.length > 0
? queries.reduce((sum, q) => sum + q.confidence, 0) / queries.length
: 0.5;
confidence += avgQueryConfidence * 0.2;
// Cap at reasonable bounds
return Math.min(0.95, Math.max(0.1, confidence));
}
/**
* Build summary prompt for incomplete reasoning
*/
private buildSummaryPrompt(question: string, reasoning: ReasoningStep[]): string {
const observations = reasoning
.map(step => `Step ${step.step}: ${step.observation}`)
.join('\n');
return `Based on the following observations from your reasoning process, please provide a final answer to the question: "${question}"
Observations:
${observations}
Final Answer:`;
}
/**
* Get current context information
*/
public getContextInfo(): { nodeCount: number; fileCount: number; hasContext: boolean } {
if (!this.context) {
return { nodeCount: 0, fileCount: 0, hasContext: false };
}
return {
nodeCount: this.context.graph.nodes.length,
fileCount: this.context.fileContents.size,
hasContext: true
};
}
}

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

36
src/core/graph/types.ts Normal file
View file

@ -0,0 +1,36 @@
export type NodeLabel =
| 'Project'
| 'Package'
| 'Module'
| 'Folder'
| 'File'
| 'Class'
| 'Function'
| 'Method'
| 'Variable';
export interface GraphNode {
id: string;
label: NodeLabel;
properties: Record<string, unknown>;
}
export type RelationshipType =
| 'CONTAINS'
| 'CALLS'
| 'INHERITS'
| 'OVERRIDES'
| 'IMPORTS';
export interface GraphRelationship {
id: string;
type: RelationshipType;
source: string;
target: string;
properties?: Record<string, unknown>;
}
export interface KnowledgeGraph {
nodes: GraphNode[];
relationships: GraphRelationship[];
}

View file

@ -0,0 +1,386 @@
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.ts';
import { generateId } from '../../lib/utils.ts';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import type Parser from 'npm:web-tree-sitter';
export interface CallResolutionInput {
graph: KnowledgeGraph;
astCache: Map<string, any>;
fileContents: Map<string, string>;
}
interface FunctionCall {
callerFilePath: string;
callerFunction: string;
calledName: string;
callType: 'function' | 'method' | 'attribute';
line: number;
column: number;
isChained?: boolean;
objectName?: string;
}
interface ImportInfo {
filePath: string;
importedName: string;
alias?: string;
fromModule: string;
importType: 'function' | 'class' | 'module' | 'attribute';
}
const BUILTIN_FUNCTIONS = new Set([
'print', 'len', 'str', 'int', 'float', 'bool', 'list', 'dict', 'tuple', 'set',
'range', 'enumerate', 'zip', 'map', 'filter', 'sum', 'max', 'min', 'abs',
'round', 'sorted', 'reversed', 'any', 'all', 'type', 'isinstance', 'hasattr',
'getattr', 'setattr', 'delattr', 'dir', 'vars', 'id', 'hash', 'repr',
'open', 'input', 'format', 'exec', 'eval', 'compile', 'globals', 'locals'
]);
export class CallProcessor {
private importCache: Map<string, ImportInfo[]> = new Map();
private functionNodes: Map<string, GraphNode> = new Map();
public async process(input: CallResolutionInput): Promise<void> {
const { graph, astCache, fileContents } = input;
// Build function node lookup for fast resolution
this.buildFunctionNodeLookup(graph);
// Extract imports from all files first
for (const [filePath, ast] of astCache) {
if (this.isPythonFile(filePath)) {
const content = fileContents.get(filePath);
if (content && ast) {
await this.extractImports(filePath, ast, content);
}
}
}
// Process function calls in all files
for (const [filePath, ast] of astCache) {
if (this.isPythonFile(filePath)) {
const content = fileContents.get(filePath);
if (content && ast) {
await this.processFunctionCalls(graph, filePath, ast, content);
}
}
}
}
private buildFunctionNodeLookup(graph: KnowledgeGraph): void {
for (const node of graph.nodes) {
if (node.label === 'Function' || node.label === 'Method') {
const filePath = node.properties.filePath as string;
const functionName = node.properties.name as string;
const key = `${filePath}:${functionName}`;
this.functionNodes.set(key, node);
}
}
}
private async extractImports(filePath: string, ast: any, content: string): Promise<void> {
const imports: ImportInfo[] = [];
this.traverseNode(ast.rootNode, (node: any) => {
if (node.type === 'import_statement') {
this.processImportStatement(node, imports, filePath);
} else if (node.type === 'import_from_statement') {
this.processImportFromStatement(node, imports, filePath);
}
});
this.importCache.set(filePath, imports);
}
private processImportStatement(node: any, imports: ImportInfo[], filePath: string): void {
const nameNode = node.childForFieldName('name');
if (nameNode) {
const moduleName = nameNode.text;
// Handle aliased imports (import module as alias)
const asNode = node.children.find((child: any) => child.type === 'as_pattern');
const alias = asNode ? asNode.childForFieldName('alias')?.text : undefined;
imports.push({
filePath,
importedName: moduleName,
alias,
fromModule: moduleName,
importType: 'module'
});
}
}
private processImportFromStatement(node: any, imports: ImportInfo[], filePath: string): void {
const moduleNameNode = node.childForFieldName('module_name');
const importListNode = node.children.find((child: any) => child.type === 'import_list');
if (moduleNameNode && importListNode) {
const moduleName = moduleNameNode.text;
this.traverseNode(importListNode, (child: any) => {
if (child.type === 'dotted_name' || child.type === 'identifier') {
const importedName = child.text;
imports.push({
filePath,
importedName,
fromModule: moduleName,
importType: 'function' // Default assumption, could be refined
});
} else if (child.type === 'as_pattern') {
const nameNode = child.childForFieldName('name');
const aliasNode = child.childForFieldName('alias');
if (nameNode && aliasNode) {
imports.push({
filePath,
importedName: nameNode.text,
alias: aliasNode.text,
fromModule: moduleName,
importType: 'function'
});
}
}
});
}
}
private async processFunctionCalls(
graph: KnowledgeGraph,
filePath: string,
ast: any,
content: string
): Promise<void> {
const functionCalls: FunctionCall[] = [];
let currentFunction: string | null = null;
this.traverseNode(ast.rootNode, (node: any) => {
// Track current function context
if (node.type === 'function_definition') {
const nameNode = node.childForFieldName('name');
if (nameNode) {
currentFunction = nameNode.text;
}
}
// Find function calls
if (node.type === 'call') {
const callInfo = this.extractCallInfo(node, filePath, currentFunction, content);
if (callInfo) {
functionCalls.push(callInfo);
}
}
});
// Resolve calls and create relationships
for (const call of functionCalls) {
await this.resolveAndCreateCallRelationship(graph, call);
}
}
private extractCallInfo(
callNode: any,
filePath: string,
currentFunction: string | null,
content: string
): FunctionCall | null {
const functionNode = callNode.childForFieldName('function');
if (!functionNode) return null;
const position = callNode.startPosition;
const line = position.row + 1;
const column = position.column + 1;
if (functionNode.type === 'identifier') {
// Simple function call: func()
return {
callerFilePath: filePath,
callerFunction: currentFunction || '<module>',
calledName: functionNode.text,
callType: 'function',
line,
column
};
} else if (functionNode.type === 'attribute') {
// Method call: obj.method()
const objectNode = functionNode.childForFieldName('object');
const attributeNode = functionNode.childForFieldName('attribute');
if (objectNode && attributeNode) {
return {
callerFilePath: filePath,
callerFunction: currentFunction || '<module>',
calledName: attributeNode.text,
callType: 'method',
line,
column,
objectName: objectNode.text
};
}
}
return null;
}
private async resolveAndCreateCallRelationship(
graph: KnowledgeGraph,
call: FunctionCall
): Promise<void> {
const callerNode = this.findCallerNode(graph, call);
if (!callerNode) return;
// Try different resolution strategies
const targetNode =
this.resolveBuiltinFunction(call) ||
this.resolveImportedFunction(call) ||
this.resolveLocalFunction(call) ||
this.resolveMethodCall(graph, call);
if (targetNode) {
const relationship = this.createCallRelationship(callerNode.id, targetNode.id, call);
graph.relationships.push(relationship);
}
}
private findCallerNode(graph: KnowledgeGraph, call: FunctionCall): GraphNode | null {
if (call.callerFunction === '<module>') {
// Call is at module level
return graph.nodes.find(node =>
node.label === 'Module' &&
node.properties.path === call.callerFilePath
) || null;
} else {
// Call is within a function
const key = `${call.callerFilePath}:${call.callerFunction}`;
return this.functionNodes.get(key) || null;
}
}
private resolveBuiltinFunction(call: FunctionCall): GraphNode | null {
if (BUILTIN_FUNCTIONS.has(call.calledName)) {
return this.getOrCreateBuiltinNode(call.calledName);
}
return null;
}
private resolveImportedFunction(call: FunctionCall): GraphNode | null {
const imports = this.importCache.get(call.callerFilePath) || [];
for (const importInfo of imports) {
// Check direct import match
if (importInfo.importedName === call.calledName || importInfo.alias === call.calledName) {
// For now, create a placeholder node for imported functions
// In a full implementation, this would resolve to actual function nodes
return this.getOrCreateImportedNode(call.calledName, importInfo.fromModule);
}
// Check module.function pattern
if (call.callType === 'method' && call.objectName === importInfo.importedName) {
return this.getOrCreateImportedNode(call.calledName, importInfo.fromModule);
}
}
return null;
}
private resolveLocalFunction(call: FunctionCall): GraphNode | null {
const key = `${call.callerFilePath}:${call.calledName}`;
return this.functionNodes.get(key) || null;
}
private resolveMethodCall(graph: KnowledgeGraph, call: FunctionCall): GraphNode | null {
if (call.callType !== 'method') return null;
// Look for method in the same file
const methods = graph.nodes.filter(node =>
node.label === 'Method' &&
node.properties.filePath === call.callerFilePath &&
node.properties.name === call.calledName
);
// Return first matching method (could be refined with class context)
return methods[0] || null;
}
private getOrCreateBuiltinNode(functionName: string): GraphNode {
const id = generateId('builtin', functionName);
return {
id,
label: 'Function',
properties: {
name: functionName,
type: 'builtin',
language: 'python'
}
};
}
private getOrCreateImportedNode(functionName: string, moduleName: string): GraphNode {
const id = generateId('imported', `${moduleName}.${functionName}`);
return {
id,
label: 'Function',
properties: {
name: functionName,
module: moduleName,
type: 'imported',
language: 'python'
}
};
}
private createCallRelationship(
callerId: string,
targetId: string,
call: FunctionCall
): GraphRelationship {
return {
id: generateId('relationship', `${callerId}-calls-${targetId}`),
type: 'CALLS',
source: callerId,
target: targetId,
properties: {
callType: call.callType,
line: call.line,
column: call.column,
...(call.objectName && { objectName: call.objectName })
}
};
}
private traverseNode(node: any, callback: (node: any) => void): void {
callback(node);
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) {
this.traverseNode(child, callback);
}
}
}
private isPythonFile(filePath: string): boolean {
return filePath.endsWith('.py');
}
public getImportInfo(filePath: string): ImportInfo[] {
return this.importCache.get(filePath) || [];
}
public getCallStats(): { totalCalls: number; callTypes: Record<string, number> } {
const stats = {
totalCalls: 0,
callTypes: {
function: 0,
method: 0,
builtin: 0,
imported: 0,
local: 0
}
};
// This would be populated during processing
return stats;
}
}

View file

@ -0,0 +1,220 @@
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.ts';
import { initTreeSitter, loadPythonParser } from '../tree-sitter/parser-loader.ts';
import { generateId } from '../../lib/utils.ts';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import type Parser from 'npm:web-tree-sitter';
export interface ParsingInput {
filePaths: string[];
fileContents: Map<string, string>;
}
interface ParsedDefinition {
type: 'function' | 'class' | 'method';
name: string;
startLine: number;
endLine: number;
parentClass?: string;
}
export class ParsingProcessor {
private parser: Parser | null = null;
private astCache: Map<string, any> = new Map();
public async process(graph: KnowledgeGraph, input: ParsingInput): Promise<void> {
const { filePaths, fileContents } = input;
// Initialize Tree-sitter parser
await this.initializeParser();
// Process each Python file
for (const filePath of filePaths) {
if (this.isPythonFile(filePath)) {
const content = fileContents.get(filePath);
if (content) {
await this.parseFile(graph, filePath, content);
}
}
}
}
private async initializeParser(): Promise<void> {
if (!this.parser) {
this.parser = await initTreeSitter();
const pythonLanguage = await loadPythonParser();
this.parser.setLanguage(pythonLanguage);
}
}
private async parseFile(graph: KnowledgeGraph, filePath: string, content: string): Promise<void> {
if (!this.parser) {
throw new Error('Parser not initialized');
}
try {
// Parse the file and cache the AST
const tree = this.parser.parse(content);
this.astCache.set(filePath, tree);
// Create module node
const moduleNode = this.createModuleNode(filePath);
graph.nodes.push(moduleNode);
// Extract definitions from the AST
const definitions = this.extractDefinitions(tree.rootNode, content);
// Create nodes for definitions and establish relationships
for (const definition of definitions) {
const definitionNode = this.createDefinitionNode(filePath, definition);
graph.nodes.push(definitionNode);
// Create CONTAINS relationship between module and definition
const relationship = this.createContainsRelationship(moduleNode.id, definitionNode.id);
graph.relationships.push(relationship);
// If it's a method, create relationship with its class
if (definition.type === 'method' && definition.parentClass) {
const classNode = this.findClassNode(graph, filePath, definition.parentClass);
if (classNode) {
const methodRelationship = this.createContainsRelationship(classNode.id, definitionNode.id);
graph.relationships.push(methodRelationship);
}
}
}
} catch (error) {
console.warn(`Failed to parse file ${filePath}:`, error);
}
}
private createModuleNode(filePath: string): GraphNode {
const pathParts = filePath.split('/');
const fileName = pathParts[pathParts.length - 1];
const moduleName = fileName.replace(/\.py$/, '');
return {
id: generateId('module', filePath),
label: 'Module',
properties: {
name: moduleName,
path: filePath,
language: 'python'
}
};
}
private createDefinitionNode(filePath: string, definition: ParsedDefinition): GraphNode {
const nodeLabel = definition.type === 'function' ? 'Function' :
definition.type === 'method' ? 'Method' : 'Class';
return {
id: generateId(definition.type, `${filePath}:${definition.name}`),
label: nodeLabel as any,
properties: {
name: definition.name,
filePath,
startLine: definition.startLine,
endLine: definition.endLine,
...(definition.parentClass && { parentClass: definition.parentClass })
}
};
}
private createContainsRelationship(sourceId: string, targetId: string): GraphRelationship {
return {
id: generateId('relationship', `${sourceId}-contains-${targetId}`),
type: 'CONTAINS',
source: sourceId,
target: targetId
};
}
private extractDefinitions(node: any, content: string): ParsedDefinition[] {
const definitions: ParsedDefinition[] = [];
const lines = content.split('\n');
this.traverseNode(node, (currentNode: any) => {
if (currentNode.type === 'function_definition') {
const nameNode = currentNode.childForFieldName('name');
if (nameNode) {
definitions.push({
type: 'function',
name: nameNode.text,
startLine: currentNode.startPosition.row + 1,
endLine: currentNode.endPosition.row + 1
});
}
} else if (currentNode.type === 'class_definition') {
const nameNode = currentNode.childForFieldName('name');
if (nameNode) {
const className = nameNode.text;
definitions.push({
type: 'class',
name: className,
startLine: currentNode.startPosition.row + 1,
endLine: currentNode.endPosition.row + 1
});
// Extract methods from the class
const methods = this.extractMethodsFromClass(currentNode, className);
definitions.push(...methods);
}
}
});
return definitions;
}
private extractMethodsFromClass(classNode: any, className: string): ParsedDefinition[] {
const methods: ParsedDefinition[] = [];
this.traverseNode(classNode, (node: any) => {
if (node.type === 'function_definition') {
const nameNode = node.childForFieldName('name');
if (nameNode) {
methods.push({
type: 'method',
name: nameNode.text,
startLine: node.startPosition.row + 1,
endLine: node.endPosition.row + 1,
parentClass: className
});
}
}
});
return methods;
}
private traverseNode(node: any, callback: (node: any) => void): void {
callback(node);
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) {
this.traverseNode(child, callback);
}
}
}
private findClassNode(graph: KnowledgeGraph, filePath: string, className: string): GraphNode | null {
return graph.nodes.find(node =>
node.label === 'Class' &&
node.properties.name === className &&
node.properties.filePath === filePath
) || null;
}
private isPythonFile(filePath: string): boolean {
return filePath.endsWith('.py');
}
public getAst(filePath: string): any {
return this.astCache.get(filePath);
}
public getCachedAsts(): Map<string, any> {
return new Map(this.astCache);
}
}

View file

@ -0,0 +1,80 @@
import type { KnowledgeGraph } from '../graph/types.ts';
import { StructureProcessor } from './structure-processor.ts';
import { ParsingProcessor } from './parsing-processor.ts';
import { CallProcessor } from './call-processor.ts';
export interface PipelineInput {
projectRoot: string;
projectName: string;
filePaths: string[];
fileContents: Map<string, string>;
}
export class GraphPipeline {
private structureProcessor: StructureProcessor;
private parsingProcessor: ParsingProcessor;
private callProcessor: CallProcessor;
constructor() {
this.structureProcessor = new StructureProcessor();
this.parsingProcessor = new ParsingProcessor();
this.callProcessor = new CallProcessor();
}
public async run(input: PipelineInput): Promise<KnowledgeGraph> {
const { projectRoot, projectName, filePaths, fileContents } = input;
const graph: KnowledgeGraph = {
nodes: [],
relationships: []
};
console.log(`Starting 3-pass ingestion for project: ${projectName}`);
// Pass 1: Structure Analysis
console.log('Pass 1: Analyzing project structure...');
await this.structureProcessor.process(graph, {
projectRoot,
projectName,
filePaths
});
// Pass 2: Code Parsing and Definition Extraction
console.log('Pass 2: Parsing code and extracting definitions...');
await this.parsingProcessor.process(graph, {
filePaths,
fileContents
});
// Pass 3: Call Resolution
console.log('Pass 3: Resolving function calls and references...');
await this.callProcessor.process({
graph,
astCache: this.parsingProcessor.getCachedAsts(),
fileContents
});
console.log(`Ingestion complete. Graph contains ${graph.nodes.length} nodes and ${graph.relationships.length} relationships.`);
return graph;
}
public getStats(graph: KnowledgeGraph): { nodeStats: Record<string, number>; relationshipStats: Record<string, number> } {
const nodeStats: Record<string, number> = {};
const relationshipStats: Record<string, number> = {};
for (const node of graph.nodes) {
nodeStats[node.label] = (nodeStats[node.label] || 0) + 1;
}
for (const relationship of graph.relationships) {
relationshipStats[relationship.type] = (relationshipStats[relationship.type] || 0) + 1;
}
return { nodeStats, relationshipStats };
}
public getCallStats(): { totalCalls: number; callTypes: Record<string, number> } {
return this.callProcessor.getCallStats();
}
}

View file

@ -0,0 +1,197 @@
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.ts';
import { generateId } from '../../lib/utils.ts';
export interface StructureInput {
projectRoot: string;
projectName: string;
filePaths: string[];
}
export class StructureProcessor {
private nodeIdMap: Map<string, string> = new Map();
public async process(graph: KnowledgeGraph, input: StructureInput): Promise<void> {
const { projectRoot, projectName, filePaths } = input;
// Create project root node
const projectNode = this.createProjectNode(projectName, projectRoot);
graph.nodes.push(projectNode);
// Extract unique folder paths from file paths
const folderPaths = this.extractFolderPaths(filePaths);
// Create folder nodes and establish hierarchy
const folderNodes = this.createFolderNodes(folderPaths);
graph.nodes.push(...folderNodes);
// Create file nodes
const fileNodes = this.createFileNodes(filePaths);
graph.nodes.push(...fileNodes);
// Establish CONTAINS relationships
this.createContainsRelationships(graph, projectNode.id, folderPaths, filePaths);
}
private createProjectNode(projectName: string, projectRoot: string): GraphNode {
const id = generateId('project', projectName);
this.nodeIdMap.set('', id); // Empty path represents project root
return {
id,
label: 'Project',
properties: {
name: projectName,
path: projectRoot,
createdAt: new Date().toISOString()
}
};
}
private extractFolderPaths(filePaths: string[]): string[] {
const folderSet = new Set<string>();
for (const filePath of filePaths) {
const pathParts = filePath.split('/');
// Generate all parent folder paths
for (let i = 1; i < pathParts.length; i++) {
const folderPath = pathParts.slice(0, i).join('/');
if (folderPath) {
folderSet.add(folderPath);
}
}
}
return Array.from(folderSet).sort();
}
private createFolderNodes(folderPaths: string[]): GraphNode[] {
const nodes: GraphNode[] = [];
for (const folderPath of folderPaths) {
const pathParts = folderPath.split('/');
const folderName = pathParts[pathParts.length - 1];
const id = generateId('folder', folderPath);
this.nodeIdMap.set(folderPath, id);
nodes.push({
id,
label: 'Folder',
properties: {
name: folderName,
path: folderPath,
depth: pathParts.length
}
});
}
return nodes;
}
private createFileNodes(filePaths: string[]): GraphNode[] {
const nodes: GraphNode[] = [];
for (const filePath of filePaths) {
const pathParts = filePath.split('/');
const fileName = pathParts[pathParts.length - 1];
const fileExtension = this.getFileExtension(fileName);
const id = generateId('file', filePath);
this.nodeIdMap.set(filePath, id);
nodes.push({
id,
label: 'File',
properties: {
name: fileName,
path: filePath,
extension: fileExtension,
isSourceFile: this.isSourceFile(fileExtension)
}
});
}
return nodes;
}
private createContainsRelationships(
graph: KnowledgeGraph,
projectId: string,
folderPaths: string[],
filePaths: string[]
): void {
const relationships: GraphRelationship[] = [];
// Project contains root folders
const rootFolders = folderPaths.filter(path => !path.includes('/'));
for (const rootFolder of rootFolders) {
const folderId = this.nodeIdMap.get(rootFolder);
if (folderId) {
relationships.push({
id: generateId('relationship', `${projectId}-contains-${folderId}`),
type: 'CONTAINS',
source: projectId,
target: folderId
});
}
}
// Folders contain subfolders
for (const folderPath of folderPaths) {
const parentPath = this.getParentPath(folderPath);
const parentId = this.nodeIdMap.get(parentPath);
const folderId = this.nodeIdMap.get(folderPath);
if (parentId && folderId && parentPath !== folderPath) {
relationships.push({
id: generateId('relationship', `${parentId}-contains-${folderId}`),
type: 'CONTAINS',
source: parentId,
target: folderId
});
}
}
// Folders and project contain files
for (const filePath of filePaths) {
const parentPath = this.getParentPath(filePath);
const parentId = this.nodeIdMap.get(parentPath);
const fileId = this.nodeIdMap.get(filePath);
if (parentId && fileId) {
relationships.push({
id: generateId('relationship', `${parentId}-contains-${fileId}`),
type: 'CONTAINS',
source: parentId,
target: fileId
});
}
}
graph.relationships.push(...relationships);
}
private getParentPath(path: string): string {
const pathParts = path.split('/');
if (pathParts.length <= 1) return '';
return pathParts.slice(0, -1).join('/');
}
private getFileExtension(fileName: string): string {
const lastDotIndex = fileName.lastIndexOf('.');
return lastDotIndex !== -1 ? fileName.substring(lastDotIndex) : '';
}
private isSourceFile(extension: string): boolean {
const sourceExtensions = new Set([
'.py', '.js', '.ts', '.tsx', '.jsx', '.java', '.cpp', '.c', '.h', '.hpp',
'.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala'
]);
return sourceExtensions.has(extension);
}
public getNodeId(path: string): string | undefined {
return this.nodeIdMap.get(path);
}
}

View file

@ -0,0 +1,30 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import Parser from "npm:web-tree-sitter";
interface Language {
[key: string]: unknown;
}
let parserInstance: Parser | null = null;
const parserCache = new Map<string, Language>();
export async function initTreeSitter(): Promise<Parser> {
if (parserInstance) return parserInstance;
// Initialize WebAssembly
await Parser.init();
parserInstance = new Parser();
return parserInstance;
}
export async function loadPythonParser(): Promise<Language> {
if (parserCache.has('python')) {
return parserCache.get('python')!;
}
// Load Python language from WASM file
const pythonLang = await Parser.Language.load('/wasm/python/tree-sitter-python.wasm');
parserCache.set('python', pythonLang);
return pythonLang;
}

68
src/index.css Normal file
View file

@ -0,0 +1,68 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

335
src/lib/export.ts Normal file
View file

@ -0,0 +1,335 @@
import type { KnowledgeGraph } from '../core/graph/types.ts';
export interface ExportOptions {
filename?: string;
includeMetadata?: boolean;
prettyPrint?: boolean;
includeTimestamp?: boolean;
}
export interface ExportMetadata {
exportedAt: string;
version: string;
nodeCount: number;
relationshipCount: number;
fileCount?: number;
processingDuration?: number;
}
export interface ExportedGraph {
metadata: ExportMetadata;
graph: KnowledgeGraph;
fileContents?: Record<string, string>;
}
/**
* Export a KnowledgeGraph to JSON format
*/
export function exportGraphToJSON(
graph: KnowledgeGraph,
options: ExportOptions = {},
fileContents?: Map<string, string>,
processingStats?: { duration: number }
): string {
const {
includeMetadata = true,
prettyPrint = true,
includeTimestamp = true
} = options;
let exportData: ExportedGraph | KnowledgeGraph;
if (includeMetadata) {
const metadata: ExportMetadata = {
exportedAt: includeTimestamp ? new Date().toISOString() : '',
version: '1.0.0',
nodeCount: graph.nodes.length,
relationshipCount: graph.relationships.length,
fileCount: fileContents?.size,
processingDuration: processingStats?.duration
};
exportData = {
metadata,
graph,
...(fileContents && { fileContents: Object.fromEntries(fileContents) })
};
} else {
exportData = graph;
}
return JSON.stringify(exportData, null, prettyPrint ? 2 : 0);
}
/**
* Trigger download of a JSON file
*/
export function downloadJSON(content: string, filename: string): void {
try {
// Create blob with JSON content
const blob = new Blob([content], { type: 'application/json' });
// Create download URL
const url = URL.createObjectURL(blob);
// Create temporary download link
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
// Add to DOM, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up URL
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download JSON file:', error);
throw new Error('Failed to download file. Please check your browser permissions.');
}
}
/**
* Generate a default filename for the export
*/
export function generateExportFilename(
projectName?: string,
includeTimestamp: boolean = true
): string {
const baseName = projectName
? `gitnexus-${projectName.replace(/[^a-zA-Z0-9-_]/g, '-')}`
: 'gitnexus-graph';
if (includeTimestamp) {
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.split('.')[0]; // Remove milliseconds
return `${baseName}_${timestamp}.json`;
}
return `${baseName}.json`;
}
/**
* Export and download a KnowledgeGraph
*/
export function exportAndDownloadGraph(
graph: KnowledgeGraph,
options: ExportOptions & { projectName?: string } = {},
fileContents?: Map<string, string>,
processingStats?: { duration: number }
): void {
const {
filename,
projectName,
includeTimestamp = true,
...exportOptions
} = options;
try {
// Generate filename if not provided
const finalFilename = filename || generateExportFilename(projectName, includeTimestamp);
// Export to JSON
const jsonContent = exportGraphToJSON(graph, exportOptions, fileContents, processingStats);
// Trigger download
downloadJSON(jsonContent, finalFilename);
console.log(`Successfully exported graph to ${finalFilename}`);
} catch (error) {
console.error('Export failed:', error);
throw error;
}
}
/**
* Calculate export file size (approximate)
*/
export function calculateExportSize(
graph: KnowledgeGraph,
includeFileContents: boolean = false,
fileContents?: Map<string, string>
): { sizeBytes: number; sizeFormatted: string } {
// Create a sample export to measure size
const sampleExport = exportGraphToJSON(
graph,
{ includeMetadata: true, prettyPrint: false },
includeFileContents ? fileContents : undefined
);
const sizeBytes = new Blob([sampleExport]).size;
const sizeFormatted = formatFileSize(sizeBytes);
return { sizeBytes, sizeFormatted };
}
/**
* Format file size in human-readable format
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Validate if a graph can be exported
*/
export function validateGraphForExport(graph: KnowledgeGraph): {
isValid: boolean;
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// Check if graph exists
if (!graph) {
errors.push('Graph is null or undefined');
return { isValid: false, errors, warnings };
}
// Check if graph has nodes
if (!graph.nodes || graph.nodes.length === 0) {
warnings.push('Graph has no nodes');
}
// Check if graph has relationships
if (!graph.relationships || graph.relationships.length === 0) {
warnings.push('Graph has no relationships');
}
// Check for invalid node IDs
const nodeIds = new Set(graph.nodes.map(n => n.id));
if (nodeIds.size !== graph.nodes.length) {
errors.push('Graph contains duplicate node IDs');
}
// Check for invalid relationships
graph.relationships.forEach((rel, index) => {
if (!nodeIds.has(rel.source)) {
errors.push(`Relationship ${index} has invalid source node ID: ${rel.source}`);
}
if (!nodeIds.has(rel.target)) {
errors.push(`Relationship ${index} has invalid target node ID: ${rel.target}`);
}
});
// Check for very large exports
const approximateSize = JSON.stringify(graph).length;
if (approximateSize > 50 * 1024 * 1024) { // 50MB
warnings.push('Export file will be very large (>50MB). Consider filtering the data.');
}
return {
isValid: errors.length === 0,
errors,
warnings
};
}
/**
* Import a KnowledgeGraph from JSON string
*/
export function importGraphFromJSON(jsonString: string): {
graph: KnowledgeGraph;
metadata?: ExportMetadata;
fileContents?: Map<string, string>;
} {
try {
const parsed = JSON.parse(jsonString);
// Check if it's an exported graph with metadata
if (parsed.metadata && parsed.graph) {
const result: {
graph: KnowledgeGraph;
metadata: ExportMetadata;
fileContents?: Map<string, string>;
} = {
graph: parsed.graph,
metadata: parsed.metadata
};
// Convert file contents back to Map if present
if (parsed.fileContents) {
result.fileContents = new Map(Object.entries(parsed.fileContents));
}
return result;
}
// Assume it's a raw graph
return { graph: parsed };
} catch (error) {
throw new Error(`Failed to import graph: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
}
}
/**
* Create a filtered export of the graph
*/
export function createFilteredExport(
graph: KnowledgeGraph,
filters: {
nodeTypes?: string[];
relationshipTypes?: string[];
filePatterns?: string[];
maxNodes?: number;
}
): KnowledgeGraph {
const { nodeTypes, relationshipTypes, filePatterns, maxNodes } = filters;
let filteredNodes = graph.nodes;
let filteredRelationships = graph.relationships;
// Filter by node types
if (nodeTypes && nodeTypes.length > 0) {
filteredNodes = filteredNodes.filter(node => nodeTypes.includes(node.label));
}
// Filter by file patterns
if (filePatterns && filePatterns.length > 0) {
filteredNodes = filteredNodes.filter(node => {
const filePath = node.properties.filePath as string;
if (!filePath) return true; // Keep nodes without file paths
return filePatterns.some(pattern => {
const regex = new RegExp(pattern.replace(/\*/g, '.*'), 'i');
return regex.test(filePath);
});
});
}
// Limit number of nodes
if (maxNodes && filteredNodes.length > maxNodes) {
filteredNodes = filteredNodes.slice(0, maxNodes);
}
// Get filtered node IDs
const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
// Filter relationships to only include those between filtered nodes
filteredRelationships = filteredRelationships.filter(rel =>
filteredNodeIds.has(rel.source) && filteredNodeIds.has(rel.target)
);
// Filter by relationship types
if (relationshipTypes && relationshipTypes.length > 0) {
filteredRelationships = filteredRelationships.filter(rel =>
relationshipTypes.includes(rel.type)
);
}
return {
nodes: filteredNodes,
relationships: filteredRelationships
};
}

15
src/lib/utils.ts Normal file
View file

@ -0,0 +1,15 @@
export function generateId(type: string, identifier: string): string {
const timestamp = Date.now();
const hash = simpleHash(identifier);
return `${type}_${hash}_${timestamp}`;
}
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}

192
src/lib/workerUtils.ts Normal file
View file

@ -0,0 +1,192 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import * as Comlink from 'npm:comlink';
import type { IngestionWorker, IngestionProgress, IngestionResult } from '../workers/ingestion.worker.ts';
import type { PipelineInput } from '../core/ingestion/pipeline.ts';
export interface WorkerProxy {
processRepository(input: PipelineInput): Promise<IngestionResult>;
processFiles(projectName: string, files: { path: string; content: string }[]): Promise<IngestionResult>;
validateRepository(input: PipelineInput): Promise<{ valid: boolean; errors: string[] }>;
getWorkerInfo(): Promise<{ version: string; capabilities: string[] }>;
setProgressCallback(callback: (progress: IngestionProgress) => void): Promise<void>;
terminate(): Promise<void>;
}
export class IngestionWorkerManager {
private worker: Worker | null = null;
private workerProxy: WorkerProxy | null = null;
private isInitialized = false;
public async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
try {
// Create the worker
this.worker = new Worker(
new URL('../workers/ingestion.worker.ts', import.meta.url).href,
{
type: 'module',
name: 'ingestion-worker'
}
);
// Wrap with Comlink
this.workerProxy = Comlink.wrap<IngestionWorker>(this.worker) as WorkerProxy;
this.isInitialized = true;
console.log('Ingestion worker initialized successfully');
} catch (error) {
throw new Error(`Failed to initialize ingestion worker: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
public async processRepository(input: PipelineInput): Promise<IngestionResult> {
await this.ensureInitialized();
try {
return await this.workerProxy!.processRepository(input);
} catch (error) {
throw new Error(`Worker processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
public async processFiles(
projectName: string,
files: { path: string; content: string }[]
): Promise<IngestionResult> {
await this.ensureInitialized();
try {
return await this.workerProxy!.processFiles(projectName, files);
} catch (error) {
throw new Error(`Worker file processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
public async validateRepository(input: PipelineInput): Promise<{ valid: boolean; errors: string[] }> {
await this.ensureInitialized();
try {
return await this.workerProxy!.validateRepository(input);
} catch (error) {
return {
valid: false,
errors: [`Validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`]
};
}
}
public async setProgressCallback(callback: (progress: IngestionProgress) => void): Promise<void> {
await this.ensureInitialized();
try {
// Wrap callback with Comlink.proxy to allow it to be called from worker
const proxiedCallback = Comlink.proxy(callback);
await this.workerProxy!.setProgressCallback(proxiedCallback);
} catch (error) {
console.warn('Failed to set progress callback:', error);
}
}
public async getWorkerInfo(): Promise<{ version: string; capabilities: string[] }> {
await this.ensureInitialized();
try {
return await this.workerProxy!.getWorkerInfo();
} catch (error) {
throw new Error(`Failed to get worker info: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
public async terminate(): Promise<void> {
if (!this.isInitialized || !this.worker) {
return;
}
try {
// Notify worker to cleanup
if (this.workerProxy) {
await this.workerProxy.terminate();
}
// Terminate the worker
this.worker.terminate();
// Cleanup references
this.worker = null;
this.workerProxy = null;
this.isInitialized = false;
console.log('Ingestion worker terminated');
} catch (error) {
console.warn('Error during worker termination:', error);
// Force terminate if cleanup fails
if (this.worker) {
this.worker.terminate();
this.worker = null;
this.workerProxy = null;
this.isInitialized = false;
}
}
}
public isWorkerReady(): boolean {
return this.isInitialized && this.worker !== null && this.workerProxy !== null;
}
private async ensureInitialized(): Promise<void> {
if (!this.isInitialized) {
await this.initialize();
}
}
}
// Singleton instance for easy access
let workerManager: IngestionWorkerManager | null = null;
export function getIngestionWorker(): IngestionWorkerManager {
if (!workerManager) {
workerManager = new IngestionWorkerManager();
}
return workerManager;
}
export async function createIngestionWorker(): Promise<IngestionWorkerManager> {
const manager = new IngestionWorkerManager();
await manager.initialize();
return manager;
}
// Utility function for processing with automatic cleanup
export async function processWithWorker<T>(
processor: (worker: IngestionWorkerManager) => Promise<T>
): Promise<T> {
const worker = await createIngestionWorker();
try {
return await processor(worker);
} finally {
await worker.terminate();
}
}
// Error handling utilities
export class WorkerError extends Error {
constructor(message: string, public readonly cause?: Error) {
super(message);
this.name = 'WorkerError';
}
}
export function isWorkerSupported(): boolean {
try {
return typeof Worker !== 'undefined';
} catch {
return false;
}
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

242
src/services/github.ts Normal file
View file

@ -0,0 +1,242 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import axios, { type AxiosInstance, type AxiosResponse } from 'npm:axios';
interface GitHubFile {
name: string;
path: string;
sha: string;
size: number;
url: string;
html_url: string;
git_url: string;
download_url: string | null;
type: 'file' | 'dir';
content?: string;
encoding?: string;
}
interface GitHubDirectory {
name: string;
path: string;
sha: string;
size: number;
url: string;
html_url: string;
git_url: string;
download_url: string | null;
type: 'file' | 'dir';
}
interface RateLimitInfo {
limit: number;
remaining: number;
reset: number;
used: number;
}
interface GitHubError {
message: string;
documentation_url?: string;
}
export class GitHubService {
private client: AxiosInstance;
private baseURL = 'https://api.github.com';
private rateLimitInfo: RateLimitInfo | null = null;
constructor(token?: string) {
this.client = axios.create({
baseURL: this.baseURL,
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'GitNexus/1.0',
...(token && { 'Authorization': `Bearer ${token}` })
},
timeout: 30000
});
this.setupInterceptors();
}
private setupInterceptors(): void {
this.client.interceptors.response.use(
(response: AxiosResponse) => {
this.updateRateLimitInfo(response);
return response;
},
(error: { response?: AxiosResponse; message: string }) => {
if (error.response) {
this.updateRateLimitInfo(error.response);
if (error.response.status === 403 && this.isRateLimited()) {
const resetTime = new Date(this.rateLimitInfo!.reset * 1000);
throw new Error(`GitHub API rate limit exceeded. Resets at ${resetTime.toISOString()}`);
}
if (error.response.status === 401) {
throw new Error('GitHub API authentication failed. Please check your token.');
}
if (error.response.status === 404) {
throw new Error('Repository or resource not found.');
}
const githubError: GitHubError = error.response.data;
throw new Error(`GitHub API error: ${githubError.message}`);
}
throw new Error(`Network error: ${error.message}`);
}
);
}
private updateRateLimitInfo(response: AxiosResponse): void {
const headers = response.headers;
if (headers['x-ratelimit-limit']) {
this.rateLimitInfo = {
limit: parseInt(headers['x-ratelimit-limit'], 10),
remaining: parseInt(headers['x-ratelimit-remaining'], 10),
reset: parseInt(headers['x-ratelimit-reset'], 10),
used: parseInt(headers['x-ratelimit-used'], 10)
};
}
}
private isRateLimited(): boolean {
return this.rateLimitInfo !== null && this.rateLimitInfo.remaining === 0;
}
public getRateLimitInfo(): RateLimitInfo | null {
return this.rateLimitInfo;
}
public async checkRateLimit(): Promise<void> {
if (this.isRateLimited()) {
const resetTime = new Date(this.rateLimitInfo!.reset * 1000);
const now = new Date();
if (now < resetTime) {
const waitTime = Math.ceil((resetTime.getTime() - now.getTime()) / 1000);
throw new Error(`Rate limit exceeded. Wait ${waitTime} seconds before making another request.`);
}
}
}
public async getRepositoryContents(
owner: string,
repo: string,
path: string = ''
): Promise<(GitHubFile | GitHubDirectory)[]> {
await this.checkRateLimit();
try {
const response = await this.client.get(`/repos/${owner}/${repo}/contents/${path}`);
if (!Array.isArray(response.data)) {
throw new Error('Expected directory contents, but received a single file.');
}
return response.data as (GitHubFile | GitHubDirectory)[];
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to fetch repository contents');
}
}
public async getFileContent(
owner: string,
repo: string,
path: string
): Promise<string> {
await this.checkRateLimit();
try {
const response = await this.client.get(`/repos/${owner}/${repo}/contents/${path}`);
const file = response.data as GitHubFile;
if (file.type !== 'file') {
throw new Error(`Path ${path} is not a file`);
}
if (!file.content || !file.encoding) {
throw new Error('File content or encoding information missing');
}
if (file.encoding === 'base64') {
try {
return atob(file.content.replace(/\s/g, ''));
} catch {
throw new Error('Failed to decode base64 content');
}
}
return file.content;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to fetch file content');
}
}
public async downloadFileRaw(
owner: string,
repo: string,
path: string
): Promise<string> {
await this.checkRateLimit();
try {
const response = await this.client.get(`/repos/${owner}/${repo}/contents/${path}`);
const file = response.data as GitHubFile;
if (file.type !== 'file' || !file.download_url) {
throw new Error(`Cannot download file: ${path}`);
}
const downloadResponse = await axios.get(file.download_url, {
timeout: 30000,
headers: {
'User-Agent': 'GitNexus/1.0'
}
});
return downloadResponse.data;
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to download file');
}
}
public async getAllFilesRecursively(
owner: string,
repo: string,
path: string = ''
): Promise<GitHubFile[]> {
const allFiles: GitHubFile[] = [];
const items = await this.getRepositoryContents(owner, repo, path);
for (const item of items) {
if (item.type === 'file') {
allFiles.push(item as GitHubFile);
} else if (item.type === 'dir') {
const subFiles = await this.getAllFilesRecursively(owner, repo, item.path);
allFiles.push(...subFiles);
}
}
return allFiles;
}
public getAuthenticationStatus(): { authenticated: boolean; rateLimitInfo: RateLimitInfo | null } {
const authHeader = this.client.defaults.headers['Authorization'];
return {
authenticated: !!authHeader,
rateLimitInfo: this.rateLimitInfo
};
}
}

267
src/services/zip.ts Normal file
View file

@ -0,0 +1,267 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import JSZip from 'npm:jszip';
interface ZipFileEntry {
path: string;
content: string;
isDirectory: boolean;
size: number;
lastModified: Date;
}
interface ExtractionOptions {
maxFileSize?: number;
maxTotalSize?: number;
allowedExtensions?: string[];
excludeDirectories?: boolean;
}
export class ZipService {
private static readonly DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static readonly DEFAULT_MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 100MB
private static readonly TEXT_EXTENSIONS = new Set([
'.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.cpp', '.c', '.h', '.hpp',
'.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala', '.clj',
'.html', '.htm', '.xml', '.css', '.scss', '.sass', '.less', '.json',
'.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.md', '.txt',
'.sql', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd',
'.dockerfile', '.gitignore', '.gitattributes', '.env', '.properties'
]);
constructor() {}
public async extractTextFiles(
file: File,
options: ExtractionOptions = {}
): Promise<Map<string, string>> {
const {
maxFileSize = ZipService.DEFAULT_MAX_FILE_SIZE,
maxTotalSize = ZipService.DEFAULT_MAX_TOTAL_SIZE,
allowedExtensions = Array.from(ZipService.TEXT_EXTENSIONS),
excludeDirectories = true
} = options;
if (!file) {
throw new Error('No file provided');
}
if (file.type !== 'application/zip' && !file.name.toLowerCase().endsWith('.zip')) {
throw new Error('File must be a ZIP archive');
}
try {
const arrayBuffer = await this.fileToArrayBuffer(file);
const zip = new JSZip();
const zipContent = await zip.loadAsync(arrayBuffer);
const extractedFiles = new Map<string, string>();
let totalExtractedSize = 0;
const files = Object.keys(zipContent.files);
for (const filePath of files) {
const zipFile = zipContent.files[filePath];
if (zipFile.dir && excludeDirectories) {
continue;
}
if (zipFile.dir) {
extractedFiles.set(filePath, '');
continue;
}
if (!this.isTextFile(filePath, allowedExtensions)) {
continue;
}
if (zipFile._data && zipFile._data.uncompressedSize > maxFileSize) {
console.warn(`Skipping file ${filePath}: exceeds maximum file size (${maxFileSize} bytes)`);
continue;
}
if (totalExtractedSize + (zipFile._data?.uncompressedSize || 0) > maxTotalSize) {
console.warn(`Stopping extraction: total size would exceed maximum (${maxTotalSize} bytes)`);
break;
}
try {
const content = await zipFile.async('text');
if (content.length > maxFileSize) {
console.warn(`Skipping file ${filePath}: content exceeds maximum file size`);
continue;
}
extractedFiles.set(filePath, content);
totalExtractedSize += content.length;
} catch (fileError) {
console.warn(`Failed to extract file ${filePath}:`, fileError);
continue;
}
}
return extractedFiles;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('corrupt') || error.message.includes('invalid')) {
throw new Error('ZIP file appears to be corrupted or invalid');
}
throw new Error(`Failed to extract ZIP file: ${error.message}`);
}
throw new Error('Unknown error occurred while extracting ZIP file');
}
}
public async getZipFileInfo(file: File): Promise<ZipFileEntry[]> {
if (!file) {
throw new Error('No file provided');
}
try {
const arrayBuffer = await this.fileToArrayBuffer(file);
const zip = new JSZip();
const zipContent = await zip.loadAsync(arrayBuffer);
const fileInfos: ZipFileEntry[] = [];
for (const [path, zipFile] of Object.entries(zipContent.files)) {
const file = zipFile as { dir: boolean; _data?: { uncompressedSize: number }; date?: Date };
fileInfos.push({
path,
content: '', // Don't load content for info request
isDirectory: file.dir,
size: file._data?.uncompressedSize || 0,
lastModified: file.date || new Date()
});
}
return fileInfos.sort((a, b) => a.path.localeCompare(b.path));
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to read ZIP file info: ${error.message}`);
}
throw new Error('Unknown error occurred while reading ZIP file info');
}
}
public async extractSpecificFiles(
file: File,
filePaths: string[]
): Promise<Map<string, string>> {
if (!file) {
throw new Error('No file provided');
}
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths specified');
}
try {
const arrayBuffer = await this.fileToArrayBuffer(file);
const zip = new JSZip();
const zipContent = await zip.loadAsync(arrayBuffer);
const extractedFiles = new Map<string, string>();
for (const filePath of filePaths) {
const zipFile = zipContent.files[filePath];
if (!zipFile) {
console.warn(`File not found in ZIP: ${filePath}`);
continue;
}
if (zipFile.dir) {
extractedFiles.set(filePath, '');
continue;
}
try {
const content = await zipFile.async('text');
extractedFiles.set(filePath, content);
} catch (fileError) {
console.warn(`Failed to extract file ${filePath}:`, fileError);
continue;
}
}
return extractedFiles;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to extract specific files from ZIP: ${error.message}`);
}
throw new Error('Unknown error occurred while extracting specific files');
}
}
public isValidZipFile(file: File): boolean {
if (!file) return false;
return (
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed' ||
file.name.toLowerCase().endsWith('.zip')
);
}
private async fileToArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
resolve(reader.result);
} else {
reject(new Error('Failed to read file as ArrayBuffer'));
}
};
reader.onerror = () => {
reject(new Error('Error reading file'));
};
reader.readAsArrayBuffer(file);
});
}
private isTextFile(filePath: string, allowedExtensions: string[]): boolean {
const extension = this.getFileExtension(filePath);
return allowedExtensions.includes(extension);
}
private getFileExtension(filePath: string): string {
const lastDotIndex = filePath.lastIndexOf('.');
if (lastDotIndex === -1) return '';
return filePath.substring(lastDotIndex).toLowerCase();
}
public getDefaultTextExtensions(): string[] {
return Array.from(ZipService.TEXT_EXTENSIONS);
}
public async validateZipFile(file: File): Promise<{ valid: boolean; error?: string }> {
try {
if (!this.isValidZipFile(file)) {
return { valid: false, error: 'File is not a valid ZIP archive' };
}
const arrayBuffer = await this.fileToArrayBuffer(file);
const zip = new JSZip();
await zip.loadAsync(arrayBuffer);
return { valid: true };
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown validation error'
};
}
}
}

View file

@ -0,0 +1,286 @@
import React, { Component, ReactNode } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
// Update state so the next render will show the fallback UI
return {
hasError: true,
error
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log the error to console for debugging
console.error('ErrorBoundary caught an error:', error);
console.error('Error info:', errorInfo);
// Update state with error details
this.setState({
error,
errorInfo
});
// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// In a production app, you might want to log this to an error reporting service
// logErrorToService(error, errorInfo);
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null
});
};
render() {
if (this.state.hasError) {
// Custom fallback UI
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<div style={containerStyle}>
<div style={errorBoxStyle}>
<div style={iconStyle}>⚠️</div>
<h2 style={titleStyle}>Something went wrong</h2>
<p style={messageStyle}>
We encountered an unexpected error. This might be due to a temporary issue
or an incompatibility with your browser.
</p>
<div style={buttonContainerStyle}>
<button
onClick={this.handleReset}
style={primaryButtonStyle}
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
style={secondaryButtonStyle}
>
Reload Page
</button>
</div>
{/* Error details (collapsible) */}
<details style={detailsStyle}>
<summary style={summaryStyle}>
Technical Details (for developers)
</summary>
<div style={errorDetailsStyle}>
<div style={errorSectionStyle}>
<strong>Error:</strong>
<pre style={preStyle}>
{this.state.error?.toString()}
</pre>
</div>
{this.state.errorInfo && (
<div style={errorSectionStyle}>
<strong>Component Stack:</strong>
<pre style={preStyle}>
{this.state.errorInfo.componentStack}
</pre>
</div>
)}
{this.state.error?.stack && (
<div style={errorSectionStyle}>
<strong>Stack Trace:</strong>
<pre style={preStyle}>
{this.state.error.stack}
</pre>
</div>
)}
</div>
</details>
<div style={helpTextStyle}>
<p>
<strong>What you can do:</strong>
</p>
<ul style={helpListStyle}>
<li>Try refreshing the page</li>
<li>Clear your browser cache and reload</li>
<li>Try a different browser</li>
<li>Check the console for additional error details</li>
</ul>
<p style={reportStyle}>
If this problem persists, please report it with the technical details above.
</p>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
// Styles
const containerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
padding: '20px',
backgroundColor: '#f8f9fa',
fontFamily: 'system-ui, -apple-system, sans-serif'
};
const errorBoxStyle: React.CSSProperties = {
maxWidth: '600px',
width: '100%',
backgroundColor: '#fff',
borderRadius: '8px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '1px solid #e9ecef',
textAlign: 'center'
};
const iconStyle: React.CSSProperties = {
fontSize: '48px',
marginBottom: '16px'
};
const titleStyle: React.CSSProperties = {
fontSize: '24px',
fontWeight: '600',
color: '#dc3545',
margin: '0 0 16px 0'
};
const messageStyle: React.CSSProperties = {
fontSize: '16px',
color: '#666',
lineHeight: '1.5',
margin: '0 0 24px 0'
};
const buttonContainerStyle: React.CSSProperties = {
display: 'flex',
gap: '12px',
justifyContent: 'center',
marginBottom: '24px'
};
const primaryButtonStyle: React.CSSProperties = {
padding: '12px 24px',
backgroundColor: '#007bff',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s ease'
};
const secondaryButtonStyle: React.CSSProperties = {
padding: '12px 24px',
backgroundColor: '#6c757d',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s ease'
};
const detailsStyle: React.CSSProperties = {
textAlign: 'left',
marginTop: '24px',
border: '1px solid #dee2e6',
borderRadius: '4px'
};
const summaryStyle: React.CSSProperties = {
padding: '12px 16px',
backgroundColor: '#f8f9fa',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
borderBottom: '1px solid #dee2e6'
};
const errorDetailsStyle: React.CSSProperties = {
padding: '16px',
fontSize: '12px'
};
const errorSectionStyle: React.CSSProperties = {
marginBottom: '16px'
};
const preStyle: React.CSSProperties = {
backgroundColor: '#f8f9fa',
padding: '12px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '11px',
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace',
color: '#e83e8c',
border: '1px solid #dee2e6',
marginTop: '8px'
};
const helpTextStyle: React.CSSProperties = {
textAlign: 'left',
marginTop: '24px',
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
fontSize: '14px'
};
const helpListStyle: React.CSSProperties = {
margin: '8px 0',
paddingLeft: '20px'
};
const reportStyle: React.CSSProperties = {
fontSize: '12px',
color: '#666',
fontStyle: 'italic',
marginTop: '12px'
};
export default ErrorBoundary;

View file

@ -0,0 +1,532 @@
import React, { useState, useRef, useEffect } from 'react';
import type { KnowledgeGraph } from '../../../core/graph/types.ts';
import { LLMService, type LLMProvider, type LLMConfig } from '../../../ai/llm-service.ts';
import { CypherGenerator } from '../../../ai/cypher-generator.ts';
import { RAGOrchestrator, type RAGResponse, type RAGOptions } from '../../../ai/orchestrator.ts';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
metadata?: {
cypherQueries?: Array<{ cypher: string; explanation: string }>;
sources?: string[];
confidence?: number;
reasoning?: Array<{ step: number; thought: string; action: string }>;
};
}
interface ChatInterfaceProps {
graph: KnowledgeGraph;
fileContents: Map<string, string>;
className?: string;
style?: React.CSSProperties;
}
interface LLMSettings {
provider: LLMProvider;
apiKey: string;
model: string;
temperature: number;
maxTokens: number;
}
const ChatInterface: React.FC<ChatInterfaceProps> = ({
graph,
fileContents,
className = '',
style = {}
}) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showReasoning, setShowReasoning] = useState(false);
// LLM Configuration
const [llmSettings, setLLMSettings] = useState<LLMSettings>({
provider: 'openai',
apiKey: '',
model: 'gpt-4o-mini',
temperature: 0.1,
maxTokens: 4000
});
// Services
const [llmService] = useState(new LLMService());
const [cypherGenerator] = useState(new CypherGenerator(llmService));
const [ragOrchestrator] = useState(new RAGOrchestrator(llmService, cypherGenerator));
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Initialize RAG context when graph or fileContents change
useEffect(() => {
ragOrchestrator.setContext({ graph, fileContents });
}, [graph, fileContents, ragOrchestrator]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim() || isLoading) return;
// Validate API key
if (!llmSettings.apiKey.trim()) {
alert('Please configure your API key in settings');
setShowSettings(true);
return;
}
if (!llmService.validateApiKey(llmSettings.provider, llmSettings.apiKey)) {
alert('Invalid API key format. Please check your settings.');
setShowSettings(true);
return;
}
const userMessage: ChatMessage = {
id: generateId(),
role: 'user',
content: inputValue.trim(),
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
try {
const llmConfig: LLMConfig = {
provider: llmSettings.provider,
apiKey: llmSettings.apiKey,
model: llmSettings.model,
temperature: llmSettings.temperature,
maxTokens: llmSettings.maxTokens
};
const ragOptions: RAGOptions = {
maxReasoningSteps: 5,
includeReasoning: showReasoning,
strictMode: false,
temperature: llmSettings.temperature
};
const response: RAGResponse = await ragOrchestrator.answerQuestion(
userMessage.content,
llmConfig,
ragOptions
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: 'assistant',
content: response.answer,
timestamp: new Date(),
metadata: {
cypherQueries: response.cypherQueries.map(q => ({
cypher: q.cypher,
explanation: q.explanation
})),
sources: response.sources,
confidence: response.confidence,
reasoning: showReasoning ? response.reasoning.map(r => ({
step: r.step,
thought: r.thought,
action: r.action
})) : undefined
}
};
setMessages(prev => [...prev, assistantMessage]);
} catch (error) {
const errorMessage: ChatMessage = {
id: generateId(),
role: 'assistant',
content: `I apologize, but I encountered an error while processing your question: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: new Date()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
// Handle key press in textarea
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as any);
}
};
// Clear conversation
const clearConversation = () => {
setMessages([]);
};
// Generate unique ID
const generateId = () => {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
// Get available models for current provider
const getAvailableModels = () => {
return llmService.getAvailableModels(llmSettings.provider);
};
const containerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
height: '600px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: '#fff',
...style
};
const headerStyle: React.CSSProperties = {
padding: '16px',
borderBottom: '1px solid #eee',
backgroundColor: '#f8f9fa',
borderRadius: '8px 8px 0 0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
};
const messagesStyle: React.CSSProperties = {
flex: 1,
overflowY: 'auto',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px'
};
const inputAreaStyle: React.CSSProperties = {
padding: '16px',
borderTop: '1px solid #eee'
};
const messageStyle = (role: 'user' | 'assistant'): React.CSSProperties => ({
padding: '12px 16px',
borderRadius: '12px',
maxWidth: '80%',
alignSelf: role === 'user' ? 'flex-end' : 'flex-start',
backgroundColor: role === 'user' ? '#007bff' : '#f1f3f4',
color: role === 'user' ? '#fff' : '#333',
wordWrap: 'break-word'
});
const buttonStyle: React.CSSProperties = {
padding: '8px 16px',
border: 'none',
borderRadius: '4px',
backgroundColor: '#007bff',
color: '#fff',
cursor: 'pointer',
fontSize: '14px'
};
const textareaStyle: React.CSSProperties = {
width: '100%',
minHeight: '60px',
padding: '12px',
border: '1px solid #ddd',
borderRadius: '4px',
resize: 'vertical',
fontSize: '14px',
fontFamily: 'inherit'
};
return (
<div className={`chat-interface ${className}`} style={containerStyle}>
{/* Header */}
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: '600' }}>💬</span>
<span style={{ fontSize: '16px', fontWeight: '600' }}>Code Assistant</span>
<span style={{
fontSize: '12px',
color: '#666',
backgroundColor: '#e9ecef',
padding: '2px 8px',
borderRadius: '12px'
}}>
{llmService.getProviderDisplayName(llmSettings.provider)}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setShowReasoning(!showReasoning)}
style={{
...buttonStyle,
backgroundColor: showReasoning ? '#28a745' : '#6c757d',
fontSize: '12px',
padding: '6px 12px'
}}
title="Toggle reasoning display"
>
🧠 Reasoning
</button>
<button
onClick={() => setShowSettings(!showSettings)}
style={{ ...buttonStyle, fontSize: '12px', padding: '6px 12px' }}
title="Settings"
>
⚙️
</button>
<button
onClick={clearConversation}
style={{
...buttonStyle,
backgroundColor: '#dc3545',
fontSize: '12px',
padding: '6px 12px'
}}
title="Clear conversation"
>
🗑️
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div style={{
padding: '16px',
backgroundColor: '#f8f9fa',
borderBottom: '1px solid #eee'
}}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px' }}>LLM Configuration</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
<div>
<label style={{ fontSize: '12px', color: '#666' }}>Provider</label>
<select
value={llmSettings.provider}
onChange={(e) => setLLMSettings(prev => ({
...prev,
provider: e.target.value as LLMProvider,
model: llmService.getAvailableModels(e.target.value as LLMProvider)[0]
}))}
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Google Gemini</option>
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#666' }}>Model</label>
<select
value={llmSettings.model}
onChange={(e) => setLLMSettings(prev => ({ ...prev, model: e.target.value }))}
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
>
{getAvailableModels().map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#666' }}>API Key</label>
<input
type="password"
value={llmSettings.apiKey}
onChange={(e) => setLLMSettings(prev => ({ ...prev, apiKey: e.target.value }))}
placeholder="Enter your API key"
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ fontSize: '12px', color: '#666' }}>
Temperature: {llmSettings.temperature}
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={llmSettings.temperature}
onChange={(e) => setLLMSettings(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: '12px', color: '#666' }}>Max Tokens</label>
<input
type="number"
min="100"
max="8000"
step="100"
value={llmSettings.maxTokens}
onChange={(e) => setLLMSettings(prev => ({ ...prev, maxTokens: parseInt(e.target.value) }))}
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
/>
</div>
</div>
</div>
)}
{/* Messages */}
<div style={messagesStyle}>
{messages.length === 0 && (
<div style={{
textAlign: 'center',
color: '#666',
fontSize: '14px',
padding: '40px 20px'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>💬</div>
<div>Ask me anything about the codebase!</div>
<div style={{ fontSize: '12px', marginTop: '8px', color: '#999' }}>
I can help you understand functions, classes, dependencies, and more.
</div>
</div>
)}
{messages.map((message) => (
<div key={message.id}>
<div style={messageStyle(message.role)}>
<div style={{ marginBottom: message.metadata ? '8px' : '0' }}>
{message.content}
</div>
{/* Metadata */}
{message.metadata && (
<div style={{ fontSize: '12px', opacity: 0.8 }}>
{message.metadata.confidence && (
<div style={{ marginBottom: '4px' }}>
Confidence: {Math.round(message.metadata.confidence * 100)}%
</div>
)}
{message.metadata.sources && message.metadata.sources.length > 0 && (
<div style={{ marginBottom: '4px' }}>
Sources: {message.metadata.sources.join(', ')}
</div>
)}
{message.metadata.cypherQueries && message.metadata.cypherQueries.length > 0 && (
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer' }}>View Queries ({message.metadata.cypherQueries.length})</summary>
{message.metadata.cypherQueries.map((query, index) => (
<div key={index} style={{
marginTop: '4px',
padding: '8px',
backgroundColor: 'rgba(0,0,0,0.1)',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '11px'
}}>
<div><strong>Query:</strong> {query.cypher}</div>
<div><strong>Explanation:</strong> {query.explanation}</div>
</div>
))}
</details>
)}
{message.metadata.reasoning && message.metadata.reasoning.length > 0 && (
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer' }}>View Reasoning ({message.metadata.reasoning.length} steps)</summary>
{message.metadata.reasoning.map((step, index) => (
<div key={index} style={{
marginTop: '4px',
padding: '8px',
backgroundColor: 'rgba(0,0,0,0.1)',
borderRadius: '4px',
fontSize: '11px'
}}>
<div><strong>Step {step.step}:</strong> {step.thought}</div>
<div><strong>Action:</strong> {step.action}</div>
</div>
))}
</details>
)}
</div>
)}
</div>
<div style={{
fontSize: '11px',
color: '#999',
textAlign: message.role === 'user' ? 'right' : 'left',
marginTop: '4px'
}}>
{message.timestamp.toLocaleTimeString()}
</div>
</div>
))}
{isLoading && (
<div style={messageStyle('assistant')}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
width: '16px',
height: '16px',
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
Thinking...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div style={inputAreaStyle}>
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px' }}>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask a question about the code..."
style={textareaStyle}
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !inputValue.trim()}
style={{
...buttonStyle,
minWidth: '80px',
opacity: (isLoading || !inputValue.trim()) ? 0.5 : 1
}}
>
{isLoading ? '...' : 'Send'}
</button>
</form>
</div>
{/* CSS for spinner animation */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
};
export default ChatInterface;

View file

@ -0,0 +1,215 @@
import React, { useState } from 'react';
import ChatInterface from './ChatInterface.tsx';
import { GraphExplorer } from '../graph/index.ts';
import type { KnowledgeGraph } from '../../../core/graph/types.ts';
interface CodeAssistantProps {
graph: KnowledgeGraph;
fileContents: Map<string, string>;
className?: string;
style?: React.CSSProperties;
}
type ViewMode = 'chat' | 'graph' | 'split';
const CodeAssistant: React.FC<CodeAssistantProps> = ({
graph,
fileContents,
className = '',
style = {}
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('split');
const containerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
height: '800px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
overflow: 'hidden',
...style
};
const headerStyle: React.CSSProperties = {
padding: '16px',
backgroundColor: '#fff',
borderBottom: '1px solid #ddd',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
};
const contentStyle: React.CSSProperties = {
flex: 1,
display: 'flex',
overflow: 'hidden'
};
const panelStyle: React.CSSProperties = {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
};
const buttonStyle = (active: boolean): React.CSSProperties => ({
padding: '8px 16px',
border: 'none',
borderRadius: '4px',
backgroundColor: active ? '#007bff' : '#6c757d',
color: '#fff',
cursor: 'pointer',
fontSize: '14px',
marginLeft: '8px'
});
const renderContent = () => {
switch (viewMode) {
case 'chat':
return (
<div style={panelStyle}>
<ChatInterface
graph={graph}
fileContents={fileContents}
style={{ height: '100%' }}
/>
</div>
);
case 'graph':
return (
<div style={panelStyle}>
<GraphExplorer
graph={graph}
style={{ height: '100%' }}
/>
</div>
);
case 'split':
return (
<>
<div style={{ ...panelStyle, marginRight: '8px' }}>
<div style={{
fontSize: '14px',
fontWeight: '600',
padding: '8px 12px',
backgroundColor: '#e9ecef',
borderRadius: '4px 4px 0 0',
marginBottom: '8px'
}}>
💬 AI Assistant
</div>
<ChatInterface
graph={graph}
fileContents={fileContents}
style={{ height: 'calc(100% - 40px)' }}
/>
</div>
<div style={{ ...panelStyle, marginLeft: '8px' }}>
<div style={{
fontSize: '14px',
fontWeight: '600',
padding: '8px 12px',
backgroundColor: '#e9ecef',
borderRadius: '4px 4px 0 0',
marginBottom: '8px'
}}>
🕸️ Knowledge Graph
</div>
<GraphExplorer
graph={graph}
style={{ height: 'calc(100% - 40px)' }}
/>
</div>
</>
);
default:
return null;
}
};
const getStatsText = () => {
const nodeCount = graph.nodes.length;
const relationshipCount = graph.relationships.length;
const fileCount = fileContents.size;
return `${nodeCount} nodes • ${relationshipCount} relationships • ${fileCount} files`;
};
return (
<div className={`code-assistant ${className}`} style={containerStyle}>
{/* Header */}
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px', fontWeight: '600' }}>🤖</span>
<div>
<div style={{ fontSize: '18px', fontWeight: '600' }}>
GitNexus Code Assistant
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{getStatsText()}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: '14px', color: '#666', marginRight: '12px' }}>
View:
</span>
<button
onClick={() => setViewMode('chat')}
style={buttonStyle(viewMode === 'chat')}
>
💬 Chat Only
</button>
<button
onClick={() => setViewMode('graph')}
style={buttonStyle(viewMode === 'graph')}
>
🕸️ Graph Only
</button>
<button
onClick={() => setViewMode('split')}
style={buttonStyle(viewMode === 'split')}
>
📱 Split View
</button>
</div>
</div>
{/* Content */}
<div style={contentStyle}>
{renderContent()}
</div>
{/* Help Text */}
{graph.nodes.length === 0 && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
color: '#666',
fontSize: '16px',
zIndex: 10
}}>
<div style={{ fontSize: '64px', marginBottom: '16px', opacity: 0.3 }}>
🤖
</div>
<div style={{ marginBottom: '8px' }}>
No knowledge graph loaded
</div>
<div style={{ fontSize: '14px', color: '#999' }}>
Please load a project to start using the AI assistant
</div>
</div>
)}
</div>
);
};
export default CodeAssistant;

View file

@ -0,0 +1,2 @@
export { default as ChatInterface } from './ChatInterface.tsx';
export { default as CodeAssistant } from './CodeAssistant.tsx';

View file

@ -0,0 +1,170 @@
import React, { useState } from 'react';
import GraphVisualization from './Visualization.tsx';
import SourceViewer from './SourceViewer.tsx';
import type { KnowledgeGraph } from '../../../core/graph/types.ts';
interface GraphExplorerProps {
graph: KnowledgeGraph;
className?: string;
style?: React.CSSProperties;
}
const GraphExplorer: React.FC<GraphExplorerProps> = ({
graph,
className = '',
style = {}
}) => {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const handleNodeSelect = (nodeId: string | null) => {
setSelectedNodeId(nodeId);
};
const containerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '16px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
...style
};
const graphContainerStyle: React.CSSProperties = {
flex: '1 1 60%',
minHeight: '400px'
};
const sourceContainerStyle: React.CSSProperties = {
flex: '1 1 40%',
minHeight: '300px'
};
const headerStyle: React.CSSProperties = {
fontSize: '18px',
fontWeight: '600',
color: '#333',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px'
};
const statsStyle: React.CSSProperties = {
display: 'flex',
gap: '24px',
fontSize: '14px',
color: '#666',
marginBottom: '16px',
padding: '12px',
backgroundColor: '#fff',
borderRadius: '4px',
border: '1px solid #ddd'
};
const statItemStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '4px'
};
// Calculate graph statistics
const nodeStats = graph.nodes.reduce((acc, node) => {
acc[node.label] = (acc[node.label] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const relationshipStats = graph.relationships.reduce((acc, rel) => {
acc[rel.type] = (acc[rel.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return (
<div className={`graph-explorer ${className}`} style={containerStyle}>
{/* Header */}
<div>
<div style={headerStyle}>
<span>🔍</span>
<span>Code Knowledge Graph Explorer</span>
</div>
{/* Statistics */}
<div style={statsStyle}>
<div style={statItemStyle}>
<span>📊</span>
<span><strong>{graph.nodes.length}</strong> nodes</span>
</div>
<div style={statItemStyle}>
<span>🔗</span>
<span><strong>{graph.relationships.length}</strong> relationships</span>
</div>
{Object.entries(nodeStats).map(([type, count]) => (
<div key={type} style={statItemStyle}>
<span>{getNodeTypeIcon(type)}</span>
<span><strong>{count}</strong> {type.toLowerCase()}s</span>
</div>
))}
</div>
</div>
{/* Graph Visualization */}
<div style={graphContainerStyle}>
<div style={{ ...headerStyle, fontSize: '16px', marginBottom: '8px' }}>
<span>🕸️</span>
<span>Interactive Graph</span>
{selectedNodeId && (
<span style={{
fontSize: '14px',
fontWeight: 'normal',
color: '#666',
marginLeft: '8px'
}}>
• Selected: {getSelectedNodeName()}
</span>
)}
</div>
<GraphVisualization
graph={graph}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
style={{ height: '100%' }}
/>
</div>
{/* Source Viewer */}
<div style={sourceContainerStyle}>
<div style={{ ...headerStyle, fontSize: '16px', marginBottom: '8px' }}>
<span>📝</span>
<span>Source Code</span>
</div>
<SourceViewer
graph={graph}
selectedNodeId={selectedNodeId}
style={{ height: '100%' }}
/>
</div>
</div>
);
function getSelectedNodeName(): string {
if (!selectedNodeId) return '';
const node = graph.nodes.find(n => n.id === selectedNodeId);
return node?.properties.name as string || node?.id || '';
}
function getNodeTypeIcon(nodeType: string): string {
switch (nodeType.toLowerCase()) {
case 'project': return '📁';
case 'folder': return '📂';
case 'file': return '📄';
case 'module': return '📦';
case 'function': return '⚡';
case 'method': return '🔧';
case 'class': return '🏗️';
case 'variable': return '📊';
default: return '📄';
}
}
};
export default GraphExplorer;

View file

@ -0,0 +1,286 @@
import React, { useMemo } from 'react';
import type { KnowledgeGraph, GraphNode } from '../../../core/graph/types.ts';
interface SourceViewerProps {
graph: KnowledgeGraph;
selectedNodeId: string | null;
className?: string;
style?: React.CSSProperties;
}
interface SourceInfo {
fileName: string;
filePath: string;
content: string;
startLine?: number;
endLine?: number;
nodeType: string;
nodeName: string;
}
const SourceViewer: React.FC<SourceViewerProps> = ({
graph,
selectedNodeId,
className = '',
style = {}
}) => {
// Find the selected node and extract source information
const sourceInfo = useMemo((): SourceInfo | null => {
if (!selectedNodeId || !graph) return null;
const selectedNode = graph.nodes.find(node => node.id === selectedNodeId);
if (!selectedNode) return null;
// Extract file path from node properties
const filePath = selectedNode.properties.filePath as string ||
selectedNode.properties.path as string;
if (!filePath) return null;
// Get file name from path
const pathParts = filePath.split('/');
const fileName = pathParts[pathParts.length - 1];
// For demonstration purposes, we'll create mock content
// In a real implementation, this would come from the file contents
const content = generateMockContent(selectedNode);
return {
fileName,
filePath,
content,
startLine: selectedNode.properties.startLine as number,
endLine: selectedNode.properties.endLine as number,
nodeType: selectedNode.label,
nodeName: selectedNode.properties.name as string || selectedNode.id
};
}, [selectedNodeId, graph]);
// Generate mock source content based on node type
const generateMockContent = (node: GraphNode): string => {
const name = node.properties.name as string || 'unknown';
switch (node.label) {
case 'Function':
return `def ${name}():
"""
Function: ${name}
"""
# Implementation here
pass`;
case 'Method':
const parentClass = node.properties.parentClass as string || 'UnknownClass';
return `class ${parentClass}:
def ${name}(self):
"""
Method: ${name}
Class: ${parentClass}
"""
# Implementation here
pass`;
case 'Class':
return `class ${name}:
"""
Class: ${name}
"""
def __init__(self):
# Constructor
pass`;
case 'Module':
return `"""
Module: ${name}
This module contains various functions and classes.
"""
# Module-level imports and code here`;
case 'File':
const extension = node.properties.extension as string || '';
if (extension === '.py') {
return `# Python file: ${name}
"""
File: ${name}
"""
# File contents would be displayed here`;
}
return `// File: ${name}
// Content of the file would be displayed here`;
case 'Project':
return `# Project: ${name}
This is the root of the project structure.
Contains modules, packages, and other project files.`;
case 'Folder':
return `# Folder: ${name}
Directory containing:
- Subfolders
- Source files
- Other project assets`;
default:
return `# ${node.label}: ${name}
Details about this ${node.label.toLowerCase()} would be shown here.`;
}
};
// Format line numbers
const formatLineNumbers = (content: string, startLine?: number): string => {
const lines = content.split('\n');
const lineStart = startLine || 1;
return lines
.map((line, index) => {
const lineNumber = (lineStart + index).toString().padStart(3, ' ');
return `${lineNumber}│ ${line}`;
})
.join('\n');
};
const defaultStyle: React.CSSProperties = {
width: '100%',
height: '400px',
border: '1px solid #ddd',
borderRadius: '4px',
backgroundColor: '#f8f9fa',
display: 'flex',
flexDirection: 'column',
...style
};
const headerStyle: React.CSSProperties = {
padding: '12px 16px',
borderBottom: '1px solid #ddd',
backgroundColor: '#fff',
borderRadius: '4px 4px 0 0',
fontSize: '14px',
fontWeight: '600',
color: '#333'
};
const contentStyle: React.CSSProperties = {
flex: 1,
padding: '16px',
overflow: 'auto',
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace',
fontSize: '13px',
lineHeight: '1.5',
backgroundColor: '#fafafa',
color: '#333',
whiteSpace: 'pre',
tabSize: 2
};
const placeholderStyle: React.CSSProperties = {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '14px',
fontStyle: 'italic'
};
const getNodeTypeColor = (nodeType: string): string => {
switch (nodeType.toLowerCase()) {
case 'project': return '#4CAF50';
case 'folder': return '#FF9800';
case 'file': return '#2196F3';
case 'module': return '#9C27B0';
case 'function': return '#00BCD4';
case 'method': return '#00BCD4';
case 'class': return '#E91E63';
case 'variable': return '#607D8B';
default: return '#666';
}
};
const getNodeTypeIcon = (nodeType: string): string => {
switch (nodeType.toLowerCase()) {
case 'project': return '📁';
case 'folder': return '📂';
case 'file': return '📄';
case 'module': return '📦';
case 'function': return '⚡';
case 'method': return '🔧';
case 'class': return '🏗️';
case 'variable': return '📊';
default: return '📄';
}
};
return (
<div className={`source-viewer ${className}`} style={defaultStyle}>
{sourceInfo ? (
<>
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px' }}>
{getNodeTypeIcon(sourceInfo.nodeType)}
</span>
<span
style={{
color: getNodeTypeColor(sourceInfo.nodeType),
fontWeight: 'bold'
}}
>
{sourceInfo.nodeType}
</span>
<span style={{ color: '#666' }}>•</span>
<span>{sourceInfo.nodeName}</span>
</div>
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '16px'
}}>
<span>📁 {sourceInfo.fileName}</span>
{sourceInfo.startLine && sourceInfo.endLine && (
<span>📍 Lines {sourceInfo.startLine}-{sourceInfo.endLine}</span>
)}
</div>
</div>
<div style={contentStyle}>
{formatLineNumbers(sourceInfo.content, sourceInfo.startLine)}
</div>
</>
) : (
<>
<div style={headerStyle}>
<span style={{ color: '#666' }}>Source Code Viewer</span>
</div>
<div style={placeholderStyle}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
📄
</div>
<div>Select a node to view its source code</div>
<div style={{
fontSize: '12px',
color: '#999',
marginTop: '8px'
}}>
Click on any node in the graph to see its details
</div>
</div>
</div>
</>
)}
</div>
);
};
export default SourceViewer;

View file

@ -0,0 +1,371 @@
import React, { useEffect, useRef, useState } from 'react';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import cytoscape from 'npm:cytoscape';
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import dagre from 'npm:cytoscape-dagre';
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../../../core/graph/types.ts';
// Register the dagre layout extension
cytoscape.use(dagre);
interface GraphVisualizationProps {
graph: KnowledgeGraph;
onNodeSelect?: (nodeId: string | null) => void;
selectedNodeId?: string | null;
className?: string;
style?: React.CSSProperties;
}
interface CytoscapeElement {
data: {
id: string;
label?: string;
source?: string;
target?: string;
nodeType?: string;
[key: string]: unknown;
};
classes?: string;
}
const GraphVisualization: React.FC<GraphVisualizationProps> = ({
graph,
onNodeSelect,
selectedNodeId,
className = '',
style = {}
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<any>(null);
const [isReady, setIsReady] = useState(false);
// Convert KnowledgeGraph to Cytoscape elements
const convertToElements = (graph: KnowledgeGraph): CytoscapeElement[] => {
const elements: CytoscapeElement[] = [];
// Add nodes
graph.nodes.forEach((node: GraphNode) => {
elements.push({
data: {
id: node.id,
label: node.properties.name as string || node.id,
nodeType: node.label.toLowerCase(),
...node.properties
},
classes: `node-${node.label.toLowerCase()}`
});
});
// Add edges
graph.relationships.forEach((rel: GraphRelationship) => {
elements.push({
data: {
id: rel.id,
source: rel.source,
target: rel.target,
label: rel.type,
relationshipType: rel.type.toLowerCase(),
...rel.properties
},
classes: `edge-${rel.type.toLowerCase()}`
});
});
return elements;
};
// Define styles for different node types
const getStylesheet = () => [
// Base node styles
{
selector: 'node',
style: {
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '12px',
'font-family': 'system-ui, -apple-system, sans-serif',
'color': '#333',
'text-wrap': 'wrap',
'text-max-width': '80px',
'width': '60px',
'height': '60px',
'border-width': '2px',
'border-style': 'solid',
'border-color': '#ccc'
}
},
// Project nodes
{
selector: '.node-project',
style: {
'background-color': '#4CAF50',
'border-color': '#388E3C',
'shape': 'round-rectangle',
'width': '80px',
'height': '40px'
}
},
// Folder nodes
{
selector: '.node-folder',
style: {
'background-color': '#FF9800',
'border-color': '#F57C00',
'shape': 'round-rectangle',
'width': '70px',
'height': '35px'
}
},
// File nodes
{
selector: '.node-file',
style: {
'background-color': '#2196F3',
'border-color': '#1976D2',
'shape': 'rectangle'
}
},
// Module nodes
{
selector: '.node-module',
style: {
'background-color': '#9C27B0',
'border-color': '#7B1FA2',
'shape': 'round-rectangle'
}
},
// Function nodes
{
selector: '.node-function',
style: {
'background-color': '#00BCD4',
'border-color': '#0097A7',
'shape': 'ellipse'
}
},
// Method nodes
{
selector: '.node-method',
style: {
'background-color': '#00BCD4',
'border-color': '#0097A7',
'shape': 'ellipse',
'width': '50px',
'height': '50px'
}
},
// Class nodes
{
selector: '.node-class',
style: {
'background-color': '#E91E63',
'border-color': '#C2185B',
'shape': 'diamond'
}
},
// Variable nodes
{
selector: '.node-variable',
style: {
'background-color': '#607D8B',
'border-color': '#455A64',
'shape': 'triangle',
'width': '40px',
'height': '40px'
}
},
// Selected node
{
selector: '.selected',
style: {
'border-width': '4px',
'border-color': '#FF5722',
'box-shadow': '0 0 20px rgba(255, 87, 34, 0.6)'
}
},
// Base edge styles
{
selector: 'edge',
style: {
'width': '2px',
'line-color': '#666',
'target-arrow-color': '#666',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': '1.2'
}
},
// CONTAINS relationships
{
selector: '.edge-contains',
style: {
'line-color': '#4CAF50',
'target-arrow-color': '#4CAF50'
}
},
// CALLS relationships
{
selector: '.edge-calls',
style: {
'line-color': '#FF5722',
'target-arrow-color': '#FF5722',
'line-style': 'dashed'
}
},
// IMPORTS relationships
{
selector: '.edge-imports',
style: {
'line-color': '#9C27B0',
'target-arrow-color': '#9C27B0',
'line-style': 'dotted'
}
},
// INHERITS relationships
{
selector: '.edge-inherits',
style: {
'line-color': '#2196F3',
'target-arrow-color': '#2196F3'
}
},
// OVERRIDES relationships
{
selector: '.edge-overrides',
style: {
'line-color': '#FF9800',
'target-arrow-color': '#FF9800'
}
}
];
// Initialize Cytoscape
useEffect(() => {
if (!containerRef.current || !graph) return;
const elements = convertToElements(graph);
const stylesheet = getStylesheet();
cyRef.current = cytoscape({
container: containerRef.current,
elements,
style: stylesheet,
layout: {
name: 'dagre',
rankDir: 'TB', // Top to bottom
spacingFactor: 1.2,
nodeSep: 50,
edgeSep: 10,
rankSep: 100
},
// Interaction options
minZoom: 0.1,
maxZoom: 3,
wheelSensitivity: 0.5,
boxSelectionEnabled: false,
autounselectify: false
});
// Handle node click events
cyRef.current.on('tap', 'node', (event: any) => {
const node = event.target;
const nodeId = node.id();
// Remove previous selection
cyRef.current.elements('.selected').removeClass('selected');
// Add selection to clicked node
node.addClass('selected');
// Notify parent component
if (onNodeSelect) {
onNodeSelect(nodeId);
}
});
// Handle background click (deselect)
cyRef.current.on('tap', (event: any) => {
if (event.target === cyRef.current) {
cyRef.current.elements('.selected').removeClass('selected');
if (onNodeSelect) {
onNodeSelect(null);
}
}
});
setIsReady(true);
// Cleanup function
return () => {
if (cyRef.current) {
cyRef.current.destroy();
cyRef.current = null;
}
setIsReady(false);
};
}, [graph, onNodeSelect]);
// Update selection when selectedNodeId prop changes
useEffect(() => {
if (!cyRef.current || !isReady) return;
// Remove all selections
cyRef.current.elements('.selected').removeClass('selected');
// Add selection to specified node
if (selectedNodeId) {
const node = cyRef.current.getElementById(selectedNodeId);
if (node.length > 0) {
node.addClass('selected');
// Center the view on the selected node
cyRef.current.center(node);
}
}
}, [selectedNodeId, isReady]);
// Fit graph to container when graph changes
useEffect(() => {
if (!cyRef.current || !isReady) return;
// Fit the graph to the container with some padding
setTimeout(() => {
cyRef.current.fit(undefined, 50);
}, 100);
}, [graph, isReady]);
const defaultStyle: React.CSSProperties = {
width: '100%',
height: '600px',
border: '1px solid #ddd',
borderRadius: '4px',
backgroundColor: '#fafafa',
...style
};
return (
<div className={`graph-visualization ${className}`}>
<div
ref={containerRef}
style={defaultStyle}
className="cytoscape-container"
/>
{!isReady && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#666',
fontSize: '14px'
}}
>
Loading graph...
</div>
)}
</div>
);
};
export default GraphVisualization;

View file

@ -0,0 +1,3 @@
export { default as GraphVisualization } from './Visualization.tsx';
export { default as SourceViewer } from './SourceViewer.tsx';
export { default as GraphExplorer } from './GraphExplorer.tsx';

View file

@ -0,0 +1,3 @@
export * from './graph/index.ts';
export * from './chat/index.ts';
export { default as ErrorBoundary } from './ErrorBoundary.tsx';

2
src/ui/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from './components/index';
export * from './pages/index';

1135
src/ui/pages/HomePage.tsx Normal file

File diff suppressed because it is too large Load diff

1
src/ui/pages/index.ts Normal file
View file

@ -0,0 +1 @@
export { default as HomePage } from './HomePage.tsx';

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,201 @@
// @ts-expect-error - npm: imports are resolved at runtime in Deno
import * as Comlink from 'npm:comlink';
import { GraphPipeline, type PipelineInput } from '../core/ingestion/pipeline.ts';
import type { KnowledgeGraph } from '../core/graph/types.ts';
export interface IngestionProgress {
phase: 'structure' | 'parsing' | 'calls' | 'complete';
message: string;
progress: number;
timestamp: number;
}
export interface IngestionResult {
success: boolean;
graph?: KnowledgeGraph;
error?: string;
stats?: {
nodeStats: Record<string, number>;
relationshipStats: Record<string, number>;
callStats: { totalCalls: number; callTypes: Record<string, number> };
};
duration: number;
}
export class IngestionWorker {
private pipeline: GraphPipeline;
private progressCallback?: (progress: IngestionProgress) => void;
constructor() {
this.pipeline = new GraphPipeline();
}
public setProgressCallback(callback: (progress: IngestionProgress) => void): void {
this.progressCallback = callback;
}
public async processRepository(input: PipelineInput): Promise<IngestionResult> {
const startTime = performance.now();
try {
this.reportProgress('structure', 'Starting repository ingestion...', 0);
// Validate input
this.validateInput(input);
this.reportProgress('structure', 'Processing project structure...', 10);
// Run the pipeline
const graph = await this.pipeline.run(input);
this.reportProgress('complete', 'Ingestion completed successfully', 100);
const endTime = performance.now();
const duration = endTime - startTime;
// Gather statistics
const stats = this.pipeline.getStats(graph);
const callStats = this.pipeline.getCallStats();
return {
success: true,
graph,
stats: {
nodeStats: stats.nodeStats,
relationshipStats: stats.relationshipStats,
callStats
},
duration
};
} catch (error) {
const endTime = performance.now();
const duration = endTime - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
this.reportProgress('complete', `Ingestion failed: ${errorMessage}`, 0);
return {
success: false,
error: errorMessage,
duration
};
}
}
public async processFiles(
projectName: string,
files: { path: string; content: string }[]
): Promise<IngestionResult> {
const fileContents = new Map<string, string>();
const filePaths: string[] = [];
for (const file of files) {
filePaths.push(file.path);
fileContents.set(file.path, file.content);
}
const input: PipelineInput = {
projectRoot: '/',
projectName,
filePaths,
fileContents
};
return this.processRepository(input);
}
public async validateRepository(input: PipelineInput): Promise<{ valid: boolean; errors: string[] }> {
const errors: string[] = [];
try {
this.validateInput(input);
// Additional validation checks
if (input.filePaths.length === 0) {
errors.push('No files provided for processing');
}
if (input.fileContents.size === 0) {
errors.push('No file contents provided');
}
// Check for Python files
const pythonFiles = input.filePaths.filter(path => path.endsWith('.py'));
if (pythonFiles.length === 0) {
errors.push('No Python files found in the repository');
}
// Validate file contents exist
for (const filePath of input.filePaths) {
if (!input.fileContents.has(filePath)) {
errors.push(`Missing content for file: ${filePath}`);
}
}
return {
valid: errors.length === 0,
errors
};
} catch (error) {
errors.push(error instanceof Error ? error.message : 'Validation failed');
return {
valid: false,
errors
};
}
}
public getWorkerInfo(): { version: string; capabilities: string[] } {
return {
version: '1.0.0',
capabilities: [
'python-parsing',
'structure-analysis',
'call-resolution',
'ast-caching',
'progress-reporting'
]
};
}
private validateInput(input: PipelineInput): void {
if (!input.projectName || input.projectName.trim().length === 0) {
throw new Error('Project name is required');
}
if (!input.projectRoot || input.projectRoot.trim().length === 0) {
throw new Error('Project root is required');
}
if (!Array.isArray(input.filePaths)) {
throw new Error('File paths must be an array');
}
if (!(input.fileContents instanceof Map)) {
throw new Error('File contents must be a Map');
}
}
private reportProgress(phase: IngestionProgress['phase'], message: string, progress: number): void {
if (this.progressCallback) {
this.progressCallback({
phase,
message,
progress,
timestamp: Date.now()
});
}
}
public terminate(): void {
// Cleanup resources if needed
console.log('Ingestion worker terminated');
}
}
// Expose the worker class via Comlink
const worker = new IngestionWorker();
Comlink.expose(worker);

24
tsconfig.app.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
worker: {
format: 'es'
},
assetsInclude: ['**/*.wasm'],
build: {
target: 'esnext',
assetsInlineLimit: 0 // Don't inline WASM files
}
})