From 485e3e00a759b874ee8e8e15e66529b80079634a Mon Sep 17 00:00:00 2001 From: abhigyantrumio Date: Sun, 3 Aug 2025 04:44:44 +0530 Subject: [PATCH] Initial commit: Complete CodeNexus application with AI-powered code analysis --- .gitignore | 24 + GITNEXUS_README.md | 555 ++++++ README.md | 1863 ++++++++++++++++++++ deno.lock | 1246 +++++++++++++ eslint.config.js | 28 + index.html | 13 + package.json | 29 + project_guide.md | 1455 +++++++++++++++ public/vite.svg | 1 + public/wasm/python/tree-sitter-python.wasm | Bin 0 -> 457796 bytes src/App.css | 42 + src/App.tsx | 12 + src/ai/cypher-generator.ts | 329 ++++ src/ai/index.ts | 16 + src/ai/langchain-orchestrator.ts | 348 ++++ src/ai/llm-service.ts | 250 +++ src/ai/orchestrator.ts | 480 +++++ src/assets/react.svg | 1 + src/core/graph/types.ts | 36 + src/core/ingestion/call-processor.ts | 386 ++++ src/core/ingestion/parsing-processor.ts | 220 +++ src/core/ingestion/pipeline.ts | 80 + src/core/ingestion/structure-processor.ts | 197 +++ src/core/tree-sitter/parser-loader.ts | 30 + src/index.css | 68 + src/lib/export.ts | 335 ++++ src/lib/utils.ts | 15 + src/lib/workerUtils.ts | 192 ++ src/main.tsx | 10 + src/services/github.ts | 242 +++ src/services/zip.ts | 267 +++ src/ui/components/ErrorBoundary.tsx | 286 +++ src/ui/components/chat/ChatInterface.tsx | 532 ++++++ src/ui/components/chat/CodeAssistant.tsx | 215 +++ src/ui/components/chat/index.ts | 2 + src/ui/components/graph/GraphExplorer.tsx | 170 ++ src/ui/components/graph/SourceViewer.tsx | 286 +++ src/ui/components/graph/Visualization.tsx | 371 ++++ src/ui/components/graph/index.ts | 3 + src/ui/components/index.ts | 3 + src/ui/index.ts | 2 + src/ui/pages/HomePage.tsx | 1135 ++++++++++++ src/ui/pages/index.ts | 1 + src/vite-env.d.ts | 1 + src/workers/ingestion.worker.ts | 201 +++ tsconfig.app.json | 24 + tsconfig.json | 7 + tsconfig.node.json | 22 + vite.config.ts | 13 + 49 files changed, 12044 insertions(+) create mode 100644 .gitignore create mode 100644 GITNEXUS_README.md create mode 100644 README.md create mode 100644 deno.lock create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 project_guide.md create mode 100644 public/vite.svg create mode 100644 public/wasm/python/tree-sitter-python.wasm create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/ai/cypher-generator.ts create mode 100644 src/ai/index.ts create mode 100644 src/ai/langchain-orchestrator.ts create mode 100644 src/ai/llm-service.ts create mode 100644 src/ai/orchestrator.ts create mode 100644 src/assets/react.svg create mode 100644 src/core/graph/types.ts create mode 100644 src/core/ingestion/call-processor.ts create mode 100644 src/core/ingestion/parsing-processor.ts create mode 100644 src/core/ingestion/pipeline.ts create mode 100644 src/core/ingestion/structure-processor.ts create mode 100644 src/core/tree-sitter/parser-loader.ts create mode 100644 src/index.css create mode 100644 src/lib/export.ts create mode 100644 src/lib/utils.ts create mode 100644 src/lib/workerUtils.ts create mode 100644 src/main.tsx create mode 100644 src/services/github.ts create mode 100644 src/services/zip.ts create mode 100644 src/ui/components/ErrorBoundary.tsx create mode 100644 src/ui/components/chat/ChatInterface.tsx create mode 100644 src/ui/components/chat/CodeAssistant.tsx create mode 100644 src/ui/components/chat/index.ts create mode 100644 src/ui/components/graph/GraphExplorer.tsx create mode 100644 src/ui/components/graph/SourceViewer.tsx create mode 100644 src/ui/components/graph/Visualization.tsx create mode 100644 src/ui/components/graph/index.ts create mode 100644 src/ui/components/index.ts create mode 100644 src/ui/index.ts create mode 100644 src/ui/pages/HomePage.tsx create mode 100644 src/ui/pages/index.ts create mode 100644 src/vite-env.d.ts create mode 100644 src/workers/ingestion.worker.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/.gitignore @@ -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? diff --git a/GITNEXUS_README.md b/GITNEXUS_README.md new file mode 100644 index 000000000..956dc7e92 --- /dev/null +++ b/GITNEXUS_README.md @@ -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 + 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 + { + console.error('Application error:', error); + }} +> + + +``` + +#### **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 + + setSelectedNode(nodeId)} + /> + +``` + +### 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; +} +``` + +### 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.* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..d1dd1fe7d --- /dev/null +++ b/README.md @@ -0,0 +1,1863 @@ +# GitNexus: Edge-Based Code Knowledge Graph Generator for Deno - Step-by-Step Implementation Guide + +This guide will walk you through building a fully edge-based code knowledge graph generator from scratch using Deno. I'll explain each concept before showing the implementation, so you understand **why** we're doing something, not just **how** to do it. + +## Phase 1: Project Setup & Core Infrastructure + +### Step 1: Project Structure and Tooling Setup + +**Why this matters:** Before writing any code, we need to set up our development environment properly. A well-structured project makes it easier to add features later and keeps everything organized. + +**Key concepts:** + +- We're using Vite (a modern build tool) with React and TypeScript +- We need special configuration for WebAssembly (WASM) files +- A clear directory structure helps us scale to multiple languages later + +**Implementation Steps:** + +1. **Create the base project:** + +```bash +# Create project root +mkdir GitNexus +cd GitNexus + +# Initialize Vite project with React and TypeScript +npm create vite@latest . -- --template react-ts + +# Initialize Deno project +deno init +``` + +2. **Create the application directory structure:** + +```bash +# Create directories for our core components +mkdir -p src/{core,core/tree-sitter,core/graph,core/ingestion,services,ai,ai/agents,ai/prompts,ui,ui/components,ui/components/graph,ui/components/chat,ui/hooks,workers,lib,config,store} +``` + +**Why this structure?** + +- `core/`: Contains the engine that builds the knowledge graph +- `services/`: Handles external interactions (GitHub API, ZIP processing) +- `ai/`: Contains the RAG and chat functionality +- `ui/`: All user interface components +- `workers/`: Web Workers for heavy processing (keeps UI responsive) +- `lib/`: Utility functions used throughout the app + +### Step 2: Configure Build Tools for WASM + +**Why this matters:** WebAssembly (WASM) is how we'll run the Tree-sitter parsers in the browser. We need special configuration to handle these binary files correctly. + +**Key concepts:** + +- WASM files are binary files that run at near-native speed in browsers +- Vite needs special configuration to handle them properly +- We want to avoid inlining large WASM files in our JavaScript bundles + +**Implementation:** + +1. **Update `vite.config.ts`:** + +```typescript +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 + } +}) +``` + +**What this does:** + +- `assetsInclude: ['**/*.wasm']` tells Vite to treat WASM files as assets +- `assetsInlineLimit: 0` ensures WASM files aren't inlined into JavaScript (they're too large) +- `worker: { format: 'es' }` configures Web Workers to use ES modules + +2. **Configure TypeScript** with a `tsconfig.json` that has strict settings for better code quality. + +**Why strict settings?** They help catch errors early and make the code more maintainable as the project grows. + +### Step 3: Set Up WASM Parser Infrastructure + +**Why this matters:** Tree-sitter is the engine that parses code into ASTs (Abstract Syntax Trees). We need to get these parsers working in the browser via WASM. + +**Key concepts:** + +- Tree-sitter parsers for different languages are written in C +- We compile them to WASM so they can run in browsers +- We need to load these parsers on demand + +**Implementation:** + +1. **Create a public directory for WASM files:** + +```bash +mkdir -p public/wasm/python +``` + +2. **Download the Tree-sitter Python parser:** + - Get `tree-sitter-python.wasm` from [tree-sitter-python releases](https://github.com/tree-sitter/tree-sitter-python/releases) + - Place it in `public/wasm/python/` + +**Why host WASM files separately?** Browsers can't access the user's file system directly for security reasons. We need to serve the WASM files from a URL. + +I'm building a Deno 2.4.1-based edge code knowledge graph generator called GitNexus. I've completed Phase 1 (project setup and core infrastructure) and now need to implement Phase 2 (Code Acquisition Module) with Deno 2.4.1 compatibility. + +Please generate the following two services with these specific requirements: + +## 1. GitHub Service (Deno 2.4.1 Implementation) + +Create a GitHubService class in `src/services/github.ts` that: + +- Uses Deno 2.4.1's native fetch API (no Node.js dependencies) +- Handles GitHub API authentication via personal access tokens +- Implements rate limit handling (GitHub allows 5,000 requests/hour with token) +- Includes methods to: + * getRepoContents(owner: string, repo: string, path = '') - fetches directory structure + * getFileContent(owner: string, repo: string, filePath: string) - fetches individual file content +- Properly handles GitHub API rate limits and errors +- Uses Deno 2.4.1-specific error handling patterns +- Includes TypeScript interfaces for return types +- Has comprehensive comments explaining key implementation choices + +Important Deno 2.4.1 considerations: + +- Use ES modules (no CommonJS) +- No Node.js-specific modules (use Deno's built-in APIs where possible) +- Handle fetch responses with proper Deno error patterns +- Include proper types for all functions +- Follow Deno 2.4.1's security model (permissions) + +## 2. ZIP Processing Service (Deno 2.4.1 Implementation) + +Create a ZipService class in `src/services/zip.ts` that: + +- Uses Deno-compatible ZIP processing (use `https://deno.land/x/zip@v1.2.3/mod.ts` instead of JSZip) +- Processes uploaded ZIP files containing code repositories +- Extracts file paths and contents into a Map +- Handles binary data properly in Deno 2.4.1 environment +- Includes error handling for corrupted ZIP files +- Has TypeScript interfaces for all types +- Includes comprehensive comments + +Important Deno 2.4.1 considerations: + +- Use Deno's file system APIs where appropriate +- Handle file reading with Deno.readFile() +- Process ZIP entries without blocking the event loop +- Use Deno's native text decoding for file contents +- Implement streaming where possible for large ZIP files +- Note that this is for a browser-based application, so the ZIP service should work with File objects from HTML inputs + +## Additional Requirements + +- All code must be Deno 2.4.1 compatible +- Use strict TypeScript with deno-lint directives where needed +- Include proper error messages that help with debugging +- Add unit test stubs for both services (using Deno's built-in test runner) +- Follow the same directory structure as Phase 1 (services directory already exists) +- Maintain the same coding style and patterns established in Phase 1 +- Include necessary imports from Deno's standard library +- Document any Deno-specific permissions required + +I'm building a Deno 2.4.1-based edge code knowledge graph generator called GitNexus. I've completed Phase 1 (project setup and core infrastructure) and now need to implement Phase 2 (Code Acquisition Module) with Deno 2.4.1 compatibility. + +Please generate the following two services with these specific requirements: + +## 1. GitHub Service (Deno 2.4.1 Implementation) + +Create a GitHubService class in `src/services/github.ts` that: + +- Uses Deno 2.4.1's native fetch API (no Node.js dependencies) +- Handles GitHub API authentication via personal access tokens +- Implements rate limit handling (GitHub allows 5,000 requests/hour with token) +- Includes methods to: + * getRepoContents(owner: string, repo: string, path = '') - fetches directory structure + * getFileContent(owner: string, repo: string, filePath: string) - fetches individual file content +- Properly handles GitHub API rate limits and errors +- Uses Deno 2.4.1-specific error handling patterns +- Includes TypeScript interfaces for return types +- Has comprehensive comments explaining key implementation choices + +Important Deno 2.4.1 considerations: + +- Use ES modules (no CommonJS) +- No Node.js-specific modules (use Deno's built-in APIs where possible) +- Handle fetch responses with proper Deno error patterns +- Include proper types for all functions +- Follow Deno 2.4.1's security model (permissions) + +## 2. ZIP Processing Service (Deno 2.4.1 Implementation) + +Create a ZipService class in `src/services/zip.ts` that: + +- Uses Deno-compatible ZIP processing (use `https://deno.land/x/zip@v1.2.3/mod.ts` instead of JSZip) +- Processes uploaded ZIP files containing code repositories +- Extracts file paths and contents into a Map +- Handles binary data properly in Deno 2.4.1 environment +- Includes error handling for corrupted ZIP files +- Has TypeScript interfaces for all types +- Includes comprehensive comments + +Important Deno 2.4.1 considerations: + +- Use Deno's file system APIs where appropriate +- Handle file reading with Deno.readFile() +- Process ZIP entries without blocking the event loop +- Use Deno's native text decoding for file contents +- Implement streaming where possible for large ZIP files +- Note that this is for a browser-based application, so the ZIP service should work with File objects from HTML inputs + +## Additional Requirements + +- All code must be Deno 2.4.1 compatible +- Use strict TypeScript with deno-lint directives where needed +- Include proper error messages that help with debugging +- Add unit test stubs for both services (using Deno's built-in test runner) +- Follow the same directory structure as Phase 1 (services directory already exists) +- Maintain the same coding style and patterns established in Phase 1 +- Include necessary imports from Deno's standard library +- Document any Deno-specific permissions required + +I'm building a Deno 2.4.1-based edge code knowledge graph generator called GitNexus. I've completed Phase 1 (project setup and core infrastructure) and now need to implement Phase 2 (Code Acquisition Module) with Deno 2.4.1 compatibility. + +Please generate the following two services with these specific requirements: + +## 1. GitHub Service (Deno 2.4.1 Implementation) + +Create a GitHubService class in `src/services/github.ts` that: + +- Uses Deno 2.4.1's native fetch API (no Node.js dependencies) +- Handles GitHub API authentication via personal access tokens +- Implements rate limit handling (GitHub allows 5,000 requests/hour with token) +- Includes methods to: + * getRepoContents(owner: string, repo: string, path = '') - fetches directory structure + * getFileContent(owner: string, repo: string, filePath: string) - fetches individual file content +- Properly handles GitHub API rate limits and errors +- Uses Deno 2.4.1-specific error handling patterns +- Includes TypeScript interfaces for return types +- Has comprehensive comments explaining key implementation choices + +Important Deno 2.4.1 considerations: + +- Use ES modules (no CommonJS) +- No Node.js-specific modules (use Deno's built-in APIs where possible) +- Handle fetch responses with proper Deno error patterns +- Include proper types for all functions +- Follow Deno 2.4.1's security model (permissions) + +## 2. ZIP Processing Service (Deno 2.4.1 Implementation) + +Create a ZipService class in `src/services/zip.ts` that: + +- Uses Deno-compatible ZIP processing (use `https://deno.land/x/zip@v1.2.3/mod.ts` instead of JSZip) +- Processes uploaded ZIP files containing code repositories +- Extracts file paths and contents into a Map +- Handles binary data properly in Deno 2.4.1 environment +- Includes error handling for corrupted ZIP files +- Has TypeScript interfaces for all types +- Includes comprehensive comments + +Important Deno 2.4.1 considerations: + +- Use Deno's file system APIs where appropriate +- Handle file reading with Deno.readFile() +- Process ZIP entries without blocking the event loop +- Use Deno's native text decoding for file contents +- Implement streaming where possible for large ZIP files +- Note that this is for a browser-based application, so the ZIP service should work with File objects from HTML inputs + +## Additional Requirements + +- All code must be Deno 2.4.1 compatible +- Use strict TypeScript with deno-lint directives where needed +- Include proper error messages that help with debugging +- Add unit test stubs for both services (using Deno's built-in test runner) +- Follow the same directory structure as Phase 1 (services directory already exists) +- Maintain the same coding style and patterns established in Phase 1 +- Include necessary imports from Deno's standard library +- Document any Deno-specific permissions required + +I'm building a Deno 2.4.1-based edge code knowledge graph generator called GitNexus. I've completed Phase 1 (project setup and core infrastructure) and now need to implement Phase 2 (Code Acquisition Module) with Deno 2.4.1 compatibility. + +Please generate the following two services with these specific requirements: + +## 1. GitHub Service (Deno 2.4.1 Implementation) + +Create a GitHubService class in `src/services/github.ts` that: + +- Uses Deno 2.4.1's native fetch API (no Node.js dependencies) +- Handles GitHub API authentication via personal access tokens +- Implements rate limit handling (GitHub allows 5,000 requests/hour with token) +- Includes methods to: + * getRepoContents(owner: string, repo: string, path = '') - fetches directory structure + * getFileContent(owner: string, repo: string, filePath: string) - fetches individual file content +- Properly handles GitHub API rate limits and errors +- Uses Deno 2.4.1-specific error handling patterns +- Includes TypeScript interfaces for return types +- Has comprehensive comments explaining key implementation choices + +Important Deno 2.4.1 considerations: + +- Use ES modules (no CommonJS) +- No Node.js-specific modules (use Deno's built-in APIs where possible) +- Handle fetch responses with proper Deno error patterns +- Include proper types for all functions +- Follow Deno 2.4.1's security model (permissions) + +## 2. ZIP Processing Service (Deno 2.4.1 Implementation) + +Create a ZipService class in `src/services/zip.ts` that: + +- Uses Deno-compatible ZIP processing (use `https://deno.land/x/zip@v1.2.3/mod.ts` instead of JSZip) +- Processes uploaded ZIP files containing code repositories +- Extracts file paths and contents into a Map +- Handles binary data properly in Deno 2.4.1 environment +- Includes error handling for corrupted ZIP files +- Has TypeScript interfaces for all types +- Includes comprehensive comments + +Important Deno 2.4.1 considerations: + +- Use Deno's file system APIs where appropriate +- Handle file reading with Deno.readFile() +- Process ZIP entries without blocking the event loop +- Use Deno's native text decoding for file contents +- Implement streaming where possible for large ZIP files +- Note that this is for a browser-based application, so the ZIP service should work with File objects from HTML inputs + +## Additional Requirements + +- All code must be Deno 2.4.1 compatible +- Use strict TypeScript with deno-lint directives where needed +- Include proper error messages that help with debugging +- Add unit test stubs for both services (using Deno's built-in test runner) +- Follow the same directory structure as Phase 1 (services directory already exists) +- Maintain the same coding style and patterns established in Phase 1 +- Include necessary imports from Deno's standard library +- Document any Deno-specific permissions required + +I'm building a Deno 2.4.1-based edge code knowledge graph generator called GitNexus. I've completed Phase 1 (project setup and core infrastructure) and now need to implement Phase 2 (Code Acquisition Module) with Deno 2.4.1 compatibility. + +Please generate the following two services with these specific requirements: + +## 1. GitHub Service (Deno 2.4.1 Implementation) + +Create a GitHubService class in `src/services/github.ts` that: + +- Uses Deno 2.4.1's native fetch API (no Node.js dependencies) +- Handles GitHub API authentication via personal access tokens +- Implements rate limit handling (GitHub allows 5,000 requests/hour with token) +- Includes methods to: + * getRepoContents(owner: string, repo: string, path = '') - fetches directory structure + * getFileContent(owner: string, repo: string, filePath: string) - fetches individual file content +- Properly handles GitHub API rate limits and errors +- Uses Deno 2.4.1-specific error handling patterns +- Includes TypeScript interfaces for return types +- Has comprehensive comments explaining key implementation choices + +Important Deno 2.4.1 considerations: + +- Use ES modules (no CommonJS) +- No Node.js-specific modules (use Deno's built-in APIs where possible) +- Handle fetch responses with proper Deno error patterns +- Include proper types for all functions +- Follow Deno 2.4.1's security model (permissions) + +## 2. ZIP Processing Service (Deno 2.4.1 Implementation) + +Create a ZipService class in `src/services/zip.ts` that: + +- Uses Deno-compatible ZIP processing (use `https://deno.land/x/zip@v1.2.3/mod.ts` instead of JSZip) +- Processes uploaded ZIP files containing code repositories +- Extracts file paths and contents into a Map +- Handles binary data properly in Deno 2.4.1 environment +- Includes error handling for corrupted ZIP files +- Has TypeScript interfaces for all types +- Includes comprehensive comments + +Important Deno 2.4.1 considerations: + +- Use Deno's file system APIs where appropriate +- Handle file reading with Deno.readFile() +- Process ZIP entries without blocking the event loop +- Use Deno's native text decoding for file contents +- Implement streaming where possible for large ZIP files +- Note that this is for a browser-based application, so the ZIP service should work with File objects from HTML inputs + +## Additional Requirements + +- All code must be Deno 2.4.1 compatible +- Use strict TypeScript with deno-lint directives where needed +- Include proper error messages that help with debugging +- Add unit test stubs for both services (using Deno's built-in test runner) +- Follow the same directory structure as Phase 1 (services directory already exists) +- Maintain the same coding style and patterns established in Phase - Include necessary imports from Deno's standard library +- Document any Deno-specific permissions required + +I'm building a Deno 2.4.1-based edge code knowledge graph generator called GitNexus. I've completed Phase 1 (project setup and core infrastructure) and now need to implement Phase 2 (Code Acquisition Module) with Deno 2.4.1 compatibility. + +Please generate the following two services with these specific requirements: + +## 1. GitHub Service (Deno 2.4.1 Implementation) + +Create a GitHubService class in `src/services/github.ts` that: + +- Uses Deno 2.4.1's native fetch API (no Node.js dependencies) +- Handles GitHub API authentication via personal access tokens +- Implements rate limit handling (GitHub allows 5,000 requests/hour with token) +- Includes methods to: + * getRepoContents(owner: string, repo: string, path = '') - fetches directory structure + * getFileContent(owner: string, repo: string, filePath: string) - fetches individual file content +- Properly handles GitHub API rate limits and errors +- Uses Deno 2.4.1-specific error handling patterns +- Includes TypeScript interfaces for return types +- Has comprehensive comments explaining key implementation choices + +Important Deno 2.4.1 considerations: + +- Use ES modules (no CommonJS) +- No Node.js-specific modules (use Deno's built-in APIs where possible) +- Handle fetch responses with proper Deno error patterns +- Include proper types for all functions +- Follow Deno 2.4.1's security model (permissions) + +## 2. ZIP Processing Service (Deno 2.4.1 Implementation) + +Create a ZipService class in `src/services/zip.ts` that: + +- Uses Deno-compatible ZIP processing (use `https://deno.land/x/zip@v1.2.3/mod.ts` instead of JSZip) +- Processes uploaded ZIP files containing code repositories +- Extracts file paths and contents into a Map +- Handles binary data properly in Deno 2.4.1 environment +- Includes error handling for corrupted ZIP files +- Has TypeScript interfaces for all types +- Includes comprehensive comments + +Important Deno 2.4.1 considerations: + +- Use Deno's file system APIs where appropriate +- Handle file reading with Deno.readFile() +- Process ZIP entries without blocking the event loop +- Use Deno's native text decoding for file contents +- Implement streaming where possible for large ZIP files +- Note that this is for a browser-based application, so the ZIP service should work with File objects from HTML inputs + +## Additional Requirements + +- All code must be Deno 2.4.1 compatible +- Use strict TypeScript with deno-lint directives where needed +- Include proper error messages that help with debugging +- Add unit test stubs for both services (using Deno's built-in test runner) +- Follow the same directory structure as Phase 1 (services directory already exists) +- Maintain the same coding style and patterns established in Phase - Include necessary imports from Deno's standard library +- Document any Deno-specific permissions required + +I'm building a Deno 2.4.1-based edge code knowledge graph generator called GitNexus. I've completed Phase 1 (project setup and core infrastructure) and now need to implement Phase 2 (Code Acquisition Module) with Deno 2.4.1 compatibility. + +Please generate the following two services with these specific requirements: + +## 1. GitHub Service (Deno 2.4.1 Implementation) + +Create a GitHubService class in `src/services/github.ts` that: + +- Uses Deno 2.4.1's native fetch API (no Node.js dependencies) +- Handles GitHub API authentication via personal access tokens +- Implements rate limit handling (GitHub allows 5,000 requests/hour with token) +- Includes methods to: + * getRepoContents(owner: string, repo: string, path = '') - fetches directory structure + * getFileContent(owner: string, repo: string, filePath: string) - fetches individual file content +- Properly handles GitHub API rate limits and errors +- Uses Deno 2.4.1-specific error handling patterns +- Includes TypeScript interfaces for return types +- Has comprehensive comments explaining key implementation choices + +Important Deno 2.4.1 considerations: + +- Use ES modules (no CommonJS) +- No Node.js-specific modules (use Deno's built-in APIs where possible) +- Handle fetch responses with proper Deno error patterns +- Include proper types for all functions +- Follow Deno 2.4.1's security model (permissions) + +## 2. ZIP Processing Service (Deno 2.4.1 Implementation) + +Create a ZipService class in `src/services/zip.ts` that: + +- Uses Deno-compatible ZIP processing (use `https://deno.land/x/zip@v1.2.3/mod.ts` instead of JSZip) +- Processes uploaded ZIP files containing code repositories +- Extracts file paths and contents into a Map +- Handles binary data properly in Deno 2.4.1 environment +- Includes error handling for corrupted ZIP files +- Has TypeScript interfaces for all types +- Includes comprehensive comments + +Important Deno 2.4.1 considerations: + +- Use Deno's file system APIs where appropriate +- Handle file reading with Deno.readFile() +- Process ZIP entries without blocking the event loop +- Use Deno's native text decoding for file contents +- Implement streaming where possible for large ZIP files +- Note that this is for a browser-based application, so the ZIP service should work with File objects from HTML inputs + +## Additional Requirements + +- All code must be Deno 2.4.1 compatible +- Use strict TypeScript with deno-lint directives where needed +- Include proper error messages that help with debugging +- Add unit test stubs for both services (using Deno's built-in test runner) +- Follow the same directory structure as Phase 1 (services directory already exists) +- Maintain the same coding style and patterns established in Phase - Include necessary imports from Deno's standard library +- Document any Deno-specific permissions required + +3. **Create a loader for Tree-sitter parsers:** + +```typescript +import WebTreeSitter from 'web-tree-sitter'; +let parserInstance: WebTreeSitter | null = null; +const parserCache = new Map(); + +export async function initTreeSitter() { + if (parserInstance) return parserInstance; + parserInstance = await WebTreeSitter.init(); + return parserInstance; +} + +export async function loadPythonParser(): Promise { + if (parserCache.has('python')) { + return parserCache.get('python')!; + } + const Parser = await initTreeSitter(); + const pythonLang = await Parser.Language.load( + '/wasm/python/tree-sitter-python.wasm' + ); + parserCache.set('python', pythonLang); + return pythonLang; +} +``` + +**How this works:** + +1. `initTreeSitter()` initializes the WebAssembly module once +2. `loadPythonParser()` loads the Python parser from the WASM file +3. We cache parsers to avoid reloading them multiple times + +**Why cache parsers?** Loading WASM files is relatively slow, so we want to do it once and reuse the parsers. + +## Phase 2: Code Acquisition Module + +### Step 4: Implement GitHub API Integration + +**Why this matters:** Users will want to analyze public GitHub repositories, so we need a way to fetch code from GitHub. + +**Key concepts:** + +- GitHub has a REST API for accessing repository contents +- We need to handle rate limits (GitHub limits how many requests you can make) +- We'll let users provide their own API tokens for higher limits + +**Implementation:** + +```typescript +export class GitHubService { + private token: string | null = null; + + setToken(token: string) { + this.token = token; + } + + async getRepoContents(owner: string, repo: string, path = '') { + const headers: HeadersInit = { + 'Accept': 'application/vnd.github.v3+json' + }; + if (this.token) { + headers['Authorization'] = `token ${this.token}`; + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${path}`, + { headers } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + return response.json(); + } +} +``` + +**How this works:** + +- `getRepoContents()` fetches the directory structure of a repository +- It uses the GitHub API with proper headers +- It handles authentication via a token + +**Important note:** GitHub API has rate limits. For unauthenticated requests, it's about 60 requests/hour. With a token, it's 5,000/hour. + +### Step 5: Implement ZIP Processing + +**Why this matters:** Not all code is on GitHub. Users might want to analyze local code or private repositories by uploading a ZIP file. + +**Key concepts:** + +- JSZip is a library for handling ZIP files in JavaScript +- We need to extract files and their contents from the ZIP +- We'll use a Map to store file paths and contents + +**Implementation:** + +```typescript +import JSZip from 'jszip'; + +export class ZipService { + async processZip(file: File): Promise> { + const zip = await JSZip.loadAsync(file); + const files = new Map(); + + for (const [filePath, zipEntry] of Object.entries(zip.files)) { + if (!zipEntry.dir) { + const content = await zipEntry.async('text'); + files.set(filePath, content); + } + } + + return files; + } +} +``` + +**How this works:** + +1. `JSZip.loadAsync(file)` loads the ZIP file +2. We iterate through all entries in the ZIP +3. For each file (not directory), we extract its content as text +4. We store the file path and content in a Map + +**Why use a Map?** It provides O(1) lookups by file path, which is important when we need to find files during graph construction. + +## Phase 3: Graph Construction Pipeline + +### Step 6: Define Graph Data Structures + +**Why this matters:** Before we can build a graph, we need to define what nodes and relationships look like. + +**Key concepts:** + +- A knowledge graph consists of nodes and relationships +- Nodes represent code elements (functions, classes, etc.) +- Relationships represent connections between elements (calls, contains, etc.) + +**Implementation:** + +```typescript +export type NodeLabel = + | 'Project' + | 'Package' + | 'Module' + | 'Folder' + | 'File' + | 'Class' + | 'Function' + | 'Method' + | 'Variable'; + +export interface GraphNode { + id: string; + label: NodeLabel; + properties: Record; +} + +export type RelationshipType = + | 'CONTAINS' + | 'CALLS' + | 'INHERITS' + | 'OVERRIDES' + | 'IMPORTS'; + +export interface GraphRelationship { + id: string; + type: RelationshipType; + source: string; + target: string; + properties?: Record; +} + +export interface KnowledgeGraph { + nodes: GraphNode[]; + relationships: GraphRelationship[]; +} +``` + +**Why these specific types?** + +- `NodeLabel` defines all possible types of code elements we'll track +- `RelationshipType` defines how code elements connect to each other +- `KnowledgeGraph` is the complete structure we'll build + +**Important relationships:** + +- `CONTAINS`: A folder contains files, a file contains functions +- `CALLS`: A function calls another function +- `IMPORTS`: One module imports from another + +### Step 7: Implement the 3-Pass Ingestion Pipeline + +**Why this matters:** Building a complete knowledge graph requires multiple passes to handle cross-file references properly. + +**Key concepts:** + +- **Pass 1**: Identify the overall structure (folders, modules) +- **Pass 2**: Parse individual files and cache ASTs +- **Pass 3**: Process function calls across files (the hardest part) + +This three-pass approach solves the "island problem" - where functions in different files appear disconnected. + +#### Pass 1: Structure Identification + +```typescript +export class StructureProcessor { + private graph: KnowledgeGraph; + private projectRoot: string; + private projectName: string; + + constructor(graph: KnowledgeGraph, projectRoot: string, projectName: string) { + this.graph = graph; + this.projectRoot = projectRoot; + this.projectName = projectName; + } + + identifyStructure(filePaths: string[]): void { + // Add Project node + this.graph.nodes.push({ + id: `project:${this.projectName}`, + label: 'Project', + properties: { name: this.projectName } + }); + + // Track directory structure + const directories = new Set(); + for (const filePath of filePaths) { + const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); + if (dirPath && !directories.has(dirPath)) { + directories.add(dirPath); + // Create Folder node + this.graph.nodes.push({ + id: `folder:${dirPath}`, + label: 'Folder', + properties: { path: dirPath } + }); + + // Create CONTAINS relationship with parent + if (dirPath.includes('/')) { + const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')); + this.graph.relationships.push({ + id: `rel:folder:${dirPath}:parent`, + type: 'CONTAINS', + source: `folder:${parentPath}`, + target: `folder:${dirPath}` + }); + } else { + // Root folder connects to project + this.graph.relationships.push({ + id: `rel:folder:${dirPath}:project`, + type: 'CONTAINS', + source: `project:${this.projectName}`, + target: `folder:${dirPath}` + }); + } + } + } + } +} +``` + +**How this works:** + +1. Creates a root Project node +2. Walks through all file paths to identify directories +3. Creates Folder nodes and CONTAINS relationships + +**Why identify structure first?** We need to know the overall organization before parsing individual files. + +#### Pass 2: File Parsing + +```typescript +export class ParsingProcessor { + private graph: KnowledgeGraph; + private astCache = new Map(); + + constructor(graph: KnowledgeGraph) { + this.graph = graph; + } + + async parseFiles(filePaths: string[], fileContents: Map): Promise> { + for (const [filePath, content] of fileContents) { + if (filePath.endsWith('.py')) { + await this.parsePythonFile(filePath, content); + } + } + return this.astCache; + } + + private async parsePythonFile(filePath: string, content: string): Promise { + const parser = await loadPythonParser(); + const tree = parser.parse(content); + // Cache the AST + this.astCache.set(filePath, tree); + // Extract definitions from the AST + this.extractDefinitions(filePath, tree, content); + } + + private extractDefinitions(filePath: string, tree: any, content: string): void { + // Extract modules + this.graph.nodes.push({ + id: `module:${filePath}`, + label: 'Module', + properties: { + path: filePath, + name: filePath.split('/').pop()!.replace('.py', ''), + extension: '.py' + } + }); + + // Extract functions from the AST + const rootNode = tree.rootNode; + const functionDefs = rootNode.descendantsOfType('function_definition'); + for (const funcNode of functionDefs) { + const nameNode = funcNode.childForFieldName('name'); + const name = nameNode ? nameNode.text : 'unknown'; + + // Calculate position + const startLine = funcNode.startPosition.row + 1; + + // Create function node + this.graph.nodes.push({ + id: `function:${filePath}:${name}`, + label: 'Function', + properties: { + name, + qualified_name: `${this.getModuleName(filePath)}.${name}`, + path: filePath, + start_line: startLine + } + }); + + // Create CONTAINS relationship with module + this.graph.relationships.push({ + id: `rel:function:${filePath}:${name}:module`, + type: 'CONTAINS', + source: `module:${filePath}`, + target: `function:${filePath}:${name}` + }); + } + } +} +``` + +**How this works:** + +1. Parses each file with the appropriate Tree-sitter parser +2. Caches the AST for later use +3. Extracts definitions (functions, classes) from the AST +4. Creates nodes and relationships in the graph + +**Why cache ASTs?** We need them in Pass 3 to resolve cross-file function calls. + +#### Pass 3: Call Resolution + +```typescript +export class CallProcessor { + private graph: KnowledgeGraph; + private astCache: Map; + private projectRoot: string; + private projectName: string; + + constructor( + graph: KnowledgeGraph, + astCache: Map, + projectRoot: string, + projectName: string + ) { + this.graph = graph; + this.astCache = astCache; + this.projectRoot = projectRoot; + this.projectName = projectName; + } + + processCalls(): void { + for (const [filePath, tree] of this.astCache) { + if (filePath.endsWith('.py')) { + this.processPythonCalls(filePath, tree); + } + } + } + + private processPythonCalls(filePath: string, tree: any): void { + const rootNode = tree.rootNode; + // Find all call expressions + const callExpressions = rootNode.descendantsOfType('call'); + for (const callNode of callExpressions) { + const functionNameNode = callNode.childForFieldName('function'); + if (!functionNameNode) continue; + + // Handle different types of function references + let targetFunctionName = ''; + if (functionNameNode.type === 'identifier') { + targetFunctionName = functionNameNode.text; + } else if (functionNameNode.type === 'attribute') { + // Handle method calls like obj.method() + const attrNode = functionNameNode; + const objectNode = attrNode.childForFieldName('object'); + const attrNameNode = attrNode.childForFieldName('attribute'); + if (objectNode && attrNameNode) { + const objectName = objectNode.text; + const methodName = attrNameNode.text; + targetFunctionName = `${objectName}.${methodName}`; + } + } + + if (!targetFunctionName) continue; + + // Try to resolve the target function + const targetNode = this.resolveTargetFunction(targetFunctionName, filePath); + if (targetNode) { + // Create CALLS relationship + const callerId = this.getCallerId(callNode, filePath); + this.graph.relationships.push({ + id: `rel:call:${callerId}:${targetNode.id}`, + type: 'CALLS', + source: callerId, + target: targetNode.id + }); + } + } + } + + private resolveTargetFunction(targetName: string, currentFilePath: string): { id: string; type: string } | null { + // 1. Check if it's a built-in function + if (this.isBuiltInFunction(targetName)) { + return { + id: `builtin:${targetName}`, + type: 'builtin' + }; + } + + // 2. Check if it's an imported function + const importInfo = this.findImportForFunction(targetName, currentFilePath); + if (importInfo) { + const targetId = `function:${importInfo.sourceFile}:${importInfo.targetName}`; + return { + id: targetId, + type: 'imported' + }; + } + + // 3. Check if it's defined in the current file + for (const node of this.graph.nodes) { + if (node.label === 'Function' && + node.properties.name === targetName && + node.properties.path === currentFilePath) { + return { + id: node.id, + type: 'local' + }; + } + } + + return null; + } +} +``` + +**How this works:** + +1. Finds all function calls in the AST +2. Determines what function is being called +3. Resolves the target function across files using imports +4. Creates CALLS relationships in the graph + +**Why is this the hardest part?** Resolving cross-file references requires understanding: + +- How imports work in the language +- How to map a simple name to a fully qualified name +- Handling edge cases like aliases (`import helper as h`) + +### Step 8: Implement Web Workers for Performance + +**Why this matters:** Parsing code and building graphs can be CPU-intensive. Web Workers keep the UI responsive. + +**Key concepts:** + +- Web Workers run JavaScript in background threads +- They can't access the DOM directly +- We use Comlink to simplify communication + +**Implementation:** + +```typescript +// src/workers/ingestion.worker.ts +import { expose } from 'comlink'; +import { GraphPipeline } from '../core/ingestion/pipeline'; + +class IngestionWorker { + async processRepository( + projectRoot: string, + projectName: string, + filePaths: string[], + fileContents: Record + ) { + const pipeline = new GraphPipeline(projectRoot, projectName); + return pipeline.run(filePaths, new Map(Object.entries(fileContents))); + } +} + +expose(new IngestionWorker()); +``` + +**How this works:** + +1. The worker runs the heavy processing in a background thread +2. We expose methods via Comlink to call them from the main thread +3. The main thread can call these methods without blocking the UI + +**Why use Web Workers?** Without them, large repositories would freeze the browser tab while processing. + +## Phase 4: Graph Visualization + +### Step 9: Implement Graph Visualization Components + +**Why this matters:** A knowledge graph is useless if users can't see and interact with it. + +**Key concepts:** + +- Cytoscape.js is a powerful graph visualization library +- We need to convert our graph data to Cytoscape's format +- Users need controls to filter and navigate the graph + +**Implementation:** + +```tsx +import React, { useEffect, useRef } from 'react'; +import cytoscape from 'cytoscape'; +import dagre from 'cytoscape-dagre'; +import { KnowledgeGraph } from '@/core/graph/types'; + +cytoscape.use(dagre); + +interface GraphVisualizationProps { + graph: KnowledgeGraph; + onNodeClick?: (nodeId: string) => void; + filter?: (node: any) => boolean; +} + +export const GraphVisualization: React.FC = ({ + graph, + onNodeClick, + filter +}) => { + const containerRef = useRef(null); + const cyRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + // Clean up previous instance + if (cyRef.current) { + cyRef.current.destroy(); + } + + // Convert our graph to Cytoscape format + const cyElements = convertToCytoscapeElements(graph, filter); + + const cy = cytoscape({ + container: containerRef.current, + elements: cyElements, + style: [ + { + selector: 'node', + style: { + 'label': 'data(label)', + 'width': 'mapData(size, 0, 100, 20, 80)', + 'height': 'mapData(size, 0, 100, 20, 80)', + 'background-color': 'data(color)', + 'text-valign': 'center', + 'text-halign': 'center', + 'font-size': '8px' + } + }, + { + selector: 'edge', + style: { + 'width': 2, + 'line-color': '#ccc', + 'target-arrow-color': '#ccc', + 'target-arrow-shape': 'triangle' + } + } + ], + layout: { + name: 'dagre', + rankDir: 'TB', + padding: 20 + } + }); + + // Add interactions + cy.on('tap', 'node', (event) => { + const node = event.target; + const nodeId = node.data('id'); + if (onNodeClick) { + onNodeClick(nodeId); + } + }); + + cyRef.current = cy; + + return () => { + if (cyRef.current) { + cyRef.current.destroy(); + cyRef.current = null; + } + }; + }, [graph, filter]); + + return ( +
+ ); +}; + +function convertToCytoscapeElements( + graph: KnowledgeGraph, + filter?: (node: any) => boolean +) { + const elements: any[] = []; + + // Add nodes + for (const node of graph.nodes) { + if (filter && !filter(node)) continue; + elements.push({ + data: { + id: node.id, + label: getNodeLabel(node), + type: node.label, + color: getNodeColor(node.label), + size: getNodeSize(node) + } + }); + } + + // Add edges + for (const rel of graph.relationships) { + elements.push({ + data: { + id: rel.id, + source: rel.source, + target: rel.target, + label: rel.type + } + }); + } + + return elements; +} +``` + +**How this works:** + +1. Converts our graph data to Cytoscape's format +2. Sets up visual styles based on node type +3. Applies a hierarchical layout (dagre) +4. Adds interaction handlers for node clicks + +**Why use Cytoscape.js?** It's specifically designed for graph visualization with: + +- Multiple layout algorithms +- Good performance for medium-sized graphs +- Extensive customization options + +### Step 10: Create Source Code Viewer + +**Why this matters:** Seeing the graph isn't enough - users need to see the actual code behind the nodes. + +**Implementation:** + +```tsx +import React, { useState, useEffect } from 'react'; +import { KnowledgeGraph } from '@/core/graph/types'; + +interface SourceViewerProps { + graph: KnowledgeGraph; + selectedNodeId: string | null; +} + +export const SourceViewer: React.FC = ({ graph, selectedNodeId }) => { + const [sourceCode, setSourceCode] = useState(''); + const [fileName, setFileName] = useState(''); + const [lineNumber, setLineNumber] = useState(null); + + useEffect(() => { + if (!selectedNodeId) { + setSourceCode(''); + setFileName(''); + setLineNumber(null); + return; + } + + // Find the node in the graph + const node = graph.nodes.find(n => n.id === selectedNodeId); + if (!node) return; + + // For functions, get the source code + if (node.label === 'Function' || node.label === 'Method') { + const filePath = node.properties.path; + const startLine = node.properties.start_line; + + // In a real implementation, you'd have the source code available + setFileName(filePath); + setLineNumber(startLine); + setSourceCode(`# Source code for ${node.properties.qualified_name} +# Line ${startLine} and following...`); + } + }, [graph, selectedNodeId]); + + if (!selectedNodeId || !sourceCode) { + return ( +
+

Select a node to view source code

+
+ ); + } + + return ( +
+
+ {fileName} + {lineNumber && ( + Line {lineNumber} + )} +
+
+
{sourceCode}
+
+
+ ); +}; +``` + +**How this works:** + +1. When a node is selected, it finds the corresponding code element +2. It displays the source code with line numbers +3. It highlights the relevant part of the code + +**Why is this important?** It bridges the gap between the abstract graph and the concrete code, helping users understand what they're seeing. + +## Phase 5: RAG Chat Interface + +### Step 11: Implement LLM Service + +**Why this matters:** The chat interface needs to connect to LLMs (Large Language Models) to translate natural language to graph queries. + +**Key concepts:** + +- We'll support multiple LLM providers (OpenAI, Anthropic, Gemini) +- Users provide their own API keys (privacy-focused) +- We need a consistent interface for different providers + +**Implementation:** + +```typescript +import { ChatOpenAI } from 'langchain/chat_models/openai'; +import { ChatAnthropic } from 'langchain/chat_models/anthropic'; +import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; + +export type LLMProvider = 'openai' | 'anthropic' | 'gemini'; + +export interface LLMConfig { + provider: LLMProvider; + apiKey: string; + model?: string; +} + +export class LLMService { + private config: LLMConfig; + + constructor(config: LLMConfig) { + this.config = config; + } + + getChatModel() { + switch (this.config.provider) { + case 'openai': + return new ChatOpenAI({ + apiKey: this.config.apiKey, + modelName: this.config.model || 'gpt-4-turbo', + temperature: 0 + }); + case 'anthropic': + return new ChatAnthropic({ + apiKey: this.config.apiKey, + modelName: this.config.model || 'claude-3-sonnet-20240229', + temperature: 0 + }); + case 'gemini': + return new ChatGoogleGenerativeAI({ + apiKey: this.config.apiKey, + modelName: this.config.model || 'gemini-1.5-pro-latest', + temperature: 0 + }); + default: + throw new Error(`Unsupported LLM provider: ${this.config.provider}`); + } + } +} +``` + +**How this works:** + +1. The service takes an LLM configuration (provider, API key, model) +2. It returns a consistent chat model interface regardless of provider +3. It handles provider-specific initialization + +**Why support multiple providers?** Different users have different preferences and API key availability. + +### Step 12: Implement Cypher Generator + +**Why this matters:** The core of the RAG system - translating natural language questions to graph queries. + +**Key concepts:** + +- We use a system prompt to instruct the LLM +- The prompt includes our graph schema +- We clean the response to get a valid Cypher query + +**Implementation:** + +```typescript +import { BaseChatModel } from 'langchain/chat_models/base'; +import { CYPHER_SYSTEM_PROMPT } from '../prompts/cypher'; + +export class CypherGenerator { + private llm: BaseChatModel; + + constructor(llm: BaseChatModel) { + this.llm = llm; + } + + async generate(naturalLanguageQuery: string): Promise { + const response = await this.llm.call([ + { role: 'system', content: CYPHER_SYSTEM_PROMPT }, + { role: 'user', content: naturalLanguageQuery } + ]); + + return this.cleanResponse(response.content); + } + + private cleanResponse(response: string): string { + // Remove markdown code blocks + let cleaned = response.replace(/```cypher/g, '').replace(/```/g, ''); + // Ensure it ends with a semicolon + if (!cleaned.trim().endsWith(';')) { + cleaned = cleaned.trim() + ';'; + } + return cleaned; + } +} +``` + +**How this works:** + +1. It sends the natural language query with a system prompt to the LLM +2. The system prompt teaches the LLM about our graph structure +3. It cleans the response to extract a valid Cypher query + +**Why is the system prompt important?** It provides the LLM with the context it needs to generate correct queries. Without it, the LLM wouldn't know about our graph schema. + +### Step 13: Implement RAG Orchestrator + +**Why this matters:** This is the "brain" of the system that coordinates the query process. + +**Key concepts:** + +- It follows a ReAct (Reason + Act) pattern +- It plans steps, uses tools, observes results, and responds +- It prevents hallucination by sticking to tool results + +**Implementation:** + +```typescript +import { BaseChatModel } from 'langchain/chat_models/base'; +import { RAG_ORCHESTRATOR_SYSTEM_PROMPT } from '../prompts/rag-orchestrator'; + +export class RAGOrchestrator { + private llm: BaseChatModel; + + constructor(llm: BaseChatModel) { + this.llm = llm; + } + + async query( + userQuery: string, + queryGraph: (cypher: string) => Promise, + retrieveCode: (nodeId: string) => Promise + ) { + // Start with the system prompt + let conversation = [ + { role: 'system', content: RAG_ORCHESTRATOR_SYSTEM_PROMPT } + ]; + + // Add the user's question + conversation.push({ role: 'user', content: userQuery }); + + // Simple ReAct loop + for (let i = 0; i < 5; i++) { // Max 5 steps + const response = await this.llm.call(conversation); + const responseContent = response.content; + + // Check if the response contains a tool call + if (responseContent.includes('Action: query_graph')) { + const match = responseContent.match(/Action Input: (.*)/); + if (match) { + const cypherQuery = match[1].trim(); + + // Execute the query + const queryResults = await queryGraph(cypherQuery); + + // Add the observation to the conversation + conversation.push({ + role: 'assistant', + content: responseContent + }); + + conversation.push({ + role: 'system', + content: `Observation: ${JSON.stringify(queryResults)}` + }); + + // If we have results, we might be done + if (queryResults.length > 0) { + break; + } + } + } + else if (responseContent.includes('Action: retrieve_code')) { + // Similar handling for code retrieval + } + else { + // This appears to be the final answer + return responseContent; + } + } + + // If we got here without a final answer, generate one + conversation.push({ + role: 'user', + content: 'Please provide your final answer based on the information gathered.' + }); + + const finalResponse = await this.llm.call(conversation); + return finalResponse.content; + } +} +``` + +**How this works:** + +1. It starts with a system prompt that defines the rules +2. It sends the user's query to the LLM +3. The LLM responds with either: + - A tool call (query_graph or retrieve_code) + - A final answer +4. If it's a tool call, it executes the tool and adds the result to the conversation +5. It repeats until it gets a final answer or hits the step limit + +**Why the step limit?** To prevent infinite loops if the LLM gets stuck. + +## Phase 6: Main Application Integration + +### Step 14: Create Main Application Component + +**Why this matters:** This brings all the pieces together into a cohesive UI. + +**Implementation:** + +```tsx +import React, { useState, useRef } from 'react'; +import { GraphVisualization } from '@/ui/components/graph/Visualization'; +import { GraphControls } from '@/ui/components/graph/Controls'; +import { SourceViewer } from '@/ui/components/graph/SourceViewer'; +import { ChatInterface } from '@/ui/components/chat/ChatInterface'; +import { KnowledgeGraph } from '@/core/graph/types'; +import { GitHubService } from '@/services/github'; +import { ZipService } from '@/services/zip'; +import { ingestionWorkerApi } from '@/lib/workerUtils'; + +export const HomePage = () => { + const [graph, setGraph] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [repoUrl, setRepoUrl] = useState(''); + const fileInputRef = useRef(null); + + const handleRepoSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!repoUrl.trim() || isLoading) return; + + setIsLoading(true); + setError(null); + + try { + // Parse the GitHub URL + const urlMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); + if (!urlMatch) { + throw new Error('Invalid GitHub repository URL'); + } + + const owner = urlMatch[1]; + const repo = urlMatch[2].replace(/\.git$/, ''); + + // Fetch repository contents + const githubService = new GitHubService(); + const contents = await githubService.getRepoContents(owner, repo); + + // Filter for Python files + const pythonFiles = contents + .filter((item: any) => item.type === 'file' && item.name.endsWith('.py')) + .map((item: any) => item.path); + + // Fetch file contents + const fileContents: Record = {}; + for (const filePath of pythonFiles) { + fileContents[filePath] = await githubService.getFileContent(owner, repo, filePath); + } + + // Process the repository + const projectName = `${owner}/${repo}`; + const processedGraph = await ingestionWorkerApi.processRepository( + repoUrl, + projectName, + pythonFiles, + fileContents + ); + + setGraph(processedGraph); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to process repository'); + console.error('Processing error:', err); + } finally { + setIsLoading(false); + } + }; + + const handleQuery = async (query: string): Promise => { + if (!graph) { + throw new Error('No graph available'); + } + + // In a real implementation, this would use the RAG orchestrator + return `I found information related to "${query}" in the codebase.`; + }; + + return ( +
+ {/* Header with repository input */} +
+
+

GitNexus

+ +
+
+
+ setRepoUrl(e.target.value)} + placeholder="https://github.com/owner/repo.git" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> + +
+
+ +
+ or + + +
+
+
+
+ +
+ {/* Graph Visualization Pane */} +
+
+ {graph ? ( + <> + {}} + onLayoutChange={() => {}} + /> +
+ +
+ + ) : ( +
+ {isLoading ? ( +
+
+

Processing repository...

+
+ ) : ( +

Enter a repository URL or upload a ZIP to get started

+ )} +
+ )} +
+
+ + {/* Right Panel */} +
+ {/* Source Viewer */} +
+
+

Source Code

+
+ {graph ? ( + + ) : ( +
+

Select a node to view source

+
+ )} +
+ + {/* Chat Interface */} +
+
+

Ask About Code

+
+ {graph ? ( + + ) : ( +
+

Process a repository to ask questions about the code

+
+ )} +
+
+
+
+ ); +}; +``` + +**How this works:** + +1. The header has inputs for GitHub URLs and ZIP uploads +2. The main area has two panes: + - Left: Graph visualization + - Right: Source viewer and chat interface +3. When a repository is processed, the graph is displayed +4. Users can click nodes to see source code and ask questions + +**Why this layout?** It provides a cohesive experience where users can: + +- See the big picture (graph) +- Drill down to specific code (source viewer) +- Ask questions about what they're seeing (chat) + +## Final Steps: Testing and Optimization + +### Step 15: Add Error Boundaries + +**Why this matters:** Inevitably, something will go wrong. We want to handle errors gracefully. + +**Implementation:** + +```tsx +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Uncaught error:", error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( +
+

Something went wrong

+

{this.state.error?.message}

+ +
+ ); + } + + return this.props.children; + } +} +``` + +**How this works:** + +- It catches JavaScript errors in child components +- It displays a friendly error message instead of a blank screen +- It allows users to try again without losing their work + +**Why use error boundaries?** They prevent a single error from breaking the entire application. + +### Step 16: Implement Performance Optimizations + +**Why this matters:** Large repositories can be slow to process. We need to keep the UI responsive. + +**Key optimizations:** + +1. **Web Workers**: Already implemented for graph processing +2. **Progress Indicators**: Show users what's happening +3. **File Filtering**: Only process relevant files +4. **Lazy Loading**: Load components as needed + +**Implementation:** + +```tsx +// Add to your GitHub processing function +const MAX_FILES = 500; // Limit for free tier +if (pythonFiles.length > MAX_FILES) { + // Offer to filter by directory or file pattern + const shouldFilter = window.confirm( + `Repository has ${pythonFiles.length} Python files (max ${MAX_FILES}). ` + + `Would you like to filter by directory or file pattern?` + ); + if (shouldFilter) { + const filterPattern = prompt( + "Enter a directory path or file pattern to filter (e.g., 'src/', '*.py')", + "src/" + ); + if (filterPattern) { + const filteredFiles = pythonFiles.filter(file => + file.includes(filterPattern) || file.endsWith(filterPattern) + ); + pythonFiles = filteredFiles; + } + } +} +``` + +**Why limit file processing?** Processing too many files can: + +- Freeze the browser tab +- Exceed GitHub API rate limits +- Use excessive memory + +### Step 17: Add Export Functionality + +**Why this matters:** Users might want to save or share their generated graphs. + +**Implementation:** + +```tsx +export function exportGraphToJson(graph: KnowledgeGraph): string { + return JSON.stringify(graph, null, 2); +} + +export function downloadGraph(graph: KnowledgeGraph, filename: string = 'gitnexus-graph.json') { + const json = exportGraphToJson(graph); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} +``` + +**How this works:** + +1. Converts the graph to JSON +2. Creates a downloadable file +3. Triggers a download + +**Why include export?** It allows users to: + +- Save their work for later +- Share graphs with teammates +- Use the data in other tools + +## Conclusion + +This implementation guide has walked you through building a complete edge-based code knowledge graph generator using Deno. By following these steps, you'll create a privacy-focused tool that runs entirely in the user's browser. + +**Key advantages of this approach:** + +- **Zero server costs**: All processing happens in the user's browser +- **Strong privacy**: Code never leaves the user's machine +- **Modular architecture**: Easy to add more languages later +- **Clear separation of concerns**: Makes the codebase maintainable +- **Deno compatibility**: Modern runtime with built-in TypeScript support + +Remember to start small (Python support only) and iterate, adding more features and language support as you validate the core functionality. The most important part is getting the graph construction pipeline working correctly - everything else builds on that foundation. diff --git a/deno.lock b/deno.lock new file mode 100644 index 000000000..5538eae60 --- /dev/null +++ b/deno.lock @@ -0,0 +1,1246 @@ +{ + "version": "4", + "specifiers": { + "npm:@eslint/js@^9.11.1": "9.13.0", + "npm:@types/react-dom@^18.3.0": "18.3.1", + "npm:@types/react@^18.3.10": "18.3.11", + "npm:@vitejs/plugin-react@^4.3.2": "4.3.3_vite@5.4.9_@babel+core@7.25.8", + "npm:eslint-plugin-react-hooks@^5.1.0-rc.0": "5.1.0-rc-fb9a90fa48-20240614_eslint@9.13.0", + "npm:eslint-plugin-react-refresh@~0.4.12": "0.4.13_eslint@9.13.0", + "npm:eslint@^9.11.1": "9.13.0", + "npm:globals@^15.9.0": "15.11.0", + "npm:react-dom@^18.3.1": "18.3.1_react@18.3.1", + "npm:react@^18.3.1": "18.3.1", + "npm:typescript-eslint@^8.7.0": "8.11.0_@typescript-eslint+parser@8.11.0__eslint@9.13.0__typescript@5.6.3_eslint@9.13.0_typescript@5.6.3", + "npm:typescript@^5.5.3": "5.6.3", + "npm:vite@^5.4.8": "5.4.9" + }, + "npm": { + "@ampproject/remapping@2.3.0": { + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@babel/code-frame@7.25.7": { + "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "dependencies": [ + "@babel/highlight", + "picocolors" + ] + }, + "@babel/compat-data@7.25.8": { + "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==" + }, + "@babel/core@7.25.8": { + "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", + "dependencies": [ + "@ampproject/remapping", + "@babel/code-frame", + "@babel/generator", + "@babel/helper-compilation-targets", + "@babel/helper-module-transforms", + "@babel/helpers", + "@babel/parser", + "@babel/template", + "@babel/traverse", + "@babel/types", + "convert-source-map", + "debug", + "gensync", + "json5", + "semver@6.3.1" + ] + }, + "@babel/generator@7.25.7": { + "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "dependencies": [ + "@babel/types", + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping", + "jsesc" + ] + }, + "@babel/helper-compilation-targets@7.25.7": { + "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", + "dependencies": [ + "@babel/compat-data", + "@babel/helper-validator-option", + "browserslist", + "lru-cache", + "semver@6.3.1" + ] + }, + "@babel/helper-module-imports@7.25.7": { + "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", + "dependencies": [ + "@babel/traverse", + "@babel/types" + ] + }, + "@babel/helper-module-transforms@7.25.7_@babel+core@7.25.8": { + "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", + "dependencies": [ + "@babel/core", + "@babel/helper-module-imports", + "@babel/helper-simple-access", + "@babel/helper-validator-identifier", + "@babel/traverse" + ] + }, + "@babel/helper-plugin-utils@7.25.7": { + "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==" + }, + "@babel/helper-simple-access@7.25.7": { + "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", + "dependencies": [ + "@babel/traverse", + "@babel/types" + ] + }, + "@babel/helper-string-parser@7.25.7": { + "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==" + }, + "@babel/helper-validator-identifier@7.25.7": { + "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==" + }, + "@babel/helper-validator-option@7.25.7": { + "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==" + }, + "@babel/helpers@7.25.7": { + "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", + "dependencies": [ + "@babel/template", + "@babel/types" + ] + }, + "@babel/highlight@7.25.7": { + "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "dependencies": [ + "@babel/helper-validator-identifier", + "chalk@2.4.2", + "js-tokens", + "picocolors" + ] + }, + "@babel/parser@7.25.8": { + "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "dependencies": [ + "@babel/types" + ] + }, + "@babel/plugin-transform-react-jsx-self@7.25.7_@babel+core@7.25.8": { + "integrity": "sha512-JD9MUnLbPL0WdVK8AWC7F7tTG2OS6u/AKKnsK+NdRhUiVdnzyR1S3kKQCaRLOiaULvUiqK6Z4JQE635VgtCFeg==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-transform-react-jsx-source@7.25.7_@babel+core@7.25.8": { + "integrity": "sha512-S/JXG/KrbIY06iyJPKfxr0qRxnhNOdkNXYBl/rmwgDd72cQLH9tEGkDm/yJPGvcSIUoikzfjMios9i+xT/uv9w==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/template@7.25.7": { + "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", + "dependencies": [ + "@babel/code-frame", + "@babel/parser", + "@babel/types" + ] + }, + "@babel/traverse@7.25.7": { + "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/parser", + "@babel/template", + "@babel/types", + "debug", + "globals@11.12.0" + ] + }, + "@babel/types@7.25.8": { + "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", + "dependencies": [ + "@babel/helper-string-parser", + "@babel/helper-validator-identifier", + "to-fast-properties" + ] + }, + "@esbuild/aix-ppc64@0.21.5": { + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==" + }, + "@esbuild/android-arm64@0.21.5": { + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==" + }, + "@esbuild/android-arm@0.21.5": { + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==" + }, + "@esbuild/android-x64@0.21.5": { + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==" + }, + "@esbuild/darwin-arm64@0.21.5": { + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==" + }, + "@esbuild/darwin-x64@0.21.5": { + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==" + }, + "@esbuild/freebsd-arm64@0.21.5": { + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==" + }, + "@esbuild/freebsd-x64@0.21.5": { + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==" + }, + "@esbuild/linux-arm64@0.21.5": { + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==" + }, + "@esbuild/linux-arm@0.21.5": { + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==" + }, + "@esbuild/linux-ia32@0.21.5": { + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==" + }, + "@esbuild/linux-loong64@0.21.5": { + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==" + }, + "@esbuild/linux-mips64el@0.21.5": { + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==" + }, + "@esbuild/linux-ppc64@0.21.5": { + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==" + }, + "@esbuild/linux-riscv64@0.21.5": { + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==" + }, + "@esbuild/linux-s390x@0.21.5": { + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==" + }, + "@esbuild/linux-x64@0.21.5": { + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==" + }, + "@esbuild/netbsd-x64@0.21.5": { + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==" + }, + "@esbuild/openbsd-x64@0.21.5": { + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==" + }, + "@esbuild/sunos-x64@0.21.5": { + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==" + }, + "@esbuild/win32-arm64@0.21.5": { + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==" + }, + "@esbuild/win32-ia32@0.21.5": { + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==" + }, + "@esbuild/win32-x64@0.21.5": { + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==" + }, + "@eslint-community/eslint-utils@4.4.0_eslint@9.13.0": { + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dependencies": [ + "eslint", + "eslint-visitor-keys@3.4.3" + ] + }, + "@eslint-community/regexpp@4.11.1": { + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==" + }, + "@eslint/config-array@0.18.0": { + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dependencies": [ + "@eslint/object-schema", + "debug", + "minimatch@3.1.2" + ] + }, + "@eslint/core@0.7.0": { + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==" + }, + "@eslint/eslintrc@3.1.0": { + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dependencies": [ + "ajv", + "debug", + "espree", + "globals@14.0.0", + "ignore", + "import-fresh", + "js-yaml", + "minimatch@3.1.2", + "strip-json-comments" + ] + }, + "@eslint/js@9.13.0": { + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==" + }, + "@eslint/object-schema@2.1.4": { + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==" + }, + "@eslint/plugin-kit@0.2.1": { + "integrity": "sha512-HFZ4Mp26nbWk9d/BpvP0YNL6W4UoZF0VFcTw/aPPA8RpOxeFQgK+ClABGgAUXs9Y/RGX/l1vOmrqz1MQt9MNuw==", + "dependencies": [ + "levn" + ] + }, + "@humanfs/core@0.19.0": { + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==" + }, + "@humanfs/node@0.16.5": { + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dependencies": [ + "@humanfs/core", + "@humanwhocodes/retry" + ] + }, + "@humanwhocodes/module-importer@1.0.1": { + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + }, + "@humanwhocodes/retry@0.3.1": { + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==" + }, + "@jridgewell/gen-mapping@0.3.5": { + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dependencies": [ + "@jridgewell/set-array", + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/set-array@1.2.1": { + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==" + }, + "@jridgewell/sourcemap-codec@1.5.0": { + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + }, + "@jridgewell/trace-mapping@0.3.25": { + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@nodelib/fs.scandir@2.1.5": { + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": [ + "@nodelib/fs.stat", + "run-parallel" + ] + }, + "@nodelib/fs.stat@2.0.5": { + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk@1.2.8": { + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": [ + "@nodelib/fs.scandir", + "fastq" + ] + }, + "@rollup/rollup-android-arm-eabi@4.24.0": { + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==" + }, + "@rollup/rollup-android-arm64@4.24.0": { + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==" + }, + "@rollup/rollup-darwin-arm64@4.24.0": { + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==" + }, + "@rollup/rollup-darwin-x64@4.24.0": { + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==" + }, + "@rollup/rollup-linux-arm-gnueabihf@4.24.0": { + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==" + }, + "@rollup/rollup-linux-arm-musleabihf@4.24.0": { + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==" + }, + "@rollup/rollup-linux-arm64-gnu@4.24.0": { + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==" + }, + "@rollup/rollup-linux-arm64-musl@4.24.0": { + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==" + }, + "@rollup/rollup-linux-powerpc64le-gnu@4.24.0": { + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==" + }, + "@rollup/rollup-linux-riscv64-gnu@4.24.0": { + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==" + }, + "@rollup/rollup-linux-s390x-gnu@4.24.0": { + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==" + }, + "@rollup/rollup-linux-x64-gnu@4.24.0": { + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==" + }, + "@rollup/rollup-linux-x64-musl@4.24.0": { + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==" + }, + "@rollup/rollup-win32-arm64-msvc@4.24.0": { + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==" + }, + "@rollup/rollup-win32-ia32-msvc@4.24.0": { + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==" + }, + "@rollup/rollup-win32-x64-msvc@4.24.0": { + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==" + }, + "@types/babel__core@7.20.5": { + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@types/babel__generator", + "@types/babel__template", + "@types/babel__traverse" + ] + }, + "@types/babel__generator@7.6.8": { + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/babel__template@7.4.4": { + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dependencies": [ + "@babel/parser", + "@babel/types" + ] + }, + "@types/babel__traverse@7.20.6": { + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/estree@1.0.6": { + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + }, + "@types/json-schema@7.0.15": { + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + "@types/prop-types@15.7.13": { + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" + }, + "@types/react-dom@18.3.1": { + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dependencies": [ + "@types/react" + ] + }, + "@types/react@18.3.11": { + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", + "dependencies": [ + "@types/prop-types", + "csstype" + ] + }, + "@typescript-eslint/eslint-plugin@8.11.0_@typescript-eslint+parser@8.11.0__eslint@9.13.0__typescript@5.6.3_eslint@9.13.0_typescript@5.6.3": { + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", + "dependencies": [ + "@eslint-community/regexpp", + "@typescript-eslint/parser", + "@typescript-eslint/scope-manager", + "@typescript-eslint/type-utils", + "@typescript-eslint/utils", + "@typescript-eslint/visitor-keys", + "eslint", + "graphemer", + "ignore", + "natural-compare", + "ts-api-utils" + ] + }, + "@typescript-eslint/parser@8.11.0_eslint@9.13.0_typescript@5.6.3": { + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", + "dependencies": [ + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/visitor-keys", + "debug", + "eslint" + ] + }, + "@typescript-eslint/scope-manager@8.11.0": { + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys" + ] + }, + "@typescript-eslint/type-utils@8.11.0_eslint@9.13.0_typescript@5.6.3": { + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", + "dependencies": [ + "@typescript-eslint/typescript-estree", + "@typescript-eslint/utils", + "debug", + "ts-api-utils" + ] + }, + "@typescript-eslint/types@8.11.0": { + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==" + }, + "@typescript-eslint/typescript-estree@8.11.0_typescript@5.6.3": { + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys", + "debug", + "fast-glob", + "is-glob", + "minimatch@9.0.5", + "semver@7.7.1", + "ts-api-utils" + ] + }, + "@typescript-eslint/utils@8.11.0_eslint@9.13.0_typescript@5.6.3": { + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "eslint" + ] + }, + "@typescript-eslint/visitor-keys@8.11.0": { + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", + "dependencies": [ + "@typescript-eslint/types", + "eslint-visitor-keys@3.4.3" + ] + }, + "@vitejs/plugin-react@4.3.3_vite@5.4.9_@babel+core@7.25.8": { + "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@types/babel__core", + "react-refresh", + "vite" + ] + }, + "acorn-jsx@5.3.2_acorn@8.13.0": { + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dependencies": [ + "acorn" + ] + }, + "acorn@8.13.0": { + "integrity": "sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==" + }, + "ajv@6.12.6": { + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": [ + "fast-deep-equal", + "fast-json-stable-stringify", + "json-schema-traverse", + "uri-js" + ] + }, + "ansi-styles@3.2.1": { + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": [ + "color-convert@1.9.3" + ] + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert@2.0.1" + ] + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion@1.1.11": { + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": [ + "balanced-match", + "concat-map" + ] + }, + "brace-expansion@2.0.1": { + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": [ + "balanced-match" + ] + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, + "browserslist@4.24.0": { + "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "dependencies": [ + "caniuse-lite", + "electron-to-chromium", + "node-releases", + "update-browserslist-db" + ] + }, + "callsites@3.1.0": { + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" + }, + "caniuse-lite@1.0.30001669": { + "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==" + }, + "chalk@2.4.2": { + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": [ + "ansi-styles@3.2.1", + "escape-string-regexp@1.0.5", + "supports-color@5.5.0" + ] + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles@4.3.0", + "supports-color@7.2.0" + ] + }, + "color-convert@1.9.3": { + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": [ + "color-name@1.1.3" + ] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name@1.1.4" + ] + }, + "color-name@1.1.3": { + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "concat-map@0.0.1": { + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "convert-source-map@2.0.0": { + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "cross-spawn@7.0.3": { + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, + "csstype@3.1.3": { + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "debug@4.4.0": { + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": [ + "ms" + ] + }, + "deep-is@0.1.4": { + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + }, + "electron-to-chromium@1.5.41": { + "integrity": "sha512-dfdv/2xNjX0P8Vzme4cfzHqnPm5xsZXwsolTYr0eyW18IUmNyG08vL+fttvinTfhKfIKdRoqkDIC9e9iWQCNYQ==" + }, + "esbuild@0.21.5": { + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-x64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ] + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "escape-string-regexp@1.0.5": { + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + }, + "escape-string-regexp@4.0.0": { + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "eslint-plugin-react-hooks@5.1.0-rc-fb9a90fa48-20240614_eslint@9.13.0": { + "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", + "dependencies": [ + "eslint" + ] + }, + "eslint-plugin-react-refresh@0.4.13_eslint@9.13.0": { + "integrity": "sha512-f1EppwrpJRWmqDTyvAyomFVDYRtrS7iTEqv3nokETnMiMzs2SSTmKRTACce4O2p4jYyowiSMvpdwC/RLcMFhuQ==", + "dependencies": [ + "eslint" + ] + }, + "eslint-scope@8.1.0": { + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dependencies": [ + "esrecurse", + "estraverse" + ] + }, + "eslint-visitor-keys@3.4.3": { + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + }, + "eslint-visitor-keys@4.1.0": { + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==" + }, + "eslint@9.13.0": { + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@eslint-community/regexpp", + "@eslint/config-array", + "@eslint/core", + "@eslint/eslintrc", + "@eslint/js", + "@eslint/plugin-kit", + "@humanfs/node", + "@humanwhocodes/module-importer", + "@humanwhocodes/retry", + "@types/estree", + "@types/json-schema", + "ajv", + "chalk@4.1.2", + "cross-spawn", + "debug", + "escape-string-regexp@4.0.0", + "eslint-scope", + "eslint-visitor-keys@4.1.0", + "espree", + "esquery", + "esutils", + "fast-deep-equal", + "file-entry-cache", + "find-up", + "glob-parent@6.0.2", + "ignore", + "imurmurhash", + "is-glob", + "json-stable-stringify-without-jsonify", + "lodash.merge", + "minimatch@3.1.2", + "natural-compare", + "optionator", + "text-table" + ] + }, + "espree@10.2.0_acorn@8.13.0": { + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dependencies": [ + "acorn", + "acorn-jsx", + "eslint-visitor-keys@4.1.0" + ] + }, + "esquery@1.6.0": { + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dependencies": [ + "estraverse" + ] + }, + "esrecurse@4.3.0": { + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dependencies": [ + "estraverse" + ] + }, + "estraverse@5.3.0": { + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + "esutils@2.0.3": { + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-glob@3.3.2": { + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": [ + "@nodelib/fs.stat", + "@nodelib/fs.walk", + "glob-parent@5.1.2", + "merge2", + "micromatch" + ] + }, + "fast-json-stable-stringify@2.1.0": { + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fast-levenshtein@2.0.6": { + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + }, + "fastq@1.17.1": { + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": [ + "reusify" + ] + }, + "file-entry-cache@8.0.0": { + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dependencies": [ + "flat-cache" + ] + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, + "find-up@5.0.0": { + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "flat-cache@4.0.1": { + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dependencies": [ + "flatted", + "keyv" + ] + }, + "flatted@3.3.1": { + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==" + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==" + }, + "gensync@1.0.0-beta.2": { + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, + "glob-parent@6.0.2": { + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": [ + "is-glob" + ] + }, + "globals@11.12.0": { + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + }, + "globals@14.0.0": { + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" + }, + "globals@15.11.0": { + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==" + }, + "graphemer@1.4.0": { + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + }, + "has-flag@3.0.0": { + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "ignore@5.3.2": { + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" + }, + "import-fresh@3.3.0": { + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dependencies": [ + "parent-module", + "resolve-from" + ] + }, + "imurmurhash@0.1.4": { + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml@4.1.0": { + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": [ + "argparse" + ] + }, + "jsesc@3.0.2": { + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==" + }, + "json-buffer@3.0.1": { + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "json-schema-traverse@0.4.1": { + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stable-stringify-without-jsonify@1.0.1": { + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + }, + "json5@2.2.3": { + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" + }, + "keyv@4.5.4": { + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dependencies": [ + "json-buffer" + ] + }, + "levn@0.4.1": { + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dependencies": [ + "prelude-ls", + "type-check" + ] + }, + "locate-path@6.0.0": { + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": [ + "p-locate" + ] + }, + "lodash.merge@4.6.2": { + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "loose-envify@1.4.0": { + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": [ + "js-tokens" + ] + }, + "lru-cache@5.1.1": { + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": [ + "yallist" + ] + }, + "merge2@1.4.1": { + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch" + ] + }, + "minimatch@3.1.2": { + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": [ + "brace-expansion@1.1.11" + ] + }, + "minimatch@9.0.5": { + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": [ + "brace-expansion@2.0.1" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@3.3.7": { + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==" + }, + "natural-compare@1.4.0": { + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + }, + "node-releases@2.0.18": { + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" + }, + "optionator@0.9.4": { + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dependencies": [ + "deep-is", + "fast-levenshtein", + "levn", + "prelude-ls", + "type-check", + "word-wrap" + ] + }, + "p-limit@3.1.0": { + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": [ + "yocto-queue" + ] + }, + "p-locate@5.0.0": { + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": [ + "p-limit" + ] + }, + "parent-module@1.0.1": { + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": [ + "callsites" + ] + }, + "path-exists@4.0.0": { + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "postcss@8.4.47": { + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "prelude-ls@1.2.1": { + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "queue-microtask@1.2.3": { + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "react-dom@18.3.1_react@18.3.1": { + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": [ + "loose-envify", + "react", + "scheduler" + ] + }, + "react-refresh@0.14.2": { + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" + }, + "react@18.3.1": { + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": [ + "loose-envify" + ] + }, + "resolve-from@4.0.0": { + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + }, + "reusify@1.0.4": { + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" + }, + "rollup@4.24.0": { + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "dependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-powerpc64le-gnu", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-msvc", + "@types/estree", + "fsevents" + ] + }, + "run-parallel@1.2.0": { + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dependencies": [ + "queue-microtask" + ] + }, + "scheduler@0.23.2": { + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": [ + "loose-envify" + ] + }, + "semver@6.3.1": { + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + }, + "semver@7.7.1": { + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "strip-json-comments@3.1.1": { + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + }, + "supports-color@5.5.0": { + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": [ + "has-flag@3.0.0" + ] + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag@4.0.0" + ] + }, + "text-table@0.2.0": { + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + }, + "to-fast-properties@2.0.0": { + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" + }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, + "ts-api-utils@1.3.0_typescript@5.6.3": { + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dependencies": [ + "typescript" + ] + }, + "type-check@0.4.0": { + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dependencies": [ + "prelude-ls" + ] + }, + "typescript-eslint@8.11.0_@typescript-eslint+parser@8.11.0__eslint@9.13.0__typescript@5.6.3_eslint@9.13.0_typescript@5.6.3": { + "integrity": "sha512-cBRGnW3FSlxaYwU8KfAewxFK5uzeOAp0l2KebIlPDOT5olVi65KDG/yjBooPBG0kGW/HLkoz1c/iuBFehcS3IA==", + "dependencies": [ + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + "@typescript-eslint/utils" + ] + }, + "typescript@5.6.3": { + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==" + }, + "update-browserslist-db@1.1.1_browserslist@4.24.0": { + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors" + ] + }, + "uri-js@4.4.1": { + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": [ + "punycode" + ] + }, + "vite@5.4.9": { + "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", + "dependencies": [ + "esbuild", + "fsevents", + "postcss", + "rollup" + ] + }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ] + }, + "word-wrap@1.2.5": { + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" + }, + "yallist@3.1.1": { + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "yocto-queue@0.1.0": { + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@eslint/js@^9.11.1", + "npm:@types/react-dom@^18.3.0", + "npm:@types/react@^18.3.10", + "npm:@vitejs/plugin-react@^4.3.2", + "npm:eslint-plugin-react-hooks@^5.1.0-rc.0", + "npm:eslint-plugin-react-refresh@~0.4.12", + "npm:eslint@^9.11.1", + "npm:globals@^15.9.0", + "npm:react-dom@^18.3.1", + "npm:react@^18.3.1", + "npm:typescript-eslint@^8.7.0", + "npm:typescript@^5.5.3", + "npm:vite@^5.4.8" + ] + } + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..092408a9f --- /dev/null +++ b/eslint.config.js @@ -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 }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 000000000..e4b78eae1 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 000000000..5f175bdcd --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/project_guide.md b/project_guide.md new file mode 100644 index 000000000..ff99e4418 --- /dev/null +++ b/project_guide.md @@ -0,0 +1,1455 @@ +# GitNexus: Edge-Based Code Knowledge Graph Generator for Deno - Step-by-Step Implementation Guide + +This guide will walk you through building a fully edge-based code knowledge graph generator from scratch using Deno. I'll explain each concept before showing the implementation, so you understand **why** we're doing something, not just **how** to do it. + +## Phase 1: Project Setup & Core Infrastructure + +### Step 1: Project Structure and Tooling Setup + +**Why this matters:** Before writing any code, we need to set up our development environment properly. A well-structured project makes it easier to add features later and keeps everything organized. + +**Key concepts:** + +- We're using Vite (a modern build tool) with React and TypeScript +- We need special configuration for WebAssembly (WASM) files +- A clear directory structure helps us scale to multiple languages later + +**Implementation Steps:** + +1. **Create the base project:** + +```bash +# Create project root +mkdir GitNexus +cd GitNexus + +# Initialize Vite project with React and TypeScript +npm create vite@latest . -- --template react-ts + +# Initialize Deno project +deno init +``` + +2. **Create the application directory structure:** + +```bash +# Create directories for our core components +mkdir -p src/{core,core/tree-sitter,core/graph,core/ingestion,services,ai,ai/agents,ai/prompts,ui,ui/components,ui/components/graph,ui/components/chat,ui/hooks,workers,lib,config,store} +``` + +**Why this structure?** + +- `core/`: Contains the engine that builds the knowledge graph +- `services/`: Handles external interactions (GitHub API, ZIP processing) +- `ai/`: Contains the RAG and chat functionality +- `ui/`: All user interface components +- `workers/`: Web Workers for heavy processing (keeps UI responsive) +- `lib/`: Utility functions used throughout the app + +### Step 2: Configure Build Tools for WASM + +**Why this matters:** WebAssembly (WASM) is how we'll run the Tree-sitter parsers in the browser. We need special configuration to handle these binary files correctly. + +**Key concepts:** + +- WASM files are binary files that run at near-native speed in browsers +- Vite needs special configuration to handle them properly +- We want to avoid inlining large WASM files in our JavaScript bundles + +**Implementation:** + +1. **Update `vite.config.ts`:** + +```typescript +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 + } +}) +``` + +**What this does:** + +- `assetsInclude: ['**/*.wasm']` tells Vite to treat WASM files as assets +- `assetsInlineLimit: 0` ensures WASM files aren't inlined into JavaScript (they're too large) +- `worker: { format: 'es' }` configures Web Workers to use ES modules + +2. **Configure TypeScript** with a `tsconfig.json` that has strict settings for better code quality. + +**Why strict settings?** They help catch errors early and make the code more maintainable as the project grows. + +### Step 3: Set Up WASM Parser Infrastructure + +**Why this matters:** Tree-sitter is the engine that parses code into ASTs (Abstract Syntax Trees). We need to get these parsers working in the browser via WASM. + +**Key concepts:** + +- Tree-sitter parsers for different languages are written in C +- We compile them to WASM so they can run in browsers +- We need to load these parsers on demand + +**Implementation:** + +1. **Create a public directory for WASM files:** + +```bash +mkdir -p public/wasm/python +``` + +2. **Download the Tree-sitter Python parser:** + - Get `tree-sitter-python.wasm` from [tree-sitter-python releases](https://github.com/tree-sitter/tree-sitter-python/releases) + - Place it in `public/wasm/python/` + +**Why host WASM files separately?** Browsers can't access the user's file system directly for security reasons. We need to serve the WASM files from a URL. + +3. **Create a loader for Tree-sitter parsers:** + +```typescript +import WebTreeSitter from 'web-tree-sitter'; +let parserInstance: WebTreeSitter | null = null; +const parserCache = new Map(); + +export async function initTreeSitter() { + if (parserInstance) return parserInstance; + parserInstance = await WebTreeSitter.init(); + return parserInstance; +} + +export async function loadPythonParser(): Promise { + if (parserCache.has('python')) { + return parserCache.get('python')!; + } + const Parser = await initTreeSitter(); + const pythonLang = await Parser.Language.load( + '/wasm/python/tree-sitter-python.wasm' + ); + parserCache.set('python', pythonLang); + return pythonLang; +} +``` + +**How this works:** + +1. `initTreeSitter()` initializes the WebAssembly module once +2. `loadPythonParser()` loads the Python parser from the WASM file +3. We cache parsers to avoid reloading them multiple times + +**Why cache parsers?** Loading WASM files is relatively slow, so we want to do it once and reuse the parsers. + +## Phase 2: Code Acquisition Module + +### Step 4: Implement GitHub API Integration + +**Why this matters:** Users will want to analyze public GitHub repositories, so we need a way to fetch code from GitHub. + +**Key concepts:** + +- GitHub has a REST API for accessing repository contents +- We need to handle rate limits (GitHub limits how many requests you can make) +- We'll let users provide their own API tokens for higher limits + +**Implementation:** + +```typescript +export class GitHubService { + private token: string | null = null; + + setToken(token: string) { + this.token = token; + } + + async getRepoContents(owner: string, repo: string, path = '') { + const headers: HeadersInit = { + 'Accept': 'application/vnd.github.v3+json' + }; + if (this.token) { + headers['Authorization'] = `token ${this.token}`; + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${path}`, + { headers } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + return response.json(); + } +} +``` + +**How this works:** + +- `getRepoContents()` fetches the directory structure of a repository +- It uses the GitHub API with proper headers +- It handles authentication via a token + +**Important note:** GitHub API has rate limits. For unauthenticated requests, it's about 60 requests/hour. With a token, it's 5,000/hour. + +### Step 5: Implement ZIP Processing + +**Why this matters:** Not all code is on GitHub. Users might want to analyze local code or private repositories by uploading a ZIP file. + +**Key concepts:** + +- JSZip is a library for handling ZIP files in JavaScript +- We need to extract files and their contents from the ZIP +- We'll use a Map to store file paths and contents + +**Implementation:** + +```typescript +import JSZip from 'jszip'; + +export class ZipService { + async processZip(file: File): Promise> { + const zip = await JSZip.loadAsync(file); + const files = new Map(); + + for (const [filePath, zipEntry] of Object.entries(zip.files)) { + if (!zipEntry.dir) { + const content = await zipEntry.async('text'); + files.set(filePath, content); + } + } + + return files; + } +} +``` + +**How this works:** + +1. `JSZip.loadAsync(file)` loads the ZIP file +2. We iterate through all entries in the ZIP +3. For each file (not directory), we extract its content as text +4. We store the file path and content in a Map + +**Why use a Map?** It provides O(1) lookups by file path, which is important when we need to find files during graph construction. + +## Phase 3: Graph Construction Pipeline + +### Step 6: Define Graph Data Structures + +**Why this matters:** Before we can build a graph, we need to define what nodes and relationships look like. + +**Key concepts:** + +- A knowledge graph consists of nodes and relationships +- Nodes represent code elements (functions, classes, etc.) +- Relationships represent connections between elements (calls, contains, etc.) + +**Implementation:** + +```typescript +export type NodeLabel = + | 'Project' + | 'Package' + | 'Module' + | 'Folder' + | 'File' + | 'Class' + | 'Function' + | 'Method' + | 'Variable'; + +export interface GraphNode { + id: string; + label: NodeLabel; + properties: Record; +} + +export type RelationshipType = + | 'CONTAINS' + | 'CALLS' + | 'INHERITS' + | 'OVERRIDES' + | 'IMPORTS'; + +export interface GraphRelationship { + id: string; + type: RelationshipType; + source: string; + target: string; + properties?: Record; +} + +export interface KnowledgeGraph { + nodes: GraphNode[]; + relationships: GraphRelationship[]; +} +``` + +**Why these specific types?** + +- `NodeLabel` defines all possible types of code elements we'll track +- `RelationshipType` defines how code elements connect to each other +- `KnowledgeGraph` is the complete structure we'll build + +**Important relationships:** + +- `CONTAINS`: A folder contains files, a file contains functions +- `CALLS`: A function calls another function +- `IMPORTS`: One module imports from another + +### Step 7: Implement the 3-Pass Ingestion Pipeline + +**Why this matters:** Building a complete knowledge graph requires multiple passes to handle cross-file references properly. + +**Key concepts:** + +- **Pass 1**: Identify the overall structure (folders, modules) +- **Pass 2**: Parse individual files and cache ASTs +- **Pass 3**: Process function calls across files (the hardest part) + +This three-pass approach solves the "island problem" - where functions in different files appear disconnected. + +#### Pass 1: Structure Identification + +```typescript +export class StructureProcessor { + private graph: KnowledgeGraph; + private projectRoot: string; + private projectName: string; + + constructor(graph: KnowledgeGraph, projectRoot: string, projectName: string) { + this.graph = graph; + this.projectRoot = projectRoot; + this.projectName = projectName; + } + + identifyStructure(filePaths: string[]): void { + // Add Project node + this.graph.nodes.push({ + id: `project:${this.projectName}`, + label: 'Project', + properties: { name: this.projectName } + }); + + // Track directory structure + const directories = new Set(); + for (const filePath of filePaths) { + const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); + if (dirPath && !directories.has(dirPath)) { + directories.add(dirPath); + // Create Folder node + this.graph.nodes.push({ + id: `folder:${dirPath}`, + label: 'Folder', + properties: { path: dirPath } + }); + + // Create CONTAINS relationship with parent + if (dirPath.includes('/')) { + const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')); + this.graph.relationships.push({ + id: `rel:folder:${dirPath}:parent`, + type: 'CONTAINS', + source: `folder:${parentPath}`, + target: `folder:${dirPath}` + }); + } else { + // Root folder connects to project + this.graph.relationships.push({ + id: `rel:folder:${dirPath}:project`, + type: 'CONTAINS', + source: `project:${this.projectName}`, + target: `folder:${dirPath}` + }); + } + } + } + } +} +``` + +**How this works:** + +1. Creates a root Project node +2. Walks through all file paths to identify directories +3. Creates Folder nodes and CONTAINS relationships + +**Why identify structure first?** We need to know the overall organization before parsing individual files. + +#### Pass 2: File Parsing + +```typescript +export class ParsingProcessor { + private graph: KnowledgeGraph; + private astCache = new Map(); + + constructor(graph: KnowledgeGraph) { + this.graph = graph; + } + + async parseFiles(filePaths: string[], fileContents: Map): Promise> { + for (const [filePath, content] of fileContents) { + if (filePath.endsWith('.py')) { + await this.parsePythonFile(filePath, content); + } + } + return this.astCache; + } + + private async parsePythonFile(filePath: string, content: string): Promise { + const parser = await loadPythonParser(); + const tree = parser.parse(content); + // Cache the AST + this.astCache.set(filePath, tree); + // Extract definitions from the AST + this.extractDefinitions(filePath, tree, content); + } + + private extractDefinitions(filePath: string, tree: any, content: string): void { + // Extract modules + this.graph.nodes.push({ + id: `module:${filePath}`, + label: 'Module', + properties: { + path: filePath, + name: filePath.split('/').pop()!.replace('.py', ''), + extension: '.py' + } + }); + + // Extract functions from the AST + const rootNode = tree.rootNode; + const functionDefs = rootNode.descendantsOfType('function_definition'); + for (const funcNode of functionDefs) { + const nameNode = funcNode.childForFieldName('name'); + const name = nameNode ? nameNode.text : 'unknown'; + + // Calculate position + const startLine = funcNode.startPosition.row + 1; + + // Create function node + this.graph.nodes.push({ + id: `function:${filePath}:${name}`, + label: 'Function', + properties: { + name, + qualified_name: `${this.getModuleName(filePath)}.${name}`, + path: filePath, + start_line: startLine + } + }); + + // Create CONTAINS relationship with module + this.graph.relationships.push({ + id: `rel:function:${filePath}:${name}:module`, + type: 'CONTAINS', + source: `module:${filePath}`, + target: `function:${filePath}:${name}` + }); + } + } +} +``` + +**How this works:** + +1. Parses each file with the appropriate Tree-sitter parser +2. Caches the AST for later use +3. Extracts definitions (functions, classes) from the AST +4. Creates nodes and relationships in the graph + +**Why cache ASTs?** We need them in Pass 3 to resolve cross-file function calls. + +#### Pass 3: Call Resolution + +```typescript +export class CallProcessor { + private graph: KnowledgeGraph; + private astCache: Map; + private projectRoot: string; + private projectName: string; + + constructor( + graph: KnowledgeGraph, + astCache: Map, + projectRoot: string, + projectName: string + ) { + this.graph = graph; + this.astCache = astCache; + this.projectRoot = projectRoot; + this.projectName = projectName; + } + + processCalls(): void { + for (const [filePath, tree] of this.astCache) { + if (filePath.endsWith('.py')) { + this.processPythonCalls(filePath, tree); + } + } + } + + private processPythonCalls(filePath: string, tree: any): void { + const rootNode = tree.rootNode; + // Find all call expressions + const callExpressions = rootNode.descendantsOfType('call'); + for (const callNode of callExpressions) { + const functionNameNode = callNode.childForFieldName('function'); + if (!functionNameNode) continue; + + // Handle different types of function references + let targetFunctionName = ''; + if (functionNameNode.type === 'identifier') { + targetFunctionName = functionNameNode.text; + } else if (functionNameNode.type === 'attribute') { + // Handle method calls like obj.method() + const attrNode = functionNameNode; + const objectNode = attrNode.childForFieldName('object'); + const attrNameNode = attrNode.childForFieldName('attribute'); + if (objectNode && attrNameNode) { + const objectName = objectNode.text; + const methodName = attrNameNode.text; + targetFunctionName = `${objectName}.${methodName}`; + } + } + + if (!targetFunctionName) continue; + + // Try to resolve the target function + const targetNode = this.resolveTargetFunction(targetFunctionName, filePath); + if (targetNode) { + // Create CALLS relationship + const callerId = this.getCallerId(callNode, filePath); + this.graph.relationships.push({ + id: `rel:call:${callerId}:${targetNode.id}`, + type: 'CALLS', + source: callerId, + target: targetNode.id + }); + } + } + } + + private resolveTargetFunction(targetName: string, currentFilePath: string): { id: string; type: string } | null { + // 1. Check if it's a built-in function + if (this.isBuiltInFunction(targetName)) { + return { + id: `builtin:${targetName}`, + type: 'builtin' + }; + } + + // 2. Check if it's an imported function + const importInfo = this.findImportForFunction(targetName, currentFilePath); + if (importInfo) { + const targetId = `function:${importInfo.sourceFile}:${importInfo.targetName}`; + return { + id: targetId, + type: 'imported' + }; + } + + // 3. Check if it's defined in the current file + for (const node of this.graph.nodes) { + if (node.label === 'Function' && + node.properties.name === targetName && + node.properties.path === currentFilePath) { + return { + id: node.id, + type: 'local' + }; + } + } + + return null; + } +} +``` + +**How this works:** + +1. Finds all function calls in the AST +2. Determines what function is being called +3. Resolves the target function across files using imports +4. Creates CALLS relationships in the graph + +**Why is this the hardest part?** Resolving cross-file references requires understanding: + +- How imports work in the language +- How to map a simple name to a fully qualified name +- Handling edge cases like aliases (`import helper as h`) + +### Step 8: Implement Web Workers for Performance + +**Why this matters:** Parsing code and building graphs can be CPU-intensive. Web Workers keep the UI responsive. + +**Key concepts:** + +- Web Workers run JavaScript in background threads +- They can't access the DOM directly +- We use Comlink to simplify communication + +**Implementation:** + +```typescript +// src/workers/ingestion.worker.ts +import { expose } from 'comlink'; +import { GraphPipeline } from '../core/ingestion/pipeline'; + +class IngestionWorker { + async processRepository( + projectRoot: string, + projectName: string, + filePaths: string[], + fileContents: Record + ) { + const pipeline = new GraphPipeline(projectRoot, projectName); + return pipeline.run(filePaths, new Map(Object.entries(fileContents))); + } +} + +expose(new IngestionWorker()); +``` + +**How this works:** + +1. The worker runs the heavy processing in a background thread +2. We expose methods via Comlink to call them from the main thread +3. The main thread can call these methods without blocking the UI + +**Why use Web Workers?** Without them, large repositories would freeze the browser tab while processing. + +## Phase 4: Graph Visualization + +### Step 9: Implement Graph Visualization Components + +**Why this matters:** A knowledge graph is useless if users can't see and interact with it. + +**Key concepts:** + +- Cytoscape.js is a powerful graph visualization library +- We need to convert our graph data to Cytoscape's format +- Users need controls to filter and navigate the graph + +**Implementation:** + +```tsx +import React, { useEffect, useRef } from 'react'; +import cytoscape from 'cytoscape'; +import dagre from 'cytoscape-dagre'; +import { KnowledgeGraph } from '@/core/graph/types'; + +cytoscape.use(dagre); + +interface GraphVisualizationProps { + graph: KnowledgeGraph; + onNodeClick?: (nodeId: string) => void; + filter?: (node: any) => boolean; +} + +export const GraphVisualization: React.FC = ({ + graph, + onNodeClick, + filter +}) => { + const containerRef = useRef(null); + const cyRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + // Clean up previous instance + if (cyRef.current) { + cyRef.current.destroy(); + } + + // Convert our graph to Cytoscape format + const cyElements = convertToCytoscapeElements(graph, filter); + + const cy = cytoscape({ + container: containerRef.current, + elements: cyElements, + style: [ + { + selector: 'node', + style: { + 'label': 'data(label)', + 'width': 'mapData(size, 0, 100, 20, 80)', + 'height': 'mapData(size, 0, 100, 20, 80)', + 'background-color': 'data(color)', + 'text-valign': 'center', + 'text-halign': 'center', + 'font-size': '8px' + } + }, + { + selector: 'edge', + style: { + 'width': 2, + 'line-color': '#ccc', + 'target-arrow-color': '#ccc', + 'target-arrow-shape': 'triangle' + } + } + ], + layout: { + name: 'dagre', + rankDir: 'TB', + padding: 20 + } + }); + + // Add interactions + cy.on('tap', 'node', (event) => { + const node = event.target; + const nodeId = node.data('id'); + if (onNodeClick) { + onNodeClick(nodeId); + } + }); + + cyRef.current = cy; + + return () => { + if (cyRef.current) { + cyRef.current.destroy(); + cyRef.current = null; + } + }; + }, [graph, filter]); + + return ( +
+ ); +}; + +function convertToCytoscapeElements( + graph: KnowledgeGraph, + filter?: (node: any) => boolean +) { + const elements: any[] = []; + + // Add nodes + for (const node of graph.nodes) { + if (filter && !filter(node)) continue; + elements.push({ + data: { + id: node.id, + label: getNodeLabel(node), + type: node.label, + color: getNodeColor(node.label), + size: getNodeSize(node) + } + }); + } + + // Add edges + for (const rel of graph.relationships) { + elements.push({ + data: { + id: rel.id, + source: rel.source, + target: rel.target, + label: rel.type + } + }); + } + + return elements; +} +``` + +**How this works:** + +1. Converts our graph data to Cytoscape's format +2. Sets up visual styles based on node type +3. Applies a hierarchical layout (dagre) +4. Adds interaction handlers for node clicks + +**Why use Cytoscape.js?** It's specifically designed for graph visualization with: + +- Multiple layout algorithms +- Good performance for medium-sized graphs +- Extensive customization options + +### Step 10: Create Source Code Viewer + +**Why this matters:** Seeing the graph isn't enough - users need to see the actual code behind the nodes. + +**Implementation:** + +```tsx +import React, { useState, useEffect } from 'react'; +import { KnowledgeGraph } from '@/core/graph/types'; + +interface SourceViewerProps { + graph: KnowledgeGraph; + selectedNodeId: string | null; +} + +export const SourceViewer: React.FC = ({ graph, selectedNodeId }) => { + const [sourceCode, setSourceCode] = useState(''); + const [fileName, setFileName] = useState(''); + const [lineNumber, setLineNumber] = useState(null); + + useEffect(() => { + if (!selectedNodeId) { + setSourceCode(''); + setFileName(''); + setLineNumber(null); + return; + } + + // Find the node in the graph + const node = graph.nodes.find(n => n.id === selectedNodeId); + if (!node) return; + + // For functions, get the source code + if (node.label === 'Function' || node.label === 'Method') { + const filePath = node.properties.path; + const startLine = node.properties.start_line; + + // In a real implementation, you'd have the source code available + setFileName(filePath); + setLineNumber(startLine); + setSourceCode(`# Source code for ${node.properties.qualified_name} +# Line ${startLine} and following...`); + } + }, [graph, selectedNodeId]); + + if (!selectedNodeId || !sourceCode) { + return ( +
+

Select a node to view source code

+
+ ); + } + + return ( +
+
+ {fileName} + {lineNumber && ( + Line {lineNumber} + )} +
+
+
{sourceCode}
+
+
+ ); +}; +``` + +**How this works:** + +1. When a node is selected, it finds the corresponding code element +2. It displays the source code with line numbers +3. It highlights the relevant part of the code + +**Why is this important?** It bridges the gap between the abstract graph and the concrete code, helping users understand what they're seeing. + +## Phase 5: RAG Chat Interface + +### Step 11: Implement LLM Service + +**Why this matters:** The chat interface needs to connect to LLMs (Large Language Models) to translate natural language to graph queries. + +**Key concepts:** + +- We'll support multiple LLM providers (OpenAI, Anthropic, Gemini) +- Users provide their own API keys (privacy-focused) +- We need a consistent interface for different providers + +**Implementation:** + +```typescript +import { ChatOpenAI } from 'langchain/chat_models/openai'; +import { ChatAnthropic } from 'langchain/chat_models/anthropic'; +import { ChatGoogleGenerativeAI } from '@langchain/google-genai'; + +export type LLMProvider = 'openai' | 'anthropic' | 'gemini'; + +export interface LLMConfig { + provider: LLMProvider; + apiKey: string; + model?: string; +} + +export class LLMService { + private config: LLMConfig; + + constructor(config: LLMConfig) { + this.config = config; + } + + getChatModel() { + switch (this.config.provider) { + case 'openai': + return new ChatOpenAI({ + apiKey: this.config.apiKey, + modelName: this.config.model || 'gpt-4-turbo', + temperature: 0 + }); + case 'anthropic': + return new ChatAnthropic({ + apiKey: this.config.apiKey, + modelName: this.config.model || 'claude-3-sonnet-20240229', + temperature: 0 + }); + case 'gemini': + return new ChatGoogleGenerativeAI({ + apiKey: this.config.apiKey, + modelName: this.config.model || 'gemini-1.5-pro-latest', + temperature: 0 + }); + default: + throw new Error(`Unsupported LLM provider: ${this.config.provider}`); + } + } +} +``` + +**How this works:** + +1. The service takes an LLM configuration (provider, API key, model) +2. It returns a consistent chat model interface regardless of provider +3. It handles provider-specific initialization + +**Why support multiple providers?** Different users have different preferences and API key availability. + +### Step 12: Implement Cypher Generator + +**Why this matters:** The core of the RAG system - translating natural language questions to graph queries. + +**Key concepts:** + +- We use a system prompt to instruct the LLM +- The prompt includes our graph schema +- We clean the response to get a valid Cypher query + +**Implementation:** + +```typescript +import { BaseChatModel } from 'langchain/chat_models/base'; +import { CYPHER_SYSTEM_PROMPT } from '../prompts/cypher'; + +export class CypherGenerator { + private llm: BaseChatModel; + + constructor(llm: BaseChatModel) { + this.llm = llm; + } + + async generate(naturalLanguageQuery: string): Promise { + const response = await this.llm.call([ + { role: 'system', content: CYPHER_SYSTEM_PROMPT }, + { role: 'user', content: naturalLanguageQuery } + ]); + + return this.cleanResponse(response.content); + } + + private cleanResponse(response: string): string { + // Remove markdown code blocks + let cleaned = response.replace(/```cypher/g, '').replace(/```/g, ''); + // Ensure it ends with a semicolon + if (!cleaned.trim().endsWith(';')) { + cleaned = cleaned.trim() + ';'; + } + return cleaned; + } +} +``` + +**How this works:** + +1. It sends the natural language query with a system prompt to the LLM +2. The system prompt teaches the LLM about our graph structure +3. It cleans the response to extract a valid Cypher query + +**Why is the system prompt important?** It provides the LLM with the context it needs to generate correct queries. Without it, the LLM wouldn't know about our graph schema. + +### Step 13: Implement RAG Orchestrator + +**Why this matters:** This is the "brain" of the system that coordinates the query process. + +**Key concepts:** + +- It follows a ReAct (Reason + Act) pattern +- It plans steps, uses tools, observes results, and responds +- It prevents hallucination by sticking to tool results + +**Implementation:** + +```typescript +import { BaseChatModel } from 'langchain/chat_models/base'; +import { RAG_ORCHESTRATOR_SYSTEM_PROMPT } from '../prompts/rag-orchestrator'; + +export class RAGOrchestrator { + private llm: BaseChatModel; + + constructor(llm: BaseChatModel) { + this.llm = llm; + } + + async query( + userQuery: string, + queryGraph: (cypher: string) => Promise, + retrieveCode: (nodeId: string) => Promise + ) { + // Start with the system prompt + let conversation = [ + { role: 'system', content: RAG_ORCHESTRATOR_SYSTEM_PROMPT } + ]; + + // Add the user's question + conversation.push({ role: 'user', content: userQuery }); + + // Simple ReAct loop + for (let i = 0; i < 5; i++) { // Max 5 steps + const response = await this.llm.call(conversation); + const responseContent = response.content; + + // Check if the response contains a tool call + if (responseContent.includes('Action: query_graph')) { + const match = responseContent.match(/Action Input: (.*)/); + if (match) { + const cypherQuery = match[1].trim(); + + // Execute the query + const queryResults = await queryGraph(cypherQuery); + + // Add the observation to the conversation + conversation.push({ + role: 'assistant', + content: responseContent + }); + + conversation.push({ + role: 'system', + content: `Observation: ${JSON.stringify(queryResults)}` + }); + + // If we have results, we might be done + if (queryResults.length > 0) { + break; + } + } + } + else if (responseContent.includes('Action: retrieve_code')) { + // Similar handling for code retrieval + } + else { + // This appears to be the final answer + return responseContent; + } + } + + // If we got here without a final answer, generate one + conversation.push({ + role: 'user', + content: 'Please provide your final answer based on the information gathered.' + }); + + const finalResponse = await this.llm.call(conversation); + return finalResponse.content; + } +} +``` + +**How this works:** + +1. It starts with a system prompt that defines the rules +2. It sends the user's query to the LLM +3. The LLM responds with either: + - A tool call (query_graph or retrieve_code) + - A final answer +4. If it's a tool call, it executes the tool and adds the result to the conversation +5. It repeats until it gets a final answer or hits the step limit + +**Why the step limit?** To prevent infinite loops if the LLM gets stuck. + +## Phase 6: Main Application Integration + +### Step 14: Create Main Application Component + +**Why this matters:** This brings all the pieces together into a cohesive UI. + +**Implementation:** + +```tsx +import React, { useState, useRef } from 'react'; +import { GraphVisualization } from '@/ui/components/graph/Visualization'; +import { GraphControls } from '@/ui/components/graph/Controls'; +import { SourceViewer } from '@/ui/components/graph/SourceViewer'; +import { ChatInterface } from '@/ui/components/chat/ChatInterface'; +import { KnowledgeGraph } from '@/core/graph/types'; +import { GitHubService } from '@/services/github'; +import { ZipService } from '@/services/zip'; +import { ingestionWorkerApi } from '@/lib/workerUtils'; + +export const HomePage = () => { + const [graph, setGraph] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [repoUrl, setRepoUrl] = useState(''); + const fileInputRef = useRef(null); + + const handleRepoSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!repoUrl.trim() || isLoading) return; + + setIsLoading(true); + setError(null); + + try { + // Parse the GitHub URL + const urlMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); + if (!urlMatch) { + throw new Error('Invalid GitHub repository URL'); + } + + const owner = urlMatch[1]; + const repo = urlMatch[2].replace(/\.git$/, ''); + + // Fetch repository contents + const githubService = new GitHubService(); + const contents = await githubService.getRepoContents(owner, repo); + + // Filter for Python files + const pythonFiles = contents + .filter((item: any) => item.type === 'file' && item.name.endsWith('.py')) + .map((item: any) => item.path); + + // Fetch file contents + const fileContents: Record = {}; + for (const filePath of pythonFiles) { + fileContents[filePath] = await githubService.getFileContent(owner, repo, filePath); + } + + // Process the repository + const projectName = `${owner}/${repo}`; + const processedGraph = await ingestionWorkerApi.processRepository( + repoUrl, + projectName, + pythonFiles, + fileContents + ); + + setGraph(processedGraph); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to process repository'); + console.error('Processing error:', err); + } finally { + setIsLoading(false); + } + }; + + const handleQuery = async (query: string): Promise => { + if (!graph) { + throw new Error('No graph available'); + } + + // In a real implementation, this would use the RAG orchestrator + return `I found information related to "${query}" in the codebase.`; + }; + + return ( +
+ {/* Header with repository input */} +
+
+

GitNexus

+ +
+
+
+ setRepoUrl(e.target.value)} + placeholder="https://github.com/owner/repo.git" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> + +
+
+ +
+ or + + +
+
+
+
+ +
+ {/* Graph Visualization Pane */} +
+
+ {graph ? ( + <> + {}} + onLayoutChange={() => {}} + /> +
+ +
+ + ) : ( +
+ {isLoading ? ( +
+
+

Processing repository...

+
+ ) : ( +

Enter a repository URL or upload a ZIP to get started

+ )} +
+ )} +
+
+ + {/* Right Panel */} +
+ {/* Source Viewer */} +
+
+

Source Code

+
+ {graph ? ( + + ) : ( +
+

Select a node to view source

+
+ )} +
+ + {/* Chat Interface */} +
+
+

Ask About Code

+
+ {graph ? ( + + ) : ( +
+

Process a repository to ask questions about the code

+
+ )} +
+
+
+
+ ); +}; +``` + +**How this works:** + +1. The header has inputs for GitHub URLs and ZIP uploads +2. The main area has two panes: + - Left: Graph visualization + - Right: Source viewer and chat interface +3. When a repository is processed, the graph is displayed +4. Users can click nodes to see source code and ask questions + +**Why this layout?** It provides a cohesive experience where users can: + +- See the big picture (graph) +- Drill down to specific code (source viewer) +- Ask questions about what they're seeing (chat) + +## Final Steps: Testing and Optimization + +### Step 15: Add Error Boundaries + +**Why this matters:** Inevitably, something will go wrong. We want to handle errors gracefully. + +**Implementation:** + +```tsx +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Uncaught error:", error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return ( +
+

Something went wrong

+

{this.state.error?.message}

+ +
+ ); + } + + return this.props.children; + } +} +``` + +**How this works:** + +- It catches JavaScript errors in child components +- It displays a friendly error message instead of a blank screen +- It allows users to try again without losing their work + +**Why use error boundaries?** They prevent a single error from breaking the entire application. + +### Step 16: Implement Performance Optimizations + +**Why this matters:** Large repositories can be slow to process. We need to keep the UI responsive. + +**Key optimizations:** + +1. **Web Workers**: Already implemented for graph processing +2. **Progress Indicators**: Show users what's happening +3. **File Filtering**: Only process relevant files +4. **Lazy Loading**: Load components as needed + +**Implementation:** + +```tsx +// Add to your GitHub processing function +const MAX_FILES = 500; // Limit for free tier +if (pythonFiles.length > MAX_FILES) { + // Offer to filter by directory or file pattern + const shouldFilter = window.confirm( + `Repository has ${pythonFiles.length} Python files (max ${MAX_FILES}). ` + + `Would you like to filter by directory or file pattern?` + ); + if (shouldFilter) { + const filterPattern = prompt( + "Enter a directory path or file pattern to filter (e.g., 'src/', '*.py')", + "src/" + ); + if (filterPattern) { + const filteredFiles = pythonFiles.filter(file => + file.includes(filterPattern) || file.endsWith(filterPattern) + ); + pythonFiles = filteredFiles; + } + } +} +``` + +**Why limit file processing?** Processing too many files can: + +- Freeze the browser tab +- Exceed GitHub API rate limits +- Use excessive memory + +### Step 17: Add Export Functionality + +**Why this matters:** Users might want to save or share their generated graphs. + +**Implementation:** + +```tsx +export function exportGraphToJson(graph: KnowledgeGraph): string { + return JSON.stringify(graph, null, 2); +} + +export function downloadGraph(graph: KnowledgeGraph, filename: string = 'gitnexus-graph.json') { + const json = exportGraphToJson(graph); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} +``` + +**How this works:** + +1. Converts the graph to JSON +2. Creates a downloadable file +3. Triggers a download + +**Why include export?** It allows users to: + +- Save their work for later +- Share graphs with teammates +- Use the data in other tools + +## Conclusion + +This implementation guide has walked you through building a complete edge-based code knowledge graph generator using Deno. By following these steps, you'll create a privacy-focused tool that runs entirely in the user's browser. + +**Key advantages of this approach:** + +- **Zero server costs**: All processing happens in the user's browser +- **Strong privacy**: Code never leaves the user's machine +- **Modular architecture**: Easy to add more languages later +- **Clear separation of concerns**: Makes the codebase maintainable +- **Deno compatibility**: Modern runtime with built-in TypeScript support + +Remember to start small (Python support only) and iterate, adding more features and language support as you validate the core functionality. The most important part is getting the graph construction pipeline working correctly - everything else builds on that foundation. + +Good luck with your implementation of GitNexus! diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wasm/python/tree-sitter-python.wasm b/public/wasm/python/tree-sitter-python.wasm new file mode 100644 index 0000000000000000000000000000000000000000..ebd96b5db34ec49ca6993c854f0fa70a2eb664c2 GIT binary patch literal 457796 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�YAVxkI-gdG`t3tGeow6Q{jI20y;d3yIupKH^H0G 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 z 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 H$#AppaTL { + return ( + + + + ); +}; + +export default App; diff --git a/src/ai/cypher-generator.ts b/src/ai/cypher-generator.ts new file mode 100644 index 000000000..5f75f4245 --- /dev/null +++ b/src/ai/cypher-generator.ts @@ -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 { + 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(); + const relationshipTypes = new Set(); + const nodeProperties = new Map>(); + + // 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(); + } +} \ No newline at end of file diff --git a/src/ai/index.ts b/src/ai/index.ts new file mode 100644 index 000000000..45de653d5 --- /dev/null +++ b/src/ai/index.ts @@ -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'; \ No newline at end of file diff --git a/src/ai/langchain-orchestrator.ts b/src/ai/langchain-orchestrator.ts new file mode 100644 index 000000000..3b4896f9b --- /dev/null +++ b/src/ai/langchain-orchestrator.ts @@ -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; +} + +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 { + 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 { + 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 { + 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}`); + } + } +} \ No newline at end of file diff --git a/src/ai/llm-service.ts b/src/ai/llm-service.ts new file mode 100644 index 000000000..46c973ffa --- /dev/null +++ b/src/ai/llm-service.ts @@ -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 = new Map(); + private defaultConfig: Partial = { + temperature: 0.1, + maxTokens: 4000, + maxRetries: 3 + }; + + // Default models for each provider + private static readonly DEFAULT_MODELS: Record = { + 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 { + 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}`; + } +} \ No newline at end of file diff --git a/src/ai/orchestrator.ts b/src/ai/orchestrator.ts new file mode 100644 index 000000000..71a8592ab --- /dev/null +++ b/src/ai/orchestrator.ts @@ -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; +} + +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 { + 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 { + 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 { + 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 { + 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 + }; + } +} \ No newline at end of file diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/core/graph/types.ts b/src/core/graph/types.ts new file mode 100644 index 000000000..77adb2af7 --- /dev/null +++ b/src/core/graph/types.ts @@ -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; +} + +export type RelationshipType = + | 'CONTAINS' + | 'CALLS' + | 'INHERITS' + | 'OVERRIDES' + | 'IMPORTS'; + +export interface GraphRelationship { + id: string; + type: RelationshipType; + source: string; + target: string; + properties?: Record; +} + +export interface KnowledgeGraph { + nodes: GraphNode[]; + relationships: GraphRelationship[]; +} \ No newline at end of file diff --git a/src/core/ingestion/call-processor.ts b/src/core/ingestion/call-processor.ts new file mode 100644 index 000000000..54955cfb1 --- /dev/null +++ b/src/core/ingestion/call-processor.ts @@ -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; + fileContents: Map; +} + +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 = new Map(); + private functionNodes: Map = new Map(); + + public async process(input: CallResolutionInput): Promise { + 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 { + 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 { + 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 || '', + 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 || '', + calledName: attributeNode.text, + callType: 'method', + line, + column, + objectName: objectNode.text + }; + } + } + + return null; + } + + private async resolveAndCreateCallRelationship( + graph: KnowledgeGraph, + call: FunctionCall + ): Promise { + 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 === '') { + // 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 } { + const stats = { + totalCalls: 0, + callTypes: { + function: 0, + method: 0, + builtin: 0, + imported: 0, + local: 0 + } + }; + + // This would be populated during processing + return stats; + } +} \ No newline at end of file diff --git a/src/core/ingestion/parsing-processor.ts b/src/core/ingestion/parsing-processor.ts new file mode 100644 index 000000000..ddf8ba4be --- /dev/null +++ b/src/core/ingestion/parsing-processor.ts @@ -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; +} + +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 = new Map(); + + public async process(graph: KnowledgeGraph, input: ParsingInput): Promise { + 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 { + 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 { + 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 { + return new Map(this.astCache); + } +} \ No newline at end of file diff --git a/src/core/ingestion/pipeline.ts b/src/core/ingestion/pipeline.ts new file mode 100644 index 000000000..f1a252e91 --- /dev/null +++ b/src/core/ingestion/pipeline.ts @@ -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; +} + +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 { + 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; relationshipStats: Record } { + const nodeStats: Record = {}; + const relationshipStats: Record = {}; + + 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 } { + return this.callProcessor.getCallStats(); + } +} \ No newline at end of file diff --git a/src/core/ingestion/structure-processor.ts b/src/core/ingestion/structure-processor.ts new file mode 100644 index 000000000..cc34e7252 --- /dev/null +++ b/src/core/ingestion/structure-processor.ts @@ -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 = new Map(); + + public async process(graph: KnowledgeGraph, input: StructureInput): Promise { + 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(); + + 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); + } +} \ No newline at end of file diff --git a/src/core/tree-sitter/parser-loader.ts b/src/core/tree-sitter/parser-loader.ts new file mode 100644 index 000000000..c63a6cb98 --- /dev/null +++ b/src/core/tree-sitter/parser-loader.ts @@ -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(); + +export async function initTreeSitter(): Promise { + if (parserInstance) return parserInstance; + + // Initialize WebAssembly + await Parser.init(); + parserInstance = new Parser(); + return parserInstance; +} + +export async function loadPythonParser(): Promise { + 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; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 000000000..6119ad9a8 --- /dev/null +++ b/src/index.css @@ -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; + } +} diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 000000000..b305ea5e1 --- /dev/null +++ b/src/lib/export.ts @@ -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; +} + +/** + * Export a KnowledgeGraph to JSON format + */ +export function exportGraphToJSON( + graph: KnowledgeGraph, + options: ExportOptions = {}, + fileContents?: Map, + 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, + 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 +): { 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; +} { + 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; + } = { + 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 + }; +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 000000000..c8d8903b9 --- /dev/null +++ b/src/lib/utils.ts @@ -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); +} \ No newline at end of file diff --git a/src/lib/workerUtils.ts b/src/lib/workerUtils.ts new file mode 100644 index 000000000..ea669b6fe --- /dev/null +++ b/src/lib/workerUtils.ts @@ -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; + processFiles(projectName: string, files: { path: string; content: string }[]): Promise; + validateRepository(input: PipelineInput): Promise<{ valid: boolean; errors: string[] }>; + getWorkerInfo(): Promise<{ version: string; capabilities: string[] }>; + setProgressCallback(callback: (progress: IngestionProgress) => void): Promise; + terminate(): Promise; +} + +export class IngestionWorkerManager { + private worker: Worker | null = null; + private workerProxy: WorkerProxy | null = null; + private isInitialized = false; + + public async initialize(): Promise { + 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(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const manager = new IngestionWorkerManager(); + await manager.initialize(); + return manager; +} + +// Utility function for processing with automatic cleanup +export async function processWithWorker( + processor: (worker: IngestionWorkerManager) => Promise +): Promise { + 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; + } +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 000000000..6f4ac9bcc --- /dev/null +++ b/src/main.tsx @@ -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( + + + , +) diff --git a/src/services/github.ts b/src/services/github.ts new file mode 100644 index 000000000..93f406dbd --- /dev/null +++ b/src/services/github.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + }; + } +} \ No newline at end of file diff --git a/src/services/zip.ts b/src/services/zip.ts new file mode 100644 index 000000000..58f97a425 --- /dev/null +++ b/src/services/zip.ts @@ -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> { + 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(); + 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 { + 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> { + 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(); + + 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 { + 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' + }; + } + } +} \ No newline at end of file diff --git a/src/ui/components/ErrorBoundary.tsx b/src/ui/components/ErrorBoundary.tsx new file mode 100644 index 000000000..7f3d34538 --- /dev/null +++ b/src/ui/components/ErrorBoundary.tsx @@ -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 { + constructor(props: ErrorBoundaryProps) { + super(props); + + this.state = { + hasError: false, + error: null, + errorInfo: null + }; + } + + static getDerivedStateFromError(error: Error): Partial { + // 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 ( +
+
+
โš ๏ธ
+ +

Something went wrong

+ +

+ We encountered an unexpected error. This might be due to a temporary issue + or an incompatibility with your browser. +

+ +
+ + + +
+ + {/* Error details (collapsible) */} +
+ + Technical Details (for developers) + + +
+
+ Error: +
+                    {this.state.error?.toString()}
+                  
+
+ + {this.state.errorInfo && ( +
+ Component Stack: +
+                      {this.state.errorInfo.componentStack}
+                    
+
+ )} + + {this.state.error?.stack && ( +
+ Stack Trace: +
+                      {this.state.error.stack}
+                    
+
+ )} +
+
+ +
+

+ What you can do: +

+
    +
  • Try refreshing the page
  • +
  • Clear your browser cache and reload
  • +
  • Try a different browser
  • +
  • Check the console for additional error details
  • +
+ +

+ If this problem persists, please report it with the technical details above. +

+
+
+
+ ); + } + + 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; \ No newline at end of file diff --git a/src/ui/components/chat/ChatInterface.tsx b/src/ui/components/chat/ChatInterface.tsx new file mode 100644 index 000000000..96a1111ca --- /dev/null +++ b/src/ui/components/chat/ChatInterface.tsx @@ -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; + className?: string; + style?: React.CSSProperties; +} + +interface LLMSettings { + provider: LLMProvider; + apiKey: string; + model: string; + temperature: number; + maxTokens: number; +} + +const ChatInterface: React.FC = ({ + graph, + fileContents, + className = '', + style = {} +}) => { + const [messages, setMessages] = useState([]); + 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({ + 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(null); + const inputRef = useRef(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 ( +
+ {/* Header */} +
+
+ ๐Ÿ’ฌ + Code Assistant + + {llmService.getProviderDisplayName(llmSettings.provider)} + +
+ +
+ + + +
+
+ + {/* Settings Panel */} + {showSettings && ( +
+

LLM Configuration

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + setLLMSettings(prev => ({ ...prev, apiKey: e.target.value }))} + placeholder="Enter your API key" + style={{ width: '100%', padding: '6px', fontSize: '14px' }} + /> +
+ +
+
+ + setLLMSettings(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))} + style={{ width: '100%' }} + /> +
+ +
+ + setLLMSettings(prev => ({ ...prev, maxTokens: parseInt(e.target.value) }))} + style={{ width: '100%', padding: '6px', fontSize: '14px' }} + /> +
+
+
+ )} + + {/* Messages */} +
+ {messages.length === 0 && ( +
+
๐Ÿ’ฌ
+
Ask me anything about the codebase!
+
+ I can help you understand functions, classes, dependencies, and more. +
+
+ )} + + {messages.map((message) => ( +
+
+
+ {message.content} +
+ + {/* Metadata */} + {message.metadata && ( +
+ {message.metadata.confidence && ( +
+ Confidence: {Math.round(message.metadata.confidence * 100)}% +
+ )} + + {message.metadata.sources && message.metadata.sources.length > 0 && ( +
+ Sources: {message.metadata.sources.join(', ')} +
+ )} + + {message.metadata.cypherQueries && message.metadata.cypherQueries.length > 0 && ( +
+ View Queries ({message.metadata.cypherQueries.length}) + {message.metadata.cypherQueries.map((query, index) => ( +
+
Query: {query.cypher}
+
Explanation: {query.explanation}
+
+ ))} +
+ )} + + {message.metadata.reasoning && message.metadata.reasoning.length > 0 && ( +
+ View Reasoning ({message.metadata.reasoning.length} steps) + {message.metadata.reasoning.map((step, index) => ( +
+
Step {step.step}: {step.thought}
+
Action: {step.action}
+
+ ))} +
+ )} +
+ )} +
+ +
+ {message.timestamp.toLocaleTimeString()} +
+
+ ))} + + {isLoading && ( +
+
+
+ Thinking... +
+
+ )} + +
+
+ + {/* Input Area */} +
+
+