This commit is contained in:
Damien 2025-02-04 01:30:36 -05:00
commit 086e311aa4
62 changed files with 19965 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

261
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,261 @@
# Data Hound Development Guide
This document provides detailed technical information for developers working on the Data Hound project. It covers architecture, development workflows, and best practices.
## Architecture Overview
### Main Process (Electron)
The main process (`electron/`) handles:
- File system operations (`services/fileSystem.ts`)
- LLM service integration (`services/llmService.ts`, `services/ollamaService.ts`)
- Vector store management (`services/vectorStore.ts`)
- IPC communication (`ipc/handlers.ts`)
- Application state persistence (`store.ts`)
#### Key Services
1. **File System Service** (`fileSystem.ts`)
- Handles file indexing and monitoring
- Manages file metadata extraction
- Implements file type detection and parsing
2. **LLM Service** (`llmService.ts`)
- Manages LLM provider connections
- Handles prompt engineering
- Implements response streaming
3. **Vector Store** (`vectorStore.ts`)
- Manages ChromaDB integration
- Handles document embeddings
- Implements semantic search functionality
### Renderer Process (React)
The renderer process (`src/`) is organized into:
- Components (`components/`)
- Contexts (`contexts/`)
- Custom hooks (`hooks/`)
- Type definitions (`electron.d.ts`)
#### Key Components
1. **ChatPanel**
- Handles user queries and LLM responses
- Manages conversation history
- Implements message rendering
2. **FileExplorer**
- Directory selection and navigation
- File list visualization
- File metadata display
3. **ScanningPanel**
- Progress visualization for file scanning
- Status updates
- Error handling
## Adding New Features
### Adding a New LLM Provider
1. Create a new service in `electron/services/`
2. Implement the provider interface:
```typescript
interface LLMProvider {
initialize(): Promise<void>;
query(prompt: string): Promise<string>;
streamResponse(prompt: string): AsyncGenerator<string>;
}
```
3. Add provider configuration to `store.ts`
4. Update the settings UI in `SettingsPanel`
5. Add the provider to the LLM service factory
### Adding File Type Support
1. Update `fileSystem.ts` with new file type detection
2. Implement parsing logic in a new service
3. Add metadata extraction
4. Update the vector store schema if needed
5. Add UI support in FileExplorer
### Adding a New Panel
1. Create component in `src/components/`
2. Add routing in `App.tsx`
3. Implement required hooks
4. Add IPC handlers if needed
5. Update navigation
## Development Workflows
### Local Development
1. Start Electron development:
```bash
npm run dev
```
This runs:
- Vite dev server for React
- Electron with hot reload
- TypeScript compilation in watch mode
2. Debug main process:
- Use VSCode launch configuration
- Console logs appear in terminal
- Break points work in VSCode
3. Debug renderer process:
- Use Chrome DevTools (Cmd/Ctrl+Shift+I)
- React DevTools available
- Network tab shows IPC calls
### Testing
1. Unit Tests:
- Located in `__tests__` directories
- Run with `npm test`
- Focus on service logic
2. Integration Tests:
- Test IPC communication
- Verify file system operations
- Check LLM integration
3. E2E Tests:
- Use Playwright
- Test full user workflows
- Verify cross-platform behavior
## Best Practices
### TypeScript
1. Use strict type checking
2. Define interfaces for all IPC messages
3. Avoid `any` - use proper types
4. Use discriminated unions for state
### React Components
1. Use functional components
2. Implement proper error boundaries
3. Memoize expensive computations
4. Use proper prop types
### Electron
1. Validate IPC messages
2. Handle window state properly
3. Implement proper error handling
4. Use proper security practices
### State Management
1. Use contexts for shared state
2. Implement proper loading states
3. Handle errors gracefully
4. Use proper TypeScript types
## Common Tasks
### Adding an IPC Handler
1. Define types in `preload-types.ts`:
```typescript
interface IPCHandlers {
newHandler: (arg: ArgType) => Promise<ReturnType>;
}
```
2. Implement handler in `ipc/handlers.ts`:
```typescript
ipcMain.handle('newHandler', async (event, arg: ArgType) => {
// Implementation
});
```
3. Add to preload script:
```typescript
newHandler: (arg: ArgType) => ipcRenderer.invoke('newHandler', arg)
```
4. Use in renderer:
```typescript
const result = await window.electron.newHandler(arg);
```
### Updating the Database Schema
1. Create migration in `electron/services/vectorStore.ts`
2. Update type definitions
3. Implement data migration
4. Update queries
5. Test migration
### Adding Settings
1. Add to store schema in `store.ts`
2. Update settings component
3. Implement validation
4. Add migration if needed
5. Update relevant services
## Troubleshooting
### Common Issues
1. **IPC Communication Failures**
- Check handler registration
- Verify type definitions
- Check error handling
2. **File System Issues**
- Verify permissions
- Check path handling
- Validate file operations
3. **LLM Integration**
- Verify API keys
- Check network connectivity
- Validate response handling
### Performance Optimization
1. **Main Process**
- Profile file system operations
- Optimize database queries
- Implement proper caching
2. **Renderer Process**
- Use React.memo for expensive components
- Implement virtual scrolling
- Optimize re-renders
## Release Process
1. Update version in `package.json`
2. Run full test suite
3. Build production version
4. Test packaged application
5. Create release notes
6. Tag release in git
7. Build installers
8. Publish release
## Contributing
1. Fork the repository
2. Create feature branch
3. Follow code style
4. Add tests
5. Submit pull request
Remember to:
- Follow TypeScript best practices
- Add proper documentation
- Include tests
- Update this guide as needed

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

BIN
ai-profile.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

View File

@ -0,0 +1,30 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"ignorePatterns": ["dist", "dist-electron", ".eslintrc.json"],
"parser": "@typescript-eslint/parser",
"plugins": ["react-refresh"],
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"no-console": ["warn", { "allow": ["warn", "error"] }]
},
"settings": {
"react": {
"version": "detect"
}
}
}

43
electron-file-search/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
dist
dist-electron
release
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.*
# TypeScript
*.tsbuildinfo
# Electron store files
config.json
# ChromaDB files
vectordb/
chroma.sqlite3
# Tesseract temp files
*.traineddata

View File

@ -0,0 +1,113 @@
# Data Hound
An Electron-based desktop application that uses AI to help you search and understand your files through natural language queries.
## Features
- Natural language search across your files
- Support for multiple file types (text, PDF, images via OCR)
- Multiple LLM provider support (OpenAI, OpenRouter, Ollama)
- Real-time file monitoring and indexing
- Vector-based semantic search
- Dark mode interface
## Tech Stack
- Electron
- React
- TypeScript
- Material-UI
- LangChain
- ChromaDB
- Tesseract.js (OCR)
## Prerequisites
- Node.js 18+
- npm 8+
- For Ollama support: Ollama running locally
- For OCR: Tesseract installed on your system
## Installation
1. Clone the repository:
```bash
git clone [repository-url]
cd electron-file-search
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
## Configuration
### LLM Providers
The application supports three LLM providers:
1. **OpenAI**
- Requires an API key
- Default model: gpt-3.5-turbo
2. **OpenRouter**
- Requires an API key
- Supports various models
- Custom base URL configuration
3. **Ollama**
- Runs locally
- No API key required
- Default URL: http://localhost:11434
- Default model: llama2
Configure your preferred provider through the settings panel in the application.
## Building
To create a production build:
```bash
npm run build
```
To package the application:
```bash
npm run package
```
This will create platform-specific installers in the `release` directory.
## Development
- `npm run dev` - Start the development server
- `npm run lint` - Run ESLint
- `npm run build` - Create a production build
- `npm run package` - Package the application
## Project Structure
```
electron-file-search/
├── electron/ # Electron main process code
│ ├── main.ts # Main entry point
│ ├── preload.ts # Preload script
│ └── services/ # Backend services
├── src/ # React renderer code
│ ├── components/ # React components
│ ├── contexts/ # React contexts
│ ├── hooks/ # Custom hooks
│ └── main.tsx # Renderer entry point
└── scripts/ # Build and development scripts
```
## License
MIT

View File

@ -0,0 +1,187 @@
import { ipcMain } from 'electron';
import { DocumentMetadata, LLMConfig, ServiceError } from '../types';
const fs = require('fs');
const path = require('path');
const os = require('os');
interface IpcHandlerParams {
_: unknown;
dirPath?: string;
path?: string;
question?: string;
config?: LLMConfig;
content?: string;
metadata?: DocumentMetadata;
}
export function setupIpcHandlers() {
// File System Handlers
ipcMain.handle('start-watching', async (_: unknown, dirPath: string) => {
try {
await (await import('../services/fileSystem')).fileSystemService.startWatching(dirPath);
return { success: true };
} catch (error) {
const err = error as Error;
console.error('Error starting watch:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('stop-watching', async (_: unknown, dirPath: string) => {
try {
await (await import('../services/fileSystem')).fileSystemService.stopWatching(dirPath);
return { success: true };
} catch (error) {
const err = error as Error;
console.error('Error stopping watch:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('add-excluded-path', async (_: unknown, path: string) => {
try {
(await import('../services/fileSystem')).fileSystemService.addExcludedPath(path);
return { success: true };
} catch (error) {
const err = error as Error;
console.error('Error adding excluded path:', err);
return { success: false, error: err.message };
}
});
// LLM Handlers
ipcMain.handle('query-llm', async (event, question: string) => {
try {
const webContents = event.sender;
const response = await (await import('../services/llmService')).llmService.query(
question,
(chunk: string) => {
webContents.send('llm-response-chunk', chunk);
}
);
return response;
} catch (error) {
const err = error as Error;
console.error('Error querying LLM:', err);
throw err;
}
});
ipcMain.handle('update-llm-config', async (_: unknown, config: LLMConfig) => {
try {
await (await import('../services/llmService')).llmService.updateConfig(config);
return { success: true };
} catch (error) {
const err = error as Error;
console.error('Error updating LLM config:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('get-llm-config', async () => {
try {
const config = (await import('../services/llmService')).llmService.getConfig();
return { success: true, data: config };
} catch (error) {
const err = error as Error;
console.error('Error getting LLM config:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('get-ollama-models', async () => {
try {
const models = await (await import('../services/llmService')).llmService.getOllamaModels();
return { success: true, data: models };
} catch (error) {
const err = error as Error;
console.error('Error getting Ollama models:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('get-documents', async () => {
try {
const documents = (await import('../services/vectorStore')).vectorStoreService.getDocuments();
return { success: true, data: documents };
} catch (error) {
const err = error as Error;
console.error('Error getting documents:', err);
return { success: false, error: err.message };
}
});
// Vector Store Handlers
ipcMain.handle('add-document', async (_: unknown, content: string, metadata: DocumentMetadata) => {
try {
await (await import('../services/vectorStore')).vectorStoreService.addDocument(content, metadata);
return { success: true };
} catch (error) {
const err = error as Error;
console.error('Error adding document:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('delete-document', async (_: unknown, path: string) => {
try {
await (await import('../services/vectorStore')).vectorStoreService.deleteDocument(path);
return { success: true };
} catch (error) {
const err = error as Error;
console.error('Error deleting document:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('update-document', async (_: unknown, content: string, metadata: DocumentMetadata) => {
try {
await (await import('../services/vectorStore')).vectorStoreService.updateDocument(content, metadata);
return { success: true };
} catch (error) {
const err = error as Error;
console.error('Error updating document:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('get-user-home', async () => {
try {
const homeDir = os.homedir();
return { success: true, data: homeDir };
} catch (error) {
const err = error as Error;
console.error('Error getting user home:', err);
return { success: false, error: err.message };
}
});
ipcMain.handle('list-directories', async (_: unknown, dirPath: string) => {
try {
const directories = await new Promise<Array<{name: string; path: string}>>((resolve, reject) => {
fs.readdir(dirPath, { withFileTypes: true }, (err, items) => {
if (err) {
reject(err);
return;
}
const folders = items
.filter(item => item.isDirectory())
.map(item => ({
name: item.name,
path: path.join(dirPath, item.name)
}));
resolve(folders);
});
});
return { success: true, data: directories };
} catch (error) {
const err = error as Error;
console.error('Error listing directories:', err);
return { success: false, error: err.message };
}
});
}
export default setupIpcHandlers;

View File

@ -0,0 +1,118 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path';
import os from 'os';
import { store as electronStore } from './store';
import { setupIpcHandlers } from './ipc/handlers';
// Initialize IPC handlers immediately
setupIpcHandlers();
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
sandbox: false,
webSecurity: true,
},
show: false,
});
// In development, use the Vite dev server
if (process.env.VITE_DEV_SERVER_URL) {
// Wait for dev server to be ready
const pollDevServer = async () => {
try {
const response = await fetch(process.env.VITE_DEV_SERVER_URL);
if (response.ok) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
mainWindow.webContents.openDevTools();
} else {
setTimeout(pollDevServer, 500);
}
} catch {
setTimeout(pollDevServer, 500);
}
};
pollDevServer();
} else {
// In production, load the built files
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
// Handle window state
const windowState = electronStore.get('windowState', {
width: 1200,
height: 800
});
mainWindow.setSize(windowState.width, windowState.height);
mainWindow.on('close', () => {
const { width, height } = mainWindow.getBounds();
electronStore.set('windowState', { width, height });
});
// Set up security headers
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
]
}
});
});
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// IPC handlers for file system operations
ipcMain.handle('get-app-path', () => {
return app.getPath('userData');
});
// Window control handlers
ipcMain.handle('window-minimize', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
window?.minimize();
});
ipcMain.handle('window-maximize', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (window?.isMaximized()) {
window.unmaximize();
} else {
window?.maximize();
}
});
ipcMain.handle('window-close', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
window?.close();
});
export default app;

View File

@ -0,0 +1,48 @@
import type { LLMConfig, DocumentMetadata } from './types';
interface Directory {
name: string;
path: string;
}
declare global {
interface Window {
electron: {
// File System Operations
startWatching: (dirPath: string) => Promise<void>;
stopWatching: (dirPath: string) => Promise<void>;
addExcludedPath: (path: string) => Promise<void>;
// LLM Operations
queryLLM: (question: string) => Promise<{
answer: string;
sources: DocumentMetadata[];
}>;
updateLLMConfig: (config: LLMConfig) => Promise<void>;
getLLMConfig: () => Promise<LLMConfig>;
getOllamaModels: () => Promise<{ success: boolean; data?: string[]; error?: string }>;
// Vector Store Operations
getDocuments: () => Promise<{ success: boolean; data?: DocumentMetadata[]; error?: string }>;
addDocument: (content: string, metadata: DocumentMetadata) => Promise<void>;
deleteDocument: (path: string) => Promise<void>;
updateDocument: (content: string, metadata: DocumentMetadata) => Promise<void>;
// File Processing
processFile: (filePath: string) => Promise<void>;
// System Paths
getUserHome: () => Promise<{ success: boolean; data?: string; error?: string }>;
getAppPath: () => Promise<string>;
// Directory Operations
listDirectories: (dirPath: string) => Promise<{ success: boolean; data?: Directory[]; error?: string }>;
// Event Handling
on: (channel: string, callback: (event: unknown, ...args: any[]) => void) => void;
off: (channel: string, callback: (event: unknown, ...args: any[]) => void) => void;
};
}
}
export {};

View File

@ -0,0 +1,76 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { LLMConfig, DocumentMetadata } from './types';
interface Directory {
name: string;
path: string;
}
interface IpcResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electron', {
// File System Operations
startWatching: async (dirPath: string): Promise<void> =>
ipcRenderer.invoke('start-watching', dirPath),
stopWatching: async (dirPath: string): Promise<void> =>
ipcRenderer.invoke('stop-watching', dirPath),
addExcludedPath: async (path: string): Promise<void> =>
ipcRenderer.invoke('add-excluded-path', path),
// LLM Operations
queryLLM: async (question: string): Promise<{
answer: string;
sources: DocumentMetadata[];
}> => ipcRenderer.invoke('query-llm', question),
updateLLMConfig: async (config: LLMConfig): Promise<void> =>
ipcRenderer.invoke('update-llm-config', config),
getLLMConfig: async (): Promise<LLMConfig> =>
ipcRenderer.invoke('get-llm-config'),
getOllamaModels: async (): Promise<IpcResponse<string[]>> =>
ipcRenderer.invoke('get-ollama-models'),
// Vector Store Operations
getDocuments: async (): Promise<IpcResponse<DocumentMetadata[]>> =>
ipcRenderer.invoke('get-documents'),
addDocument: async (content: string, metadata: DocumentMetadata): Promise<void> =>
ipcRenderer.invoke('add-document', content, metadata),
deleteDocument: async (path: string): Promise<void> =>
ipcRenderer.invoke('delete-document', path),
updateDocument: async (content: string, metadata: DocumentMetadata): Promise<void> =>
ipcRenderer.invoke('update-document', content, metadata),
// File Processing
processFile: async (filePath: string): Promise<void> =>
ipcRenderer.invoke('process-file', filePath),
// System Paths
getUserHome: async (): Promise<IpcResponse<string>> =>
ipcRenderer.invoke('get-user-home'),
getAppPath: async (): Promise<string> =>
ipcRenderer.invoke('get-app-path'),
// Directory Operations
listDirectories: async (dirPath: string): Promise<IpcResponse<Directory[]>> =>
ipcRenderer.invoke('list-directories', dirPath),
// Event Handling
on: (channel: string, callback: (event: unknown, ...args: any[]) => void) => {
ipcRenderer.on(channel, callback);
},
off: (channel: string, callback: (event: unknown, ...args: any[]) => void) => {
ipcRenderer.removeListener(channel, callback);
},
// Window Controls
minimizeWindow: () => ipcRenderer.invoke('window-minimize'),
maximizeWindow: () => ipcRenderer.invoke('window-maximize'),
closeWindow: () => ipcRenderer.invoke('window-close'),
});
export type { Directory, IpcResponse };

View File

@ -0,0 +1,104 @@
import { FSWatcher } from 'chokidar';
import * as chokidar from 'chokidar';
import Store from 'electron-store';
import { ServiceError } from '../types';
const store = new Store<{
watchedPaths: string[];
excludedPaths: string[];
}>();
class FileSystemService {
private watchers: Map<string, FSWatcher>;
private excludedPaths: Set<string>;
constructor() {
this.watchers = new Map();
this.excludedPaths = new Set(store.get('excludedPaths', []));
// Add example paths
const examplePaths = [
'C:/example/llm-only',
'C:/example/ocr-only',
'C:/example/both-enabled'
];
// Set initial watched paths
store.set('watchedPaths', examplePaths);
// Start watching example paths
examplePaths.forEach(path => {
this.watchers.set(path, null); // Add to watchers without actual watcher since paths don't exist
});
}
public async startWatching(dirPath: string): Promise<void> {
if (this.watchers.has(dirPath)) {
throw new ServiceError(`Already watching directory: ${dirPath}`);
}
const watcher = chokidar.watch(dirPath, {
ignored: [
/(^|[\/\\])\../, // Ignore dotfiles
'**/node_modules/**',
...Array.from(this.excludedPaths),
],
persistent: true,
ignoreInitial: false,
});
watcher.on('add', path => {
console.log(`File ${path} has been added`);
// TODO: Process file
});
watcher.on('change', path => {
console.log(`File ${path} has been changed`);
// TODO: Process file changes
});
watcher.on('unlink', path => {
console.log(`File ${path} has been removed`);
// TODO: Remove from vector store
});
this.watchers.set(dirPath, watcher);
const watchedPaths = store.get('watchedPaths', []);
if (!watchedPaths.includes(dirPath)) {
store.set('watchedPaths', [...watchedPaths, dirPath]);
}
}
public async stopWatching(dirPath: string): Promise<void> {
const watcher = this.watchers.get(dirPath);
if (!watcher) {
throw new ServiceError(`Not watching directory: ${dirPath}`);
}
await watcher.close();
this.watchers.delete(dirPath);
const watchedPaths = store.get('watchedPaths', []);
store.set('watchedPaths', watchedPaths.filter(p => p !== dirPath));
}
public addExcludedPath(path: string): void {
this.excludedPaths.add(path);
store.set('excludedPaths', Array.from(this.excludedPaths));
// Update all watchers with new excluded path
this.watchers.forEach(watcher => {
watcher.unwatch(path);
});
}
public getWatchedPaths(): string[] {
return Array.from(this.watchers.keys());
}
public getExcludedPaths(): string[] {
return Array.from(this.excludedPaths);
}
}
export const fileSystemService = new FileSystemService();

View File

@ -0,0 +1,236 @@
import { ServiceError, LLMConfig, DocumentMetadata } from '../types';
const { store } = require('../store');
import OpenAI from 'openai';
import { OpenRouter } from 'openrouter-client';
import { ollamaService } from './ollamaService';
type Message = { role: 'system' | 'user' | 'assistant'; content: string };
interface OpenAIClient {
chat: {
completions: {
create: Function;
};
};
}
interface OpenRouterStreamResponse {
success: boolean;
data?: {
choices: Array<{ delta?: { content?: string }; message?: { content: string } }>;
};
errorCode?: number;
errorMessage?: string;
}
type OpenRouterConfig = {
temperature?: number;
model?: string;
stream?: boolean;
};
interface OpenRouterClient {
chat: (messages: Message[], config?: OpenRouterConfig) => Promise<OpenRouterStreamResponse>;
}
export class LLMService {
#config: LLMConfig;
#openaiClient: OpenAIClient | null;
#openrouterClient: OpenRouterClient | null;
constructor() {
const storedConfig = store.get('llm_config');
this.#config = storedConfig || {
provider: 'ollama',
model: 'jimscard/blackhat-hacker:v2',
baseUrl: 'http://localhost:11434',
temperature: 0.7,
apiKey: null
};
// Ensure config is saved with defaults
store.set('llm_config', this.#config);
this.#openaiClient = null;
this.#openrouterClient = null;
this.#initializeClient();
}
/**
* @private
*/
#initializeClient() {
switch (this.#config?.provider) {
case 'openai':
if (!this.#config.apiKey) {
throw new ServiceError('OpenAI API key is required');
}
this.#openaiClient = new OpenAI({
apiKey: this.#config.apiKey,
baseURL: this.#config.baseUrl,
});
break;
case 'openrouter':
if (!this.#config.apiKey) {
throw new ServiceError('OpenRouter API key is required');
}
this.#openrouterClient = new OpenRouter(this.#config.apiKey);
break;
case 'ollama':
if (this.#config.baseUrl) {
ollamaService.updateBaseUrl(this.#config.baseUrl);
}
break;
}
}
async query(
question: string,
onChunk?: (chunk: string) => void
): Promise<{ answer: string, sources: DocumentMetadata[] }> {
if (!this.#config?.provider) {
throw new ServiceError('LLM provider not configured');
}
try {
let response;
switch (this.#config.provider) {
case 'openai':
if (!this.#openaiClient) {
throw new ServiceError('OpenAI client not initialized');
}
const openaiResponse = await this.#openaiClient.chat.completions.create({
model: this.#config.model || 'gpt-3.5-turbo',
messages: [{ role: 'user', content: question }],
temperature: this.#config.temperature || 0.7,
stream: true,
});
let openaiText = '';
for await (const chunk of openaiResponse) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
openaiText += content;
onChunk?.(content);
}
}
response = openaiText;
break;
case 'openrouter':
if (!this.#openrouterClient) {
throw new ServiceError('OpenRouter client not initialized');
}
const openrouterResponse = await this.#openrouterClient.chat(
[{ role: 'user', content: question }],
{
model: this.#config.model || 'openai/gpt-3.5-turbo',
temperature: this.#config.temperature || 0.7,
stream: true,
}
);
if (!openrouterResponse.success) {
throw new ServiceError(openrouterResponse.errorMessage || 'OpenRouter request failed');
}
let routerText = '';
for await (const chunk of openrouterResponse.data?.choices || []) {
const content = chunk.delta?.content || chunk.message?.content || '';
if (content) {
routerText += content;
onChunk?.(content);
}
}
response = routerText;
break;
case 'ollama':
const ollamaResponse = await ollamaService.chat({
model: this.#config.model || 'phi4:latest',
messages: [{ role: 'user', content: question }],
temperature: this.#config.temperature,
onChunk,
});
response = ollamaResponse.message.content;
break;
default:
throw new ServiceError(`Unsupported provider: ${this.#config.provider}`);
}
/** @type {DocumentMetadata[]} */
const sources = []; // TODO: Implement source retrieval from vector store
return {
answer: response,
sources,
};
} catch (error) {
console.error('Error querying LLM:', error);
throw new ServiceError(
error instanceof Error ? error.message : 'Unknown error occurred'
);
}
}
/**
* @returns {LLMConfig}
*/
getConfig() {
return this.#config;
}
/**
* @param {LLMConfig} newConfig - The new LLM configuration
*/
async updateConfig(newConfig) {
// Validate required fields from schema
if (!newConfig.provider) {
throw new ServiceError('Provider is required');
}
// Clean config to only include allowed properties from schema
const cleanConfig = {
provider: newConfig.provider,
apiKey: newConfig.apiKey ?? null,
model: newConfig.model ?? (newConfig.provider === 'ollama' ? 'phi4' : null),
baseUrl: newConfig.provider === 'ollama' ? (newConfig.baseUrl ?? 'http://localhost:11434') : (newConfig.baseUrl ?? null),
temperature: typeof newConfig.temperature === 'number' ? newConfig.temperature : 0.7
};
// Validate provider-specific requirements
if (cleanConfig.provider !== 'ollama' && !cleanConfig.apiKey) {
throw new ServiceError(`${cleanConfig.provider} requires an API key`);
}
try {
store.set('llm_config', cleanConfig);
this.#config = cleanConfig;
this.#initializeClient();
} catch (error) {
throw new ServiceError(
error instanceof Error ? error.message : 'Failed to update config'
);
}
}
/**
* Get available models from Ollama server
* @returns {Promise<string[]>} List of model names
*/
async getOllamaModels() {
try {
return await ollamaService.getModels();
} catch (error) {
console.error('Error fetching Ollama models:', error);
throw new ServiceError(
error instanceof Error ? error.message : 'Failed to fetch Ollama models'
);
}
}
}
const llmService = new LLMService();
export { llmService };

View File

@ -0,0 +1,222 @@
import { ServiceError } from '../types';
import { net } from 'electron';
interface OllamaModel {
name: string;
modified_at: string;
size: number;
digest: string;
}
interface OllamaListResponse {
models: Array<{
name: string;
model: string;
modified_at: string;
size: number;
digest: string;
}>;
}
interface OllamaChatResponse {
message: {
content: string;
};
}
interface OllamaChatParams {
model: string;
messages: Array<{ role: string; content: string }>;
temperature?: number;
onChunk?: (chunk: string) => void;
}
class OllamaService {
private baseUrl: string = 'http://127.0.0.1:11434';
private async makeRequest<T>(
path: string,
method: string = 'GET',
body?: any,
onChunk?: (chunk: string) => void
): Promise<T> {
return new Promise((resolve, reject) => {
try {
const url = new URL(path, this.baseUrl);
const request = net.request({
url: url.toString(),
method,
headers: {
'Content-Type': 'application/json',
}
});
let responseData = '';
let streamBuffer = '';
request.on('response', (response) => {
if (response.statusCode !== 200) {
const error = new Error(`HTTP error! status: ${response.statusCode}`);
console.error('Ollama request failed:', {
path,
statusCode: response.statusCode,
error
});
reject(error);
return;
}
response.on('data', (chunk) => {
try {
const chunkStr = chunk.toString();
if (path === '/api/chat') {
// Handle streaming chat response
streamBuffer += chunkStr;
const lines = streamBuffer.split('\n');
// Process all complete lines except the last one
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
if (!line) continue;
try {
const parsed = JSON.parse(line);
if (parsed.message?.content && onChunk) {
onChunk(parsed.message.content);
}
} catch (e) {
console.warn('Failed to parse chat chunk:', { line, error: e });
}
}
// Keep the last potentially incomplete line
streamBuffer = lines[lines.length - 1];
} else {
// For non-streaming endpoints
responseData += chunkStr;
}
} catch (e) {
console.error('Error processing chunk:', e);
}
});
response.on('end', () => {
try {
if (path === '/api/chat') {
// Handle any remaining data in the buffer
if (streamBuffer.trim()) {
try {
const parsed = JSON.parse(streamBuffer);
if (parsed.message?.content && onChunk) {
onChunk(parsed.message.content);
}
} catch (e) {
console.warn('Failed to parse final chat chunk:', { buffer: streamBuffer, error: e });
}
}
resolve({
message: {
content: ''
}
} as T);
} else {
// For non-streaming endpoints
const trimmedResponse = responseData.trim();
if (!trimmedResponse) {
throw new Error('Empty response received');
}
resolve(JSON.parse(trimmedResponse) as T);
}
} catch (e) {
reject(new Error(`Failed to process response: ${e.message}`));
}
});
});
request.on('error', (error) => {
console.error('Request error:', {
path,
error: error.message
});
reject(error);
});
if (body) {
const bodyStr = JSON.stringify(body);
console.log('Sending request:', {
path,
method,
body: bodyStr
});
request.write(bodyStr);
}
request.end();
} catch (e) {
console.error('Failed to make request:', e);
reject(e);
}
});
}
async getModels(): Promise<string[]> {
try {
const response = await this.makeRequest<OllamaListResponse>('/api/tags');
return response.models.map(model => model.name);
} catch (error) {
console.error('Error fetching Ollama models:', error);
throw new ServiceError(
error instanceof Error ? error.message : 'Failed to fetch Ollama models'
);
}
}
async chat(params: OllamaChatParams): Promise<OllamaChatResponse> {
if (!params?.model || !params?.messages?.length) {
throw new ServiceError('Invalid chat parameters: model and messages are required');
}
try {
console.log('Starting chat request:', {
model: params.model,
messageCount: params.messages.length
});
const { onChunk, temperature, ...requestParams } = params;
const response = await this.makeRequest<OllamaChatResponse>(
'/api/chat',
'POST',
{
...requestParams,
stream: true,
temperature: temperature ?? 0.7
},
onChunk
);
if (!response?.message) {
throw new Error('Invalid response format from Ollama');
}
return response;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to chat with Ollama';
console.error('Chat error:', {
error: errorMessage,
params: {
model: params.model,
messageCount: params.messages.length
}
});
throw new ServiceError(errorMessage);
}
}
updateBaseUrl(baseUrl: string) {
this.baseUrl = baseUrl;
}
}
export const ollamaService = new OllamaService();

View File

@ -0,0 +1,70 @@
import Store from 'electron-store';
import { DocumentMetadata } from '../types';
const store = new Store<{
documents: Record<string, DocumentMetadata>;
}>();
class VectorStoreService {
private documents: Map<string, DocumentMetadata>;
constructor() {
this.documents = new Map(Object.entries(store.get('documents', {})));
// Add example documents if none exist
if (this.documents.size === 0) {
const examples: DocumentMetadata[] = [
{
path: 'C:/example/llm-only',
type: 'directory',
lastModified: Date.now(),
size: 0,
hasEmbeddings: true,
hasOcr: false
},
{
path: 'C:/example/ocr-only',
type: 'directory',
lastModified: Date.now(),
size: 0,
hasEmbeddings: false,
hasOcr: true
},
{
path: 'C:/example/both-enabled',
type: 'directory',
lastModified: Date.now(),
size: 0,
hasEmbeddings: true,
hasOcr: true
}
];
examples.forEach(doc => {
this.documents.set(doc.path, doc);
});
store.set('documents', Object.fromEntries(this.documents));
}
}
public async addDocument(content: string, metadata: DocumentMetadata): Promise<void> {
// TODO: Implement vector storage
this.documents.set(metadata.path, metadata);
store.set('documents', Object.fromEntries(this.documents));
}
public async deleteDocument(path: string): Promise<void> {
this.documents.delete(path);
store.set('documents', Object.fromEntries(this.documents));
}
public async updateDocument(content: string, metadata: DocumentMetadata): Promise<void> {
await this.addDocument(content, metadata);
}
public getDocuments(): DocumentMetadata[] {
return Array.from(this.documents.values());
}
}
export const vectorStoreService = new VectorStoreService();

View File

@ -0,0 +1,14 @@
import { ElectronStore } from 'electron-store';
declare module 'electron-store' {
interface ElectronStore<T> {
get<K extends keyof T>(key: K): T[K];
set<K extends keyof T>(key: K, value: T[K]): void;
// Add other methods as needed
has<K extends keyof T>(key: K): boolean;
delete<K extends keyof T>(key: K): void;
clear(): void;
}
}
export = ElectronStore;

View File

@ -0,0 +1,111 @@
import Store from 'electron-store';
interface WindowState {
width: number;
height: number;
}
interface LLMConfig {
provider: 'openai' | 'openrouter' | 'ollama';
apiKey?: string | null;
model?: string;
baseUrl?: string;
temperature?: number;
}
interface Document {
path: string;
type: string;
lastModified: number;
size: number;
}
interface Schema {
windowState: WindowState;
llm_config: LLMConfig;
watchedPaths: string[];
excludedPaths: string[];
documents: Record<string, Document>;
}
const schema: Store.Schema<Schema> = {
windowState: {
type: 'object',
properties: {
width: { type: 'number', minimum: 400 },
height: { type: 'number', minimum: 300 }
},
required: ['width', 'height'],
additionalProperties: false,
default: {
width: 1200,
height: 800
}
},
llm_config: {
type: 'object',
properties: {
provider: {
type: 'string',
enum: ['openai', 'openrouter', 'ollama']
},
apiKey: { type: ['string', 'null'] },
model: { type: 'string' },
baseUrl: { type: 'string' },
temperature: { type: 'number', minimum: 0, maximum: 1 }
},
required: ['provider'],
additionalProperties: false,
default: {
provider: 'ollama',
model: 'phi4',
baseUrl: 'http://localhost:11434',
temperature: 0.7
}
},
watchedPaths: {
type: 'array',
items: { type: 'string' },
default: []
},
excludedPaths: {
type: 'array',
items: { type: 'string' },
default: []
},
documents: {
type: 'object',
additionalProperties: {
type: 'object',
properties: {
path: { type: 'string' },
type: { type: 'string' },
lastModified: { type: 'number' },
size: { type: 'number' }
},
required: ['path', 'type', 'lastModified', 'size']
},
default: {}
}
};
export const store = new Store<Schema>({
schema,
name: 'config',
clearInvalidConfig: true,
migrations: {
'>=0.0.1': (store) => {
const currentConfig = store.get('llm_config');
if (currentConfig) {
const cleanConfig = {
provider: currentConfig.provider || 'ollama',
apiKey: currentConfig.apiKey,
model: currentConfig.model,
baseUrl: currentConfig.baseUrl,
temperature: currentConfig.temperature
};
store.set('llm_config', cleanConfig);
}
}
}
});

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noImplicitAny": false,
"sourceMap": true,
"baseUrl": ".",
"outDir": "../dist-electron",
"resolveJsonModule": true,
"jsx": "react-jsx",
"paths": {
"*": ["../node_modules/*"]
},
"types": ["node", "electron"]
},
"include": ["./**/*.ts", "./**/*.cts"],
"exclude": ["../src"]
}

View File

@ -0,0 +1,22 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"target": "ES2020",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noImplicitAny": true,
"sourceMap": true,
"baseUrl": ".",
"outDir": "../dist-electron",
"moduleResolution": "Node",
"resolveJsonModule": true,
"paths": {
"*": ["../node_modules/*"]
}
},
"files": ["preload.ts"],
"include": ["preload-types.ts", "types.ts"]
}

View File

@ -0,0 +1,33 @@
export interface LLMConfig {
provider: 'openai' | 'openrouter' | 'ollama';
apiKey?: string;
model?: string;
baseUrl?: string;
temperature?: number;
}
export interface DocumentMetadata {
path: string;
type: string;
lastModified: number;
size: number;
hasEmbeddings?: boolean;
hasOcr?: boolean;
}
export class ServiceError extends Error {
constructor(message: string) {
super(message);
this.name = 'ServiceError';
}
}
// For CommonJS compatibility
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
ServiceError,
// Export interface types for TypeScript
LLMConfig: Symbol.for('LLMConfig'),
DocumentMetadata: Symbol.for('DocumentMetadata')
};
}

View File

@ -0,0 +1,8 @@
declare module 'pdfjs-dist/build/pdf.mjs' {
export * from 'pdfjs-dist';
}
declare module 'pdfjs-dist/build/pdf.worker.mjs' {
const workerSrc: string;
export default workerSrc;
}

View File

@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:;" />
<title>Data Hound</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9955
electron-file-search/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
{
"name": "electron-file-search",
"private": true,
"version": "0.1.0",
"main": "dist-electron/main.js",
"scripts": {
"dev": "node scripts/dev.cjs",
"build": "node scripts/build.js",
"build:vite": "vite build",
"clean": "rimraf dist dist-electron .vite",
"preview": "vite preview",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.8",
"@mui/icons-material": "^5.15.7",
"@mui/material": "^5.15.7",
"chokidar": "^3.5.3",
"electron-store": "^8.1.0",
"highlight.js": "^11.11.1",
"ollama": "^0.5.12",
"openai": "^4.82.0",
"openrouter-client": "^1.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.3",
"rehype-highlight": "^7.0.2"
},
"devDependencies": {
"@types/node": "^20.11.16",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"electron": "^28.1.0",
"electron-builder": "^24.9.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,50 @@
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
async function build() {
try {
// Build Vite/React
console.log('Building Vite/React...');
await spawnAsync('npm', ['run', 'build:vite']);
// Build main process
console.log('Building main process...');
await spawnAsync('tsc', ['-p', 'electron/tsconfig.json']);
// Build preload script
console.log('Building preload script...');
await spawnAsync('tsc', ['-p', 'electron/tsconfig.preload.json']);
console.log('Build complete!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
function spawnAsync(command, args) {
return new Promise((resolve, reject) => {
const childProcess = spawn(command, args, {
stdio: 'inherit',
shell: true,
});
childProcess.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Process exited with code ${code}`));
}
});
childProcess.on('error', (error) => {
reject(error);
});
});
}
build();

View File

@ -0,0 +1,150 @@
const { spawn } = require('child_process');
const { createServer, build } = require('vite');
const electron = require('electron');
const path = require('path');
const fs = require('fs');
/**
* @type {import('vite').LogLevel}
*/
const LOG_LEVEL = 'info';
/** @type {import('vite').InlineConfig} */
const sharedConfig = {
mode: 'development',
build: {
watch: {},
},
logLevel: LOG_LEVEL,
};
/** @type {import('vite').InlineConfig} */
const preloadConfig = {
...sharedConfig,
configFile: false,
root: path.join(__dirname, '..'),
build: {
...sharedConfig.build,
outDir: 'dist-electron',
lib: {
entry: path.join(__dirname, '../electron/preload.ts'),
formats: ['cjs'],
},
rollupOptions: {
external: ['electron'],
output: {
entryFileNames: '[name].js',
},
},
emptyOutDir: true,
},
};
// Find all TypeScript files in the electron directory
const electronDir = path.join(__dirname, '../electron');
const entries = {};
function addEntry(file) {
const relativePath = path.relative(electronDir, file);
const name = relativePath.replace(/\.ts$/, '');
entries[name] = file;
}
function scanDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
scanDirectory(fullPath);
} else if (file.endsWith('.ts')) {
addEntry(fullPath);
}
});
}
scanDirectory(electronDir);
/** @type {import('vite').InlineConfig} */
const mainConfig = {
...sharedConfig,
configFile: false,
root: path.join(__dirname, '..'),
build: {
...sharedConfig.build,
outDir: 'dist-electron',
lib: {
entry: entries,
formats: ['cjs'],
},
rollupOptions: {
external: ['electron', 'electron-store', 'path', 'os', 'chokidar', 'openai', 'openrouter-client', 'ollama'],
output: {
entryFileNames: '[name].js',
format: 'cjs',
preserveModules: true,
preserveModulesRoot: path.join(__dirname, '../electron'),
},
},
emptyOutDir: false,
},
};
/**
* @type {(server: import('vite').ViteDevServer) => Promise<import('rollup').RollupWatcher>}
*/
function watchMain(server) {
/**
* @type {import('child_process').ChildProcessWithoutNullStreams | null}
*/
let electronProcess = null;
return build({
...mainConfig,
plugins: [{
name: 'electron-main-watcher',
writeBundle() {
if (electronProcess) {
electronProcess.kill();
electronProcess = null;
}
electronProcess = spawn(electron, ['.'], {
stdio: 'inherit',
env: {
...process.env,
VITE_DEV_SERVER_URL: `http://localhost:${server.config.server.port}`,
},
});
},
}],
});
}
/**
* @type {() => Promise<import('rollup').RollupWatcher>}
*/
function watchPreload() {
return build(preloadConfig);
}
// bootstrap
async function start() {
try {
console.log('Starting Vite dev server...');
const server = await createServer({ ...sharedConfig });
await server.listen();
console.log(`Dev server running at: ${server.config.server.port}`);
console.log('Building preload script...');
await watchPreload();
console.log('Starting Electron...');
await watchMain(server);
console.log('Development environment ready!');
} catch (error) {
console.error('Error starting dev server:', error);
process.exit(1);
}
}
start();

View File

@ -0,0 +1,155 @@
import { spawn } from 'child_process';
import { createServer, build } from 'vite';
import electron from 'electron/index.js';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* @type {import('vite').LogLevel}
*/
const LOG_LEVEL = 'info';
/** @type {import('vite').InlineConfig} */
const sharedConfig = {
mode: 'development',
build: {
watch: {},
},
logLevel: LOG_LEVEL,
};
/** @type {import('vite').InlineConfig} */
const preloadConfig = {
...sharedConfig,
configFile: false,
root: path.join(__dirname, '..'),
build: {
...sharedConfig.build,
outDir: 'dist-electron',
lib: {
entry: path.join(__dirname, '../electron/preload.ts'),
formats: ['cjs'],
},
rollupOptions: {
external: ['electron'],
output: {
entryFileNames: '[name].js',
},
},
emptyOutDir: true,
},
};
// Find all TypeScript files in the electron directory
const electronDir = path.join(__dirname, '../electron');
const entries = {};
function addEntry(file) {
const relativePath = path.relative(electronDir, file);
const name = relativePath.replace(/\.ts$/, '');
entries[name] = file;
}
function scanDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
scanDirectory(fullPath);
} else if (file.endsWith('.ts')) {
addEntry(fullPath);
}
});
}
scanDirectory(electronDir);
/** @type {import('vite').InlineConfig} */
const mainConfig = {
...sharedConfig,
configFile: false,
root: path.join(__dirname, '..'),
build: {
...sharedConfig.build,
outDir: 'dist-electron',
lib: {
entry: entries,
formats: ['cjs'],
},
rollupOptions: {
external: ['electron', 'electron-store', 'path', 'os', 'chokidar', 'openai', 'openrouter-client', 'ollama'],
output: {
entryFileNames: '[name].js',
format: 'cjs',
preserveModules: true,
preserveModulesRoot: path.join(__dirname, '../electron'),
},
},
emptyOutDir: false,
},
};
/**
* @type {(server: import('vite').ViteDevServer) => Promise<import('rollup').RollupWatcher>}
*/
function watchMain(server) {
/**
* @type {import('child_process').ChildProcessWithoutNullStreams | null}
*/
let electronProcess = null;
return build({
...mainConfig,
plugins: [{
name: 'electron-main-watcher',
writeBundle() {
if (electronProcess) {
electronProcess.kill();
electronProcess = null;
}
electronProcess = spawn(electron, ['.'], {
stdio: 'inherit',
env: {
...process.env,
VITE_DEV_SERVER_URL: `http://localhost:${server.config.server.port}`,
},
});
},
}],
});
}
/**
* @type {() => Promise<import('rollup').RollupWatcher>}
*/
function watchPreload() {
return build(preloadConfig);
}
// bootstrap
async function start() {
try {
console.log('Starting Vite dev server...');
const server = await createServer({ ...sharedConfig });
await server.listen();
console.log(`Dev server running at: ${server.config.server.port}`);
console.log('Building preload script...');
await watchPreload();
console.log('Starting Electron...');
await watchMain(server);
console.log('Development environment ready!');
} catch (error) {
console.error('Error starting dev server:', error);
process.exit(1);
}
}
start();

View File

@ -0,0 +1,67 @@
import { spawn } from 'child_process';
import { watch } from 'fs';
import { exec } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
let electronProcess = null;
let isElectronRunning = false;
function runElectron() {
if (isElectronRunning) {
electronProcess.kill();
isElectronRunning = false;
}
// Set environment variable for development
const env = { ...process.env, NODE_ENV: 'development' };
electronProcess = spawn('electron', ['.'], {
stdio: 'inherit',
env
});
isElectronRunning = true;
electronProcess.on('close', () => {
isElectronRunning = false;
});
}
// Initial build
exec('tsc -p electron/tsconfig.json', (error) => {
if (error) {
console.error('Error building Electron files:', error);
return;
}
console.log('Initial Electron build complete');
runElectron();
});
// Watch for changes
watch('electron', { recursive: true }, (eventType, filename) => {
if (filename && filename.endsWith('.ts')) {
console.log(`File ${filename} changed, rebuilding...`);
exec('tsc -p electron/tsconfig.json', (error) => {
if (error) {
console.error('Error rebuilding Electron files:', error);
return;
}
console.log('Rebuild complete, restarting Electron...');
runElectron();
});
}
});
// Handle process termination
process.on('SIGINT', () => {
if (electronProcess) {
electronProcess.kill();
}
process.exit();
});

View File

@ -0,0 +1,287 @@
import React, { useState } from 'react';
import { Box, CssBaseline, ThemeProvider, createTheme, Tabs, Tab, TextField, IconButton } from '@mui/material';
import { Send as SendIcon, DeleteOutline as ClearIcon, Close as CloseIcon, Remove as MinimizeIcon, Fullscreen as MaximizeIcon } from '@mui/icons-material';
import { useChat } from './hooks/useChat';
import ChatPanel from './components/ChatPanel';
import FileExplorer from './components/FileExplorer';
import SettingsPanel from './components/SettingsPanel';
import HomePanel from './components/HomePanel';
import ScanningPanel from './components/ScanningPanel';
import ReportingPanel from './components/ReportingPanel';
import { ElectronProvider } from './contexts/ElectronContext';
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#2196f3', // Lighter blue
light: '#64b5f6',
dark: '#1976d2',
contrastText: '#ffffff',
},
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
overflow: 'hidden',
},
},
},
},
});
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps & { [key: string]: any }) {
const { children, value, index, ...other } = props;
return (
<Box
role="tabpanel"
hidden={value !== index}
sx={{
flex: value === index ? 1 : 'none',
display: value === index ? 'flex' : 'none',
flexDirection: 'column',
overflow: 'auto',
height: value === index ? '100%' : 0,
minHeight: value === index ? 0 : 'none',
'&::-webkit-scrollbar': {
width: '8px',
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: (theme) => theme.palette.divider,
borderRadius: '4px',
'&:hover': {
background: (theme) => theme.palette.action.hover,
},
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
}}
{...other}
>
{value === index && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{children}
</Box>
)}
</Box>
);
}
function AppContent() {
const [currentTab, setCurrentTab] = useState(0);
const { isLoading, sendMessage, clearMessages, messages } = useChat();
const [input, setInput] = useState('');
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const message = input;
setInput('');
setCurrentTab(1); // Switch to chat tab
await sendMessage(message);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
bgcolor: 'background.default',
color: 'text.primary',
}}>
{/* Custom titlebar */}
<Box
sx={{
height: '28px',
bgcolor: '#2f2f2f',
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'drag',
px: 1,
position: 'relative',
'& .window-controls': {
WebkitAppRegion: 'no-drag',
display: 'flex',
gap: '8px',
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
},
'& .control-button': {
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
bgcolor: '#1976d2',
opacity: 0.7,
'&:hover': {
opacity: 1,
bgcolor: '#2196f3',
},
'& svg': {
width: '14px',
height: '14px',
color: '#fff',
},
},
}}
>
<div className="window-controls">
<button
className="control-button"
onClick={() => window.electron.closeWindow()}
>
<CloseIcon />
</button>
<button
className="control-button"
onClick={() => window.electron.minimizeWindow()}
>
<MinimizeIcon />
</button>
<button
className="control-button"
onClick={() => window.electron.maximizeWindow()}
>
<MaximizeIcon />
</button>
</div>
</Box>
{/* Main content */}
<Box sx={{
display: 'flex',
flex: 1,
overflow: 'hidden',
}}>
<Box sx={{
width: 300,
borderRight: '1px solid',
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.paper',
}}>
<FileExplorer />
</Box>
<Box sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
bgcolor: 'background.paper',
}}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={currentTab} onChange={handleTabChange}>
<Tab label="Home" />
<Tab label="Chat" />
<Tab label="Settings" />
<Tab label="Scanning" />
<Tab label="Reports" />
</Tabs>
</Box>
<Box sx={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
pb: '64px' // Add padding for the input height
}}>
<TabPanel value={currentTab} index={0}>
<HomePanel />
</TabPanel>
<TabPanel value={currentTab} index={1}>
<ChatPanel messages={messages} />
</TabPanel>
<TabPanel value={currentTab} index={2}>
<SettingsPanel />
</TabPanel>
<TabPanel value={currentTab} index={3}>
<ScanningPanel />
</TabPanel>
<TabPanel value={currentTab} index={4}>
<ReportingPanel />
</TabPanel>
</Box>
</Box>
</Box>
<Box
component="form"
onSubmit={handleSubmit}
sx={{
p: 2,
borderTop: '1px solid',
borderColor: 'divider',
display: 'flex',
gap: 1,
bgcolor: 'background.paper',
position: 'fixed',
bottom: 0,
right: 0,
left: 0,
zIndex: 1200,
boxSizing: 'border-box'
}}
>
<TextField
fullWidth
variant="outlined"
placeholder="Ask a question..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
size="small"
/>
<IconButton
type="submit"
disabled={isLoading || !input.trim()}
color="primary"
>
<SendIcon />
</IconButton>
<IconButton
onClick={clearMessages}
color="error"
disabled={messages.length === 0}
>
<ClearIcon />
</IconButton>
</Box>
</Box>
</ThemeProvider>
);
}
function App() {
return (
<ElectronProvider>
<AppContent />
</ElectronProvider>
);
}
export default App;

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,206 @@
import React, { useEffect, useRef } from 'react';
import { Box, Typography, Paper, Avatar } from '@mui/material';
import type { DocumentMetadata } from '../../../electron/types';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github.css';
interface ChatMessage {
id: string;
text: string;
isUser: boolean;
timestamp: string;
sources?: DocumentMetadata[];
avatar?: string;
}
interface MessageListProps {
messages: ChatMessage[];
}
export default function MessageList({ messages }: MessageListProps) {
const messageListRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
}
}, [messages]);
return (
<Box
ref={messageListRef}
sx={{
flex: 1,
overflow: 'auto',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
scrollBehavior: 'smooth',
'&::-webkit-scrollbar': {
width: '8px',
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: (theme) => theme.palette.divider,
borderRadius: '4px',
'&:hover': {
background: (theme) => theme.palette.action.hover,
},
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
}}>
{messages.map((message) => (
<Box
key={message.id}
sx={{
display: 'flex',
flexDirection: message.isUser ? 'row-reverse' : 'row',
alignItems: 'flex-start',
gap: 2,
maxWidth: '80%',
alignSelf: message.isUser ? 'flex-end' : 'flex-start',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
<Avatar
src={message.isUser ? '/profiles/user-profile.webp' : '/profiles/ai-profile.webp'}
alt={message.isUser ? 'User' : 'AI'}
variant="square"
sx={{
width: 40,
height: 40,
boxShadow: 1
}}
/>
<Typography
variant="caption"
sx={{
fontSize: '0.75rem',
color: 'text.secondary',
fontWeight: 500
}}
>
{message.isUser ? 'User' : 'Data Hound'}
</Typography>
</Box>
<Box
component={Paper}
elevation={1}
sx={{
p: 2,
flex: 1,
bgcolor: message.isUser ? 'primary.main' : 'background.paper',
color: message.isUser ? 'primary.contrastText' : 'text.primary',
transition: 'all 0.2s ease-in-out',
boxShadow: 1,
borderRadius: 2,
'&:hover': {
boxShadow: 2,
},
}}
>
<Box sx={{
'& .markdown-body': {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: '1.5em',
lineHeight: 1.6,
'& pre': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? '#1e1e1e' : '#f6f8fa',
padding: 2,
borderRadius: 1,
overflow: 'auto'
},
'& code': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? '#1e1e1e' : '#f6f8fa',
padding: '0.2em 0.4em',
borderRadius: 1,
fontSize: '85%'
},
'& h1, & h2, & h3, & h4, & h5, & h6': {
marginTop: '24px',
marginBottom: '16px',
fontWeight: 600,
lineHeight: 1.25
},
'& p': {
marginTop: '0',
marginBottom: '16px'
},
'& a': {
color: (theme) => theme.palette.primary.main,
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline'
}
},
'& img': {
maxWidth: '100%',
height: 'auto'
},
'& blockquote': {
padding: '0 1em',
color: (theme) => theme.palette.text.secondary,
borderLeft: (theme) => `0.25em solid ${theme.palette.divider}`,
margin: '0 0 16px 0'
},
'& ul, & ol': {
paddingLeft: '2em',
marginBottom: '16px'
}
}
}}>
<ReactMarkdown
className="markdown-body"
rehypePlugins={[rehypeHighlight]}
>
{message.text}
</ReactMarkdown>
</Box>
{message.sources && message.sources.length > 0 && (
<Box sx={{ mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
mb: 0.5,
fontWeight: 'medium'
}}
>
Sources:
</Typography>
{message.sources.map((source, index) => (
<Typography
key={index}
variant="caption"
component="div"
color="text.secondary"
sx={{
pl: 1,
borderLeft: '2px solid',
borderColor: 'divider'
}}
>
{source.path}
</Typography>
))}
</Box>
)}
<Typography
variant="caption"
color={message.isUser ? '#ffffff' : 'text.secondary'}
sx={{ display: 'block', mt: 0.5 }}
>
{new Date(message.timestamp).toLocaleTimeString()}
</Typography>
</Box>
</Box>
))}
</Box>
);
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Box } from '@mui/material';
import MessageList from './MessageList';
interface ChatPanelProps {
messages: Array<{
role: 'user' | 'assistant';
content: string;
sources?: any[];
}>;
}
export default function ChatPanel({ messages }: ChatPanelProps) {
return (
<Box sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
flex: 1,
minHeight: 0
}}>
<MessageList
messages={messages.map((msg, index) => ({
id: String(index),
text: msg.content,
isUser: msg.role === 'user',
timestamp: new Date().toISOString(),
sources: msg.sources,
}))}
/>
</Box>
);
}

View File

@ -0,0 +1,214 @@
import React, { useState } from 'react';
import {
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
List,
ListItem,
ListItemText,
ListItemIcon,
CircularProgress,
Alert,
Box,
Typography,
} from '@mui/material';
import {
Folder as FolderIcon,
AddCircleOutline as AddIcon,
Google as GoogleIcon,
Cloud as DropboxIcon,
Chat as DiscordIcon,
Computer as LocalIcon,
} from '@mui/icons-material';
import { useElectron } from '../../hooks/useElectron';
interface Directory {
name: string;
path: string;
}
interface DirectoryPickerProps {
onSelect: (dirPath: string) => void;
}
export default function DirectoryPicker({ onSelect }: DirectoryPickerProps) {
const electron = useElectron();
const [open, setOpen] = useState(false);
const [directories, setDirectories] = useState<Directory[]>([]);
const [currentPath, setCurrentPath] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOpen = async () => {
try {
setOpen(true);
setIsLoading(true);
setError(null);
const homeResponse = await electron.getUserHome();
if (!homeResponse.success || !homeResponse.data) {
throw new Error(homeResponse.error || 'Failed to get user home directory');
}
const homePath = homeResponse.data;
setCurrentPath(homePath);
const dirResponse = await electron.listDirectories(homePath);
if (!dirResponse.success || !dirResponse.data) {
throw new Error(dirResponse.error || 'Failed to load directories');
}
setDirectories(dirResponse.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
console.error('Error loading directories:', err);
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setOpen(false);
setError(null);
};
const handleDirectoryClick = async (dirPath: string) => {
try {
setIsLoading(true);
setError(null);
setCurrentPath(dirPath);
const dirResponse = await electron.listDirectories(dirPath);
if (!dirResponse.success || !dirResponse.data) {
throw new Error(dirResponse.error || 'Failed to load directories');
}
setDirectories(dirResponse.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
console.error('Error loading directories:', err);
} finally {
setIsLoading(false);
}
};
const handleSelect = () => {
onSelect(currentPath);
handleClose();
};
return (
<>
<Box>
<Box sx={{
position: 'relative',
display: 'flex',
justifyContent: 'flex-end',
mt: 1.5,
mr: 2,
mb: 2,
}}>
<Typography sx={{ pl:2 ,flexGrow: 1 }} variant="h6">Folders</Typography>
<Button
variant="contained"
onClick={handleOpen}
size="small"
sx={{
minWidth: 0,
width: '32px',
height: '32px',
borderRadius: '50%',
padding: 0,
overflow: 'hidden',
backgroundColor: 'primary.main',
transition: 'all 0.3s ease',
'& .MuiButton-startIcon': {
margin: 0,
},
'& .buttonText': {
width: 0,
overflow: 'hidden',
transition: 'width 0.3s ease',
whiteSpace: 'nowrap',
},
'&:hover': {
backgroundColor: 'primary.dark',
width: '180px',
borderRadius: '16px',
'& .buttonText': {
width: '110px',
marginLeft: '8px',
},
},
}}
>
<AddIcon sx={{ fontSize: 20 }} />
<span className="buttonText">Add New Folder</span>
</Button>
</Box>
</Box>
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
>
<DialogTitle>Select Directory</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{isLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
<CircularProgress />
</div>
) : (
<List>
{currentPath && (
<ListItem
button
onClick={() => {
const parentPath = currentPath.split(/[/\\]/).slice(0, -1).join('/');
if (parentPath) {
handleDirectoryClick(parentPath);
}
}}
>
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
<ListItemText primary=".." secondary="Go up" />
</ListItem>
)}
{directories.map((dir) => (
<ListItem
key={dir.path}
button
onClick={() => handleDirectoryClick(dir.path)}
>
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
<ListItemText
primary={dir.name}
secondary={dir.path}
/>
</ListItem>
))}
</List>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button
onClick={handleSelect}
variant="contained"
disabled={!currentPath}
>
Select Directory
</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@ -0,0 +1,94 @@
import React from 'react';
import { Box, Typography, List, ListItem, ListItemText, ListItemIcon, IconButton, Tooltip } from '@mui/material';
import { Folder as FolderIcon, Psychology as LLMIcon, ImageSearch as OCRIcon } from '@mui/icons-material';
import { DocumentMetadata } from '../../../electron/types';
import DirectoryPicker from './DirectoryPicker';
import { useFileSystem } from '../../hooks/useFileSystem';
interface FileMetadata {
[path: string]: DocumentMetadata;
}
export default function FileExplorer() {
const { watchedDirectories, watchDirectory, unwatchDirectory } = useFileSystem();
const [fileMetadata, setFileMetadata] = React.useState<FileMetadata>({});
React.useEffect(() => {
// Get document metadata from electron store
window.electron.getDocuments().then((response) => {
if (response.success && response.data) {
const metadata: FileMetadata = {};
response.data.forEach(doc => {
metadata[doc.path] = doc;
});
setFileMetadata(metadata);
}
});
}, [watchedDirectories]);
const handleDirectorySelect = async (dirPath: string) => {
if (!watchedDirectories.includes(dirPath)) {
await watchDirectory(dirPath);
}
};
const handleDirectoryRemove = async (dirPath: string) => {
await unwatchDirectory(dirPath);
};
return (
<Box sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
}}>
<DirectoryPicker onSelect={handleDirectorySelect} />
<Box sx={{ flex: 1, overflow: 'auto' }}>
<List>
{watchedDirectories.map((dir) => (
<ListItem
key={dir}
secondaryAction={
<IconButton
edge="end"
size="small"
onClick={() => handleDirectoryRemove(dir)}
>
×
</IconButton>
}
>
<ListItemIcon sx={{ display: 'flex', gap: 0.5 }}>
<FolderIcon />
{fileMetadata[dir]?.hasEmbeddings && (
<Tooltip title="LLM Embeddings Enabled">
<LLMIcon color="primary" fontSize="small" />
</Tooltip>
)}
{fileMetadata[dir]?.hasOcr && (
<Tooltip title="OCR Enabled">
<OCRIcon color="secondary" fontSize="small" />
</Tooltip>
)}
</ListItemIcon>
<ListItemText
primary={dir.split(/[/\\]/).pop()}
secondary={dir}
secondaryTypographyProps={{
sx: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
}}
/>
</ListItem>
))}
</List>
</Box>
</Box>
);
}

View File

@ -0,0 +1,87 @@
import React from 'react';
import { Box, Typography, Paper, List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
import { Search as SearchIcon, Chat as ChatIcon, Assessment as ReportIcon, Scanner as ScannerIcon } from '@mui/icons-material';
const HomePanel = () => {
return (
<Box sx={{
p: 3,
height: '100%',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0
}}>
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h5" gutterBottom>Welcome to Data Hound</Typography>
<Typography variant="body1" paragraph>
This application helps you search through your files and interact with their contents using AI assistance.
</Typography>
<List>
<ListItem>
<ListItemIcon>
<SearchIcon color="primary" />
</ListItemIcon>
<ListItemText
primary="File Explorer"
secondary="Add directories to search through your files"
/>
</ListItem>
<ListItem>
<ListItemIcon>
<ChatIcon color="primary" />
</ListItemIcon>
<ListItemText
primary="Chat"
secondary="Ask questions about your files and get AI-powered answers"
/>
</ListItem>
<ListItem>
<ListItemIcon>
<ReportIcon color="primary" />
</ListItemIcon>
<ListItemText
primary="Reports"
secondary="Generate detailed reports and statistics about your files"
/>
</ListItem>
<ListItem>
<ListItemIcon>
<ScannerIcon color="primary" />
</ListItemIcon>
<ListItemText
primary="Scanning"
secondary="Scan and index your files for faster searching"
/>
</ListItem>
</List>
</Paper>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Quick Tips</Typography>
<List>
<ListItem>
<ListItemText
primary="• Use the File Explorer to add directories you want to search through"
/>
</ListItem>
<ListItem>
<ListItemText
primary="• Generate reports to understand your file distribution and usage"
/>
</ListItem>
<ListItem>
<ListItemText
primary="• Ask natural language questions about your files in Chat"
/>
</ListItem>
</List>
</Paper>
</Box>
);
};
export default HomePanel;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { Box, Typography, Paper } from '@mui/material';
const ReportingPanel = () => {
return (
<Box sx={{
p: 3,
height: '100%',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0
}}>
<Paper sx={{ p: 3 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>File Statistics</Typography>
<Typography variant="body1" paragraph>
View detailed statistics and insights about your scanned files:
</Typography>
<ul style={{ paddingLeft: '24px' }}>
<li>Total number of files by type</li>
<li>File size distribution</li>
<li>Most common file extensions</li>
<li>Recently modified files</li>
<li>Duplicate file detection</li>
</ul>
</Box>
<Box>
<Typography variant="h6" gutterBottom>Export Options</Typography>
<Typography variant="body1" paragraph>
Export your file analysis reports in various formats:
</Typography>
<ul style={{ paddingLeft: '24px' }}>
<li>CSV for spreadsheet analysis</li>
<li>PDF for documentation</li>
<li>JSON for data processing</li>
</ul>
</Box>
</Paper>
</Box>
);
};
export default ReportingPanel;

View File

@ -0,0 +1,25 @@
import React from 'react';
import { Box, Typography, Paper } from '@mui/material';
const ScanningPanel = () => {
return (
<Box sx={{
p: 3,
height: '100%',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0
}}>
<Paper sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>Scanning</Typography>
<Typography variant="body1">
Scanning functionality will be implemented here.
</Typography>
</Paper>
</Box>
);
};
export default ScanningPanel;

View File

@ -0,0 +1,261 @@
import React, { useState, useEffect } from 'react';
import {
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Alert,
Box,
Typography,
Paper,
Divider,
Switch,
FormControlLabel,
} from '@mui/material';
import { useLLMConfig } from '../../hooks/useLLMConfig';
import { useOllamaModels } from '../../hooks/useOllamaModels';
import type { LLMConfig } from '../../../electron/types';
const defaultConfig: LLMConfig = {
provider: 'ollama',
model: 'jimscard/blackhat-hacker:v2',
baseUrl: 'http://localhost:11434',
temperature: 0.7,
apiKey: undefined
};
export default function SettingsPanel() {
const { config, isLoading, error, updateConfig, reloadConfig } = useLLMConfig();
const ollamaModels = useOllamaModels();
const [formData, setFormData] = useState<LLMConfig>(defaultConfig);
const [isSaving, setIsSaving] = useState(false);
// Reload config when component becomes visible
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
reloadConfig();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [reloadConfig]);
useEffect(() => {
if (!config) {
setFormData(defaultConfig);
return;
}
// Initialize form data with loaded config, only using defaults for missing values
setFormData({
provider: config.provider,
model: config.model || defaultConfig.model,
baseUrl: config.baseUrl || defaultConfig.baseUrl,
temperature: config.temperature ?? defaultConfig.temperature,
apiKey: config.apiKey
});
}, [config]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSaving(true);
await updateConfig(formData);
} catch (error) {
console.error('Failed to save settings:', error);
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{
p: 3,
height: '100%',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0
}}>
{/* Search Settings */}
<Paper sx={{ p: 3, mb: 3 }} >
<Typography variant="h6" gutterBottom>Scan Settings</Typography>
<FormControlLabel
control={<Switch defaultChecked />}
label="Include file contents in search"
sx={{ mb: 2, display: 'block' }}
/>
<FormControlLabel
control={<Switch defaultChecked />}
label="Include file names in search"
sx={{ mb: 2, display: 'block' }}
/>
<FormControlLabel
control={<Switch defaultChecked />}
label="Use OCR to extract text from images/pdfs?"
sx={{ mb: 2, display: 'block' }}
/>
<FormControlLabel
control={<Switch defaultChecked />}
label="Unzip files to check contents?"
sx={{ mb: 2, display: 'block' }}
/>
<TextField
fullWidth
label="File extensions to ignore"
placeholder="e.g., .git, .env, node_modules"
margin="normal"
/>
</Paper>
{/* Search Settings */}
<Paper sx={{ p: 3, mb: 3 }} >
<Typography variant="h6" gutterBottom>Connections</Typography>
<FormControlLabel
control={<Switch disabled />}
label="Google Drive"
sx={{ mb: 2, display: 'block' }}
/>
<FormControlLabel
control={<Switch disabled />}
label="Dropbox"
sx={{ mb: 2, display: 'block' }}
/>
<FormControlLabel
control={<Switch disabled />}
label="Discord"
sx={{ mb: 2, display: 'block' }}
/>
</Paper>
{/* LLM Settings */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>LLM Settings</Typography>
<form onSubmit={handleSubmit}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<FormControl fullWidth margin="normal">
<InputLabel>Provider</InputLabel>
<Select
value={formData.provider || defaultConfig.provider}
label="Provider"
onChange={(e) => setFormData(prev => ({
...prev,
provider: e.target.value as LLMConfig['provider']
}))}
>
<MenuItem value="openai">OpenAI</MenuItem>
<MenuItem value="openrouter">OpenRouter</MenuItem>
<MenuItem value="ollama">Ollama</MenuItem>
</Select>
</FormControl>
{formData.provider !== 'ollama' && (
<TextField
fullWidth
margin="normal"
label="API Key"
type="password"
value={formData.apiKey ?? ''}
onChange={(e) => setFormData(prev => ({
...prev,
apiKey: e.target.value
}))}
/>
)}
{formData.provider === 'ollama' ? (
<FormControl fullWidth margin="normal">
<InputLabel>Model</InputLabel>
<Select
value={formData.model || defaultConfig.model}
label="Model"
onChange={(e) => setFormData(prev => ({
...prev,
model: e.target.value
}))}
displayEmpty
>
<MenuItem value="jimscard/blackhat-hacker:v2">jimscard/blackhat-hacker:v2</MenuItem>
{ollamaModels.models.map((model) => (
model !== 'jimscard/blackhat-hacker:v2' && <MenuItem key={model} value={model}>{model}</MenuItem>
))}
</Select>
{ollamaModels.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{ollamaModels.error}
</Alert>
)}
</FormControl>
) : (
<TextField
fullWidth
margin="normal"
label="Model"
value={formData.model ?? defaultConfig.model}
onChange={(e) => setFormData(prev => ({
...prev,
model: e.target.value
}))}
/>
)}
{formData.provider === 'ollama' && (
<TextField
fullWidth
margin="normal"
label="Base URL"
value={formData.baseUrl ?? defaultConfig.baseUrl}
onChange={(e) => setFormData(prev => ({
...prev,
baseUrl: e.target.value
}))}
/>
)}
<TextField
fullWidth
margin="normal"
label="Temperature"
type="number"
inputProps={{ min: 0, max: 1, step: 0.1 }}
value={formData.temperature ?? defaultConfig.temperature}
onChange={(e) => setFormData(prev => ({
...prev,
temperature: parseFloat(e.target.value)
}))}
/>
<Box sx={{ mt: 3 }}>
<Button
type="submit"
variant="contained"
disabled={isSaving}
fullWidth
>
{isSaving ? <CircularProgress size={24} /> : 'Save LLM Settings'}
</Button>
</Box>
</form>
</Paper>
</Box>
);
}

View File

@ -0,0 +1,24 @@
import React, { createContext, useMemo } from 'react';
export const ElectronContext = createContext<Window['electron'] | null>(null);
ElectronContext.displayName = 'ElectronContext';
interface ElectronProviderProps {
children: React.ReactNode;
}
export const ElectronProvider: React.FC<ElectronProviderProps> = ({ children }) => {
const electronApi = useMemo(() => {
if (typeof window === 'undefined' || !window.electron) {
console.error('Electron API not found in window object');
return null;
}
return window.electron;
}, []);
return (
<ElectronContext.Provider value={electronApi}>
{children}
</ElectronContext.Provider>
);
};

53
electron-file-search/src/electron.d.ts vendored Normal file
View File

@ -0,0 +1,53 @@
import type { LLMConfig, DocumentMetadata } from '../electron/types';
interface Directory {
name: string;
path: string;
}
declare global {
interface Window {
electron: {
// File System Operations
startWatching: (dirPath: string) => Promise<void>;
stopWatching: (dirPath: string) => Promise<void>;
addExcludedPath: (path: string) => Promise<void>;
// LLM Operations
queryLLM: (question: string) => Promise<{
answer: string;
sources: DocumentMetadata[];
}>;
updateLLMConfig: (config: LLMConfig) => Promise<void>;
getLLMConfig: () => Promise<LLMConfig>;
getOllamaModels: () => Promise<{ success: boolean; data?: string[]; error?: string }>;
// Vector Store Operations
getDocuments: () => Promise<{ success: boolean; data?: DocumentMetadata[]; error?: string }>;
addDocument: (content: string, metadata: DocumentMetadata) => Promise<void>;
deleteDocument: (path: string) => Promise<void>;
updateDocument: (content: string, metadata: DocumentMetadata) => Promise<void>;
// File Processing
processFile: (filePath: string) => Promise<void>;
// System Paths
getUserHome: () => Promise<{ success: boolean; data?: string; error?: string }>;
getAppPath: () => Promise<string>;
// Directory Operations
listDirectories: (dirPath: string) => Promise<{ success: boolean; data?: Directory[]; error?: string }>;
// Event Handling
on: (channel: string, callback: (event: unknown, ...args: any[]) => void) => void;
off: (channel: string, callback: (event: unknown, ...args: any[]) => void) => void;
// Window Controls
minimizeWindow: () => Promise<void>;
maximizeWindow: () => Promise<void>;
closeWindow: () => Promise<void>;
};
}
}
export {};

View File

@ -0,0 +1,89 @@
import { useState, useCallback, useEffect } from 'react';
import { useElectron } from './useElectron';
import type { DocumentMetadata } from '../../electron/types';
interface Message {
role: 'user' | 'assistant';
content: string;
sources?: DocumentMetadata[];
}
export function useChat() {
const electron = useElectron();
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
let isStale = false;
const handleChunk = (_: unknown, chunk: string) => {
if (!isStale) {
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
if (lastMessage?.role === 'assistant') {
// Update the last message's content with the new chunk
const updatedMessages = [...prev];
updatedMessages[prev.length - 1] = {
...lastMessage,
content: lastMessage.content + chunk,
};
return updatedMessages;
}
return prev;
});
}
};
electron.on('llm-response-chunk', handleChunk);
return () => {
isStale = true;
electron.off('llm-response-chunk', handleChunk);
};
}, [electron]);
const sendMessage = useCallback(async (content: string) => {
try {
setIsLoading(true);
setMessages(prev => [
...prev,
{ role: 'user', content },
{ role: 'assistant', content: '' }, // Add empty assistant message that will be updated with chunks
]);
const response = await electron.queryLLM(content);
// Final update with response content and sources
setMessages(prev => {
const updatedMessages = [...prev];
const lastMessage = updatedMessages[updatedMessages.length - 1];
if (lastMessage?.role === 'assistant') {
lastMessage.content = lastMessage.content || response.answer;
lastMessage.sources = response.sources;
}
return updatedMessages;
});
} catch (error) {
console.error('Error sending message:', error);
setMessages(prev => {
const updatedMessages = [...prev];
const lastMessage = updatedMessages[updatedMessages.length - 1];
if (lastMessage?.role === 'assistant') {
lastMessage.content = 'Sorry, there was an error processing your request.';
}
return updatedMessages;
});
} finally {
setIsLoading(false);
}
}, [electron]);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
return {
messages,
isLoading,
sendMessage,
clearMessages,
};
}

View File

@ -0,0 +1,10 @@
import { useContext } from 'react';
import { ElectronContext } from '../contexts/ElectronContext';
export const useElectron = () => {
const context = useContext(ElectronContext);
if (!context) {
throw new Error('useElectron must be used within an ElectronProvider');
}
return context;
};

View File

@ -0,0 +1,40 @@
import { useState, useCallback } from 'react';
import { useElectron } from './useElectron';
export function useFileSystem() {
const electron = useElectron();
const [watchedDirectories, setWatchedDirectories] = useState<string[]>([]);
const watchDirectory = useCallback(async (dirPath: string) => {
try {
await electron.startWatching(dirPath);
setWatchedDirectories(prev => [...prev, dirPath]);
} catch (error) {
console.error('Error watching directory:', error);
}
}, [electron]);
const unwatchDirectory = useCallback(async (dirPath: string) => {
try {
await electron.stopWatching(dirPath);
setWatchedDirectories(prev => prev.filter(dir => dir !== dirPath));
} catch (error) {
console.error('Error unwatching directory:', error);
}
}, [electron]);
const addExcludedPath = useCallback(async (path: string) => {
try {
await electron.addExcludedPath(path);
} catch (error) {
console.error('Error adding excluded path:', error);
}
}, [electron]);
return {
watchedDirectories,
watchDirectory,
unwatchDirectory,
addExcludedPath,
};
}

View File

@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from 'react';
import { useElectron } from './useElectron';
import type { LLMConfig } from '../../electron/types';
export function useLLMConfig() {
const electron = useElectron();
const [config, setConfig] = useState<LLMConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setIsLoading(true);
setError(null);
const config = await electron.getLLMConfig().catch(err => {
console.error('Error loading LLM config:', err);
return null;
});
setConfig(config || {
provider: 'ollama',
model: 'phi4',
baseUrl: 'http://localhost:11434',
temperature: 0.7,
apiKey: undefined
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load LLM config');
console.error('Error loading LLM config:', err);
} finally {
setIsLoading(false);
}
};
const updateConfig = useCallback(async (newConfig: LLMConfig) => {
try {
setIsLoading(true);
setError(null);
await electron.updateLLMConfig(newConfig);
setConfig(newConfig);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update LLM config');
console.error('Error updating LLM config:', err);
throw err;
} finally {
setIsLoading(false);
}
}, [electron]);
return {
config,
isLoading,
error,
updateConfig,
reloadConfig: loadConfig,
};
}

View File

@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { useElectron } from './useElectron';
export function useOllamaModels() {
const electron = useElectron();
const [models, setModels] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadModels();
}, []);
const loadModels = async () => {
try {
setIsLoading(true);
setError(null);
const response = await electron.getOllamaModels();
if (response.success) {
setModels(response.data || []);
} else {
throw new Error(response.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Ollama models');
console.error('Error loading Ollama models:', err);
} finally {
setIsLoading(false);
}
};
return {
models,
isLoading,
error,
reloadModels: loadModels,
};
}

View File

@ -0,0 +1,16 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
}
#root {
height: 100%;
width: 100%;
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [
{ "path": "./electron/tsconfig.json" },
{ "path": "./electron/tsconfig.preload.json" }
]
}

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'esnext',
rollupOptions: {
external: ['http', 'https', 'path', 'fs', 'electron']
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5174,
},
clearScreen: false,
});

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

5912
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "test",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.1.6"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"@eslint/eslintrc": "^3"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

18
tailwind.config.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}