mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
Initial commit: Complete CodeNexus application with AI-powered code analysis
This commit is contained in:
commit
485e3e00a7
49 changed files with 12044 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
555
GITNEXUS_README.md
Normal file
555
GITNEXUS_README.md
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
# 🔍 GitNexus - Code Knowledge Graph Explorer
|
||||
|
||||
GitNexus is a comprehensive code analysis and exploration tool that transforms repositories into interactive knowledge graphs, enabling AI-powered insights about code structure, relationships, and functionality.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 📊 **Code Analysis & Visualization**
|
||||
- **GitHub Integration**: Analyze any public GitHub repository directly from URL
|
||||
- **ZIP File Support**: Upload and analyze local code archives
|
||||
- **Interactive Knowledge Graph**: Visualize code structure with Cytoscape.js
|
||||
- **Multi-language Support**: Currently optimized for Python with extensible architecture
|
||||
- **Smart Filtering**: Directory and file pattern filters to focus analysis scope
|
||||
- **Performance Optimization**: Configurable file limits with confirmation dialogs for large repositories
|
||||
|
||||
### 🤖 **AI-Powered Chat Interface**
|
||||
- **Multiple LLM Providers**: OpenAI, Anthropic (Claude), Google Gemini
|
||||
- **ReAct Agent Pattern**: Uses proper LangChain ReAct implementation for reasoning
|
||||
- **Tool-Augmented Responses**: Graph queries, code retrieval, file search
|
||||
- **Context-Aware**: Maintains conversation history with configurable memory
|
||||
|
||||
### 🔧 **Advanced Processing Pipeline**
|
||||
- **3-Pass Ingestion Strategy**:
|
||||
1. **Structure Analysis**: Project hierarchy and file organization
|
||||
2. **Code Parsing**: AST-based extraction using Tree-sitter
|
||||
3. **Call Resolution**: Function/method call relationship mapping
|
||||
- **Web Worker Processing**: Non-blocking UI with progress tracking
|
||||
- **Intelligent Caching**: AST and processing result optimization
|
||||
- **Error Resilience**: Comprehensive error boundaries and recovery mechanisms
|
||||
|
||||
### 🎨 **Modern UI/UX**
|
||||
- **Responsive Design**: Adaptive layout for different screen sizes
|
||||
- **Real-time Progress**: Live updates during repository processing
|
||||
- **Interactive Graph**: Node selection, zooming, panning
|
||||
- **Split-Panel Layout**: Graph visualization + AI chat interface
|
||||
- **Settings Management**: Persistent configuration for API keys and preferences
|
||||
- **Export Functionality**: Download knowledge graphs as JSON with metadata
|
||||
- **Performance Controls**: File limits, filtering, and optimization settings
|
||||
|
||||
### 🛡️ **Reliability & Performance**
|
||||
- **Error Boundaries**: Graceful error handling with user-friendly recovery options
|
||||
- **Performance Monitoring**: Real-time processing statistics and export size calculation
|
||||
- **Memory Management**: Efficient handling of large repositories with configurable limits
|
||||
- **Progress Tracking**: Detailed progress indicators with phase-specific messaging
|
||||
- **Confirmation Dialogs**: Smart warnings for potentially expensive operations
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### **Frontend Stack**
|
||||
- **React 18** with TypeScript
|
||||
- **Vite** for fast development and building
|
||||
- **Cytoscape.js** for graph visualization
|
||||
- **Custom CSS** with modern design patterns
|
||||
- **Error Boundaries** for robust error handling
|
||||
|
||||
### **Processing Engine**
|
||||
- **Deno Runtime** for TypeScript execution
|
||||
- **Tree-sitter WASM** for syntax parsing
|
||||
- **Web Workers** for background processing
|
||||
- **Comlink** for worker communication
|
||||
|
||||
### **AI Integration**
|
||||
- **LangChain.js** with proper ReAct agent implementation
|
||||
- **Multiple LLM Support**: OpenAI, Anthropic, Gemini
|
||||
- **Tool-based Architecture**: Graph queries, code retrieval, file search
|
||||
- **Cypher Query Generation**: Natural language to graph queries
|
||||
|
||||
### **Services Layer**
|
||||
```
|
||||
src/
|
||||
├── services/ # External API integrations
|
||||
│ ├── github.ts # GitHub REST API client
|
||||
│ └── zip.ts # ZIP file processing
|
||||
├── core/ # Core processing logic
|
||||
│ ├── graph/ # Knowledge graph types
|
||||
│ ├── ingestion/ # 3-pass processing pipeline
|
||||
│ └── tree-sitter/ # Syntax parsing infrastructure
|
||||
├── ai/ # AI and RAG components
|
||||
│ ├── llm-service.ts # Multi-provider LLM client
|
||||
│ ├── cypher-generator.ts # NL to Cypher translation
|
||||
│ ├── orchestrator.ts # Custom ReAct implementation
|
||||
│ └── langchain-orchestrator.ts # Standard LangChain ReAct
|
||||
├── workers/ # Web Worker implementations
|
||||
├── ui/ # React components and pages
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ │ ├── ErrorBoundary.tsx # Error handling component
|
||||
│ │ ├── graph/ # Graph visualization components
|
||||
│ │ └── chat/ # Chat interface components
|
||||
│ └── pages/ # Application pages
|
||||
├── lib/ # Shared utilities
|
||||
│ └── export.ts # Graph export functionality
|
||||
└── App.tsx # Main application entry point
|
||||
```
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- **Node.js 18+** and **npm/yarn**
|
||||
- **Deno 1.40+** for development
|
||||
- **API Keys** for AI features (OpenAI, Anthropic, or Gemini)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd gitnexus
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start development server**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. **Open in browser**
|
||||
```
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **GitHub Token (Optional)**
|
||||
- Increases rate limit from 60 to 5,000 requests/hour
|
||||
- Generate at: https://github.com/settings/tokens
|
||||
- Requires no special permissions for public repos
|
||||
|
||||
2. **AI API Keys**
|
||||
- **OpenAI**: Get from https://platform.openai.com/api-keys
|
||||
- **Anthropic**: Get from https://console.anthropic.com/
|
||||
- **Gemini**: Get from https://makersuite.google.com/app/apikey
|
||||
|
||||
3. **Performance Settings**
|
||||
- **File Limit**: Configure maximum files to process (default: 500)
|
||||
- **Directory Filters**: Focus on specific directories (e.g., "src", "lib")
|
||||
- **File Patterns**: Filter by file types (e.g., "*.py", "*.js", "*.ts")
|
||||
|
||||
## 💡 Usage
|
||||
|
||||
### Analyzing a Repository
|
||||
|
||||
1. **GitHub Repository**
|
||||
```
|
||||
1. Enter GitHub URL: https://github.com/owner/repo
|
||||
2. Optional: Set directory/file filters to focus analysis
|
||||
3. Click "Analyze"
|
||||
4. For large repos: Confirm processing or adjust filters
|
||||
5. Wait for processing (structure → parsing → call resolution)
|
||||
6. Explore the interactive graph
|
||||
```
|
||||
|
||||
2. **ZIP File Upload**
|
||||
```
|
||||
1. Click "Choose File" and select a .zip file
|
||||
2. Optional: Configure filters before processing
|
||||
3. Click "Analyze"
|
||||
4. Processing will extract and analyze text files
|
||||
5. Explore results in the graph visualization
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
1. **Directory Filtering**
|
||||
```
|
||||
- Enter directory names: "src", "lib", "components"
|
||||
- Focuses analysis on specific parts of the codebase
|
||||
- Reduces processing time and memory usage
|
||||
```
|
||||
|
||||
2. **File Pattern Filtering**
|
||||
```
|
||||
- Use patterns: "*.py", "*.js", "*.ts"
|
||||
- Supports wildcards: "test*.py", "*util*"
|
||||
- Comma-separated: "*.py,*.js,*.ts"
|
||||
```
|
||||
|
||||
3. **File Limits**
|
||||
```
|
||||
- Default limit: 500 files
|
||||
- Configurable in settings (50-2000 files)
|
||||
- Large repositories show confirmation dialog
|
||||
- Automatic truncation to limit if confirmed
|
||||
```
|
||||
|
||||
### Using the AI Chat
|
||||
|
||||
1. **Configure API Key**
|
||||
```
|
||||
1. Click the ⚙️ settings button
|
||||
2. Choose your preferred LLM provider
|
||||
3. Enter your API key
|
||||
4. Select model (e.g., gpt-4o-mini, claude-3-haiku)
|
||||
```
|
||||
|
||||
2. **Ask Questions**
|
||||
```
|
||||
- "What functions are in the main.py file?"
|
||||
- "Show me all classes that inherit from BaseClass"
|
||||
- "How does the authentication system work?"
|
||||
- "Find all functions that call the database"
|
||||
```
|
||||
|
||||
### Exporting Data
|
||||
|
||||
1. **Export Knowledge Graph**
|
||||
```
|
||||
1. Click the 📥 Export button after processing
|
||||
2. Downloads JSON file with graph data and metadata
|
||||
3. Includes processing statistics and timestamps
|
||||
4. File size shown in UI before export
|
||||
```
|
||||
|
||||
2. **Export Format**
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"exportedAt": "2024-01-01T12:00:00.000Z",
|
||||
"version": "1.0.0",
|
||||
"nodeCount": 150,
|
||||
"relationshipCount": 200,
|
||||
"fileCount": 25,
|
||||
"processingDuration": 5000
|
||||
},
|
||||
"graph": {
|
||||
"nodes": [...],
|
||||
"relationships": [...]
|
||||
},
|
||||
"fileContents": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Graph Interaction
|
||||
|
||||
- **Node Selection**: Click any node to highlight and view details
|
||||
- **Zoom & Pan**: Mouse wheel to zoom, drag to pan
|
||||
- **Node Types**: Different colors/shapes for files, functions, classes, etc.
|
||||
- **Relationships**: Arrows show CONTAINS, CALLS, INHERITS relationships
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
GitNexus/
|
||||
├── src/
|
||||
│ ├── services/ # External integrations
|
||||
│ ├── core/ # Processing pipeline
|
||||
│ ├── ai/ # AI and RAG systems
|
||||
│ ├── workers/ # Web Workers
|
||||
│ ├── ui/ # React components
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ │ ├── ErrorBoundary.tsx
|
||||
│ │ │ ├── graph/ # Graph components
|
||||
│ │ │ └── chat/ # Chat components
|
||||
│ │ └── pages/ # Application pages
|
||||
│ ├── lib/ # Utilities
|
||||
│ │ └── export.ts # Export functionality
|
||||
│ └── App.tsx # Main application
|
||||
├── public/
|
||||
│ └── wasm/ # Tree-sitter WASM files
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
#### **Error Handling**
|
||||
```typescript
|
||||
// ErrorBoundary component with recovery options
|
||||
<ErrorBoundary
|
||||
onError={(error, errorInfo) => {
|
||||
console.error('Application error:', error);
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
#### **Performance Optimization**
|
||||
```typescript
|
||||
// File filtering and limits
|
||||
const filterFiles = (files: any[]) => {
|
||||
return files
|
||||
.filter(file => matchesDirectoryFilter(file))
|
||||
.filter(file => matchesPatternFilter(file))
|
||||
.slice(0, maxFiles);
|
||||
};
|
||||
```
|
||||
|
||||
#### **Export Functionality**
|
||||
```typescript
|
||||
// Export with metadata
|
||||
exportAndDownloadGraph(graph, {
|
||||
projectName: 'my-project',
|
||||
includeMetadata: true,
|
||||
prettyPrint: true
|
||||
}, fileContents, { duration: 5000 });
|
||||
```
|
||||
|
||||
#### **Processing Pipeline**
|
||||
```typescript
|
||||
// 3-pass ingestion strategy with progress tracking
|
||||
const pipeline = new GraphPipeline();
|
||||
const result = await pipeline.run({
|
||||
projectRoot: '/',
|
||||
projectName: 'MyProject',
|
||||
filePaths: ['src/main.py', 'src/utils.py'],
|
||||
fileContents: new Map([
|
||||
['src/main.py', 'def main(): pass'],
|
||||
['src/utils.py', 'def helper(): pass']
|
||||
])
|
||||
});
|
||||
```
|
||||
|
||||
#### **AI Integration**
|
||||
```typescript
|
||||
// LangChain ReAct agent with error handling
|
||||
const orchestrator = new LangChainRAGOrchestrator(llmService, cypherGenerator);
|
||||
await orchestrator.setContext({ graph, fileContents }, llmConfig);
|
||||
const response = await orchestrator.answerQuestion("How does auth work?");
|
||||
```
|
||||
|
||||
#### **Graph Visualization**
|
||||
```typescript
|
||||
// Interactive graph component with error boundaries
|
||||
<ErrorBoundary>
|
||||
<GraphExplorer
|
||||
graph={knowledgeGraph}
|
||||
onNodeSelect={(nodeId) => setSelectedNode(nodeId)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. **New Language Support**
|
||||
```typescript
|
||||
// Add parser in core/tree-sitter/
|
||||
export const loadJavaScriptParser = async () => {
|
||||
// Load JS Tree-sitter grammar
|
||||
};
|
||||
```
|
||||
|
||||
2. **Custom AI Tools**
|
||||
```typescript
|
||||
// Add tools in ai/langchain-orchestrator.ts
|
||||
const customTool = tool(
|
||||
async (input: { query: string }) => {
|
||||
// Tool implementation
|
||||
},
|
||||
{
|
||||
name: "custom_tool",
|
||||
description: "Custom functionality",
|
||||
schema: z.object({ query: z.string() })
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
3. **Export Formats**
|
||||
```typescript
|
||||
// Add new export formats in lib/export.ts
|
||||
export function exportToCSV(graph: KnowledgeGraph): string {
|
||||
// CSV export implementation
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 Testing & Quality Assurance
|
||||
|
||||
### Error Handling
|
||||
- **Error Boundaries**: Catch and display JavaScript errors gracefully
|
||||
- **User Recovery**: Allow users to reset component state after errors
|
||||
- **Detailed Logging**: Console logging for debugging and error reporting
|
||||
- **Fallback UI**: User-friendly error messages with recovery options
|
||||
|
||||
### Performance Testing
|
||||
1. **Large Repository Handling**
|
||||
- Test with repositories containing 1000+ files
|
||||
- Verify confirmation dialogs for file limits
|
||||
- Monitor memory usage during processing
|
||||
- Test filtering effectiveness
|
||||
|
||||
2. **UI Responsiveness**
|
||||
- Ensure non-blocking processing with Web Workers
|
||||
- Verify progress indicators update correctly
|
||||
- Test error recovery mechanisms
|
||||
- Validate export functionality with large graphs
|
||||
|
||||
3. **Error Scenarios**
|
||||
- Network failures during GitHub API calls
|
||||
- Corrupted ZIP files
|
||||
- Invalid API keys
|
||||
- Memory exhaustion scenarios
|
||||
|
||||
### Manual Testing Checklist
|
||||
- [ ] GitHub repository analysis with various sizes
|
||||
- [ ] ZIP file upload and extraction
|
||||
- [ ] Directory and file pattern filtering
|
||||
- [ ] Large repository confirmation dialog
|
||||
- [ ] Export functionality with different options
|
||||
- [ ] Error boundary activation and recovery
|
||||
- [ ] API key validation for all providers
|
||||
- [ ] Settings persistence across sessions
|
||||
- [ ] Graph visualization interactions
|
||||
- [ ] Chat interface with different LLM providers
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```env
|
||||
# Optional: Pre-configure API keys
|
||||
VITE_OPENAI_API_KEY=sk-...
|
||||
VITE_ANTHROPIC_API_KEY=sk-ant-...
|
||||
VITE_GEMINI_API_KEY=...
|
||||
|
||||
# Performance settings
|
||||
VITE_DEFAULT_MAX_FILES=500
|
||||
VITE_ENABLE_DEBUG_LOGGING=false
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0"]
|
||||
```
|
||||
|
||||
### Performance Monitoring
|
||||
```javascript
|
||||
// Add performance monitoring
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === 'measure') {
|
||||
console.log(`${entry.name}: ${entry.duration}ms`);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe({ entryTypes: ['measure'] });
|
||||
```
|
||||
|
||||
## 🔒 Security & Privacy
|
||||
|
||||
- **Client-Side Processing**: All analysis happens in your browser
|
||||
- **API Keys**: Stored locally, never transmitted to our servers
|
||||
- **GitHub Access**: Uses public API, respects repository permissions
|
||||
- **Data Privacy**: No code or analysis results are stored remotely
|
||||
- **Error Logging**: Sensitive data excluded from error reports
|
||||
- **Export Security**: User-controlled data export with no server interaction
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
### Development Setup
|
||||
1. Fork the repository
|
||||
2. Create feature branch: `git checkout -b feature/amazing-feature`
|
||||
3. Make changes and test thoroughly
|
||||
4. Run the testing checklist above
|
||||
5. Commit: `git commit -m 'Add amazing feature'`
|
||||
6. Push: `git push origin feature/amazing-feature`
|
||||
7. Open a Pull Request
|
||||
|
||||
### Code Style
|
||||
- **TypeScript**: Strict mode enabled
|
||||
- **ESLint**: Follow configured rules
|
||||
- **Prettier**: Auto-formatting
|
||||
- **Comments**: Minimal, only when necessary
|
||||
- **Error Handling**: Comprehensive error boundaries and recovery
|
||||
- **Performance**: Consider memory usage and processing time
|
||||
|
||||
### Testing Guidelines
|
||||
- Test error scenarios and edge cases
|
||||
- Verify performance with large datasets
|
||||
- Ensure graceful degradation
|
||||
- Test all export functionality
|
||||
- Validate error boundary behavior
|
||||
|
||||
## 📚 Technical Details
|
||||
|
||||
### Knowledge Graph Schema
|
||||
```typescript
|
||||
interface KnowledgeGraph {
|
||||
nodes: GraphNode[]; // Code entities
|
||||
relationships: GraphRelationship[]; // Connections
|
||||
}
|
||||
|
||||
// Node types: Project, Folder, File, Module, Class, Function, Method, Variable
|
||||
// Relationship types: CONTAINS, CALLS, INHERITS, OVERRIDES, IMPORTS
|
||||
```
|
||||
|
||||
### Export Format
|
||||
```typescript
|
||||
interface ExportedGraph {
|
||||
metadata: {
|
||||
exportedAt: string;
|
||||
version: string;
|
||||
nodeCount: number;
|
||||
relationshipCount: number;
|
||||
fileCount?: number;
|
||||
processingDuration?: number;
|
||||
};
|
||||
graph: KnowledgeGraph;
|
||||
fileContents?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
### Error Boundary Implementation
|
||||
- **Component-Level**: Individual components wrapped for isolation
|
||||
- **Application-Level**: Top-level boundary for catastrophic failures
|
||||
- **Recovery Options**: Reset state, reload page, or continue with fallback
|
||||
- **Error Reporting**: Detailed technical information for developers
|
||||
|
||||
### Performance Optimizations
|
||||
- **Web Workers**: Non-blocking processing
|
||||
- **AST Caching**: Reuse parsed syntax trees
|
||||
- **Progressive Loading**: Stream results as available
|
||||
- **Memory Management**: Efficient data structures
|
||||
- **File Filtering**: Reduce processing scope
|
||||
- **Confirmation Dialogs**: Prevent accidental expensive operations
|
||||
|
||||
### ReAct Agent Implementation
|
||||
- **Standard LangChain**: Uses `createReactAgent` from `@langchain/langgraph/prebuilt`
|
||||
- **Custom Implementation**: Manual ReAct loop for educational purposes
|
||||
- **Tools**: Graph queries, code retrieval, file search
|
||||
- **Memory**: Conversation persistence with thread management
|
||||
- **Error Recovery**: Graceful handling of API failures
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **Tree-sitter**: Syntax parsing infrastructure
|
||||
- **LangChain.js**: AI agent framework
|
||||
- **Cytoscape.js**: Graph visualization
|
||||
- **React**: UI framework with error boundaries
|
||||
- **Vite**: Build tool and dev server
|
||||
|
||||
---
|
||||
|
||||
**GitNexus** - Transforming code into knowledge, one repository at a time. 🚀
|
||||
|
||||
*Now with enhanced reliability, performance optimization, and comprehensive error handling for production-ready code analysis.*
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.9.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.7.0",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
1455
project_guide.md
Normal file
1455
project_guide.md
Normal file
File diff suppressed because it is too large
Load diff
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/wasm/python/tree-sitter-python.wasm
Normal file
BIN
public/wasm/python/tree-sitter-python.wasm
Normal file
Binary file not shown.
42
src/App.css
Normal file
42
src/App.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
12
src/App.tsx
Normal file
12
src/App.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import { HomePage, ErrorBoundary } from './ui/index.ts';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<HomePage />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
329
src/ai/cypher-generator.ts
Normal file
329
src/ai/cypher-generator.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { HumanMessage, SystemMessage } from 'npm:@langchain/core/messages';
|
||||
import type { LLMService, LLMConfig } from './llm-service.ts';
|
||||
import type { KnowledgeGraph } from '../core/graph/types.ts';
|
||||
|
||||
export interface CypherQuery {
|
||||
cypher: string;
|
||||
explanation: string;
|
||||
confidence: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface CypherGenerationOptions {
|
||||
maxRetries?: number;
|
||||
includeExamples?: boolean;
|
||||
strictMode?: boolean;
|
||||
}
|
||||
|
||||
export class CypherGenerator {
|
||||
private llmService: LLMService;
|
||||
private graphSchema: string = '';
|
||||
|
||||
// Common Cypher patterns and examples
|
||||
private static readonly CYPHER_EXAMPLES = [
|
||||
{
|
||||
question: "What functions are in the main.py file?",
|
||||
cypher: "MATCH (f:File {name: 'main.py'})-[:CONTAINS]->(func:Function) RETURN func.name"
|
||||
},
|
||||
{
|
||||
question: "Which functions call the authenticate function?",
|
||||
cypher: "MATCH (caller)-[:CALLS]->(target:Function {name: 'authenticate'}) RETURN caller.name"
|
||||
},
|
||||
{
|
||||
question: "Show me all classes in the project",
|
||||
cypher: "MATCH (c:Class) RETURN c.name, c.filePath"
|
||||
},
|
||||
{
|
||||
question: "What modules import numpy?",
|
||||
cypher: "MATCH (m:Module)-[:IMPORTS]->(lib) WHERE lib.name CONTAINS 'numpy' RETURN m.name"
|
||||
}
|
||||
];
|
||||
|
||||
constructor(llmService: LLMService) {
|
||||
this.llmService = llmService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the graph schema for better query generation
|
||||
*/
|
||||
public updateSchema(graph: KnowledgeGraph): void {
|
||||
this.graphSchema = this.generateSchemaDescription(graph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Cypher query from natural language
|
||||
*/
|
||||
public async generateQuery(
|
||||
question: string,
|
||||
llmConfig: LLMConfig,
|
||||
options: CypherGenerationOptions = {}
|
||||
): Promise<CypherQuery> {
|
||||
const { maxRetries = 2, includeExamples = true, strictMode = false } = options;
|
||||
|
||||
let lastError: string | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const systemPrompt = this.buildSystemPrompt(includeExamples, strictMode, lastError);
|
||||
const userPrompt = this.buildUserPrompt(question);
|
||||
|
||||
const messages = [
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userPrompt)
|
||||
];
|
||||
|
||||
const response = await this.llmService.chat(llmConfig, messages);
|
||||
const result = this.parseResponse(response.content);
|
||||
|
||||
// Validate the generated query
|
||||
const validation = this.validateQuery(result.cypher);
|
||||
if (!validation.isValid) {
|
||||
lastError = validation.error!;
|
||||
if (attempt < maxRetries) {
|
||||
console.warn(`Query validation failed (attempt ${attempt + 1}): ${validation.error}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
warnings: validation.warnings
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (attempt < maxRetries) {
|
||||
console.warn(`Query generation failed (attempt ${attempt + 1}): ${lastError}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to generate Cypher query after ${maxRetries + 1} attempts: ${lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unexpected error in query generation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system prompt with schema and examples
|
||||
*/
|
||||
private buildSystemPrompt(includeExamples: boolean, strictMode: boolean, lastError?: string | null): string {
|
||||
let prompt = `You are a Cypher query expert for a code knowledge graph. Your task is to convert natural language questions into valid Cypher queries.
|
||||
|
||||
GRAPH SCHEMA:
|
||||
${this.graphSchema}
|
||||
|
||||
NODE TYPES:
|
||||
- Project: Root project node
|
||||
- Folder: Directory containers
|
||||
- File: Source code files
|
||||
- Module: Python modules (.py files)
|
||||
- Class: Class definitions
|
||||
- Function: Function definitions
|
||||
- Method: Class method definitions
|
||||
- Variable: Variable declarations
|
||||
|
||||
RELATIONSHIP TYPES:
|
||||
- CONTAINS: Hierarchical containment (Project->Folder, Folder->File, File->Function, etc.)
|
||||
- CALLS: Function/method calls between code entities
|
||||
- INHERITS: Class inheritance relationships
|
||||
- IMPORTS: Module import relationships
|
||||
- OVERRIDES: Method override relationships
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. Always use MATCH patterns to find nodes
|
||||
2. Use WHERE clauses for filtering by properties
|
||||
3. Node properties include: name, filePath, startLine, endLine, type
|
||||
4. Return meaningful information, not just node IDs
|
||||
5. Use case-insensitive matching when appropriate (e.g., WHERE toLower(n.name) CONTAINS toLower('search'))
|
||||
6. Prefer specific node types over generic matches
|
||||
7. Always return results in a readable format`;
|
||||
|
||||
if (includeExamples) {
|
||||
prompt += `\n\nEXAMPLES:`;
|
||||
for (const example of CypherGenerator.CYPHER_EXAMPLES) {
|
||||
prompt += `\nQ: "${example.question}"\nA: ${example.cypher}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (strictMode) {
|
||||
prompt += `\n\nSTRICT MODE: Only generate queries that exactly match the schema. Do not make assumptions about node properties that aren't explicitly defined.`;
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
prompt += `\n\nPREVIOUS ERROR: The last query attempt failed with: "${lastError}". Please fix this issue in your new query.`;
|
||||
}
|
||||
|
||||
prompt += `\n\nRESPONSE FORMAT:
|
||||
Provide your response in this exact JSON format:
|
||||
{
|
||||
"cypher": "your cypher query here",
|
||||
"explanation": "brief explanation of what the query does",
|
||||
"confidence": 0.85
|
||||
}
|
||||
|
||||
The confidence should be a number between 0 and 1 indicating how confident you are in the query.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user prompt with the question
|
||||
*/
|
||||
private buildUserPrompt(question: string): string {
|
||||
return `Please convert this question to a Cypher query: "${question}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the LLM response to extract Cypher query
|
||||
*/
|
||||
private parseResponse(response: string): { cypher: string; explanation: string; confidence: number } {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
cypher: parsed.cypher || '',
|
||||
explanation: parsed.explanation || '',
|
||||
confidence: parsed.confidence || 0.5
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: try to extract Cypher from code blocks
|
||||
const cypherMatch = response.match(/```(?:cypher)?\s*(.*?)\s*```/s);
|
||||
if (cypherMatch) {
|
||||
return {
|
||||
cypher: cypherMatch[1].trim(),
|
||||
explanation: 'Generated Cypher query',
|
||||
confidence: 0.7
|
||||
};
|
||||
}
|
||||
|
||||
// Last resort: use the entire response as cypher
|
||||
return {
|
||||
cypher: response.trim(),
|
||||
explanation: 'Raw LLM response',
|
||||
confidence: 0.3
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse LLM response: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the generated Cypher query
|
||||
*/
|
||||
private validateQuery(cypher: string): { isValid: boolean; error?: string; warnings?: string[] } {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!cypher || cypher.trim().length === 0) {
|
||||
return { isValid: false, error: 'Empty query generated' };
|
||||
}
|
||||
|
||||
// Basic syntax checks
|
||||
const upperCypher = cypher.toUpperCase();
|
||||
|
||||
// Must have MATCH or CREATE or other valid starting keywords
|
||||
if (!upperCypher.match(/^\s*(MATCH|CREATE|MERGE|WITH|RETURN|CALL|SHOW)/)) {
|
||||
return { isValid: false, error: 'Query must start with a valid Cypher keyword (MATCH, CREATE, etc.)' };
|
||||
}
|
||||
|
||||
// Check for balanced parentheses
|
||||
const openParens = (cypher.match(/\(/g) || []).length;
|
||||
const closeParens = (cypher.match(/\)/g) || []).length;
|
||||
if (openParens !== closeParens) {
|
||||
return { isValid: false, error: 'Unbalanced parentheses in query' };
|
||||
}
|
||||
|
||||
// Check for balanced brackets
|
||||
const openBrackets = (cypher.match(/\[/g) || []).length;
|
||||
const closeBrackets = (cypher.match(/\]/g) || []).length;
|
||||
if (openBrackets !== closeBrackets) {
|
||||
return { isValid: false, error: 'Unbalanced brackets in query' };
|
||||
}
|
||||
|
||||
// Check for balanced braces
|
||||
const openBraces = (cypher.match(/\{/g) || []).length;
|
||||
const closeBraces = (cypher.match(/\}/g) || []).length;
|
||||
if (openBraces !== closeBraces) {
|
||||
return { isValid: false, error: 'Unbalanced braces in query' };
|
||||
}
|
||||
|
||||
// Warn about potentially expensive operations
|
||||
if (upperCypher.includes('MATCH ()') || upperCypher.includes('MATCH (*)')) {
|
||||
warnings.push('Query matches all nodes - this could be expensive');
|
||||
}
|
||||
|
||||
if (!upperCypher.includes('RETURN') && !upperCypher.includes('DELETE') && !upperCypher.includes('SET')) {
|
||||
warnings.push('Query does not return results');
|
||||
}
|
||||
|
||||
return { isValid: true, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a schema description from the knowledge graph
|
||||
*/
|
||||
private generateSchemaDescription(graph: KnowledgeGraph): string {
|
||||
const nodeTypes = new Set<string>();
|
||||
const relationshipTypes = new Set<string>();
|
||||
const nodeProperties = new Map<string, Set<string>>();
|
||||
|
||||
// Analyze nodes
|
||||
graph.nodes.forEach(node => {
|
||||
nodeTypes.add(node.label);
|
||||
|
||||
if (!nodeProperties.has(node.label)) {
|
||||
nodeProperties.set(node.label, new Set());
|
||||
}
|
||||
|
||||
Object.keys(node.properties).forEach(prop => {
|
||||
nodeProperties.get(node.label)!.add(prop);
|
||||
});
|
||||
});
|
||||
|
||||
// Analyze relationships
|
||||
graph.relationships.forEach(rel => {
|
||||
relationshipTypes.add(rel.type);
|
||||
});
|
||||
|
||||
let schema = `NODES (${graph.nodes.length} total):\n`;
|
||||
for (const nodeType of Array.from(nodeTypes).sort()) {
|
||||
const props = nodeProperties.get(nodeType);
|
||||
const propList = props ? Array.from(props).sort().join(', ') : 'none';
|
||||
const count = graph.nodes.filter(n => n.label === nodeType).length;
|
||||
schema += `- ${nodeType} (${count}): ${propList}\n`;
|
||||
}
|
||||
|
||||
schema += `\nRELATIONSHIPS (${graph.relationships.length} total):\n`;
|
||||
for (const relType of Array.from(relationshipTypes).sort()) {
|
||||
const count = graph.relationships.filter(r => r.type === relType).length;
|
||||
schema += `- ${relType} (${count})\n`;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current schema description
|
||||
*/
|
||||
public getSchema(): string {
|
||||
return this.graphSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean and format a Cypher query
|
||||
*/
|
||||
public cleanQuery(cypher: string): string {
|
||||
return cypher
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s*([(),\[\]{}])\s*/g, '$1')
|
||||
.replace(/\s*([=<>!]+)\s*/g, ' $1 ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
16
src/ai/index.ts
Normal file
16
src/ai/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export { LLMService, type LLMProvider, type LLMConfig, type ChatResponse } from './llm-service.ts';
|
||||
export { CypherGenerator, type CypherQuery, type CypherGenerationOptions } from './cypher-generator.ts';
|
||||
export {
|
||||
RAGOrchestrator,
|
||||
type RAGContext,
|
||||
type RAGResponse,
|
||||
type RAGOptions,
|
||||
type ToolResult,
|
||||
type ReasoningStep
|
||||
} from './orchestrator.ts';
|
||||
export {
|
||||
LangChainRAGOrchestrator,
|
||||
type LangChainRAGContext,
|
||||
type LangChainRAGResponse,
|
||||
type LangChainRAGOptions
|
||||
} from './langchain-orchestrator.ts';
|
||||
348
src/ai/langchain-orchestrator.ts
Normal file
348
src/ai/langchain-orchestrator.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { createReactAgent } from 'npm:@langchain/langgraph/prebuilt';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { MemorySaver } from 'npm:@langchain/langgraph';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { tool } from 'npm:@langchain/core/tools';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { z } from 'npm:zod';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { SystemMessage } from 'npm:@langchain/core/messages';
|
||||
import type { LLMService, LLMConfig } from './llm-service.ts';
|
||||
import type { CypherGenerator } from './cypher-generator.ts';
|
||||
import type { KnowledgeGraph } from '../core/graph/types.ts';
|
||||
|
||||
export interface LangChainRAGContext {
|
||||
graph: KnowledgeGraph;
|
||||
fileContents: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface LangChainRAGResponse {
|
||||
answer: string;
|
||||
sources: string[];
|
||||
confidence: number;
|
||||
toolCalls: Array<{
|
||||
tool: string;
|
||||
input: any;
|
||||
output: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LangChainRAGOptions {
|
||||
maxIterations?: number;
|
||||
temperature?: number;
|
||||
enableMemory?: boolean;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
export class LangChainRAGOrchestrator {
|
||||
private llmService: LLMService;
|
||||
private cypherGenerator: CypherGenerator;
|
||||
private context: LangChainRAGContext | null = null;
|
||||
private agent: any = null;
|
||||
private memory: any = null;
|
||||
|
||||
constructor(llmService: LLMService, cypherGenerator: CypherGenerator) {
|
||||
this.llmService = llmService;
|
||||
this.cypherGenerator = cypherGenerator;
|
||||
this.memory = new MemorySaver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current context and initialize the agent
|
||||
*/
|
||||
public async setContext(context: LangChainRAGContext, llmConfig: LLMConfig): Promise<void> {
|
||||
this.context = context;
|
||||
this.cypherGenerator.updateSchema(context.graph);
|
||||
|
||||
// Create LangChain-compliant tools
|
||||
const tools = this.createTools(llmConfig);
|
||||
|
||||
// Get the LLM from our service
|
||||
const llm = this.llmService.getChatModel(llmConfig);
|
||||
|
||||
// Create system message for ReAct behavior
|
||||
const systemMessage = this.buildSystemMessage();
|
||||
|
||||
// Create the ReAct agent using LangGraph
|
||||
this.agent = createReactAgent({
|
||||
llm,
|
||||
tools,
|
||||
messageModifier: systemMessage,
|
||||
checkpointSaver: this.memory
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a question using the LangChain ReAct agent
|
||||
*/
|
||||
public async answerQuestion(
|
||||
question: string,
|
||||
options: LangChainRAGOptions = {}
|
||||
): Promise<LangChainRAGResponse> {
|
||||
if (!this.agent || !this.context) {
|
||||
throw new Error('Agent not initialized. Call setContext() first.');
|
||||
}
|
||||
|
||||
const {
|
||||
maxIterations = 10,
|
||||
enableMemory = false,
|
||||
threadId = 'default'
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const config = enableMemory
|
||||
? {
|
||||
configurable: { thread_id: threadId },
|
||||
recursionLimit: maxIterations
|
||||
}
|
||||
: { recursionLimit: maxIterations };
|
||||
|
||||
// Stream the agent execution
|
||||
const stream = await this.agent.stream(
|
||||
{ messages: [{ role: "user", content: question }] },
|
||||
config
|
||||
);
|
||||
|
||||
let finalAnswer = '';
|
||||
const toolCalls: Array<{ tool: string; input: any; output: string }> = [];
|
||||
const sources: string[] = [];
|
||||
|
||||
// Process the stream
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.agent) {
|
||||
const lastMessage = chunk.agent.messages[chunk.agent.messages.length - 1];
|
||||
if (lastMessage.content) {
|
||||
finalAnswer = lastMessage.content;
|
||||
}
|
||||
} else if (chunk.tools) {
|
||||
const toolMessage = chunk.tools.messages[chunk.tools.messages.length - 1];
|
||||
if (toolMessage.tool_calls) {
|
||||
toolMessage.tool_calls.forEach((toolCall: any) => {
|
||||
toolCalls.push({
|
||||
tool: toolCall.name,
|
||||
input: toolCall.args,
|
||||
output: toolMessage.content || ''
|
||||
});
|
||||
|
||||
// Extract sources from tool calls
|
||||
if (toolCall.name === 'get_code_content') {
|
||||
sources.push(toolCall.args.filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate confidence based on successful tool usage
|
||||
const confidence = this.calculateConfidence(toolCalls);
|
||||
|
||||
return {
|
||||
answer: finalAnswer || 'I was unable to find a complete answer to your question.',
|
||||
sources: Array.from(new Set(sources)),
|
||||
confidence,
|
||||
toolCalls
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`LangChain RAG orchestration failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LangChain-compliant tools
|
||||
*/
|
||||
private createTools(llmConfig: LLMConfig) {
|
||||
const queryGraphTool = tool(
|
||||
async (input: { question: string }) => {
|
||||
try {
|
||||
const cypherQuery = await this.cypherGenerator.generateQuery(input.question, llmConfig);
|
||||
const mockResults = this.simulateGraphQuery(cypherQuery.cypher);
|
||||
|
||||
return `Query: ${cypherQuery.cypher}\n\nResults:\n${mockResults}\n\nExplanation: ${cypherQuery.explanation}`;
|
||||
} catch (error) {
|
||||
return `Error generating graph query: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "query_graph",
|
||||
description: "Query the code knowledge graph using natural language. Use this to find information about code structure, relationships, functions, classes, etc.",
|
||||
schema: z.object({
|
||||
question: z.string().describe("Natural language question about the codebase")
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const getCodeContentTool = tool(
|
||||
async (input: { filePath: string }) => {
|
||||
if (!this.context) {
|
||||
return 'No context available';
|
||||
}
|
||||
|
||||
const content = this.context.fileContents.get(input.filePath);
|
||||
if (!content) {
|
||||
// Try to find similar file paths
|
||||
const similarFiles = Array.from(this.context.fileContents.keys())
|
||||
.filter(path => path.includes(input.filePath) || input.filePath.includes(path))
|
||||
.slice(0, 3);
|
||||
|
||||
if (similarFiles.length > 0) {
|
||||
return `File not found. Similar files available: ${similarFiles.join(', ')}`;
|
||||
}
|
||||
return 'File not found';
|
||||
}
|
||||
|
||||
return `File: ${input.filePath}\n\n${content}`;
|
||||
},
|
||||
{
|
||||
name: "get_code_content",
|
||||
description: "Retrieve the source code content of a specific file. Use this when you need to examine the actual code implementation.",
|
||||
schema: z.object({
|
||||
filePath: z.string().describe("The path to the file whose content you want to retrieve")
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const searchFilesTool = tool(
|
||||
async (input: { pattern: string }) => {
|
||||
if (!this.context) {
|
||||
return 'No context available';
|
||||
}
|
||||
|
||||
const matchingFiles: string[] = [];
|
||||
const lowerPattern = input.pattern.toLowerCase();
|
||||
|
||||
// Search in file paths
|
||||
for (const filePath of this.context.fileContents.keys()) {
|
||||
if (filePath.toLowerCase().includes(lowerPattern)) {
|
||||
matchingFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Search in file contents
|
||||
for (const [filePath, content] of this.context.fileContents.entries()) {
|
||||
if (!matchingFiles.includes(filePath) &&
|
||||
content.toLowerCase().includes(lowerPattern)) {
|
||||
matchingFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return matchingFiles.length > 0
|
||||
? `Found ${matchingFiles.length} files:\n${matchingFiles.slice(0, 10).join('\n')}${matchingFiles.length > 10 ? '\n... and more' : ''}`
|
||||
: 'No files found matching the pattern';
|
||||
},
|
||||
{
|
||||
name: "search_files",
|
||||
description: "Search for files matching a pattern or containing specific text. Use this to find relevant files in the codebase.",
|
||||
schema: z.object({
|
||||
pattern: z.string().describe("Search pattern or text to look for in file names or content")
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return [queryGraphTool, getCodeContentTool, searchFilesTool];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system message for ReAct behavior
|
||||
*/
|
||||
private buildSystemMessage(): SystemMessage {
|
||||
const systemPrompt = `You are an expert code analyst that helps users understand codebases by using available tools.
|
||||
|
||||
You have access to the following tools:
|
||||
1. query_graph: Query the code knowledge graph using natural language
|
||||
2. get_code_content: Retrieve the source code content of a specific file
|
||||
3. search_files: Search for files matching a pattern or containing specific text
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Always think step by step about what information you need
|
||||
- Use tools to gather factual information before providing answers
|
||||
- Base your responses ONLY on information retrieved from tools
|
||||
- If you cannot find information, say so explicitly
|
||||
- Provide helpful, accurate answers about the codebase structure and functionality
|
||||
- When referencing code, always cite the specific files you examined
|
||||
|
||||
Your goal is to provide accurate, evidence-based answers about the codebase using the available tools.`;
|
||||
|
||||
return new SystemMessage(systemPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate graph query execution (same as before)
|
||||
*/
|
||||
private simulateGraphQuery(cypher: string): string {
|
||||
if (!this.context) return 'No context available';
|
||||
|
||||
const upperCypher = cypher.toUpperCase();
|
||||
|
||||
if (upperCypher.includes('FUNCTION') && upperCypher.includes('RETURN')) {
|
||||
const functions = this.context.graph.nodes
|
||||
.filter(n => n.label === 'Function')
|
||||
.slice(0, 5)
|
||||
.map(n => `${n.properties.name} (${n.properties.filePath})`)
|
||||
.join('\n');
|
||||
return functions || 'No functions found';
|
||||
}
|
||||
|
||||
if (upperCypher.includes('CLASS') && upperCypher.includes('RETURN')) {
|
||||
const classes = this.context.graph.nodes
|
||||
.filter(n => n.label === 'Class')
|
||||
.slice(0, 5)
|
||||
.map(n => `${n.properties.name} (${n.properties.filePath})`)
|
||||
.join('\n');
|
||||
return classes || 'No classes found';
|
||||
}
|
||||
|
||||
if (upperCypher.includes('FILE') && upperCypher.includes('RETURN')) {
|
||||
const files = this.context.graph.nodes
|
||||
.filter(n => n.label === 'File')
|
||||
.slice(0, 5)
|
||||
.map(n => n.properties.name)
|
||||
.join('\n');
|
||||
return files || 'No files found';
|
||||
}
|
||||
|
||||
return 'Query executed successfully (simulated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence based on tool usage
|
||||
*/
|
||||
private calculateConfidence(toolCalls: Array<{ tool: string; input: any; output: string }>): number {
|
||||
if (toolCalls.length === 0) return 0.3;
|
||||
|
||||
const successfulCalls = toolCalls.filter(call =>
|
||||
!call.output.includes('Error') &&
|
||||
!call.output.includes('not found') &&
|
||||
call.output.length > 10
|
||||
).length;
|
||||
|
||||
const baseConfidence = 0.5;
|
||||
const toolBonus = (successfulCalls / toolCalls.length) * 0.4;
|
||||
|
||||
return Math.min(0.95, Math.max(0.1, baseConfidence + toolBonus));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current context information
|
||||
*/
|
||||
public getContextInfo(): { nodeCount: number; fileCount: number; hasContext: boolean; hasAgent: boolean } {
|
||||
return {
|
||||
nodeCount: this.context?.graph.nodes.length || 0,
|
||||
fileCount: this.context?.fileContents.size || 0,
|
||||
hasContext: !!this.context,
|
||||
hasAgent: !!this.agent
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear memory for a specific thread
|
||||
*/
|
||||
public async clearMemory(threadId: string): Promise<void> {
|
||||
if (this.memory) {
|
||||
// Note: MemorySaver doesn't have a direct clear method in the current API
|
||||
// This would need to be implemented based on the specific memory backend
|
||||
console.log(`Memory clearing not implemented for thread: ${threadId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
250
src/ai/llm-service.ts
Normal file
250
src/ai/llm-service.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { ChatOpenAI } from 'npm:@langchain/openai';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { ChatAnthropic } from 'npm:@langchain/anthropic';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { ChatGoogleGenerativeAI } from 'npm:@langchain/google-genai';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import type { BaseMessage } from 'npm:@langchain/core/messages';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import type { BaseChatModel } from 'npm:@langchain/core/language_models/chat_models';
|
||||
|
||||
export type LLMProvider = 'openai' | 'anthropic' | 'gemini';
|
||||
|
||||
export interface LLMConfig {
|
||||
provider: LLMProvider;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
content: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
model?: string;
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
export class LLMService {
|
||||
private models: Map<string, BaseChatModel> = new Map();
|
||||
private defaultConfig: Partial<LLMConfig> = {
|
||||
temperature: 0.1,
|
||||
maxTokens: 4000,
|
||||
maxRetries: 3
|
||||
};
|
||||
|
||||
// Default models for each provider
|
||||
private static readonly DEFAULT_MODELS: Record<LLMProvider, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-haiku-20240307',
|
||||
gemini: 'gemini-1.5-flash'
|
||||
};
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Initialize or get a chat model for the specified provider
|
||||
*/
|
||||
public getChatModel(config: LLMConfig): BaseChatModel {
|
||||
const cacheKey = this.getCacheKey(config);
|
||||
|
||||
if (this.models.has(cacheKey)) {
|
||||
return this.models.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const model = this.createChatModel(config);
|
||||
this.models.set(cacheKey, model);
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message and get a response
|
||||
*/
|
||||
public async chat(
|
||||
config: LLMConfig,
|
||||
messages: BaseMessage[],
|
||||
options?: { stream?: boolean }
|
||||
): Promise<ChatResponse> {
|
||||
try {
|
||||
const model = this.getChatModel(config);
|
||||
|
||||
if (options?.stream) {
|
||||
// For streaming, we'd need to handle this differently
|
||||
// For now, we'll just use regular invoke
|
||||
console.warn('Streaming not implemented yet, falling back to regular invoke');
|
||||
}
|
||||
|
||||
const response = await model.invoke(messages);
|
||||
|
||||
return {
|
||||
content: response.content as string,
|
||||
usage: response.response_metadata?.usage ? {
|
||||
promptTokens: response.response_metadata.usage.prompt_tokens || 0,
|
||||
completionTokens: response.response_metadata.usage.completion_tokens || 0,
|
||||
totalTokens: response.response_metadata.usage.total_tokens || 0
|
||||
} : undefined,
|
||||
model: config.model || LLMService.DEFAULT_MODELS[config.provider],
|
||||
finishReason: response.response_metadata?.finish_reason
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`LLM chat failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key format for different providers
|
||||
*/
|
||||
public validateApiKey(provider: LLMProvider, apiKey: string): boolean {
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return apiKey.startsWith('sk-') && apiKey.length > 20;
|
||||
case 'anthropic':
|
||||
return apiKey.startsWith('sk-ant-') && apiKey.length > 20;
|
||||
case 'gemini':
|
||||
return apiKey.length > 20; // Google API keys don't have a consistent prefix
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for a provider
|
||||
*/
|
||||
public getAvailableModels(provider: LLMProvider): string[] {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return [
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo'
|
||||
];
|
||||
case 'anthropic':
|
||||
return [
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-haiku-20240307'
|
||||
];
|
||||
case 'gemini':
|
||||
return [
|
||||
'gemini-1.5-pro',
|
||||
'gemini-1.5-flash',
|
||||
'gemini-1.0-pro'
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display name
|
||||
*/
|
||||
public getProviderDisplayName(provider: LLMProvider): string {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return 'OpenAI';
|
||||
case 'anthropic':
|
||||
return 'Anthropic';
|
||||
case 'gemini':
|
||||
return 'Google Gemini';
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection with the provider
|
||||
*/
|
||||
public async testConnection(config: LLMConfig): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const model = this.createChatModel(config);
|
||||
|
||||
// Send a simple test message
|
||||
const testMessages = [{
|
||||
role: 'user' as const,
|
||||
content: 'Hello, this is a connection test. Please respond with "OK".'
|
||||
}];
|
||||
|
||||
const response = await model.invoke(testMessages);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Connection test failed'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached models (useful for updating API keys)
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.models.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a chat model instance based on the provider
|
||||
*/
|
||||
private createChatModel(config: LLMConfig): BaseChatModel {
|
||||
const mergedConfig = { ...this.defaultConfig, ...config };
|
||||
const model = mergedConfig.model || LLMService.DEFAULT_MODELS[config.provider];
|
||||
|
||||
switch (config.provider) {
|
||||
case 'openai':
|
||||
return new ChatOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
model,
|
||||
temperature: mergedConfig.temperature,
|
||||
maxTokens: mergedConfig.maxTokens,
|
||||
maxRetries: mergedConfig.maxRetries,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
case 'anthropic':
|
||||
return new ChatAnthropic({
|
||||
apiKey: config.apiKey,
|
||||
model,
|
||||
temperature: mergedConfig.temperature,
|
||||
maxTokens: mergedConfig.maxTokens,
|
||||
maxRetries: mergedConfig.maxRetries,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
case 'gemini':
|
||||
return new ChatGoogleGenerativeAI({
|
||||
apiKey: config.apiKey,
|
||||
model,
|
||||
temperature: mergedConfig.temperature,
|
||||
maxOutputTokens: mergedConfig.maxTokens,
|
||||
maxRetries: mergedConfig.maxRetries
|
||||
});
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${config.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for model instances
|
||||
*/
|
||||
private getCacheKey(config: LLMConfig): string {
|
||||
const model = config.model || LLMService.DEFAULT_MODELS[config.provider];
|
||||
return `${config.provider}:${model}:${config.apiKey.slice(-8)}:${config.temperature}:${config.maxTokens}`;
|
||||
}
|
||||
}
|
||||
480
src/ai/orchestrator.ts
Normal file
480
src/ai/orchestrator.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import { HumanMessage, SystemMessage, AIMessage } from 'npm:@langchain/core/messages';
|
||||
import type { LLMService, LLMConfig } from './llm-service.ts';
|
||||
import type { CypherGenerator, CypherQuery } from './cypher-generator.ts';
|
||||
import type { KnowledgeGraph, GraphNode } from '../core/graph/types.ts';
|
||||
|
||||
export interface RAGContext {
|
||||
graph: KnowledgeGraph;
|
||||
fileContents: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
toolName: string;
|
||||
input: string;
|
||||
output: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReasoningStep {
|
||||
step: number;
|
||||
thought: string;
|
||||
action: string;
|
||||
actionInput: string;
|
||||
observation: string;
|
||||
toolResult?: ToolResult;
|
||||
}
|
||||
|
||||
export interface RAGResponse {
|
||||
answer: string;
|
||||
reasoning: ReasoningStep[];
|
||||
cypherQueries: CypherQuery[];
|
||||
confidence: number;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export interface RAGOptions {
|
||||
maxReasoningSteps?: number;
|
||||
includeReasoning?: boolean;
|
||||
strictMode?: boolean;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export class RAGOrchestrator {
|
||||
private llmService: LLMService;
|
||||
private cypherGenerator: CypherGenerator;
|
||||
private context: RAGContext | null = null;
|
||||
|
||||
constructor(llmService: LLMService, cypherGenerator: CypherGenerator) {
|
||||
this.llmService = llmService;
|
||||
this.cypherGenerator = cypherGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current context (graph and file contents)
|
||||
*/
|
||||
public setContext(context: RAGContext): void {
|
||||
this.context = context;
|
||||
this.cypherGenerator.updateSchema(context.graph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a question using ReAct pattern
|
||||
*/
|
||||
public async answerQuestion(
|
||||
question: string,
|
||||
llmConfig: LLMConfig,
|
||||
options: RAGOptions = {}
|
||||
): Promise<RAGResponse> {
|
||||
if (!this.context) {
|
||||
throw new Error('Context not set. Call setContext() first.');
|
||||
}
|
||||
|
||||
const {
|
||||
maxReasoningSteps = 5,
|
||||
includeReasoning = true,
|
||||
strictMode = false,
|
||||
temperature = 0.1
|
||||
} = options;
|
||||
|
||||
const reasoning: ReasoningStep[] = [];
|
||||
const cypherQueries: CypherQuery[] = [];
|
||||
const sources: string[] = [];
|
||||
|
||||
// Enhanced LLM config for reasoning
|
||||
const reasoningConfig: LLMConfig = {
|
||||
...llmConfig,
|
||||
temperature: temperature
|
||||
};
|
||||
|
||||
let currentStep = 1;
|
||||
let finalAnswer = '';
|
||||
let confidence = 0.5;
|
||||
|
||||
try {
|
||||
// Initial system prompt for ReAct
|
||||
const systemPrompt = this.buildReActSystemPrompt(strictMode);
|
||||
const conversation = [new SystemMessage(systemPrompt)];
|
||||
|
||||
// Add the user question
|
||||
conversation.push(new HumanMessage(`Question: ${question}`));
|
||||
|
||||
while (currentStep <= maxReasoningSteps) {
|
||||
// Get reasoning from LLM
|
||||
const response = await this.llmService.chat(reasoningConfig, conversation);
|
||||
const reasoning_step = this.parseReasoningStep(response.content, currentStep);
|
||||
|
||||
reasoning.push(reasoning_step);
|
||||
|
||||
// Check if we have a final answer
|
||||
if (reasoning_step.action.toLowerCase().includes('final_answer')) {
|
||||
finalAnswer = reasoning_step.actionInput;
|
||||
confidence = this.calculateConfidence(reasoning, cypherQueries);
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute the action
|
||||
let toolResult: ToolResult | null = null;
|
||||
|
||||
try {
|
||||
if (reasoning_step.action.toLowerCase().includes('query_graph')) {
|
||||
toolResult = await this.executeGraphQuery(reasoning_step.actionInput, reasoningConfig);
|
||||
if (toolResult.success) {
|
||||
cypherQueries.push(...this.extractCypherQueries(toolResult));
|
||||
}
|
||||
} else if (reasoning_step.action.toLowerCase().includes('get_code')) {
|
||||
toolResult = await this.getCodeContent(reasoning_step.actionInput);
|
||||
if (toolResult.success) {
|
||||
sources.push(...this.extractSources(toolResult));
|
||||
}
|
||||
} else if (reasoning_step.action.toLowerCase().includes('search_files')) {
|
||||
toolResult = await this.searchFiles(reasoning_step.actionInput);
|
||||
} else {
|
||||
toolResult = {
|
||||
toolName: 'unknown',
|
||||
input: reasoning_step.actionInput,
|
||||
output: 'Unknown action type',
|
||||
success: false,
|
||||
error: `Unknown action: ${reasoning_step.action}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
toolResult = {
|
||||
toolName: reasoning_step.action,
|
||||
input: reasoning_step.actionInput,
|
||||
output: '',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
// Update the reasoning step with tool result
|
||||
reasoning_step.observation = toolResult.output;
|
||||
reasoning_step.toolResult = toolResult;
|
||||
|
||||
// Add the tool result to conversation
|
||||
conversation.push(new AIMessage(response.content));
|
||||
conversation.push(new HumanMessage(`Observation: ${toolResult.output}`));
|
||||
|
||||
currentStep++;
|
||||
}
|
||||
|
||||
// If we didn't get a final answer, generate one based on the reasoning
|
||||
if (!finalAnswer && reasoning.length > 0) {
|
||||
const summaryPrompt = this.buildSummaryPrompt(question, reasoning);
|
||||
conversation.push(new HumanMessage(summaryPrompt));
|
||||
|
||||
const summaryResponse = await this.llmService.chat(reasoningConfig, conversation);
|
||||
finalAnswer = summaryResponse.content;
|
||||
confidence = Math.max(0.3, confidence - 0.2); // Lower confidence for incomplete reasoning
|
||||
}
|
||||
|
||||
return {
|
||||
answer: finalAnswer || 'I was unable to find a complete answer to your question.',
|
||||
reasoning: includeReasoning ? reasoning : [],
|
||||
cypherQueries,
|
||||
confidence,
|
||||
sources: Array.from(new Set(sources)) // Remove duplicates
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`RAG orchestration failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ReAct system prompt
|
||||
*/
|
||||
private buildReActSystemPrompt(strictMode: boolean): string {
|
||||
const prompt = `You are an expert code analyst using a ReAct (Reasoning + Acting) approach to answer questions about a codebase.
|
||||
|
||||
You have access to the following tools:
|
||||
1. query_graph(question): Query the code knowledge graph using natural language
|
||||
2. get_code(file_path): Retrieve the source code content of a specific file
|
||||
3. search_files(pattern): Search for files matching a pattern or containing specific text
|
||||
|
||||
IMPORTANT INSTRUCTIONS:
|
||||
- Always think step by step using the format: Thought: [your reasoning]
|
||||
- Then specify an action using: Action: [tool_name]
|
||||
- Provide the input using: Action Input: [input for the tool]
|
||||
- After receiving an observation, continue reasoning or provide a final answer
|
||||
- Use Final Answer: [your answer] when you have sufficient information
|
||||
- Base your answers ONLY on the information retrieved from tools
|
||||
- Do not make assumptions or hallucinate information
|
||||
- If you cannot find information, say so explicitly
|
||||
|
||||
RESPONSE FORMAT:
|
||||
Thought: [Your reasoning about what to do next]
|
||||
Action: [query_graph, get_code, search_files, or Final Answer]
|
||||
Action Input: [The input for the action]
|
||||
|
||||
After receiving an Observation, continue with:
|
||||
Thought: [Your analysis of the observation]
|
||||
Action: [Next action or Final Answer]
|
||||
Action Input: [Input for next action or your final answer]
|
||||
|
||||
${strictMode ? '\nSTRICT MODE: Only use information explicitly found in the tools. Do not infer or assume anything.' : ''}
|
||||
|
||||
Remember: Your goal is to provide accurate, evidence-based answers about the codebase.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a reasoning step from LLM response
|
||||
*/
|
||||
private parseReasoningStep(response: string, stepNumber: number): ReasoningStep {
|
||||
const thoughtMatch = response.match(/Thought:\s*(.*?)(?=\n|Action:|$)/s);
|
||||
const actionMatch = response.match(/Action:\s*(.*?)(?=\n|Action Input:|$)/s);
|
||||
const inputMatch = response.match(/Action Input:\s*(.*?)(?=\n|$)/s);
|
||||
|
||||
return {
|
||||
step: stepNumber,
|
||||
thought: thoughtMatch?.[1]?.trim() || 'No thought provided',
|
||||
action: actionMatch?.[1]?.trim() || 'unknown',
|
||||
actionInput: inputMatch?.[1]?.trim() || '',
|
||||
observation: '' // Will be filled after tool execution
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a graph query using the Cypher generator
|
||||
*/
|
||||
private async executeGraphQuery(question: string, llmConfig: LLMConfig): Promise<ToolResult> {
|
||||
try {
|
||||
const cypherQuery = await this.cypherGenerator.generateQuery(question, llmConfig);
|
||||
|
||||
// For now, we'll simulate query execution since we don't have a real graph database
|
||||
// In a real implementation, this would execute the Cypher query against Neo4j or similar
|
||||
const mockResults = this.simulateGraphQuery(cypherQuery.cypher);
|
||||
|
||||
return {
|
||||
toolName: 'query_graph',
|
||||
input: question,
|
||||
output: `Query: ${cypherQuery.cypher}\n\nResults:\n${mockResults}\n\nExplanation: ${cypherQuery.explanation}`,
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toolName: 'query_graph',
|
||||
input: question,
|
||||
output: '',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Query execution failed'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code content from a file
|
||||
*/
|
||||
private async getCodeContent(filePath: string): Promise<ToolResult> {
|
||||
if (!this.context) {
|
||||
return {
|
||||
toolName: 'get_code',
|
||||
input: filePath,
|
||||
output: '',
|
||||
success: false,
|
||||
error: 'No context available'
|
||||
};
|
||||
}
|
||||
|
||||
const content = this.context.fileContents.get(filePath);
|
||||
if (!content) {
|
||||
// Try to find similar file paths
|
||||
const similarFiles = Array.from(this.context.fileContents.keys())
|
||||
.filter(path => path.includes(filePath) || filePath.includes(path))
|
||||
.slice(0, 3);
|
||||
|
||||
if (similarFiles.length > 0) {
|
||||
return {
|
||||
toolName: 'get_code',
|
||||
input: filePath,
|
||||
output: `File not found. Similar files available: ${similarFiles.join(', ')}`,
|
||||
success: false,
|
||||
error: 'File not found'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toolName: 'get_code',
|
||||
input: filePath,
|
||||
output: 'File not found',
|
||||
success: false,
|
||||
error: 'File not found'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toolName: 'get_code',
|
||||
input: filePath,
|
||||
output: `File: ${filePath}\n\n${content}`,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for files matching a pattern
|
||||
*/
|
||||
private async searchFiles(pattern: string): Promise<ToolResult> {
|
||||
if (!this.context) {
|
||||
return {
|
||||
toolName: 'search_files',
|
||||
input: pattern,
|
||||
output: '',
|
||||
success: false,
|
||||
error: 'No context available'
|
||||
};
|
||||
}
|
||||
|
||||
const matchingFiles: string[] = [];
|
||||
const lowerPattern = pattern.toLowerCase();
|
||||
|
||||
// Search in file paths
|
||||
for (const filePath of this.context.fileContents.keys()) {
|
||||
if (filePath.toLowerCase().includes(lowerPattern)) {
|
||||
matchingFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Search in file contents
|
||||
for (const [filePath, content] of this.context.fileContents.entries()) {
|
||||
if (!matchingFiles.includes(filePath) &&
|
||||
content.toLowerCase().includes(lowerPattern)) {
|
||||
matchingFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolName: 'search_files',
|
||||
input: pattern,
|
||||
output: matchingFiles.length > 0
|
||||
? `Found ${matchingFiles.length} files:\n${matchingFiles.slice(0, 10).join('\n')}${matchingFiles.length > 10 ? '\n... and more' : ''}`
|
||||
: 'No files found matching the pattern',
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate graph query execution (placeholder for real implementation)
|
||||
*/
|
||||
private simulateGraphQuery(cypher: string): string {
|
||||
if (!this.context) return 'No context available';
|
||||
|
||||
// Simple simulation based on common patterns
|
||||
const upperCypher = cypher.toUpperCase();
|
||||
|
||||
if (upperCypher.includes('FUNCTION') && upperCypher.includes('RETURN')) {
|
||||
const functions = this.context.graph.nodes
|
||||
.filter(n => n.label === 'Function')
|
||||
.slice(0, 5)
|
||||
.map(n => `${n.properties.name} (${n.properties.filePath})`)
|
||||
.join('\n');
|
||||
return functions || 'No functions found';
|
||||
}
|
||||
|
||||
if (upperCypher.includes('CLASS') && upperCypher.includes('RETURN')) {
|
||||
const classes = this.context.graph.nodes
|
||||
.filter(n => n.label === 'Class')
|
||||
.slice(0, 5)
|
||||
.map(n => `${n.properties.name} (${n.properties.filePath})`)
|
||||
.join('\n');
|
||||
return classes || 'No classes found';
|
||||
}
|
||||
|
||||
if (upperCypher.includes('FILE') && upperCypher.includes('RETURN')) {
|
||||
const files = this.context.graph.nodes
|
||||
.filter(n => n.label === 'File')
|
||||
.slice(0, 5)
|
||||
.map(n => n.properties.name)
|
||||
.join('\n');
|
||||
return files || 'No files found';
|
||||
}
|
||||
|
||||
return 'Query executed successfully (simulated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Cypher queries from tool results
|
||||
*/
|
||||
private extractCypherQueries(toolResult: ToolResult): CypherQuery[] {
|
||||
const queries: CypherQuery[] = [];
|
||||
const queryMatch = toolResult.output.match(/Query: (.*?)(?=\n|$)/);
|
||||
|
||||
if (queryMatch) {
|
||||
queries.push({
|
||||
cypher: queryMatch[1],
|
||||
explanation: 'Generated during reasoning',
|
||||
confidence: 0.8
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sources from tool results
|
||||
*/
|
||||
private extractSources(toolResult: ToolResult): string[] {
|
||||
const sources: string[] = [];
|
||||
const fileMatch = toolResult.output.match(/File: (.*?)(?=\n|$)/);
|
||||
|
||||
if (fileMatch) {
|
||||
sources.push(fileMatch[1]);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence based on reasoning quality
|
||||
*/
|
||||
private calculateConfidence(reasoning: ReasoningStep[], queries: CypherQuery[]): number {
|
||||
let confidence = 0.5;
|
||||
|
||||
// Boost confidence for successful tool usage
|
||||
const successfulSteps = reasoning.filter(step => step.toolResult?.success).length;
|
||||
confidence += (successfulSteps / reasoning.length) * 0.3;
|
||||
|
||||
// Boost confidence for high-quality queries
|
||||
const avgQueryConfidence = queries.length > 0
|
||||
? queries.reduce((sum, q) => sum + q.confidence, 0) / queries.length
|
||||
: 0.5;
|
||||
confidence += avgQueryConfidence * 0.2;
|
||||
|
||||
// Cap at reasonable bounds
|
||||
return Math.min(0.95, Math.max(0.1, confidence));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build summary prompt for incomplete reasoning
|
||||
*/
|
||||
private buildSummaryPrompt(question: string, reasoning: ReasoningStep[]): string {
|
||||
const observations = reasoning
|
||||
.map(step => `Step ${step.step}: ${step.observation}`)
|
||||
.join('\n');
|
||||
|
||||
return `Based on the following observations from your reasoning process, please provide a final answer to the question: "${question}"
|
||||
|
||||
Observations:
|
||||
${observations}
|
||||
|
||||
Final Answer:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current context information
|
||||
*/
|
||||
public getContextInfo(): { nodeCount: number; fileCount: number; hasContext: boolean } {
|
||||
if (!this.context) {
|
||||
return { nodeCount: 0, fileCount: 0, hasContext: false };
|
||||
}
|
||||
|
||||
return {
|
||||
nodeCount: this.context.graph.nodes.length,
|
||||
fileCount: this.context.fileContents.size,
|
||||
hasContext: true
|
||||
};
|
||||
}
|
||||
}
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
36
src/core/graph/types.ts
Normal file
36
src/core/graph/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export type NodeLabel =
|
||||
| 'Project'
|
||||
| 'Package'
|
||||
| 'Module'
|
||||
| 'Folder'
|
||||
| 'File'
|
||||
| 'Class'
|
||||
| 'Function'
|
||||
| 'Method'
|
||||
| 'Variable';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: NodeLabel;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RelationshipType =
|
||||
| 'CONTAINS'
|
||||
| 'CALLS'
|
||||
| 'INHERITS'
|
||||
| 'OVERRIDES'
|
||||
| 'IMPORTS';
|
||||
|
||||
export interface GraphRelationship {
|
||||
id: string;
|
||||
type: RelationshipType;
|
||||
source: string;
|
||||
target: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface KnowledgeGraph {
|
||||
nodes: GraphNode[];
|
||||
relationships: GraphRelationship[];
|
||||
}
|
||||
386
src/core/ingestion/call-processor.ts
Normal file
386
src/core/ingestion/call-processor.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.ts';
|
||||
import { generateId } from '../../lib/utils.ts';
|
||||
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import type Parser from 'npm:web-tree-sitter';
|
||||
|
||||
export interface CallResolutionInput {
|
||||
graph: KnowledgeGraph;
|
||||
astCache: Map<string, any>;
|
||||
fileContents: Map<string, string>;
|
||||
}
|
||||
|
||||
interface FunctionCall {
|
||||
callerFilePath: string;
|
||||
callerFunction: string;
|
||||
calledName: string;
|
||||
callType: 'function' | 'method' | 'attribute';
|
||||
line: number;
|
||||
column: number;
|
||||
isChained?: boolean;
|
||||
objectName?: string;
|
||||
}
|
||||
|
||||
interface ImportInfo {
|
||||
filePath: string;
|
||||
importedName: string;
|
||||
alias?: string;
|
||||
fromModule: string;
|
||||
importType: 'function' | 'class' | 'module' | 'attribute';
|
||||
}
|
||||
|
||||
const BUILTIN_FUNCTIONS = new Set([
|
||||
'print', 'len', 'str', 'int', 'float', 'bool', 'list', 'dict', 'tuple', 'set',
|
||||
'range', 'enumerate', 'zip', 'map', 'filter', 'sum', 'max', 'min', 'abs',
|
||||
'round', 'sorted', 'reversed', 'any', 'all', 'type', 'isinstance', 'hasattr',
|
||||
'getattr', 'setattr', 'delattr', 'dir', 'vars', 'id', 'hash', 'repr',
|
||||
'open', 'input', 'format', 'exec', 'eval', 'compile', 'globals', 'locals'
|
||||
]);
|
||||
|
||||
export class CallProcessor {
|
||||
private importCache: Map<string, ImportInfo[]> = new Map();
|
||||
private functionNodes: Map<string, GraphNode> = new Map();
|
||||
|
||||
public async process(input: CallResolutionInput): Promise<void> {
|
||||
const { graph, astCache, fileContents } = input;
|
||||
|
||||
// Build function node lookup for fast resolution
|
||||
this.buildFunctionNodeLookup(graph);
|
||||
|
||||
// Extract imports from all files first
|
||||
for (const [filePath, ast] of astCache) {
|
||||
if (this.isPythonFile(filePath)) {
|
||||
const content = fileContents.get(filePath);
|
||||
if (content && ast) {
|
||||
await this.extractImports(filePath, ast, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process function calls in all files
|
||||
for (const [filePath, ast] of astCache) {
|
||||
if (this.isPythonFile(filePath)) {
|
||||
const content = fileContents.get(filePath);
|
||||
if (content && ast) {
|
||||
await this.processFunctionCalls(graph, filePath, ast, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildFunctionNodeLookup(graph: KnowledgeGraph): void {
|
||||
for (const node of graph.nodes) {
|
||||
if (node.label === 'Function' || node.label === 'Method') {
|
||||
const filePath = node.properties.filePath as string;
|
||||
const functionName = node.properties.name as string;
|
||||
const key = `${filePath}:${functionName}`;
|
||||
this.functionNodes.set(key, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async extractImports(filePath: string, ast: any, content: string): Promise<void> {
|
||||
const imports: ImportInfo[] = [];
|
||||
|
||||
this.traverseNode(ast.rootNode, (node: any) => {
|
||||
if (node.type === 'import_statement') {
|
||||
this.processImportStatement(node, imports, filePath);
|
||||
} else if (node.type === 'import_from_statement') {
|
||||
this.processImportFromStatement(node, imports, filePath);
|
||||
}
|
||||
});
|
||||
|
||||
this.importCache.set(filePath, imports);
|
||||
}
|
||||
|
||||
private processImportStatement(node: any, imports: ImportInfo[], filePath: string): void {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
const moduleName = nameNode.text;
|
||||
|
||||
// Handle aliased imports (import module as alias)
|
||||
const asNode = node.children.find((child: any) => child.type === 'as_pattern');
|
||||
const alias = asNode ? asNode.childForFieldName('alias')?.text : undefined;
|
||||
|
||||
imports.push({
|
||||
filePath,
|
||||
importedName: moduleName,
|
||||
alias,
|
||||
fromModule: moduleName,
|
||||
importType: 'module'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private processImportFromStatement(node: any, imports: ImportInfo[], filePath: string): void {
|
||||
const moduleNameNode = node.childForFieldName('module_name');
|
||||
const importListNode = node.children.find((child: any) => child.type === 'import_list');
|
||||
|
||||
if (moduleNameNode && importListNode) {
|
||||
const moduleName = moduleNameNode.text;
|
||||
|
||||
this.traverseNode(importListNode, (child: any) => {
|
||||
if (child.type === 'dotted_name' || child.type === 'identifier') {
|
||||
const importedName = child.text;
|
||||
imports.push({
|
||||
filePath,
|
||||
importedName,
|
||||
fromModule: moduleName,
|
||||
importType: 'function' // Default assumption, could be refined
|
||||
});
|
||||
} else if (child.type === 'as_pattern') {
|
||||
const nameNode = child.childForFieldName('name');
|
||||
const aliasNode = child.childForFieldName('alias');
|
||||
|
||||
if (nameNode && aliasNode) {
|
||||
imports.push({
|
||||
filePath,
|
||||
importedName: nameNode.text,
|
||||
alias: aliasNode.text,
|
||||
fromModule: moduleName,
|
||||
importType: 'function'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async processFunctionCalls(
|
||||
graph: KnowledgeGraph,
|
||||
filePath: string,
|
||||
ast: any,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let currentFunction: string | null = null;
|
||||
|
||||
this.traverseNode(ast.rootNode, (node: any) => {
|
||||
// Track current function context
|
||||
if (node.type === 'function_definition') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
currentFunction = nameNode.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Find function calls
|
||||
if (node.type === 'call') {
|
||||
const callInfo = this.extractCallInfo(node, filePath, currentFunction, content);
|
||||
if (callInfo) {
|
||||
functionCalls.push(callInfo);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve calls and create relationships
|
||||
for (const call of functionCalls) {
|
||||
await this.resolveAndCreateCallRelationship(graph, call);
|
||||
}
|
||||
}
|
||||
|
||||
private extractCallInfo(
|
||||
callNode: any,
|
||||
filePath: string,
|
||||
currentFunction: string | null,
|
||||
content: string
|
||||
): FunctionCall | null {
|
||||
const functionNode = callNode.childForFieldName('function');
|
||||
if (!functionNode) return null;
|
||||
|
||||
const position = callNode.startPosition;
|
||||
const line = position.row + 1;
|
||||
const column = position.column + 1;
|
||||
|
||||
if (functionNode.type === 'identifier') {
|
||||
// Simple function call: func()
|
||||
return {
|
||||
callerFilePath: filePath,
|
||||
callerFunction: currentFunction || '<module>',
|
||||
calledName: functionNode.text,
|
||||
callType: 'function',
|
||||
line,
|
||||
column
|
||||
};
|
||||
} else if (functionNode.type === 'attribute') {
|
||||
// Method call: obj.method()
|
||||
const objectNode = functionNode.childForFieldName('object');
|
||||
const attributeNode = functionNode.childForFieldName('attribute');
|
||||
|
||||
if (objectNode && attributeNode) {
|
||||
return {
|
||||
callerFilePath: filePath,
|
||||
callerFunction: currentFunction || '<module>',
|
||||
calledName: attributeNode.text,
|
||||
callType: 'method',
|
||||
line,
|
||||
column,
|
||||
objectName: objectNode.text
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveAndCreateCallRelationship(
|
||||
graph: KnowledgeGraph,
|
||||
call: FunctionCall
|
||||
): Promise<void> {
|
||||
const callerNode = this.findCallerNode(graph, call);
|
||||
if (!callerNode) return;
|
||||
|
||||
// Try different resolution strategies
|
||||
const targetNode =
|
||||
this.resolveBuiltinFunction(call) ||
|
||||
this.resolveImportedFunction(call) ||
|
||||
this.resolveLocalFunction(call) ||
|
||||
this.resolveMethodCall(graph, call);
|
||||
|
||||
if (targetNode) {
|
||||
const relationship = this.createCallRelationship(callerNode.id, targetNode.id, call);
|
||||
graph.relationships.push(relationship);
|
||||
}
|
||||
}
|
||||
|
||||
private findCallerNode(graph: KnowledgeGraph, call: FunctionCall): GraphNode | null {
|
||||
if (call.callerFunction === '<module>') {
|
||||
// Call is at module level
|
||||
return graph.nodes.find(node =>
|
||||
node.label === 'Module' &&
|
||||
node.properties.path === call.callerFilePath
|
||||
) || null;
|
||||
} else {
|
||||
// Call is within a function
|
||||
const key = `${call.callerFilePath}:${call.callerFunction}`;
|
||||
return this.functionNodes.get(key) || null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBuiltinFunction(call: FunctionCall): GraphNode | null {
|
||||
if (BUILTIN_FUNCTIONS.has(call.calledName)) {
|
||||
return this.getOrCreateBuiltinNode(call.calledName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveImportedFunction(call: FunctionCall): GraphNode | null {
|
||||
const imports = this.importCache.get(call.callerFilePath) || [];
|
||||
|
||||
for (const importInfo of imports) {
|
||||
// Check direct import match
|
||||
if (importInfo.importedName === call.calledName || importInfo.alias === call.calledName) {
|
||||
// For now, create a placeholder node for imported functions
|
||||
// In a full implementation, this would resolve to actual function nodes
|
||||
return this.getOrCreateImportedNode(call.calledName, importInfo.fromModule);
|
||||
}
|
||||
|
||||
// Check module.function pattern
|
||||
if (call.callType === 'method' && call.objectName === importInfo.importedName) {
|
||||
return this.getOrCreateImportedNode(call.calledName, importInfo.fromModule);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveLocalFunction(call: FunctionCall): GraphNode | null {
|
||||
const key = `${call.callerFilePath}:${call.calledName}`;
|
||||
return this.functionNodes.get(key) || null;
|
||||
}
|
||||
|
||||
private resolveMethodCall(graph: KnowledgeGraph, call: FunctionCall): GraphNode | null {
|
||||
if (call.callType !== 'method') return null;
|
||||
|
||||
// Look for method in the same file
|
||||
const methods = graph.nodes.filter(node =>
|
||||
node.label === 'Method' &&
|
||||
node.properties.filePath === call.callerFilePath &&
|
||||
node.properties.name === call.calledName
|
||||
);
|
||||
|
||||
// Return first matching method (could be refined with class context)
|
||||
return methods[0] || null;
|
||||
}
|
||||
|
||||
private getOrCreateBuiltinNode(functionName: string): GraphNode {
|
||||
const id = generateId('builtin', functionName);
|
||||
return {
|
||||
id,
|
||||
label: 'Function',
|
||||
properties: {
|
||||
name: functionName,
|
||||
type: 'builtin',
|
||||
language: 'python'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getOrCreateImportedNode(functionName: string, moduleName: string): GraphNode {
|
||||
const id = generateId('imported', `${moduleName}.${functionName}`);
|
||||
return {
|
||||
id,
|
||||
label: 'Function',
|
||||
properties: {
|
||||
name: functionName,
|
||||
module: moduleName,
|
||||
type: 'imported',
|
||||
language: 'python'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private createCallRelationship(
|
||||
callerId: string,
|
||||
targetId: string,
|
||||
call: FunctionCall
|
||||
): GraphRelationship {
|
||||
return {
|
||||
id: generateId('relationship', `${callerId}-calls-${targetId}`),
|
||||
type: 'CALLS',
|
||||
source: callerId,
|
||||
target: targetId,
|
||||
properties: {
|
||||
callType: call.callType,
|
||||
line: call.line,
|
||||
column: call.column,
|
||||
...(call.objectName && { objectName: call.objectName })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private traverseNode(node: any, callback: (node: any) => void): void {
|
||||
callback(node);
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) {
|
||||
this.traverseNode(child, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isPythonFile(filePath: string): boolean {
|
||||
return filePath.endsWith('.py');
|
||||
}
|
||||
|
||||
public getImportInfo(filePath: string): ImportInfo[] {
|
||||
return this.importCache.get(filePath) || [];
|
||||
}
|
||||
|
||||
public getCallStats(): { totalCalls: number; callTypes: Record<string, number> } {
|
||||
const stats = {
|
||||
totalCalls: 0,
|
||||
callTypes: {
|
||||
function: 0,
|
||||
method: 0,
|
||||
builtin: 0,
|
||||
imported: 0,
|
||||
local: 0
|
||||
}
|
||||
};
|
||||
|
||||
// This would be populated during processing
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
220
src/core/ingestion/parsing-processor.ts
Normal file
220
src/core/ingestion/parsing-processor.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.ts';
|
||||
import { initTreeSitter, loadPythonParser } from '../tree-sitter/parser-loader.ts';
|
||||
import { generateId } from '../../lib/utils.ts';
|
||||
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import type Parser from 'npm:web-tree-sitter';
|
||||
|
||||
export interface ParsingInput {
|
||||
filePaths: string[];
|
||||
fileContents: Map<string, string>;
|
||||
}
|
||||
|
||||
interface ParsedDefinition {
|
||||
type: 'function' | 'class' | 'method';
|
||||
name: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
parentClass?: string;
|
||||
}
|
||||
|
||||
export class ParsingProcessor {
|
||||
private parser: Parser | null = null;
|
||||
private astCache: Map<string, any> = new Map();
|
||||
|
||||
public async process(graph: KnowledgeGraph, input: ParsingInput): Promise<void> {
|
||||
const { filePaths, fileContents } = input;
|
||||
|
||||
// Initialize Tree-sitter parser
|
||||
await this.initializeParser();
|
||||
|
||||
// Process each Python file
|
||||
for (const filePath of filePaths) {
|
||||
if (this.isPythonFile(filePath)) {
|
||||
const content = fileContents.get(filePath);
|
||||
if (content) {
|
||||
await this.parseFile(graph, filePath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeParser(): Promise<void> {
|
||||
if (!this.parser) {
|
||||
this.parser = await initTreeSitter();
|
||||
const pythonLanguage = await loadPythonParser();
|
||||
this.parser.setLanguage(pythonLanguage);
|
||||
}
|
||||
}
|
||||
|
||||
private async parseFile(graph: KnowledgeGraph, filePath: string, content: string): Promise<void> {
|
||||
if (!this.parser) {
|
||||
throw new Error('Parser not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the file and cache the AST
|
||||
const tree = this.parser.parse(content);
|
||||
this.astCache.set(filePath, tree);
|
||||
|
||||
// Create module node
|
||||
const moduleNode = this.createModuleNode(filePath);
|
||||
graph.nodes.push(moduleNode);
|
||||
|
||||
// Extract definitions from the AST
|
||||
const definitions = this.extractDefinitions(tree.rootNode, content);
|
||||
|
||||
// Create nodes for definitions and establish relationships
|
||||
for (const definition of definitions) {
|
||||
const definitionNode = this.createDefinitionNode(filePath, definition);
|
||||
graph.nodes.push(definitionNode);
|
||||
|
||||
// Create CONTAINS relationship between module and definition
|
||||
const relationship = this.createContainsRelationship(moduleNode.id, definitionNode.id);
|
||||
graph.relationships.push(relationship);
|
||||
|
||||
// If it's a method, create relationship with its class
|
||||
if (definition.type === 'method' && definition.parentClass) {
|
||||
const classNode = this.findClassNode(graph, filePath, definition.parentClass);
|
||||
if (classNode) {
|
||||
const methodRelationship = this.createContainsRelationship(classNode.id, definitionNode.id);
|
||||
graph.relationships.push(methodRelationship);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private createModuleNode(filePath: string): GraphNode {
|
||||
const pathParts = filePath.split('/');
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
const moduleName = fileName.replace(/\.py$/, '');
|
||||
|
||||
return {
|
||||
id: generateId('module', filePath),
|
||||
label: 'Module',
|
||||
properties: {
|
||||
name: moduleName,
|
||||
path: filePath,
|
||||
language: 'python'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private createDefinitionNode(filePath: string, definition: ParsedDefinition): GraphNode {
|
||||
const nodeLabel = definition.type === 'function' ? 'Function' :
|
||||
definition.type === 'method' ? 'Method' : 'Class';
|
||||
|
||||
return {
|
||||
id: generateId(definition.type, `${filePath}:${definition.name}`),
|
||||
label: nodeLabel as any,
|
||||
properties: {
|
||||
name: definition.name,
|
||||
filePath,
|
||||
startLine: definition.startLine,
|
||||
endLine: definition.endLine,
|
||||
...(definition.parentClass && { parentClass: definition.parentClass })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private createContainsRelationship(sourceId: string, targetId: string): GraphRelationship {
|
||||
return {
|
||||
id: generateId('relationship', `${sourceId}-contains-${targetId}`),
|
||||
type: 'CONTAINS',
|
||||
source: sourceId,
|
||||
target: targetId
|
||||
};
|
||||
}
|
||||
|
||||
private extractDefinitions(node: any, content: string): ParsedDefinition[] {
|
||||
const definitions: ParsedDefinition[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
this.traverseNode(node, (currentNode: any) => {
|
||||
if (currentNode.type === 'function_definition') {
|
||||
const nameNode = currentNode.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
definitions.push({
|
||||
type: 'function',
|
||||
name: nameNode.text,
|
||||
startLine: currentNode.startPosition.row + 1,
|
||||
endLine: currentNode.endPosition.row + 1
|
||||
});
|
||||
}
|
||||
} else if (currentNode.type === 'class_definition') {
|
||||
const nameNode = currentNode.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
const className = nameNode.text;
|
||||
definitions.push({
|
||||
type: 'class',
|
||||
name: className,
|
||||
startLine: currentNode.startPosition.row + 1,
|
||||
endLine: currentNode.endPosition.row + 1
|
||||
});
|
||||
|
||||
// Extract methods from the class
|
||||
const methods = this.extractMethodsFromClass(currentNode, className);
|
||||
definitions.push(...methods);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private extractMethodsFromClass(classNode: any, className: string): ParsedDefinition[] {
|
||||
const methods: ParsedDefinition[] = [];
|
||||
|
||||
this.traverseNode(classNode, (node: any) => {
|
||||
if (node.type === 'function_definition') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
methods.push({
|
||||
type: 'method',
|
||||
name: nameNode.text,
|
||||
startLine: node.startPosition.row + 1,
|
||||
endLine: node.endPosition.row + 1,
|
||||
parentClass: className
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
private traverseNode(node: any, callback: (node: any) => void): void {
|
||||
callback(node);
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) {
|
||||
this.traverseNode(child, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findClassNode(graph: KnowledgeGraph, filePath: string, className: string): GraphNode | null {
|
||||
return graph.nodes.find(node =>
|
||||
node.label === 'Class' &&
|
||||
node.properties.name === className &&
|
||||
node.properties.filePath === filePath
|
||||
) || null;
|
||||
}
|
||||
|
||||
private isPythonFile(filePath: string): boolean {
|
||||
return filePath.endsWith('.py');
|
||||
}
|
||||
|
||||
public getAst(filePath: string): any {
|
||||
return this.astCache.get(filePath);
|
||||
}
|
||||
|
||||
public getCachedAsts(): Map<string, any> {
|
||||
return new Map(this.astCache);
|
||||
}
|
||||
}
|
||||
80
src/core/ingestion/pipeline.ts
Normal file
80
src/core/ingestion/pipeline.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { KnowledgeGraph } from '../graph/types.ts';
|
||||
import { StructureProcessor } from './structure-processor.ts';
|
||||
import { ParsingProcessor } from './parsing-processor.ts';
|
||||
import { CallProcessor } from './call-processor.ts';
|
||||
|
||||
export interface PipelineInput {
|
||||
projectRoot: string;
|
||||
projectName: string;
|
||||
filePaths: string[];
|
||||
fileContents: Map<string, string>;
|
||||
}
|
||||
|
||||
export class GraphPipeline {
|
||||
private structureProcessor: StructureProcessor;
|
||||
private parsingProcessor: ParsingProcessor;
|
||||
private callProcessor: CallProcessor;
|
||||
|
||||
constructor() {
|
||||
this.structureProcessor = new StructureProcessor();
|
||||
this.parsingProcessor = new ParsingProcessor();
|
||||
this.callProcessor = new CallProcessor();
|
||||
}
|
||||
|
||||
public async run(input: PipelineInput): Promise<KnowledgeGraph> {
|
||||
const { projectRoot, projectName, filePaths, fileContents } = input;
|
||||
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [],
|
||||
relationships: []
|
||||
};
|
||||
|
||||
console.log(`Starting 3-pass ingestion for project: ${projectName}`);
|
||||
|
||||
// Pass 1: Structure Analysis
|
||||
console.log('Pass 1: Analyzing project structure...');
|
||||
await this.structureProcessor.process(graph, {
|
||||
projectRoot,
|
||||
projectName,
|
||||
filePaths
|
||||
});
|
||||
|
||||
// Pass 2: Code Parsing and Definition Extraction
|
||||
console.log('Pass 2: Parsing code and extracting definitions...');
|
||||
await this.parsingProcessor.process(graph, {
|
||||
filePaths,
|
||||
fileContents
|
||||
});
|
||||
|
||||
// Pass 3: Call Resolution
|
||||
console.log('Pass 3: Resolving function calls and references...');
|
||||
await this.callProcessor.process({
|
||||
graph,
|
||||
astCache: this.parsingProcessor.getCachedAsts(),
|
||||
fileContents
|
||||
});
|
||||
|
||||
console.log(`Ingestion complete. Graph contains ${graph.nodes.length} nodes and ${graph.relationships.length} relationships.`);
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
public getStats(graph: KnowledgeGraph): { nodeStats: Record<string, number>; relationshipStats: Record<string, number> } {
|
||||
const nodeStats: Record<string, number> = {};
|
||||
const relationshipStats: Record<string, number> = {};
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
nodeStats[node.label] = (nodeStats[node.label] || 0) + 1;
|
||||
}
|
||||
|
||||
for (const relationship of graph.relationships) {
|
||||
relationshipStats[relationship.type] = (relationshipStats[relationship.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return { nodeStats, relationshipStats };
|
||||
}
|
||||
|
||||
public getCallStats(): { totalCalls: number; callTypes: Record<string, number> } {
|
||||
return this.callProcessor.getCallStats();
|
||||
}
|
||||
}
|
||||
197
src/core/ingestion/structure-processor.ts
Normal file
197
src/core/ingestion/structure-processor.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.ts';
|
||||
import { generateId } from '../../lib/utils.ts';
|
||||
|
||||
export interface StructureInput {
|
||||
projectRoot: string;
|
||||
projectName: string;
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
export class StructureProcessor {
|
||||
private nodeIdMap: Map<string, string> = new Map();
|
||||
|
||||
public async process(graph: KnowledgeGraph, input: StructureInput): Promise<void> {
|
||||
const { projectRoot, projectName, filePaths } = input;
|
||||
|
||||
// Create project root node
|
||||
const projectNode = this.createProjectNode(projectName, projectRoot);
|
||||
graph.nodes.push(projectNode);
|
||||
|
||||
// Extract unique folder paths from file paths
|
||||
const folderPaths = this.extractFolderPaths(filePaths);
|
||||
|
||||
// Create folder nodes and establish hierarchy
|
||||
const folderNodes = this.createFolderNodes(folderPaths);
|
||||
graph.nodes.push(...folderNodes);
|
||||
|
||||
// Create file nodes
|
||||
const fileNodes = this.createFileNodes(filePaths);
|
||||
graph.nodes.push(...fileNodes);
|
||||
|
||||
// Establish CONTAINS relationships
|
||||
this.createContainsRelationships(graph, projectNode.id, folderPaths, filePaths);
|
||||
}
|
||||
|
||||
private createProjectNode(projectName: string, projectRoot: string): GraphNode {
|
||||
const id = generateId('project', projectName);
|
||||
this.nodeIdMap.set('', id); // Empty path represents project root
|
||||
|
||||
return {
|
||||
id,
|
||||
label: 'Project',
|
||||
properties: {
|
||||
name: projectName,
|
||||
path: projectRoot,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private extractFolderPaths(filePaths: string[]): string[] {
|
||||
const folderSet = new Set<string>();
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const pathParts = filePath.split('/');
|
||||
|
||||
// Generate all parent folder paths
|
||||
for (let i = 1; i < pathParts.length; i++) {
|
||||
const folderPath = pathParts.slice(0, i).join('/');
|
||||
if (folderPath) {
|
||||
folderSet.add(folderPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(folderSet).sort();
|
||||
}
|
||||
|
||||
private createFolderNodes(folderPaths: string[]): GraphNode[] {
|
||||
const nodes: GraphNode[] = [];
|
||||
|
||||
for (const folderPath of folderPaths) {
|
||||
const pathParts = folderPath.split('/');
|
||||
const folderName = pathParts[pathParts.length - 1];
|
||||
const id = generateId('folder', folderPath);
|
||||
|
||||
this.nodeIdMap.set(folderPath, id);
|
||||
|
||||
nodes.push({
|
||||
id,
|
||||
label: 'Folder',
|
||||
properties: {
|
||||
name: folderName,
|
||||
path: folderPath,
|
||||
depth: pathParts.length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private createFileNodes(filePaths: string[]): GraphNode[] {
|
||||
const nodes: GraphNode[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const pathParts = filePath.split('/');
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
const fileExtension = this.getFileExtension(fileName);
|
||||
const id = generateId('file', filePath);
|
||||
|
||||
this.nodeIdMap.set(filePath, id);
|
||||
|
||||
nodes.push({
|
||||
id,
|
||||
label: 'File',
|
||||
properties: {
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
extension: fileExtension,
|
||||
isSourceFile: this.isSourceFile(fileExtension)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private createContainsRelationships(
|
||||
graph: KnowledgeGraph,
|
||||
projectId: string,
|
||||
folderPaths: string[],
|
||||
filePaths: string[]
|
||||
): void {
|
||||
const relationships: GraphRelationship[] = [];
|
||||
|
||||
// Project contains root folders
|
||||
const rootFolders = folderPaths.filter(path => !path.includes('/'));
|
||||
for (const rootFolder of rootFolders) {
|
||||
const folderId = this.nodeIdMap.get(rootFolder);
|
||||
if (folderId) {
|
||||
relationships.push({
|
||||
id: generateId('relationship', `${projectId}-contains-${folderId}`),
|
||||
type: 'CONTAINS',
|
||||
source: projectId,
|
||||
target: folderId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Folders contain subfolders
|
||||
for (const folderPath of folderPaths) {
|
||||
const parentPath = this.getParentPath(folderPath);
|
||||
const parentId = this.nodeIdMap.get(parentPath);
|
||||
const folderId = this.nodeIdMap.get(folderPath);
|
||||
|
||||
if (parentId && folderId && parentPath !== folderPath) {
|
||||
relationships.push({
|
||||
id: generateId('relationship', `${parentId}-contains-${folderId}`),
|
||||
type: 'CONTAINS',
|
||||
source: parentId,
|
||||
target: folderId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Folders and project contain files
|
||||
for (const filePath of filePaths) {
|
||||
const parentPath = this.getParentPath(filePath);
|
||||
const parentId = this.nodeIdMap.get(parentPath);
|
||||
const fileId = this.nodeIdMap.get(filePath);
|
||||
|
||||
if (parentId && fileId) {
|
||||
relationships.push({
|
||||
id: generateId('relationship', `${parentId}-contains-${fileId}`),
|
||||
type: 'CONTAINS',
|
||||
source: parentId,
|
||||
target: fileId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
graph.relationships.push(...relationships);
|
||||
}
|
||||
|
||||
private getParentPath(path: string): string {
|
||||
const pathParts = path.split('/');
|
||||
if (pathParts.length <= 1) return '';
|
||||
return pathParts.slice(0, -1).join('/');
|
||||
}
|
||||
|
||||
private getFileExtension(fileName: string): string {
|
||||
const lastDotIndex = fileName.lastIndexOf('.');
|
||||
return lastDotIndex !== -1 ? fileName.substring(lastDotIndex) : '';
|
||||
}
|
||||
|
||||
private isSourceFile(extension: string): boolean {
|
||||
const sourceExtensions = new Set([
|
||||
'.py', '.js', '.ts', '.tsx', '.jsx', '.java', '.cpp', '.c', '.h', '.hpp',
|
||||
'.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala'
|
||||
]);
|
||||
return sourceExtensions.has(extension);
|
||||
}
|
||||
|
||||
public getNodeId(path: string): string | undefined {
|
||||
return this.nodeIdMap.get(path);
|
||||
}
|
||||
}
|
||||
30
src/core/tree-sitter/parser-loader.ts
Normal file
30
src/core/tree-sitter/parser-loader.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import Parser from "npm:web-tree-sitter";
|
||||
|
||||
interface Language {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let parserInstance: Parser | null = null;
|
||||
const parserCache = new Map<string, Language>();
|
||||
|
||||
export async function initTreeSitter(): Promise<Parser> {
|
||||
if (parserInstance) return parserInstance;
|
||||
|
||||
// Initialize WebAssembly
|
||||
await Parser.init();
|
||||
parserInstance = new Parser();
|
||||
return parserInstance;
|
||||
}
|
||||
|
||||
export async function loadPythonParser(): Promise<Language> {
|
||||
if (parserCache.has('python')) {
|
||||
return parserCache.get('python')!;
|
||||
}
|
||||
|
||||
// Load Python language from WASM file
|
||||
const pythonLang = await Parser.Language.load('/wasm/python/tree-sitter-python.wasm');
|
||||
|
||||
parserCache.set('python', pythonLang);
|
||||
return pythonLang;
|
||||
}
|
||||
68
src/index.css
Normal file
68
src/index.css
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
335
src/lib/export.ts
Normal file
335
src/lib/export.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import type { KnowledgeGraph } from '../core/graph/types.ts';
|
||||
|
||||
export interface ExportOptions {
|
||||
filename?: string;
|
||||
includeMetadata?: boolean;
|
||||
prettyPrint?: boolean;
|
||||
includeTimestamp?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportMetadata {
|
||||
exportedAt: string;
|
||||
version: string;
|
||||
nodeCount: number;
|
||||
relationshipCount: number;
|
||||
fileCount?: number;
|
||||
processingDuration?: number;
|
||||
}
|
||||
|
||||
export interface ExportedGraph {
|
||||
metadata: ExportMetadata;
|
||||
graph: KnowledgeGraph;
|
||||
fileContents?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a KnowledgeGraph to JSON format
|
||||
*/
|
||||
export function exportGraphToJSON(
|
||||
graph: KnowledgeGraph,
|
||||
options: ExportOptions = {},
|
||||
fileContents?: Map<string, string>,
|
||||
processingStats?: { duration: number }
|
||||
): string {
|
||||
const {
|
||||
includeMetadata = true,
|
||||
prettyPrint = true,
|
||||
includeTimestamp = true
|
||||
} = options;
|
||||
|
||||
let exportData: ExportedGraph | KnowledgeGraph;
|
||||
|
||||
if (includeMetadata) {
|
||||
const metadata: ExportMetadata = {
|
||||
exportedAt: includeTimestamp ? new Date().toISOString() : '',
|
||||
version: '1.0.0',
|
||||
nodeCount: graph.nodes.length,
|
||||
relationshipCount: graph.relationships.length,
|
||||
fileCount: fileContents?.size,
|
||||
processingDuration: processingStats?.duration
|
||||
};
|
||||
|
||||
exportData = {
|
||||
metadata,
|
||||
graph,
|
||||
...(fileContents && { fileContents: Object.fromEntries(fileContents) })
|
||||
};
|
||||
} else {
|
||||
exportData = graph;
|
||||
}
|
||||
|
||||
return JSON.stringify(exportData, null, prettyPrint ? 2 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger download of a JSON file
|
||||
*/
|
||||
export function downloadJSON(content: string, filename: string): void {
|
||||
try {
|
||||
// Create blob with JSON content
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
|
||||
// Create download URL
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create temporary download link
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
|
||||
// Add to DOM, click, and remove
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up URL
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to download JSON file:', error);
|
||||
throw new Error('Failed to download file. Please check your browser permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a default filename for the export
|
||||
*/
|
||||
export function generateExportFilename(
|
||||
projectName?: string,
|
||||
includeTimestamp: boolean = true
|
||||
): string {
|
||||
const baseName = projectName
|
||||
? `gitnexus-${projectName.replace(/[^a-zA-Z0-9-_]/g, '-')}`
|
||||
: 'gitnexus-graph';
|
||||
|
||||
if (includeTimestamp) {
|
||||
const timestamp = new Date().toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.replace('T', '_')
|
||||
.split('.')[0]; // Remove milliseconds
|
||||
return `${baseName}_${timestamp}.json`;
|
||||
}
|
||||
|
||||
return `${baseName}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export and download a KnowledgeGraph
|
||||
*/
|
||||
export function exportAndDownloadGraph(
|
||||
graph: KnowledgeGraph,
|
||||
options: ExportOptions & { projectName?: string } = {},
|
||||
fileContents?: Map<string, string>,
|
||||
processingStats?: { duration: number }
|
||||
): void {
|
||||
const {
|
||||
filename,
|
||||
projectName,
|
||||
includeTimestamp = true,
|
||||
...exportOptions
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Generate filename if not provided
|
||||
const finalFilename = filename || generateExportFilename(projectName, includeTimestamp);
|
||||
|
||||
// Export to JSON
|
||||
const jsonContent = exportGraphToJSON(graph, exportOptions, fileContents, processingStats);
|
||||
|
||||
// Trigger download
|
||||
downloadJSON(jsonContent, finalFilename);
|
||||
|
||||
console.log(`Successfully exported graph to ${finalFilename}`);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate export file size (approximate)
|
||||
*/
|
||||
export function calculateExportSize(
|
||||
graph: KnowledgeGraph,
|
||||
includeFileContents: boolean = false,
|
||||
fileContents?: Map<string, string>
|
||||
): { sizeBytes: number; sizeFormatted: string } {
|
||||
// Create a sample export to measure size
|
||||
const sampleExport = exportGraphToJSON(
|
||||
graph,
|
||||
{ includeMetadata: true, prettyPrint: false },
|
||||
includeFileContents ? fileContents : undefined
|
||||
);
|
||||
|
||||
const sizeBytes = new Blob([sampleExport]).size;
|
||||
const sizeFormatted = formatFileSize(sizeBytes);
|
||||
|
||||
return { sizeBytes, sizeFormatted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a graph can be exported
|
||||
*/
|
||||
export function validateGraphForExport(graph: KnowledgeGraph): {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check if graph exists
|
||||
if (!graph) {
|
||||
errors.push('Graph is null or undefined');
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Check if graph has nodes
|
||||
if (!graph.nodes || graph.nodes.length === 0) {
|
||||
warnings.push('Graph has no nodes');
|
||||
}
|
||||
|
||||
// Check if graph has relationships
|
||||
if (!graph.relationships || graph.relationships.length === 0) {
|
||||
warnings.push('Graph has no relationships');
|
||||
}
|
||||
|
||||
// Check for invalid node IDs
|
||||
const nodeIds = new Set(graph.nodes.map(n => n.id));
|
||||
if (nodeIds.size !== graph.nodes.length) {
|
||||
errors.push('Graph contains duplicate node IDs');
|
||||
}
|
||||
|
||||
// Check for invalid relationships
|
||||
graph.relationships.forEach((rel, index) => {
|
||||
if (!nodeIds.has(rel.source)) {
|
||||
errors.push(`Relationship ${index} has invalid source node ID: ${rel.source}`);
|
||||
}
|
||||
if (!nodeIds.has(rel.target)) {
|
||||
errors.push(`Relationship ${index} has invalid target node ID: ${rel.target}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for very large exports
|
||||
const approximateSize = JSON.stringify(graph).length;
|
||||
if (approximateSize > 50 * 1024 * 1024) { // 50MB
|
||||
warnings.push('Export file will be very large (>50MB). Consider filtering the data.');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a KnowledgeGraph from JSON string
|
||||
*/
|
||||
export function importGraphFromJSON(jsonString: string): {
|
||||
graph: KnowledgeGraph;
|
||||
metadata?: ExportMetadata;
|
||||
fileContents?: Map<string, string>;
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
// Check if it's an exported graph with metadata
|
||||
if (parsed.metadata && parsed.graph) {
|
||||
const result: {
|
||||
graph: KnowledgeGraph;
|
||||
metadata: ExportMetadata;
|
||||
fileContents?: Map<string, string>;
|
||||
} = {
|
||||
graph: parsed.graph,
|
||||
metadata: parsed.metadata
|
||||
};
|
||||
|
||||
// Convert file contents back to Map if present
|
||||
if (parsed.fileContents) {
|
||||
result.fileContents = new Map(Object.entries(parsed.fileContents));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Assume it's a raw graph
|
||||
return { graph: parsed };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to import graph: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filtered export of the graph
|
||||
*/
|
||||
export function createFilteredExport(
|
||||
graph: KnowledgeGraph,
|
||||
filters: {
|
||||
nodeTypes?: string[];
|
||||
relationshipTypes?: string[];
|
||||
filePatterns?: string[];
|
||||
maxNodes?: number;
|
||||
}
|
||||
): KnowledgeGraph {
|
||||
const { nodeTypes, relationshipTypes, filePatterns, maxNodes } = filters;
|
||||
|
||||
let filteredNodes = graph.nodes;
|
||||
let filteredRelationships = graph.relationships;
|
||||
|
||||
// Filter by node types
|
||||
if (nodeTypes && nodeTypes.length > 0) {
|
||||
filteredNodes = filteredNodes.filter(node => nodeTypes.includes(node.label));
|
||||
}
|
||||
|
||||
// Filter by file patterns
|
||||
if (filePatterns && filePatterns.length > 0) {
|
||||
filteredNodes = filteredNodes.filter(node => {
|
||||
const filePath = node.properties.filePath as string;
|
||||
if (!filePath) return true; // Keep nodes without file paths
|
||||
|
||||
return filePatterns.some(pattern => {
|
||||
const regex = new RegExp(pattern.replace(/\*/g, '.*'), 'i');
|
||||
return regex.test(filePath);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Limit number of nodes
|
||||
if (maxNodes && filteredNodes.length > maxNodes) {
|
||||
filteredNodes = filteredNodes.slice(0, maxNodes);
|
||||
}
|
||||
|
||||
// Get filtered node IDs
|
||||
const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
|
||||
|
||||
// Filter relationships to only include those between filtered nodes
|
||||
filteredRelationships = filteredRelationships.filter(rel =>
|
||||
filteredNodeIds.has(rel.source) && filteredNodeIds.has(rel.target)
|
||||
);
|
||||
|
||||
// Filter by relationship types
|
||||
if (relationshipTypes && relationshipTypes.length > 0) {
|
||||
filteredRelationships = filteredRelationships.filter(rel =>
|
||||
relationshipTypes.includes(rel.type)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: filteredNodes,
|
||||
relationships: filteredRelationships
|
||||
};
|
||||
}
|
||||
15
src/lib/utils.ts
Normal file
15
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export function generateId(type: string, identifier: string): string {
|
||||
const timestamp = Date.now();
|
||||
const hash = simpleHash(identifier);
|
||||
return `${type}_${hash}_${timestamp}`;
|
||||
}
|
||||
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
192
src/lib/workerUtils.ts
Normal file
192
src/lib/workerUtils.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import * as Comlink from 'npm:comlink';
|
||||
import type { IngestionWorker, IngestionProgress, IngestionResult } from '../workers/ingestion.worker.ts';
|
||||
import type { PipelineInput } from '../core/ingestion/pipeline.ts';
|
||||
|
||||
export interface WorkerProxy {
|
||||
processRepository(input: PipelineInput): Promise<IngestionResult>;
|
||||
processFiles(projectName: string, files: { path: string; content: string }[]): Promise<IngestionResult>;
|
||||
validateRepository(input: PipelineInput): Promise<{ valid: boolean; errors: string[] }>;
|
||||
getWorkerInfo(): Promise<{ version: string; capabilities: string[] }>;
|
||||
setProgressCallback(callback: (progress: IngestionProgress) => void): Promise<void>;
|
||||
terminate(): Promise<void>;
|
||||
}
|
||||
|
||||
export class IngestionWorkerManager {
|
||||
private worker: Worker | null = null;
|
||||
private workerProxy: WorkerProxy | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the worker
|
||||
this.worker = new Worker(
|
||||
new URL('../workers/ingestion.worker.ts', import.meta.url).href,
|
||||
{
|
||||
type: 'module',
|
||||
name: 'ingestion-worker'
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap with Comlink
|
||||
this.workerProxy = Comlink.wrap<IngestionWorker>(this.worker) as WorkerProxy;
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('Ingestion worker initialized successfully');
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to initialize ingestion worker: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async processRepository(input: PipelineInput): Promise<IngestionResult> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
return await this.workerProxy!.processRepository(input);
|
||||
} catch (error) {
|
||||
throw new Error(`Worker processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async processFiles(
|
||||
projectName: string,
|
||||
files: { path: string; content: string }[]
|
||||
): Promise<IngestionResult> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
return await this.workerProxy!.processFiles(projectName, files);
|
||||
} catch (error) {
|
||||
throw new Error(`Worker file processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async validateRepository(input: PipelineInput): Promise<{ valid: boolean; errors: string[] }> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
return await this.workerProxy!.validateRepository(input);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async setProgressCallback(callback: (progress: IngestionProgress) => void): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
// Wrap callback with Comlink.proxy to allow it to be called from worker
|
||||
const proxiedCallback = Comlink.proxy(callback);
|
||||
await this.workerProxy!.setProgressCallback(proxiedCallback);
|
||||
} catch (error) {
|
||||
console.warn('Failed to set progress callback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getWorkerInfo(): Promise<{ version: string; capabilities: string[] }> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
return await this.workerProxy!.getWorkerInfo();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get worker info: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async terminate(): Promise<void> {
|
||||
if (!this.isInitialized || !this.worker) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Notify worker to cleanup
|
||||
if (this.workerProxy) {
|
||||
await this.workerProxy.terminate();
|
||||
}
|
||||
|
||||
// Terminate the worker
|
||||
this.worker.terminate();
|
||||
|
||||
// Cleanup references
|
||||
this.worker = null;
|
||||
this.workerProxy = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
console.log('Ingestion worker terminated');
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Error during worker termination:', error);
|
||||
|
||||
// Force terminate if cleanup fails
|
||||
if (this.worker) {
|
||||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
this.workerProxy = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public isWorkerReady(): boolean {
|
||||
return this.isInitialized && this.worker !== null && this.workerProxy !== null;
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for easy access
|
||||
let workerManager: IngestionWorkerManager | null = null;
|
||||
|
||||
export function getIngestionWorker(): IngestionWorkerManager {
|
||||
if (!workerManager) {
|
||||
workerManager = new IngestionWorkerManager();
|
||||
}
|
||||
return workerManager;
|
||||
}
|
||||
|
||||
export async function createIngestionWorker(): Promise<IngestionWorkerManager> {
|
||||
const manager = new IngestionWorkerManager();
|
||||
await manager.initialize();
|
||||
return manager;
|
||||
}
|
||||
|
||||
// Utility function for processing with automatic cleanup
|
||||
export async function processWithWorker<T>(
|
||||
processor: (worker: IngestionWorkerManager) => Promise<T>
|
||||
): Promise<T> {
|
||||
const worker = await createIngestionWorker();
|
||||
|
||||
try {
|
||||
return await processor(worker);
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling utilities
|
||||
export class WorkerError extends Error {
|
||||
constructor(message: string, public readonly cause?: Error) {
|
||||
super(message);
|
||||
this.name = 'WorkerError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkerSupported(): boolean {
|
||||
try {
|
||||
return typeof Worker !== 'undefined';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
242
src/services/github.ts
Normal file
242
src/services/github.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'npm:axios';
|
||||
|
||||
interface GitHubFile {
|
||||
name: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
url: string;
|
||||
html_url: string;
|
||||
git_url: string;
|
||||
download_url: string | null;
|
||||
type: 'file' | 'dir';
|
||||
content?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
interface GitHubDirectory {
|
||||
name: string;
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
url: string;
|
||||
html_url: string;
|
||||
git_url: string;
|
||||
download_url: string | null;
|
||||
type: 'file' | 'dir';
|
||||
}
|
||||
|
||||
interface RateLimitInfo {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
used: number;
|
||||
}
|
||||
|
||||
interface GitHubError {
|
||||
message: string;
|
||||
documentation_url?: string;
|
||||
}
|
||||
|
||||
export class GitHubService {
|
||||
private client: AxiosInstance;
|
||||
private baseURL = 'https://api.github.com';
|
||||
private rateLimitInfo: RateLimitInfo | null = null;
|
||||
|
||||
constructor(token?: string) {
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'GitNexus/1.0',
|
||||
...(token && { 'Authorization': `Bearer ${token}` })
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
this.client.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
this.updateRateLimitInfo(response);
|
||||
return response;
|
||||
},
|
||||
(error: { response?: AxiosResponse; message: string }) => {
|
||||
if (error.response) {
|
||||
this.updateRateLimitInfo(error.response);
|
||||
|
||||
if (error.response.status === 403 && this.isRateLimited()) {
|
||||
const resetTime = new Date(this.rateLimitInfo!.reset * 1000);
|
||||
throw new Error(`GitHub API rate limit exceeded. Resets at ${resetTime.toISOString()}`);
|
||||
}
|
||||
|
||||
if (error.response.status === 401) {
|
||||
throw new Error('GitHub API authentication failed. Please check your token.');
|
||||
}
|
||||
|
||||
if (error.response.status === 404) {
|
||||
throw new Error('Repository or resource not found.');
|
||||
}
|
||||
|
||||
const githubError: GitHubError = error.response.data;
|
||||
throw new Error(`GitHub API error: ${githubError.message}`);
|
||||
}
|
||||
|
||||
throw new Error(`Network error: ${error.message}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private updateRateLimitInfo(response: AxiosResponse): void {
|
||||
const headers = response.headers;
|
||||
if (headers['x-ratelimit-limit']) {
|
||||
this.rateLimitInfo = {
|
||||
limit: parseInt(headers['x-ratelimit-limit'], 10),
|
||||
remaining: parseInt(headers['x-ratelimit-remaining'], 10),
|
||||
reset: parseInt(headers['x-ratelimit-reset'], 10),
|
||||
used: parseInt(headers['x-ratelimit-used'], 10)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private isRateLimited(): boolean {
|
||||
return this.rateLimitInfo !== null && this.rateLimitInfo.remaining === 0;
|
||||
}
|
||||
|
||||
public getRateLimitInfo(): RateLimitInfo | null {
|
||||
return this.rateLimitInfo;
|
||||
}
|
||||
|
||||
public async checkRateLimit(): Promise<void> {
|
||||
if (this.isRateLimited()) {
|
||||
const resetTime = new Date(this.rateLimitInfo!.reset * 1000);
|
||||
const now = new Date();
|
||||
|
||||
if (now < resetTime) {
|
||||
const waitTime = Math.ceil((resetTime.getTime() - now.getTime()) / 1000);
|
||||
throw new Error(`Rate limit exceeded. Wait ${waitTime} seconds before making another request.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getRepositoryContents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string = ''
|
||||
): Promise<(GitHubFile | GitHubDirectory)[]> {
|
||||
await this.checkRateLimit();
|
||||
|
||||
try {
|
||||
const response = await this.client.get(`/repos/${owner}/${repo}/contents/${path}`);
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
throw new Error('Expected directory contents, but received a single file.');
|
||||
}
|
||||
|
||||
return response.data as (GitHubFile | GitHubDirectory)[];
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to fetch repository contents');
|
||||
}
|
||||
}
|
||||
|
||||
public async getFileContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string
|
||||
): Promise<string> {
|
||||
await this.checkRateLimit();
|
||||
|
||||
try {
|
||||
const response = await this.client.get(`/repos/${owner}/${repo}/contents/${path}`);
|
||||
const file = response.data as GitHubFile;
|
||||
|
||||
if (file.type !== 'file') {
|
||||
throw new Error(`Path ${path} is not a file`);
|
||||
}
|
||||
|
||||
if (!file.content || !file.encoding) {
|
||||
throw new Error('File content or encoding information missing');
|
||||
}
|
||||
|
||||
if (file.encoding === 'base64') {
|
||||
try {
|
||||
return atob(file.content.replace(/\s/g, ''));
|
||||
} catch {
|
||||
throw new Error('Failed to decode base64 content');
|
||||
}
|
||||
}
|
||||
|
||||
return file.content;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to fetch file content');
|
||||
}
|
||||
}
|
||||
|
||||
public async downloadFileRaw(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string
|
||||
): Promise<string> {
|
||||
await this.checkRateLimit();
|
||||
|
||||
try {
|
||||
const response = await this.client.get(`/repos/${owner}/${repo}/contents/${path}`);
|
||||
const file = response.data as GitHubFile;
|
||||
|
||||
if (file.type !== 'file' || !file.download_url) {
|
||||
throw new Error(`Cannot download file: ${path}`);
|
||||
}
|
||||
|
||||
const downloadResponse = await axios.get(file.download_url, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'User-Agent': 'GitNexus/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
return downloadResponse.data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to download file');
|
||||
}
|
||||
}
|
||||
|
||||
public async getAllFilesRecursively(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string = ''
|
||||
): Promise<GitHubFile[]> {
|
||||
const allFiles: GitHubFile[] = [];
|
||||
const items = await this.getRepositoryContents(owner, repo, path);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === 'file') {
|
||||
allFiles.push(item as GitHubFile);
|
||||
} else if (item.type === 'dir') {
|
||||
const subFiles = await this.getAllFilesRecursively(owner, repo, item.path);
|
||||
allFiles.push(...subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
public getAuthenticationStatus(): { authenticated: boolean; rateLimitInfo: RateLimitInfo | null } {
|
||||
const authHeader = this.client.defaults.headers['Authorization'];
|
||||
return {
|
||||
authenticated: !!authHeader,
|
||||
rateLimitInfo: this.rateLimitInfo
|
||||
};
|
||||
}
|
||||
}
|
||||
267
src/services/zip.ts
Normal file
267
src/services/zip.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import JSZip from 'npm:jszip';
|
||||
|
||||
interface ZipFileEntry {
|
||||
path: string;
|
||||
content: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
interface ExtractionOptions {
|
||||
maxFileSize?: number;
|
||||
maxTotalSize?: number;
|
||||
allowedExtensions?: string[];
|
||||
excludeDirectories?: boolean;
|
||||
}
|
||||
|
||||
export class ZipService {
|
||||
private static readonly DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
private static readonly DEFAULT_MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
private static readonly TEXT_EXTENSIONS = new Set([
|
||||
'.js', '.ts', '.tsx', '.jsx', '.py', '.java', '.cpp', '.c', '.h', '.hpp',
|
||||
'.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala', '.clj',
|
||||
'.html', '.htm', '.xml', '.css', '.scss', '.sass', '.less', '.json',
|
||||
'.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.md', '.txt',
|
||||
'.sql', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd',
|
||||
'.dockerfile', '.gitignore', '.gitattributes', '.env', '.properties'
|
||||
]);
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async extractTextFiles(
|
||||
file: File,
|
||||
options: ExtractionOptions = {}
|
||||
): Promise<Map<string, string>> {
|
||||
const {
|
||||
maxFileSize = ZipService.DEFAULT_MAX_FILE_SIZE,
|
||||
maxTotalSize = ZipService.DEFAULT_MAX_TOTAL_SIZE,
|
||||
allowedExtensions = Array.from(ZipService.TEXT_EXTENSIONS),
|
||||
excludeDirectories = true
|
||||
} = options;
|
||||
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
if (file.type !== 'application/zip' && !file.name.toLowerCase().endsWith('.zip')) {
|
||||
throw new Error('File must be a ZIP archive');
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await this.fileToArrayBuffer(file);
|
||||
const zip = new JSZip();
|
||||
const zipContent = await zip.loadAsync(arrayBuffer);
|
||||
|
||||
const extractedFiles = new Map<string, string>();
|
||||
let totalExtractedSize = 0;
|
||||
|
||||
const files = Object.keys(zipContent.files);
|
||||
|
||||
for (const filePath of files) {
|
||||
const zipFile = zipContent.files[filePath];
|
||||
|
||||
if (zipFile.dir && excludeDirectories) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (zipFile.dir) {
|
||||
extractedFiles.set(filePath, '');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.isTextFile(filePath, allowedExtensions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (zipFile._data && zipFile._data.uncompressedSize > maxFileSize) {
|
||||
console.warn(`Skipping file ${filePath}: exceeds maximum file size (${maxFileSize} bytes)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (totalExtractedSize + (zipFile._data?.uncompressedSize || 0) > maxTotalSize) {
|
||||
console.warn(`Stopping extraction: total size would exceed maximum (${maxTotalSize} bytes)`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await zipFile.async('text');
|
||||
|
||||
if (content.length > maxFileSize) {
|
||||
console.warn(`Skipping file ${filePath}: content exceeds maximum file size`);
|
||||
continue;
|
||||
}
|
||||
|
||||
extractedFiles.set(filePath, content);
|
||||
totalExtractedSize += content.length;
|
||||
|
||||
} catch (fileError) {
|
||||
console.warn(`Failed to extract file ${filePath}:`, fileError);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return extractedFiles;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('corrupt') || error.message.includes('invalid')) {
|
||||
throw new Error('ZIP file appears to be corrupted or invalid');
|
||||
}
|
||||
throw new Error(`Failed to extract ZIP file: ${error.message}`);
|
||||
}
|
||||
throw new Error('Unknown error occurred while extracting ZIP file');
|
||||
}
|
||||
}
|
||||
|
||||
public async getZipFileInfo(file: File): Promise<ZipFileEntry[]> {
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await this.fileToArrayBuffer(file);
|
||||
const zip = new JSZip();
|
||||
const zipContent = await zip.loadAsync(arrayBuffer);
|
||||
|
||||
const fileInfos: ZipFileEntry[] = [];
|
||||
|
||||
for (const [path, zipFile] of Object.entries(zipContent.files)) {
|
||||
const file = zipFile as { dir: boolean; _data?: { uncompressedSize: number }; date?: Date };
|
||||
fileInfos.push({
|
||||
path,
|
||||
content: '', // Don't load content for info request
|
||||
isDirectory: file.dir,
|
||||
size: file._data?.uncompressedSize || 0,
|
||||
lastModified: file.date || new Date()
|
||||
});
|
||||
}
|
||||
|
||||
return fileInfos.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to read ZIP file info: ${error.message}`);
|
||||
}
|
||||
throw new Error('Unknown error occurred while reading ZIP file info');
|
||||
}
|
||||
}
|
||||
|
||||
public async extractSpecificFiles(
|
||||
file: File,
|
||||
filePaths: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths specified');
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await this.fileToArrayBuffer(file);
|
||||
const zip = new JSZip();
|
||||
const zipContent = await zip.loadAsync(arrayBuffer);
|
||||
|
||||
const extractedFiles = new Map<string, string>();
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const zipFile = zipContent.files[filePath];
|
||||
|
||||
if (!zipFile) {
|
||||
console.warn(`File not found in ZIP: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (zipFile.dir) {
|
||||
extractedFiles.set(filePath, '');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await zipFile.async('text');
|
||||
extractedFiles.set(filePath, content);
|
||||
} catch (fileError) {
|
||||
console.warn(`Failed to extract file ${filePath}:`, fileError);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return extractedFiles;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to extract specific files from ZIP: ${error.message}`);
|
||||
}
|
||||
throw new Error('Unknown error occurred while extracting specific files');
|
||||
}
|
||||
}
|
||||
|
||||
public isValidZipFile(file: File): boolean {
|
||||
if (!file) return false;
|
||||
|
||||
return (
|
||||
file.type === 'application/zip' ||
|
||||
file.type === 'application/x-zip-compressed' ||
|
||||
file.name.toLowerCase().endsWith('.zip')
|
||||
);
|
||||
}
|
||||
|
||||
private async fileToArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as ArrayBuffer'));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Error reading file'));
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
private isTextFile(filePath: string, allowedExtensions: string[]): boolean {
|
||||
const extension = this.getFileExtension(filePath);
|
||||
return allowedExtensions.includes(extension);
|
||||
}
|
||||
|
||||
private getFileExtension(filePath: string): string {
|
||||
const lastDotIndex = filePath.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) return '';
|
||||
|
||||
return filePath.substring(lastDotIndex).toLowerCase();
|
||||
}
|
||||
|
||||
public getDefaultTextExtensions(): string[] {
|
||||
return Array.from(ZipService.TEXT_EXTENSIONS);
|
||||
}
|
||||
|
||||
public async validateZipFile(file: File): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.isValidZipFile(file)) {
|
||||
return { valid: false, error: 'File is not a valid ZIP archive' };
|
||||
}
|
||||
|
||||
const arrayBuffer = await this.fileToArrayBuffer(file);
|
||||
const zip = new JSZip();
|
||||
await zip.loadAsync(arrayBuffer);
|
||||
|
||||
return { valid: true };
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown validation error'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
286
src/ui/components/ErrorBoundary.tsx
Normal file
286
src/ui/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return {
|
||||
hasError: true,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log the error to console for debugging
|
||||
console.error('ErrorBoundary caught an error:', error);
|
||||
console.error('Error info:', errorInfo);
|
||||
|
||||
// Update state with error details
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo
|
||||
});
|
||||
|
||||
// Call optional error handler
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// In a production app, you might want to log this to an error reporting service
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback UI
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={errorBoxStyle}>
|
||||
<div style={iconStyle}>⚠️</div>
|
||||
|
||||
<h2 style={titleStyle}>Something went wrong</h2>
|
||||
|
||||
<p style={messageStyle}>
|
||||
We encountered an unexpected error. This might be due to a temporary issue
|
||||
or an incompatibility with your browser.
|
||||
</p>
|
||||
|
||||
<div style={buttonContainerStyle}>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={secondaryButtonStyle}
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error details (collapsible) */}
|
||||
<details style={detailsStyle}>
|
||||
<summary style={summaryStyle}>
|
||||
Technical Details (for developers)
|
||||
</summary>
|
||||
|
||||
<div style={errorDetailsStyle}>
|
||||
<div style={errorSectionStyle}>
|
||||
<strong>Error:</strong>
|
||||
<pre style={preStyle}>
|
||||
{this.state.error?.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{this.state.errorInfo && (
|
||||
<div style={errorSectionStyle}>
|
||||
<strong>Component Stack:</strong>
|
||||
<pre style={preStyle}>
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.error?.stack && (
|
||||
<div style={errorSectionStyle}>
|
||||
<strong>Stack Trace:</strong>
|
||||
<pre style={preStyle}>
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style={helpTextStyle}>
|
||||
<p>
|
||||
<strong>What you can do:</strong>
|
||||
</p>
|
||||
<ul style={helpListStyle}>
|
||||
<li>Try refreshing the page</li>
|
||||
<li>Clear your browser cache and reload</li>
|
||||
<li>Try a different browser</li>
|
||||
<li>Check the console for additional error details</li>
|
||||
</ul>
|
||||
|
||||
<p style={reportStyle}>
|
||||
If this problem persists, please report it with the technical details above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Styles
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif'
|
||||
};
|
||||
|
||||
const errorBoxStyle: React.CSSProperties = {
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e9ecef',
|
||||
textAlign: 'center'
|
||||
};
|
||||
|
||||
const iconStyle: React.CSSProperties = {
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px'
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
fontSize: '24px',
|
||||
fontWeight: '600',
|
||||
color: '#dc3545',
|
||||
margin: '0 0 16px 0'
|
||||
};
|
||||
|
||||
const messageStyle: React.CSSProperties = {
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 24px 0'
|
||||
};
|
||||
|
||||
const buttonContainerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '24px'
|
||||
};
|
||||
|
||||
const primaryButtonStyle: React.CSSProperties = {
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#007bff',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s ease'
|
||||
};
|
||||
|
||||
const secondaryButtonStyle: React.CSSProperties = {
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s ease'
|
||||
};
|
||||
|
||||
const detailsStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
marginTop: '24px',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '4px'
|
||||
};
|
||||
|
||||
const summaryStyle: React.CSSProperties = {
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
borderBottom: '1px solid #dee2e6'
|
||||
};
|
||||
|
||||
const errorDetailsStyle: React.CSSProperties = {
|
||||
padding: '16px',
|
||||
fontSize: '12px'
|
||||
};
|
||||
|
||||
const errorSectionStyle: React.CSSProperties = {
|
||||
marginBottom: '16px'
|
||||
};
|
||||
|
||||
const preStyle: React.CSSProperties = {
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'auto',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace',
|
||||
color: '#e83e8c',
|
||||
border: '1px solid #dee2e6',
|
||||
marginTop: '8px'
|
||||
};
|
||||
|
||||
const helpTextStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
const helpListStyle: React.CSSProperties = {
|
||||
margin: '8px 0',
|
||||
paddingLeft: '20px'
|
||||
};
|
||||
|
||||
const reportStyle: React.CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
marginTop: '12px'
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
532
src/ui/components/chat/ChatInterface.tsx
Normal file
532
src/ui/components/chat/ChatInterface.tsx
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { KnowledgeGraph } from '../../../core/graph/types.ts';
|
||||
import { LLMService, type LLMProvider, type LLMConfig } from '../../../ai/llm-service.ts';
|
||||
import { CypherGenerator } from '../../../ai/cypher-generator.ts';
|
||||
import { RAGOrchestrator, type RAGResponse, type RAGOptions } from '../../../ai/orchestrator.ts';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
metadata?: {
|
||||
cypherQueries?: Array<{ cypher: string; explanation: string }>;
|
||||
sources?: string[];
|
||||
confidence?: number;
|
||||
reasoning?: Array<{ step: number; thought: string; action: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
graph: KnowledgeGraph;
|
||||
fileContents: Map<string, string>;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface LLMSettings {
|
||||
provider: LLMProvider;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||||
graph,
|
||||
fileContents,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showReasoning, setShowReasoning] = useState(false);
|
||||
|
||||
// LLM Configuration
|
||||
const [llmSettings, setLLMSettings] = useState<LLMSettings>({
|
||||
provider: 'openai',
|
||||
apiKey: '',
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.1,
|
||||
maxTokens: 4000
|
||||
});
|
||||
|
||||
// Services
|
||||
const [llmService] = useState(new LLMService());
|
||||
const [cypherGenerator] = useState(new CypherGenerator(llmService));
|
||||
const [ragOrchestrator] = useState(new RAGOrchestrator(llmService, cypherGenerator));
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Initialize RAG context when graph or fileContents change
|
||||
useEffect(() => {
|
||||
ragOrchestrator.setContext({ graph, fileContents });
|
||||
}, [graph, fileContents, ragOrchestrator]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputValue.trim() || isLoading) return;
|
||||
|
||||
// Validate API key
|
||||
if (!llmSettings.apiKey.trim()) {
|
||||
alert('Please configure your API key in settings');
|
||||
setShowSettings(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!llmService.validateApiKey(llmSettings.provider, llmSettings.apiKey)) {
|
||||
alert('Invalid API key format. Please check your settings.');
|
||||
setShowSettings(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: inputValue.trim(),
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputValue('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const llmConfig: LLMConfig = {
|
||||
provider: llmSettings.provider,
|
||||
apiKey: llmSettings.apiKey,
|
||||
model: llmSettings.model,
|
||||
temperature: llmSettings.temperature,
|
||||
maxTokens: llmSettings.maxTokens
|
||||
};
|
||||
|
||||
const ragOptions: RAGOptions = {
|
||||
maxReasoningSteps: 5,
|
||||
includeReasoning: showReasoning,
|
||||
strictMode: false,
|
||||
temperature: llmSettings.temperature
|
||||
};
|
||||
|
||||
const response: RAGResponse = await ragOrchestrator.answerQuestion(
|
||||
userMessage.content,
|
||||
llmConfig,
|
||||
ragOptions
|
||||
);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: response.answer,
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
cypherQueries: response.cypherQueries.map(q => ({
|
||||
cypher: q.cypher,
|
||||
explanation: q.explanation
|
||||
})),
|
||||
sources: response.sources,
|
||||
confidence: response.confidence,
|
||||
reasoning: showReasoning ? response.reasoning.map(r => ({
|
||||
step: r.step,
|
||||
thought: r.thought,
|
||||
action: r.action
|
||||
})) : undefined
|
||||
}
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: `I apologize, but I encountered an error while processing your question: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle key press in textarea
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e as any);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear conversation
|
||||
const clearConversation = () => {
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
// Generate unique ID
|
||||
const generateId = () => {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
// Get available models for current provider
|
||||
const getAvailableModels = () => {
|
||||
return llmService.getAvailableModels(llmSettings.provider);
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '600px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#fff',
|
||||
...style
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #eee',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
};
|
||||
|
||||
const messagesStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px'
|
||||
};
|
||||
|
||||
const inputAreaStyle: React.CSSProperties = {
|
||||
padding: '16px',
|
||||
borderTop: '1px solid #eee'
|
||||
};
|
||||
|
||||
const messageStyle = (role: 'user' | 'assistant'): React.CSSProperties => ({
|
||||
padding: '12px 16px',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '80%',
|
||||
alignSelf: role === 'user' ? 'flex-end' : 'flex-start',
|
||||
backgroundColor: role === 'user' ? '#007bff' : '#f1f3f4',
|
||||
color: role === 'user' ? '#fff' : '#333',
|
||||
wordWrap: 'break-word'
|
||||
});
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#007bff',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
minHeight: '60px',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
resize: 'vertical',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`chat-interface ${className}`} style={containerStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '18px', fontWeight: '600' }}>💬</span>
|
||||
<span style={{ fontSize: '16px', fontWeight: '600' }}>Code Assistant</span>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
backgroundColor: '#e9ecef',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px'
|
||||
}}>
|
||||
{llmService.getProviderDisplayName(llmSettings.provider)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => setShowReasoning(!showReasoning)}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: showReasoning ? '#28a745' : '#6c757d',
|
||||
fontSize: '12px',
|
||||
padding: '6px 12px'
|
||||
}}
|
||||
title="Toggle reasoning display"
|
||||
>
|
||||
🧠 Reasoning
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
style={{ ...buttonStyle, fontSize: '12px', padding: '6px 12px' }}
|
||||
title="Settings"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: '#dc3545',
|
||||
fontSize: '12px',
|
||||
padding: '6px 12px'
|
||||
}}
|
||||
title="Clear conversation"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderBottom: '1px solid #eee'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px' }}>LLM Configuration</h4>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#666' }}>Provider</label>
|
||||
<select
|
||||
value={llmSettings.provider}
|
||||
onChange={(e) => setLLMSettings(prev => ({
|
||||
...prev,
|
||||
provider: e.target.value as LLMProvider,
|
||||
model: llmService.getAvailableModels(e.target.value as LLMProvider)[0]
|
||||
}))}
|
||||
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#666' }}>Model</label>
|
||||
<select
|
||||
value={llmSettings.model}
|
||||
onChange={(e) => setLLMSettings(prev => ({ ...prev, model: e.target.value }))}
|
||||
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
|
||||
>
|
||||
{getAvailableModels().map(model => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '12px', color: '#666' }}>API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={llmSettings.apiKey}
|
||||
onChange={(e) => setLLMSettings(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder="Enter your API key"
|
||||
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#666' }}>
|
||||
Temperature: {llmSettings.temperature}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={llmSettings.temperature}
|
||||
onChange={(e) => setLLMSettings(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#666' }}>Max Tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
max="8000"
|
||||
step="100"
|
||||
value={llmSettings.maxTokens}
|
||||
onChange={(e) => setLLMSettings(prev => ({ ...prev, maxTokens: parseInt(e.target.value) }))}
|
||||
style={{ width: '100%', padding: '6px', fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div style={messagesStyle}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>💬</div>
|
||||
<div>Ask me anything about the codebase!</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '8px', color: '#999' }}>
|
||||
I can help you understand functions, classes, dependencies, and more.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
<div style={messageStyle(message.role)}>
|
||||
<div style={{ marginBottom: message.metadata ? '8px' : '0' }}>
|
||||
{message.content}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{message.metadata && (
|
||||
<div style={{ fontSize: '12px', opacity: 0.8 }}>
|
||||
{message.metadata.confidence && (
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
Confidence: {Math.round(message.metadata.confidence * 100)}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.metadata.sources && message.metadata.sources.length > 0 && (
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
Sources: {message.metadata.sources.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.metadata.cypherQueries && message.metadata.cypherQueries.length > 0 && (
|
||||
<details style={{ marginTop: '8px' }}>
|
||||
<summary style={{ cursor: 'pointer' }}>View Queries ({message.metadata.cypherQueries.length})</summary>
|
||||
{message.metadata.cypherQueries.map((query, index) => (
|
||||
<div key={index} style={{
|
||||
marginTop: '4px',
|
||||
padding: '8px',
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div><strong>Query:</strong> {query.cypher}</div>
|
||||
<div><strong>Explanation:</strong> {query.explanation}</div>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
|
||||
{message.metadata.reasoning && message.metadata.reasoning.length > 0 && (
|
||||
<details style={{ marginTop: '8px' }}>
|
||||
<summary style={{ cursor: 'pointer' }}>View Reasoning ({message.metadata.reasoning.length} steps)</summary>
|
||||
{message.metadata.reasoning.map((step, index) => (
|
||||
<div key={index} style={{
|
||||
marginTop: '4px',
|
||||
padding: '8px',
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div><strong>Step {step.step}:</strong> {step.thought}</div>
|
||||
<div><strong>Action:</strong> {step.action}</div>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
textAlign: message.role === 'user' ? 'right' : 'left',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div style={messageStyle('assistant')}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid #ccc',
|
||||
borderTop: '2px solid #007bff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
Thinking...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div style={inputAreaStyle}>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px' }}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask a question about the code..."
|
||||
style={textareaStyle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !inputValue.trim()}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
minWidth: '80px',
|
||||
opacity: (isLoading || !inputValue.trim()) ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
{isLoading ? '...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInterface;
|
||||
215
src/ui/components/chat/CodeAssistant.tsx
Normal file
215
src/ui/components/chat/CodeAssistant.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import React, { useState } from 'react';
|
||||
import ChatInterface from './ChatInterface.tsx';
|
||||
import { GraphExplorer } from '../graph/index.ts';
|
||||
import type { KnowledgeGraph } from '../../../core/graph/types.ts';
|
||||
|
||||
interface CodeAssistantProps {
|
||||
graph: KnowledgeGraph;
|
||||
fileContents: Map<string, string>;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
type ViewMode = 'chat' | 'graph' | 'split';
|
||||
|
||||
const CodeAssistant: React.FC<CodeAssistantProps> = ({
|
||||
graph,
|
||||
fileContents,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split');
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '800px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
...style
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff',
|
||||
borderBottom: '1px solid #ddd',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
overflow: 'hidden'
|
||||
};
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
};
|
||||
|
||||
const buttonStyle = (active: boolean): React.CSSProperties => ({
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: active ? '#007bff' : '#6c757d',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
marginLeft: '8px'
|
||||
});
|
||||
|
||||
const renderContent = () => {
|
||||
switch (viewMode) {
|
||||
case 'chat':
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<ChatInterface
|
||||
graph={graph}
|
||||
fileContents={fileContents}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'graph':
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<GraphExplorer
|
||||
graph={graph}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'split':
|
||||
return (
|
||||
<>
|
||||
<div style={{ ...panelStyle, marginRight: '8px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#e9ecef',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
💬 AI Assistant
|
||||
</div>
|
||||
<ChatInterface
|
||||
graph={graph}
|
||||
fileContents={fileContents}
|
||||
style={{ height: 'calc(100% - 40px)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ ...panelStyle, marginLeft: '8px' }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#e9ecef',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
🕸️ Knowledge Graph
|
||||
</div>
|
||||
<GraphExplorer
|
||||
graph={graph}
|
||||
style={{ height: 'calc(100% - 40px)' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatsText = () => {
|
||||
const nodeCount = graph.nodes.length;
|
||||
const relationshipCount = graph.relationships.length;
|
||||
const fileCount = fileContents.size;
|
||||
|
||||
return `${nodeCount} nodes • ${relationshipCount} relationships • ${fileCount} files`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`code-assistant ${className}`} style={containerStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '20px', fontWeight: '600' }}>🤖</span>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600' }}>
|
||||
GitNexus Code Assistant
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
{getStatsText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '14px', color: '#666', marginRight: '12px' }}>
|
||||
View:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setViewMode('chat')}
|
||||
style={buttonStyle(viewMode === 'chat')}
|
||||
>
|
||||
💬 Chat Only
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('graph')}
|
||||
style={buttonStyle(viewMode === 'graph')}
|
||||
>
|
||||
🕸️ Graph Only
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('split')}
|
||||
style={buttonStyle(viewMode === 'split')}
|
||||
>
|
||||
📱 Split View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={contentStyle}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
{graph.nodes.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '16px',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '16px', opacity: 0.3 }}>
|
||||
🤖
|
||||
</div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
No knowledge graph loaded
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#999' }}>
|
||||
Please load a project to start using the AI assistant
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeAssistant;
|
||||
2
src/ui/components/chat/index.ts
Normal file
2
src/ui/components/chat/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ChatInterface } from './ChatInterface.tsx';
|
||||
export { default as CodeAssistant } from './CodeAssistant.tsx';
|
||||
170
src/ui/components/graph/GraphExplorer.tsx
Normal file
170
src/ui/components/graph/GraphExplorer.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React, { useState } from 'react';
|
||||
import GraphVisualization from './Visualization.tsx';
|
||||
import SourceViewer from './SourceViewer.tsx';
|
||||
import type { KnowledgeGraph } from '../../../core/graph/types.ts';
|
||||
|
||||
interface GraphExplorerProps {
|
||||
graph: KnowledgeGraph;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const GraphExplorer: React.FC<GraphExplorerProps> = ({
|
||||
graph,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
const handleNodeSelect = (nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px',
|
||||
...style
|
||||
};
|
||||
|
||||
const graphContainerStyle: React.CSSProperties = {
|
||||
flex: '1 1 60%',
|
||||
minHeight: '400px'
|
||||
};
|
||||
|
||||
const sourceContainerStyle: React.CSSProperties = {
|
||||
flex: '1 1 40%',
|
||||
minHeight: '300px'
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
};
|
||||
|
||||
const statsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: '24px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ddd'
|
||||
};
|
||||
|
||||
const statItemStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
};
|
||||
|
||||
// Calculate graph statistics
|
||||
const nodeStats = graph.nodes.reduce((acc, node) => {
|
||||
acc[node.label] = (acc[node.label] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const relationshipStats = graph.relationships.reduce((acc, rel) => {
|
||||
acc[rel.type] = (acc[rel.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return (
|
||||
<div className={`graph-explorer ${className}`} style={containerStyle}>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div style={headerStyle}>
|
||||
<span>🔍</span>
|
||||
<span>Code Knowledge Graph Explorer</span>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div style={statsStyle}>
|
||||
<div style={statItemStyle}>
|
||||
<span>📊</span>
|
||||
<span><strong>{graph.nodes.length}</strong> nodes</span>
|
||||
</div>
|
||||
<div style={statItemStyle}>
|
||||
<span>🔗</span>
|
||||
<span><strong>{graph.relationships.length}</strong> relationships</span>
|
||||
</div>
|
||||
{Object.entries(nodeStats).map(([type, count]) => (
|
||||
<div key={type} style={statItemStyle}>
|
||||
<span>{getNodeTypeIcon(type)}</span>
|
||||
<span><strong>{count}</strong> {type.toLowerCase()}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph Visualization */}
|
||||
<div style={graphContainerStyle}>
|
||||
<div style={{ ...headerStyle, fontSize: '16px', marginBottom: '8px' }}>
|
||||
<span>🕸️</span>
|
||||
<span>Interactive Graph</span>
|
||||
{selectedNodeId && (
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: '#666',
|
||||
marginLeft: '8px'
|
||||
}}>
|
||||
• Selected: {getSelectedNodeName()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<GraphVisualization
|
||||
graph={graph}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Viewer */}
|
||||
<div style={sourceContainerStyle}>
|
||||
<div style={{ ...headerStyle, fontSize: '16px', marginBottom: '8px' }}>
|
||||
<span>📝</span>
|
||||
<span>Source Code</span>
|
||||
</div>
|
||||
<SourceViewer
|
||||
graph={graph}
|
||||
selectedNodeId={selectedNodeId}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function getSelectedNodeName(): string {
|
||||
if (!selectedNodeId) return '';
|
||||
const node = graph.nodes.find(n => n.id === selectedNodeId);
|
||||
return node?.properties.name as string || node?.id || '';
|
||||
}
|
||||
|
||||
function getNodeTypeIcon(nodeType: string): string {
|
||||
switch (nodeType.toLowerCase()) {
|
||||
case 'project': return '📁';
|
||||
case 'folder': return '📂';
|
||||
case 'file': return '📄';
|
||||
case 'module': return '📦';
|
||||
case 'function': return '⚡';
|
||||
case 'method': return '🔧';
|
||||
case 'class': return '🏗️';
|
||||
case 'variable': return '📊';
|
||||
default: return '📄';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default GraphExplorer;
|
||||
286
src/ui/components/graph/SourceViewer.tsx
Normal file
286
src/ui/components/graph/SourceViewer.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import type { KnowledgeGraph, GraphNode } from '../../../core/graph/types.ts';
|
||||
|
||||
interface SourceViewerProps {
|
||||
graph: KnowledgeGraph;
|
||||
selectedNodeId: string | null;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface SourceInfo {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
content: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
nodeType: string;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
const SourceViewer: React.FC<SourceViewerProps> = ({
|
||||
graph,
|
||||
selectedNodeId,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
// Find the selected node and extract source information
|
||||
const sourceInfo = useMemo((): SourceInfo | null => {
|
||||
if (!selectedNodeId || !graph) return null;
|
||||
|
||||
const selectedNode = graph.nodes.find(node => node.id === selectedNodeId);
|
||||
if (!selectedNode) return null;
|
||||
|
||||
// Extract file path from node properties
|
||||
const filePath = selectedNode.properties.filePath as string ||
|
||||
selectedNode.properties.path as string;
|
||||
|
||||
if (!filePath) return null;
|
||||
|
||||
// Get file name from path
|
||||
const pathParts = filePath.split('/');
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
|
||||
// For demonstration purposes, we'll create mock content
|
||||
// In a real implementation, this would come from the file contents
|
||||
const content = generateMockContent(selectedNode);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
content,
|
||||
startLine: selectedNode.properties.startLine as number,
|
||||
endLine: selectedNode.properties.endLine as number,
|
||||
nodeType: selectedNode.label,
|
||||
nodeName: selectedNode.properties.name as string || selectedNode.id
|
||||
};
|
||||
}, [selectedNodeId, graph]);
|
||||
|
||||
// Generate mock source content based on node type
|
||||
const generateMockContent = (node: GraphNode): string => {
|
||||
const name = node.properties.name as string || 'unknown';
|
||||
|
||||
switch (node.label) {
|
||||
case 'Function':
|
||||
return `def ${name}():
|
||||
"""
|
||||
Function: ${name}
|
||||
"""
|
||||
# Implementation here
|
||||
pass`;
|
||||
|
||||
case 'Method':
|
||||
const parentClass = node.properties.parentClass as string || 'UnknownClass';
|
||||
return `class ${parentClass}:
|
||||
def ${name}(self):
|
||||
"""
|
||||
Method: ${name}
|
||||
Class: ${parentClass}
|
||||
"""
|
||||
# Implementation here
|
||||
pass`;
|
||||
|
||||
case 'Class':
|
||||
return `class ${name}:
|
||||
"""
|
||||
Class: ${name}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Constructor
|
||||
pass`;
|
||||
|
||||
case 'Module':
|
||||
return `"""
|
||||
Module: ${name}
|
||||
|
||||
This module contains various functions and classes.
|
||||
"""
|
||||
|
||||
# Module-level imports and code here`;
|
||||
|
||||
case 'File':
|
||||
const extension = node.properties.extension as string || '';
|
||||
if (extension === '.py') {
|
||||
return `# Python file: ${name}
|
||||
|
||||
"""
|
||||
File: ${name}
|
||||
"""
|
||||
|
||||
# File contents would be displayed here`;
|
||||
}
|
||||
return `// File: ${name}
|
||||
// Content of the file would be displayed here`;
|
||||
|
||||
case 'Project':
|
||||
return `# Project: ${name}
|
||||
|
||||
This is the root of the project structure.
|
||||
Contains modules, packages, and other project files.`;
|
||||
|
||||
case 'Folder':
|
||||
return `# Folder: ${name}
|
||||
|
||||
Directory containing:
|
||||
- Subfolders
|
||||
- Source files
|
||||
- Other project assets`;
|
||||
|
||||
default:
|
||||
return `# ${node.label}: ${name}
|
||||
|
||||
Details about this ${node.label.toLowerCase()} would be shown here.`;
|
||||
}
|
||||
};
|
||||
|
||||
// Format line numbers
|
||||
const formatLineNumbers = (content: string, startLine?: number): string => {
|
||||
const lines = content.split('\n');
|
||||
const lineStart = startLine || 1;
|
||||
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
const lineNumber = (lineStart + index).toString().padStart(3, ' ');
|
||||
return `${lineNumber}│ ${line}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const defaultStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '400px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...style
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #ddd',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#333'
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
overflow: 'auto',
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
backgroundColor: '#fafafa',
|
||||
color: '#333',
|
||||
whiteSpace: 'pre',
|
||||
tabSize: 2
|
||||
};
|
||||
|
||||
const placeholderStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
fontStyle: 'italic'
|
||||
};
|
||||
|
||||
const getNodeTypeColor = (nodeType: string): string => {
|
||||
switch (nodeType.toLowerCase()) {
|
||||
case 'project': return '#4CAF50';
|
||||
case 'folder': return '#FF9800';
|
||||
case 'file': return '#2196F3';
|
||||
case 'module': return '#9C27B0';
|
||||
case 'function': return '#00BCD4';
|
||||
case 'method': return '#00BCD4';
|
||||
case 'class': return '#E91E63';
|
||||
case 'variable': return '#607D8B';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeTypeIcon = (nodeType: string): string => {
|
||||
switch (nodeType.toLowerCase()) {
|
||||
case 'project': return '📁';
|
||||
case 'folder': return '📂';
|
||||
case 'file': return '📄';
|
||||
case 'module': return '📦';
|
||||
case 'function': return '⚡';
|
||||
case 'method': return '🔧';
|
||||
case 'class': return '🏗️';
|
||||
case 'variable': return '📊';
|
||||
default: return '📄';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`source-viewer ${className}`} style={defaultStyle}>
|
||||
{sourceInfo ? (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{getNodeTypeIcon(sourceInfo.nodeType)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: getNodeTypeColor(sourceInfo.nodeType),
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{sourceInfo.nodeType}
|
||||
</span>
|
||||
<span style={{ color: '#666' }}>•</span>
|
||||
<span>{sourceInfo.nodeName}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<span>📁 {sourceInfo.fileName}</span>
|
||||
{sourceInfo.startLine && sourceInfo.endLine && (
|
||||
<span>📍 Lines {sourceInfo.startLine}-{sourceInfo.endLine}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={contentStyle}>
|
||||
{formatLineNumbers(sourceInfo.content, sourceInfo.startLine)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<span style={{ color: '#666' }}>Source Code Viewer</span>
|
||||
</div>
|
||||
<div style={placeholderStyle}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
|
||||
📄
|
||||
</div>
|
||||
<div>Select a node to view its source code</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
marginTop: '8px'
|
||||
}}>
|
||||
Click on any node in the graph to see its details
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourceViewer;
|
||||
371
src/ui/components/graph/Visualization.tsx
Normal file
371
src/ui/components/graph/Visualization.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import cytoscape from 'npm:cytoscape';
|
||||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import dagre from 'npm:cytoscape-dagre';
|
||||
import type { KnowledgeGraph, GraphNode, GraphRelationship } from '../../../core/graph/types.ts';
|
||||
|
||||
// Register the dagre layout extension
|
||||
cytoscape.use(dagre);
|
||||
|
||||
interface GraphVisualizationProps {
|
||||
graph: KnowledgeGraph;
|
||||
onNodeSelect?: (nodeId: string | null) => void;
|
||||
selectedNodeId?: string | null;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface CytoscapeElement {
|
||||
data: {
|
||||
id: string;
|
||||
label?: string;
|
||||
source?: string;
|
||||
target?: string;
|
||||
nodeType?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
classes?: string;
|
||||
}
|
||||
|
||||
const GraphVisualization: React.FC<GraphVisualizationProps> = ({
|
||||
graph,
|
||||
onNodeSelect,
|
||||
selectedNodeId,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cyRef = useRef<any>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Convert KnowledgeGraph to Cytoscape elements
|
||||
const convertToElements = (graph: KnowledgeGraph): CytoscapeElement[] => {
|
||||
const elements: CytoscapeElement[] = [];
|
||||
|
||||
// Add nodes
|
||||
graph.nodes.forEach((node: GraphNode) => {
|
||||
elements.push({
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.properties.name as string || node.id,
|
||||
nodeType: node.label.toLowerCase(),
|
||||
...node.properties
|
||||
},
|
||||
classes: `node-${node.label.toLowerCase()}`
|
||||
});
|
||||
});
|
||||
|
||||
// Add edges
|
||||
graph.relationships.forEach((rel: GraphRelationship) => {
|
||||
elements.push({
|
||||
data: {
|
||||
id: rel.id,
|
||||
source: rel.source,
|
||||
target: rel.target,
|
||||
label: rel.type,
|
||||
relationshipType: rel.type.toLowerCase(),
|
||||
...rel.properties
|
||||
},
|
||||
classes: `edge-${rel.type.toLowerCase()}`
|
||||
});
|
||||
});
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
// Define styles for different node types
|
||||
const getStylesheet = () => [
|
||||
// Base node styles
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'label': 'data(label)',
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'font-size': '12px',
|
||||
'font-family': 'system-ui, -apple-system, sans-serif',
|
||||
'color': '#333',
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': '80px',
|
||||
'width': '60px',
|
||||
'height': '60px',
|
||||
'border-width': '2px',
|
||||
'border-style': 'solid',
|
||||
'border-color': '#ccc'
|
||||
}
|
||||
},
|
||||
// Project nodes
|
||||
{
|
||||
selector: '.node-project',
|
||||
style: {
|
||||
'background-color': '#4CAF50',
|
||||
'border-color': '#388E3C',
|
||||
'shape': 'round-rectangle',
|
||||
'width': '80px',
|
||||
'height': '40px'
|
||||
}
|
||||
},
|
||||
// Folder nodes
|
||||
{
|
||||
selector: '.node-folder',
|
||||
style: {
|
||||
'background-color': '#FF9800',
|
||||
'border-color': '#F57C00',
|
||||
'shape': 'round-rectangle',
|
||||
'width': '70px',
|
||||
'height': '35px'
|
||||
}
|
||||
},
|
||||
// File nodes
|
||||
{
|
||||
selector: '.node-file',
|
||||
style: {
|
||||
'background-color': '#2196F3',
|
||||
'border-color': '#1976D2',
|
||||
'shape': 'rectangle'
|
||||
}
|
||||
},
|
||||
// Module nodes
|
||||
{
|
||||
selector: '.node-module',
|
||||
style: {
|
||||
'background-color': '#9C27B0',
|
||||
'border-color': '#7B1FA2',
|
||||
'shape': 'round-rectangle'
|
||||
}
|
||||
},
|
||||
// Function nodes
|
||||
{
|
||||
selector: '.node-function',
|
||||
style: {
|
||||
'background-color': '#00BCD4',
|
||||
'border-color': '#0097A7',
|
||||
'shape': 'ellipse'
|
||||
}
|
||||
},
|
||||
// Method nodes
|
||||
{
|
||||
selector: '.node-method',
|
||||
style: {
|
||||
'background-color': '#00BCD4',
|
||||
'border-color': '#0097A7',
|
||||
'shape': 'ellipse',
|
||||
'width': '50px',
|
||||
'height': '50px'
|
||||
}
|
||||
},
|
||||
// Class nodes
|
||||
{
|
||||
selector: '.node-class',
|
||||
style: {
|
||||
'background-color': '#E91E63',
|
||||
'border-color': '#C2185B',
|
||||
'shape': 'diamond'
|
||||
}
|
||||
},
|
||||
// Variable nodes
|
||||
{
|
||||
selector: '.node-variable',
|
||||
style: {
|
||||
'background-color': '#607D8B',
|
||||
'border-color': '#455A64',
|
||||
'shape': 'triangle',
|
||||
'width': '40px',
|
||||
'height': '40px'
|
||||
}
|
||||
},
|
||||
// Selected node
|
||||
{
|
||||
selector: '.selected',
|
||||
style: {
|
||||
'border-width': '4px',
|
||||
'border-color': '#FF5722',
|
||||
'box-shadow': '0 0 20px rgba(255, 87, 34, 0.6)'
|
||||
}
|
||||
},
|
||||
// Base edge styles
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': '2px',
|
||||
'line-color': '#666',
|
||||
'target-arrow-color': '#666',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': '1.2'
|
||||
}
|
||||
},
|
||||
// CONTAINS relationships
|
||||
{
|
||||
selector: '.edge-contains',
|
||||
style: {
|
||||
'line-color': '#4CAF50',
|
||||
'target-arrow-color': '#4CAF50'
|
||||
}
|
||||
},
|
||||
// CALLS relationships
|
||||
{
|
||||
selector: '.edge-calls',
|
||||
style: {
|
||||
'line-color': '#FF5722',
|
||||
'target-arrow-color': '#FF5722',
|
||||
'line-style': 'dashed'
|
||||
}
|
||||
},
|
||||
// IMPORTS relationships
|
||||
{
|
||||
selector: '.edge-imports',
|
||||
style: {
|
||||
'line-color': '#9C27B0',
|
||||
'target-arrow-color': '#9C27B0',
|
||||
'line-style': 'dotted'
|
||||
}
|
||||
},
|
||||
// INHERITS relationships
|
||||
{
|
||||
selector: '.edge-inherits',
|
||||
style: {
|
||||
'line-color': '#2196F3',
|
||||
'target-arrow-color': '#2196F3'
|
||||
}
|
||||
},
|
||||
// OVERRIDES relationships
|
||||
{
|
||||
selector: '.edge-overrides',
|
||||
style: {
|
||||
'line-color': '#FF9800',
|
||||
'target-arrow-color': '#FF9800'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Initialize Cytoscape
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !graph) return;
|
||||
|
||||
const elements = convertToElements(graph);
|
||||
const stylesheet = getStylesheet();
|
||||
|
||||
cyRef.current = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements,
|
||||
style: stylesheet,
|
||||
layout: {
|
||||
name: 'dagre',
|
||||
rankDir: 'TB', // Top to bottom
|
||||
spacingFactor: 1.2,
|
||||
nodeSep: 50,
|
||||
edgeSep: 10,
|
||||
rankSep: 100
|
||||
},
|
||||
// Interaction options
|
||||
minZoom: 0.1,
|
||||
maxZoom: 3,
|
||||
wheelSensitivity: 0.5,
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: false
|
||||
});
|
||||
|
||||
// Handle node click events
|
||||
cyRef.current.on('tap', 'node', (event: any) => {
|
||||
const node = event.target;
|
||||
const nodeId = node.id();
|
||||
|
||||
// Remove previous selection
|
||||
cyRef.current.elements('.selected').removeClass('selected');
|
||||
|
||||
// Add selection to clicked node
|
||||
node.addClass('selected');
|
||||
|
||||
// Notify parent component
|
||||
if (onNodeSelect) {
|
||||
onNodeSelect(nodeId);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle background click (deselect)
|
||||
cyRef.current.on('tap', (event: any) => {
|
||||
if (event.target === cyRef.current) {
|
||||
cyRef.current.elements('.selected').removeClass('selected');
|
||||
if (onNodeSelect) {
|
||||
onNodeSelect(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setIsReady(true);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (cyRef.current) {
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
setIsReady(false);
|
||||
};
|
||||
}, [graph, onNodeSelect]);
|
||||
|
||||
// Update selection when selectedNodeId prop changes
|
||||
useEffect(() => {
|
||||
if (!cyRef.current || !isReady) return;
|
||||
|
||||
// Remove all selections
|
||||
cyRef.current.elements('.selected').removeClass('selected');
|
||||
|
||||
// Add selection to specified node
|
||||
if (selectedNodeId) {
|
||||
const node = cyRef.current.getElementById(selectedNodeId);
|
||||
if (node.length > 0) {
|
||||
node.addClass('selected');
|
||||
// Center the view on the selected node
|
||||
cyRef.current.center(node);
|
||||
}
|
||||
}
|
||||
}, [selectedNodeId, isReady]);
|
||||
|
||||
// Fit graph to container when graph changes
|
||||
useEffect(() => {
|
||||
if (!cyRef.current || !isReady) return;
|
||||
|
||||
// Fit the graph to the container with some padding
|
||||
setTimeout(() => {
|
||||
cyRef.current.fit(undefined, 50);
|
||||
}, 100);
|
||||
}, [graph, isReady]);
|
||||
|
||||
const defaultStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '600px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#fafafa',
|
||||
...style
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`graph-visualization ${className}`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={defaultStyle}
|
||||
className="cytoscape-container"
|
||||
/>
|
||||
{!isReady && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
color: '#666',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Loading graph...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GraphVisualization;
|
||||
3
src/ui/components/graph/index.ts
Normal file
3
src/ui/components/graph/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as GraphVisualization } from './Visualization.tsx';
|
||||
export { default as SourceViewer } from './SourceViewer.tsx';
|
||||
export { default as GraphExplorer } from './GraphExplorer.tsx';
|
||||
3
src/ui/components/index.ts
Normal file
3
src/ui/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './graph/index.ts';
|
||||
export * from './chat/index.ts';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary.tsx';
|
||||
2
src/ui/index.ts
Normal file
2
src/ui/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './components/index';
|
||||
export * from './pages/index';
|
||||
1135
src/ui/pages/HomePage.tsx
Normal file
1135
src/ui/pages/HomePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
1
src/ui/pages/index.ts
Normal file
1
src/ui/pages/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as HomePage } from './HomePage.tsx';
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
201
src/workers/ingestion.worker.ts
Normal file
201
src/workers/ingestion.worker.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// @ts-expect-error - npm: imports are resolved at runtime in Deno
|
||||
import * as Comlink from 'npm:comlink';
|
||||
import { GraphPipeline, type PipelineInput } from '../core/ingestion/pipeline.ts';
|
||||
import type { KnowledgeGraph } from '../core/graph/types.ts';
|
||||
|
||||
export interface IngestionProgress {
|
||||
phase: 'structure' | 'parsing' | 'calls' | 'complete';
|
||||
message: string;
|
||||
progress: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IngestionResult {
|
||||
success: boolean;
|
||||
graph?: KnowledgeGraph;
|
||||
error?: string;
|
||||
stats?: {
|
||||
nodeStats: Record<string, number>;
|
||||
relationshipStats: Record<string, number>;
|
||||
callStats: { totalCalls: number; callTypes: Record<string, number> };
|
||||
};
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class IngestionWorker {
|
||||
private pipeline: GraphPipeline;
|
||||
private progressCallback?: (progress: IngestionProgress) => void;
|
||||
|
||||
constructor() {
|
||||
this.pipeline = new GraphPipeline();
|
||||
}
|
||||
|
||||
public setProgressCallback(callback: (progress: IngestionProgress) => void): void {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
public async processRepository(input: PipelineInput): Promise<IngestionResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
this.reportProgress('structure', 'Starting repository ingestion...', 0);
|
||||
|
||||
// Validate input
|
||||
this.validateInput(input);
|
||||
|
||||
this.reportProgress('structure', 'Processing project structure...', 10);
|
||||
|
||||
// Run the pipeline
|
||||
const graph = await this.pipeline.run(input);
|
||||
|
||||
this.reportProgress('complete', 'Ingestion completed successfully', 100);
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Gather statistics
|
||||
const stats = this.pipeline.getStats(graph);
|
||||
const callStats = this.pipeline.getCallStats();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
graph,
|
||||
stats: {
|
||||
nodeStats: stats.nodeStats,
|
||||
relationshipStats: stats.relationshipStats,
|
||||
callStats
|
||||
},
|
||||
duration
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
this.reportProgress('complete', `Ingestion failed: ${errorMessage}`, 0);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
duration
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async processFiles(
|
||||
projectName: string,
|
||||
files: { path: string; content: string }[]
|
||||
): Promise<IngestionResult> {
|
||||
const fileContents = new Map<string, string>();
|
||||
const filePaths: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
filePaths.push(file.path);
|
||||
fileContents.set(file.path, file.content);
|
||||
}
|
||||
|
||||
const input: PipelineInput = {
|
||||
projectRoot: '/',
|
||||
projectName,
|
||||
filePaths,
|
||||
fileContents
|
||||
};
|
||||
|
||||
return this.processRepository(input);
|
||||
}
|
||||
|
||||
public async validateRepository(input: PipelineInput): Promise<{ valid: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
this.validateInput(input);
|
||||
|
||||
// Additional validation checks
|
||||
if (input.filePaths.length === 0) {
|
||||
errors.push('No files provided for processing');
|
||||
}
|
||||
|
||||
if (input.fileContents.size === 0) {
|
||||
errors.push('No file contents provided');
|
||||
}
|
||||
|
||||
// Check for Python files
|
||||
const pythonFiles = input.filePaths.filter(path => path.endsWith('.py'));
|
||||
if (pythonFiles.length === 0) {
|
||||
errors.push('No Python files found in the repository');
|
||||
}
|
||||
|
||||
// Validate file contents exist
|
||||
for (const filePath of input.filePaths) {
|
||||
if (!input.fileContents.has(filePath)) {
|
||||
errors.push(`Missing content for file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : 'Validation failed');
|
||||
return {
|
||||
valid: false,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getWorkerInfo(): { version: string; capabilities: string[] } {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
capabilities: [
|
||||
'python-parsing',
|
||||
'structure-analysis',
|
||||
'call-resolution',
|
||||
'ast-caching',
|
||||
'progress-reporting'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private validateInput(input: PipelineInput): void {
|
||||
if (!input.projectName || input.projectName.trim().length === 0) {
|
||||
throw new Error('Project name is required');
|
||||
}
|
||||
|
||||
if (!input.projectRoot || input.projectRoot.trim().length === 0) {
|
||||
throw new Error('Project root is required');
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.filePaths)) {
|
||||
throw new Error('File paths must be an array');
|
||||
}
|
||||
|
||||
if (!(input.fileContents instanceof Map)) {
|
||||
throw new Error('File contents must be a Map');
|
||||
}
|
||||
}
|
||||
|
||||
private reportProgress(phase: IngestionProgress['phase'], message: string, progress: number): void {
|
||||
if (this.progressCallback) {
|
||||
this.progressCallback({
|
||||
phase,
|
||||
message,
|
||||
progress,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public terminate(): void {
|
||||
// Cleanup resources if needed
|
||||
console.log('Ingestion worker terminated');
|
||||
}
|
||||
}
|
||||
|
||||
// Expose the worker class via Comlink
|
||||
const worker = new IngestionWorker();
|
||||
Comlink.expose(worker);
|
||||
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
assetsInclude: ['**/*.wasm'],
|
||||
build: {
|
||||
target: 'esnext',
|
||||
assetsInlineLimit: 0 // Don't inline WASM files
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue