Merge main into feature/automatic-discovery-llms-sitemap-430

- Resolved conflicts in progress_mapper.py to include discovery stage (3-4%)
- Resolved conflicts in crawling_service.py to maintain both discovery feature and main improvements
- Resolved conflicts in test_progress_mapper.py to include tests for discovery stage
- Kept all optimizations and improvements from main
- Maintained discovery feature functionality with proper integration
This commit is contained in:
leex279
2025-09-20 09:27:36 +02:00
265 changed files with 28898 additions and 12424 deletions

View File

@@ -53,9 +53,6 @@ VITE_SHOW_DEVTOOLS=false
# proxy where you want to expose the frontend on a single external domain.
PROD=false
# Embedding Configuration
# Dimensions for embedding vectors (1536 for OpenAI text-embedding-3-small)
EMBEDDING_DIMENSIONS=1536
# NOTE: All other configuration has been moved to database management!
# Run the credentials_setup.sql file in your Supabase SQL editor to set up the credentials table.

4
.gitignore vendored
View File

@@ -4,7 +4,11 @@ __pycache__
.claude/settings.local.json
PRPs/local
PRPs/completed/
PRPs/stories/
/logs/
.zed
tmp/
temp/
UAT/
.DS_Store

408
AGENTS.md
View File

@@ -1,4 +1,6 @@
# AGENTS.md
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Beta Development Guidelines
@@ -6,9 +8,13 @@
### Core Principles
- **No backwards compatibility** - remove deprecated code immediately
- **No backwards compatibility; we follow a fixforward approach** remove deprecated code immediately
- **Detailed errors over graceful failures** - we want to identify and fix issues fast
- **Break things to improve them** - beta is for rapid iteration
- **Continuous improvement** - embrace change and learn from mistakes
- **KISS** - keep it simple
- **DRY** when appropriate
- **YAGNI** — don't implement features that are not needed
### Error Handling
@@ -38,51 +44,7 @@ These operations should continue but track and report failures clearly:
#### Critical Nuance: Never Accept Corrupted Data
When a process should continue despite failures, it must **skip the failed item entirely** rather than storing corrupted data:
**❌ WRONG - Silent Corruption:**
```python
try:
embedding = create_embedding(text)
except Exception as e:
embedding = [0.0] * 1536 # NEVER DO THIS - corrupts database
store_document(doc, embedding)
```
**✅ CORRECT - Skip Failed Items:**
```python
try:
embedding = create_embedding(text)
store_document(doc, embedding) # Only store on success
except Exception as e:
failed_items.append({'doc': doc, 'error': str(e)})
logger.error(f"Skipping document {doc.id}: {e}")
# Continue with next document, don't store anything
```
**✅ CORRECT - Batch Processing with Failure Tracking:**
```python
def process_batch(items):
results = {'succeeded': [], 'failed': []}
for item in items:
try:
result = process_item(item)
results['succeeded'].append(result)
except Exception as e:
results['failed'].append({
'item': item,
'error': str(e),
'traceback': traceback.format_exc()
})
logger.error(f"Failed to process {item.id}: {e}")
# Always return both successes and failures
return results
```
When a process should continue despite failures, it must **skip the failed item entirely** rather than storing corrupted data
#### Error Message Guidelines
@@ -96,22 +58,11 @@ def process_batch(items):
### Code Quality
- Remove dead code immediately rather than maintaining it - no backward compatibility or legacy functions
- Prioritize functionality over production-ready patterns
- Avoid backward compatibility mappings or legacy function wrappers
- Fix forward
- Focus on user experience and feature completeness
- When updating code, don't reference what is changing (avoid keywords like LEGACY, CHANGED, REMOVED), instead focus on comments that document just the functionality of the code
## Architecture Overview
Archon V2 Beta is a microservices-based knowledge management system with MCP (Model Context Protocol) integration:
- **Frontend (port 3737)**: React + TypeScript + Vite + TailwindCSS
- **UI Strategy**: Radix UI primitives in `/features`, custom components in legacy `/components`
- **State Management**: TanStack Query for all data fetching in `/features`
- **Styling**: Tron-inspired glassmorphism with Tailwind CSS
- **Main Server (port 8181)**: FastAPI with HTTP polling for updates
- **MCP Server (port 8051)**: Lightweight HTTP-based MCP protocol server
- **Agents Service (port 8052)**: PydanticAI agents for AI/ML operations
- **Database**: Supabase (PostgreSQL + pgvector for embeddings)
- When updating code, don't reference what is changing (avoid keywords like SIMPLIFIED, ENHANCED, LEGACY, CHANGED, REMOVED), instead focus on comments that document just the functionality of the code
- When commenting on code in the codebase, only comment on the functionality and reasoning behind the code. Refrain from speaking to Archon being in "beta" or referencing anything else that comes from these global rules.
## Development Commands
@@ -120,210 +71,134 @@ Archon V2 Beta is a microservices-based knowledge management system with MCP (Mo
```bash
npm run dev # Start development server on port 3737
npm run build # Build for production
npm run lint # Run ESLint
npm run test # Run Vitest tests
npm run test:coverage # Run tests with coverage report
```
npm run lint # Run ESLint on legacy code (excludes /features)
npm run lint:files path/to/file.tsx # Lint specific files
# Biome Linter Guide for AI Assistants
# Biome for /src/features directory only
npm run biome # Check features directory
npm run biome:fix # Auto-fix issues
npm run biome:format # Format code (120 char lines)
npm run biome:ai # Machine-readable JSON output for AI
npm run biome:ai-fix # Auto-fix with JSON output
## Overview
# Testing
npm run test # Run all tests in watch mode
npm run test:ui # Run with Vitest UI interface
npm run test:coverage:stream # Run once with streaming output
vitest run src/features/projects # Test specific directory
This project uses Biome for linting and formatting the `/src/features` directory. Biome provides fast, machine-readable feedback that AI assistants can use to improve code quality.
## Configuration
Biome is configured in `biome.json`:
- **Scope**: Only checks `/src/features/**` directory
- **Formatting**: 2 spaces, 80 char line width
- **Linting**: Recommended rules enabled
- **Import Organization**: Automatically sorts and groups imports
## AI Assistant Workflow in the new /features directory
1. **Check Issues**: Run `npm run biome:ai` to get JSON output
2. **Parse Output**: Extract error locations and types
3. **Apply Fixes**:
- Run `npm run biome:ai-fix` for auto-fixable issues
- Manually fix remaining issues based on patterns above
4. **Verify**: Run `npm run biome:ai` again to confirm fixes
## JSON Output Format
When using `biome:ai`, the output is structured JSON:
```json
{
"diagnostics": [
{
"file": "path/to/file.tsx",
"line": 10,
"column": 5,
"severity": "error",
"message": "Description of the issue",
"rule": "lint/a11y/useButtonType"
}
]
}
# TypeScript
npx tsc --noEmit # Check all TypeScript errors
npx tsc --noEmit 2>&1 | grep "src/features" # Check features only
```
### Backend (python/)
```bash
# Using uv package manager
uv sync # Install/update dependencies
uv run pytest # Run tests
uv run python -m src.server.main # Run server locally
# Using uv package manager (preferred)
uv sync --group all # Install all dependencies
uv run python -m src.server.main # Run server locally on 8181
uv run pytest # Run all tests
uv run pytest tests/test_api_essentials.py -v # Run specific test
uv run ruff check # Run linter
uv run ruff check --fix # Auto-fix linting issues
uv run mypy src/ # Type check
# With Docker
docker-compose up --build -d # Start all services
docker-compose logs -f # View logs
docker-compose restart # Restart services
# Docker operations
docker compose up --build -d # Start all services
docker compose --profile backend up -d # Backend only (for hybrid dev)
docker compose logs -f archon-server # View server logs
docker compose logs -f archon-mcp # View MCP server logs
docker compose restart archon-server # Restart after code changes
docker compose down # Stop all services
docker compose down -v # Stop and remove volumes
```
### Testing
### Quick Workflows
```bash
# Frontend tests (from archon-ui-main/)
npm run test:coverage:stream # Run with streaming output
npm run test:ui # Run with Vitest UI
# Hybrid development (recommended) - backend in Docker, frontend local
make dev # Or manually: docker compose --profile backend up -d && cd archon-ui-main && npm run dev
# Backend tests (from python/)
uv run pytest tests/test_api_essentials.py -v
uv run pytest tests/test_service_integration.py -v
# Full Docker mode
make dev-docker # Or: docker compose up --build -d
# Run linters before committing
make lint # Runs both frontend and backend linters
make lint-fe # Frontend only (ESLint + Biome)
make lint-be # Backend only (Ruff + MyPy)
# Testing
make test # Run all tests
make test-fe # Frontend tests only
make test-be # Backend tests only
```
## Key API Endpoints
## Architecture Overview
### Knowledge Base
@PRPs/ai_docs/ARCHITECTURE.md
- `POST /api/knowledge/crawl` - Crawl a website
- `POST /api/knowledge/upload` - Upload documents (PDF, DOCX, MD)
- `GET /api/knowledge/items` - List knowledge items
- `POST /api/knowledge/search` - RAG search
#### TanStack Query Implementation
### MCP Integration
For architecture and file references:
@PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md
- `GET /api/mcp/health` - MCP server status
- `POST /api/mcp/tools/{tool_name}` - Execute MCP tool
- `GET /api/mcp/tools` - List available tools
For code patterns and examples:
@PRPs/ai_docs/QUERY_PATTERNS.md
### Projects & Tasks (when enabled)
#### Service Layer Pattern
- `GET /api/projects` - List all projects
- `POST /api/projects` - Create project
- `GET /api/projects/{id}` - Get single project
- `PUT /api/projects/{id}` - Update project
- `DELETE /api/projects/{id}` - Delete project
- `GET /api/projects/{id}/tasks` - Get tasks for project (use this, not getTasks)
- `POST /api/tasks` - Create task
- `PUT /api/tasks/{id}` - Update task
- `DELETE /api/tasks/{id}` - Delete task
See implementation examples:
## Polling Architecture
- API routes: `python/src/server/api_routes/projects_api.py`
- Service layer: `python/src/server/services/project_service.py`
- Pattern: API Route → Service → Database
### HTTP Polling (replaced Socket.IO)
#### Error Handling Patterns
- **Polling intervals**: 1-2s for active operations, 5-10s for background data
- **ETag caching**: Reduces bandwidth by ~70% via 304 Not Modified responses
- **Smart pausing**: Stops polling when browser tab is inactive
- **Progress endpoints**: `/api/progress/crawl`, `/api/progress/project-creation`
See implementation examples:
### Key Polling Hooks
- Custom exceptions: `python/src/server/exceptions.py`
- Exception handlers: `python/src/server/main.py` (search for @app.exception_handler)
- Service error handling: `python/src/server/services/` (various services)
- `usePolling` - Generic polling with ETag support
- `useDatabaseMutation` - Optimistic updates with rollback
- `useProjectMutation` - Project-specific operations
## ETag Implementation
## Environment Variables
Required in `.env`:
```bash
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key-here
```
Optional:
```bash
OPENAI_API_KEY=your-openai-key # Can be set via UI
LOGFIRE_TOKEN=your-logfire-token # For observability
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
```
## File Organization
### Frontend Structure
- `src/components/` - Legacy UI components (custom-built)
- `src/features/` - Modern vertical slice architecture with Radix UI
- `ui/primitives/` - Radix UI primitives with Tron glassmorphism
- `projects/` - Project management feature
- `tasks/` - Task management sub-feature
- `src/pages/` - Main application pages
- `src/services/` - API communication and business logic
- `src/hooks/` - Custom React hooks
- `src/contexts/` - React context providers
### UI Libraries
- **Radix UI** (@radix-ui/react-\*) - Unstyled, accessible primitives for `/features`
- **TanStack Query** - Data fetching and caching for `/features`
- **React DnD** - Drag and drop for Kanban boards
- **Tailwind CSS** - Utility-first styling with Tron-inspired glassmorphism
- **Framer Motion** - Animations (minimal usage)
### Theme Management
- **ThemeContext** - Manages light/dark theme state
- **Tailwind dark mode** - Uses `dark:` prefix with selector strategy
- **Automatic switching** - All components respect theme via Tailwind classes
- **Persistent** - Theme choice saved in localStorage
- **Tron aesthetic** - Stronger neon glows in dark mode, subtle in light mode
We're migrating to a vertical slice architecture where each feature is self-contained. Features are organized by domain hierarchy - main features contain their sub-features. For example, tasks are a sub-feature of projects, so they live at `features/projects/tasks/` rather than as separate siblings. Each feature level has its own components, hooks, types, and services folders. This keeps related code together and makes the codebase more maintainable as it scales.
### Backend Structure
- `src/server/` - Main FastAPI application
- `src/server/api_routes/` - API route handlers
- `src/server/services/` - Business logic services
- `src/mcp/` - MCP server implementation
- `src/agents/` - PydanticAI agent implementations
@PRPs/ai_docs/ETAG_IMPLEMENTATION.md
## Database Schema
Key tables in Supabase:
- `sources` - Crawled websites and uploaded documents
- Stores metadata, crawl status, and configuration
- `documents` - Processed document chunks with embeddings
- Text chunks with vector embeddings for semantic search
- `projects` - Project management (optional feature)
- Contains features array, documents, and metadata
- `tasks` - Task tracking linked to projects
- Status: todo, doing, review, done
- Assignee: User, Archon, AI IDE Agent
- `code_examples` - Extracted code snippets
- Language, summary, and relevance metadata
## API Naming Conventions
### Task Status Values
@PRPs/ai_docs/API_NAMING_CONVENTIONS.md
Use database values directly (no UI mapping):
Use database values directly (no mapping in the FE typesafe from BE and up):
- `todo`, `doing`, `review`, `done`
## Environment Variables
### Service Method Patterns
Required in `.env`:
- `get[Resource]sByProject(projectId)` - Scoped queries
- `get[Resource](id)` - Single resource
- `create[Resource](data)` - Create operations
- `update[Resource](id, updates)` - Updates
- `delete[Resource](id)` - Soft deletes
```bash
SUPABASE_URL=https://your-project.supabase.co # Or http://host.docker.internal:8000 for local
SUPABASE_SERVICE_KEY=your-service-key-here # Use legacy key format for cloud Supabase
```
### State Naming
- `is[Action]ing` - Loading states (e.g., `isSwitchingProject`)
- `[resource]Error` - Error messages
- `selected[Resource]` - Current selection
Optional variables and full configuration:
See `python/.env.example` for complete list
## Common Development Tasks
@@ -332,57 +207,96 @@ Use database values directly (no UI mapping):
1. Create route handler in `python/src/server/api_routes/`
2. Add service logic in `python/src/server/services/`
3. Include router in `python/src/server/main.py`
4. Update frontend service in `archon-ui-main/src/services/`
4. Update frontend service in `archon-ui-main/src/features/[feature]/services/`
### Add a new UI component
For **features** directory (preferred for new components):
### Add a new UI component in features directory
1. Use Radix UI primitives from `src/features/ui/primitives/`
2. Create component in relevant feature folder under `src/features/`
3. Use TanStack Query for data fetching
4. Apply Tron-inspired glassmorphism styling with Tailwind
2. Create component in relevant feature folder under `src/features/[feature]/components/`
3. Define types in `src/features/[feature]/types/`
4. Use TanStack Query hook from `src/features/[feature]/hooks/`
5. Apply Tron-inspired glassmorphism styling with Tailwind
For **legacy** components:
### Add or modify MCP tools
1. Create component in `archon-ui-main/src/components/`
2. Add to page in `archon-ui-main/src/pages/`
3. Include any new API calls in services
4. Add tests in `archon-ui-main/test/`
1. MCP tools are in `python/src/mcp_server/features/[feature]/[feature]_tools.py`
2. Follow the pattern:
- `find_[resource]` - Handles list, search, and get single item operations
- `manage_[resource]` - Handles create, update, delete with an "action" parameter
3. Register tools in the feature's `__init__.py` file
### Debug MCP connection issues
1. Check MCP health: `curl http://localhost:8051/health`
2. View MCP logs: `docker-compose logs archon-mcp`
2. View MCP logs: `docker compose logs archon-mcp`
3. Test tool execution via UI MCP page
4. Verify Supabase connection and credentials
### Fix TypeScript/Linting Issues
```bash
# TypeScript errors in features
npx tsc --noEmit 2>&1 | grep "src/features"
# Biome auto-fix for features
npm run biome:fix
# ESLint for legacy code
npm run lint:files src/components/SomeComponent.tsx
```
## Code Quality Standards
We enforce code quality through automated linting and type checking:
### Frontend
- **TypeScript**: Strict mode enabled, no implicit any
- **Biome** for `/src/features/`: 120 char lines, double quotes, trailing commas
- **ESLint** for legacy code: Standard React rules
- **Testing**: Vitest with React Testing Library
### Backend
- **Python 3.12** with 120 character line length
- **Ruff** for linting - checks for errors, warnings, unused imports, and code style
- **Mypy** for type checking - ensures type safety across the codebase
- **Auto-formatting** on save in IDEs to maintain consistent style
- Run `uv run ruff check` and `uv run mypy src/` locally before committing
- **Ruff** for linting - checks for errors, warnings, unused imports
- **Mypy** for type checking - ensures type safety
- **Pytest** for testing with async support
## MCP Tools Available
When connected to Cursor/Windsurf:
When connected to Claude/Cursor/Windsurf, the following tools are available:
- `archon:perform_rag_query` - Search knowledge base
- `archon:search_code_examples` - Find code snippets
- `archon:manage_project` - Project operations
- `archon:manage_task` - Task management
- `archon:get_available_sources` - List knowledge sources
### Knowledge Base Tools
- `archon:rag_search_knowledge_base` - Search knowledge base for relevant content
- `archon:rag_search_code_examples` - Find code snippets in the knowledge base
- `archon:rag_get_available_sources` - List available knowledge sources
### Project Management
- `archon:find_projects` - Find all projects, search, or get specific project (by project_id)
- `archon:manage_project` - Manage projects with actions: "create", "update", "delete"
### Task Management
- `archon:find_tasks` - Find tasks with search, filters, or get specific task (by task_id)
- `archon:manage_task` - Manage tasks with actions: "create", "update", "delete"
### Document Management
- `archon:find_documents` - Find documents, search, or get specific document (by document_id)
- `archon:manage_document` - Manage documents with actions: "create", "update", "delete"
### Version Control
- `archon:find_versions` - Find version history or get specific version
- `archon:manage_version` - Manage versions with actions: "create", "restore"
## Important Notes
- Projects feature is optional - toggle in Settings UI
- All services communicate via HTTP, not gRPC
- HTTP polling handles all updates (Socket.IO removed)
- HTTP polling handles all updates
- Frontend uses Vite proxy for API calls in development
- Python backend uses `uv` for dependency management
- Docker Compose handles service orchestration
- we use tanstack query NO PROP DRILLING! refacring in progress!
- TanStack Query for all data fetching - NO PROP DRILLING
- Vertical slice architecture in `/features` - features own their sub-features

272
CLAUDE.md
View File

@@ -8,9 +8,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### Core Principles
- **No backwards compatibility** - remove deprecated code immediately
- **No backwards compatibility; we follow a fixforward approach** remove deprecated code immediately
- **Detailed errors over graceful failures** - we want to identify and fix issues fast
- **Break things to improve them** - beta is for rapid iteration
- **Continuous improvement** - embrace change and learn from mistakes
- **KISS** - keep it simple
- **DRY** when appropriate
- **YAGNI** — don't implement features that are not needed
### Error Handling
@@ -40,51 +44,7 @@ These operations should continue but track and report failures clearly:
#### Critical Nuance: Never Accept Corrupted Data
When a process should continue despite failures, it must **skip the failed item entirely** rather than storing corrupted data:
**❌ WRONG - Silent Corruption:**
```python
try:
embedding = create_embedding(text)
except Exception as e:
embedding = [0.0] * 1536 # NEVER DO THIS - corrupts database
store_document(doc, embedding)
```
**✅ CORRECT - Skip Failed Items:**
```python
try:
embedding = create_embedding(text)
store_document(doc, embedding) # Only store on success
except Exception as e:
failed_items.append({'doc': doc, 'error': str(e)})
logger.error(f"Skipping document {doc.id}: {e}")
# Continue with next document, don't store anything
```
**✅ CORRECT - Batch Processing with Failure Tracking:**
```python
def process_batch(items):
results = {'succeeded': [], 'failed': []}
for item in items:
try:
result = process_item(item)
results['succeeded'].append(result)
except Exception as e:
results['failed'].append({
'item': item,
'error': str(e),
'traceback': traceback.format_exc()
})
logger.error(f"Failed to process {item.id}: {e}")
# Always return both successes and failures
return results
```
When a process should continue despite failures, it must **skip the failed item entirely** rather than storing corrupted data
#### Error Message Guidelines
@@ -98,9 +58,10 @@ def process_batch(items):
### Code Quality
- Remove dead code immediately rather than maintaining it - no backward compatibility or legacy functions
- Prioritize functionality over production-ready patterns
- Avoid backward compatibility mappings or legacy function wrappers
- Fix forward
- Focus on user experience and feature completeness
- When updating code, don't reference what is changing (avoid keywords like LEGACY, CHANGED, REMOVED), instead focus on comments that document just the functionality of the code
- When updating code, don't reference what is changing (avoid keywords like SIMPLIFIED, ENHANCED, LEGACY, CHANGED, REMOVED), instead focus on comments that document just the functionality of the code
- When commenting on code in the codebase, only comment on the functionality and reasoning behind the code. Refrain from speaking to Archon being in "beta" or referencing anything else that comes from these global rules.
## Development Commands
@@ -175,139 +136,33 @@ make test-be # Backend tests only
## Architecture Overview
Archon Beta is a microservices-based knowledge management system with MCP (Model Context Protocol) integration:
@PRPs/ai_docs/ARCHITECTURE.md
### Service Architecture
#### TanStack Query Implementation
- **Frontend (port 3737)**: React + TypeScript + Vite + TailwindCSS
- **Dual UI Strategy**:
- `/features` - Modern vertical slice with Radix UI primitives + TanStack Query
- `/components` - Legacy custom components (being migrated)
- **State Management**: TanStack Query for all data fetching (no prop drilling)
- **Styling**: Tron-inspired glassmorphism with Tailwind CSS
- **Linting**: Biome for `/features`, ESLint for legacy code
For architecture and file references:
@PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md
- **Main Server (port 8181)**: FastAPI with HTTP polling for updates
- Handles all business logic, database operations, and external API calls
- WebSocket support removed in favor of HTTP polling with ETag caching
- **MCP Server (port 8051)**: Lightweight HTTP-based MCP protocol server
- Provides tools for AI assistants (Claude, Cursor, Windsurf)
- Exposes knowledge search, task management, and project operations
- **Agents Service (port 8052)**: PydanticAI agents for AI/ML operations
- Handles complex AI workflows and document processing
- **Database**: Supabase (PostgreSQL + pgvector for embeddings)
- Cloud or local Supabase both supported
- pgvector for semantic search capabilities
### Frontend Architecture Details
#### Vertical Slice Architecture (/features)
Features are organized by domain hierarchy with self-contained modules:
```
src/features/
├── ui/
│ ├── primitives/ # Radix UI base components
│ ├── hooks/ # Shared UI hooks (useSmartPolling, etc)
│ └── types/ # UI type definitions
├── projects/
│ ├── components/ # Project UI components
│ ├── hooks/ # Project hooks (useProjectQueries, etc)
│ ├── services/ # Project API services
│ ├── types/ # Project type definitions
│ ├── tasks/ # Tasks sub-feature (nested under projects)
│ │ ├── components/
│ │ ├── hooks/ # Task-specific hooks
│ │ ├── services/ # Task API services
│ │ └── types/
│ └── documents/ # Documents sub-feature
│ ├── components/
│ ├── services/
│ └── types/
```
#### TanStack Query Patterns
All data fetching uses TanStack Query with consistent patterns:
```typescript
// Query keys factory pattern
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
detail: (id: string) => [...projectKeys.all, "detail", id] as const,
};
// Smart polling with visibility awareness
const { refetchInterval } = useSmartPolling(10000); // Pauses when tab inactive
// Optimistic updates with rollback
useMutation({
onMutate: async (data) => {
await queryClient.cancelQueries(key);
const previous = queryClient.getQueryData(key);
queryClient.setQueryData(key, optimisticData);
return { previous };
},
onError: (err, vars, context) => {
if (context?.previous) {
queryClient.setQueryData(key, context.previous);
}
},
});
```
### Backend Architecture Details
For code patterns and examples:
@PRPs/ai_docs/QUERY_PATTERNS.md
#### Service Layer Pattern
```python
# API Route -> Service -> Database
# src/server/api_routes/projects.py
@router.get("/{project_id}")
async def get_project(project_id: str):
return await project_service.get_project(project_id)
# src/server/services/project_service.py
async def get_project(project_id: str):
# Business logic here
return await db.fetch_project(project_id)
```
See implementation examples:
- API routes: `python/src/server/api_routes/projects_api.py`
- Service layer: `python/src/server/services/project_service.py`
- Pattern: API Route → Service → Database
#### Error Handling Patterns
```python
# Use specific exceptions
class ProjectNotFoundError(Exception): pass
class ValidationError(Exception): pass
See implementation examples:
- Custom exceptions: `python/src/server/exceptions.py`
- Exception handlers: `python/src/server/main.py` (search for @app.exception_handler)
- Service error handling: `python/src/server/services/` (various services)
# Rich error responses
@app.exception_handler(ProjectNotFoundError)
async def handle_not_found(request, exc):
return JSONResponse(
status_code=404,
content={"detail": str(exc), "type": "not_found"}
)
```
## ETag Implementation
## Polling Architecture
### HTTP Polling (replaced Socket.IO)
- **Polling intervals**: 1-2s for active operations, 5-10s for background data
- **ETag caching**: Reduces bandwidth by ~70% via 304 Not Modified responses
- **Smart pausing**: Stops polling when browser tab is inactive
- **Progress endpoints**: `/api/progress/{id}` for operation tracking
### Key Polling Hooks
- `useSmartPolling` - Adjusts interval based on page visibility/focus
- `useCrawlProgressPolling` - Specialized for crawl progress with auto-cleanup
- `useProjectTasks` - Smart polling for task lists
@PRPs/ai_docs/ETAG_IMPLEMENTATION.md
## Database Schema
@@ -327,25 +182,9 @@ Key tables in Supabase:
## API Naming Conventions
### Task Status Values
@PRPs/ai_docs/API_NAMING_CONVENTIONS.md
Use database values directly (no UI mapping):
- `todo`, `doing`, `review`, `done`
### Service Method Patterns
- `get[Resource]sByProject(projectId)` - Scoped queries
- `get[Resource](id)` - Single resource
- `create[Resource](data)` - Create operations
- `update[Resource](id, updates)` - Updates
- `delete[Resource](id)` - Soft deletes
### State Naming
- `is[Action]ing` - Loading states (e.g., `isSwitchingProject`)
- `[resource]Error` - Error messages
- `selected[Resource]` - Current selection
Use database values directly (no FE mapping; typesafe endtoend from BE upward):
## Environment Variables
@@ -356,15 +195,8 @@ SUPABASE_URL=https://your-project.supabase.co # Or http://host.docker.internal:
SUPABASE_SERVICE_KEY=your-service-key-here # Use legacy key format for cloud Supabase
```
Optional:
```bash
LOGFIRE_TOKEN=your-logfire-token # For observability
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
ARCHON_SERVER_PORT=8181 # Server port
ARCHON_MCP_PORT=8051 # MCP server port
ARCHON_UI_PORT=3737 # Frontend port
```
Optional variables and full configuration:
See `python/.env.example` for complete list
## Common Development Tasks
@@ -383,6 +215,14 @@ ARCHON_UI_PORT=3737 # Frontend port
4. Use TanStack Query hook from `src/features/[feature]/hooks/`
5. Apply Tron-inspired glassmorphism styling with Tailwind
### Add or modify MCP tools
1. MCP tools are in `python/src/mcp_server/features/[feature]/[feature]_tools.py`
2. Follow the pattern:
- `find_[resource]` - Handles list, search, and get single item operations
- `manage_[resource]` - Handles create, update, delete with an "action" parameter
3. Register tools in the feature's `__init__.py` file
### Debug MCP connection issues
1. Check MCP health: `curl http://localhost:8051/health`
@@ -421,22 +261,38 @@ npm run lint:files src/components/SomeComponent.tsx
## MCP Tools Available
When connected to Client/Cursor/Windsurf:
When connected to Claude/Cursor/Windsurf, the following tools are available:
- `archon:perform_rag_query` - Search knowledge base
- `archon:search_code_examples` - Find code snippets
- `archon:create_project` - Create new project
- `archon:list_projects` - List all projects
- `archon:create_task` - Create task in project
- `archon:list_tasks` - List and filter tasks
- `archon:update_task` - Update task status/details
- `archon:get_available_sources` - List knowledge sources
### Knowledge Base Tools
- `archon:rag_search_knowledge_base` - Search knowledge base for relevant content
- `archon:rag_search_code_examples` - Find code snippets in the knowledge base
- `archon:rag_get_available_sources` - List available knowledge sources
### Project Management
- `archon:find_projects` - Find all projects, search, or get specific project (by project_id)
- `archon:manage_project` - Manage projects with actions: "create", "update", "delete"
### Task Management
- `archon:find_tasks` - Find tasks with search, filters, or get specific task (by task_id)
- `archon:manage_task` - Manage tasks with actions: "create", "update", "delete"
### Document Management
- `archon:find_documents` - Find documents, search, or get specific document (by document_id)
- `archon:manage_document` - Manage documents with actions: "create", "update", "delete"
### Version Control
- `archon:find_versions` - Find version history or get specific version
- `archon:manage_version` - Manage versions with actions: "create", "restore"
## Important Notes
- Projects feature is optional - toggle in Settings UI
- All services communicate via HTTP, not gRPC
- HTTP polling handles all updates
- TanStack Query handles all data fetching; smart HTTP polling is used where appropriate (no WebSockets)
- Frontend uses Vite proxy for API calls in development
- Python backend uses `uv` for dependency management
- Docker Compose handles service orchestration

View File

@@ -149,7 +149,7 @@ Test these things using both the UI and the MCP server. This process will be sim
- This creates your own copy of the repository
```bash
# Clone your fork (replace 'your-username' with your GitHub username)
# Clone your fork from main branch for contributing (replace 'your-username' with your GitHub username)
git clone https://github.com/your-username/archon.git
cd archon
@@ -157,6 +157,8 @@ Test these things using both the UI and the MCP server. This process will be sim
git remote add upstream https://github.com/coleam00/archon.git
```
**Note:** The `main` branch is used for contributions and contains the latest development work. The `stable` branch is for users who want a more tested, stable version of Archon.
2. **🤖 AI Coding Assistant Setup**
**IMPORTANT**: If you're using AI coding assistants to help contribute to Archon, set up our global rules for optimal results.
@@ -169,7 +171,7 @@ Test these things using both the UI and the MCP server. This process will be sim
3. **Create Feature Branch**
**Best Practice**: Always create a feature branch rather than working directly on main. This keeps your main branch clean and makes it easier to sync with the upstream repository.
**Best Practice**: Always create a feature branch from main rather than working directly on it. This keeps your main branch clean and makes it easier to sync with the upstream repository.
```bash
git checkout -b feature/your-feature-name

View File

@@ -1,163 +1,249 @@
# API Naming Conventions
## Overview
This document defines the naming conventions used throughout the Archon V2 codebase for consistency and clarity.
## Task Status Values
**Database values only - no UI mapping:**
- `todo` - Task is in backlog/todo state
- `doing` - Task is actively being worked on
- `review` - Task is pending review
- `done` - Task is completed
This document describes the actual naming conventions used throughout Archon's codebase based on current implementation patterns. All examples reference real files where these patterns are implemented.
## Service Method Naming
## Backend API Endpoints
### Project Service (`projectService.ts`)
### RESTful Route Patterns
**Reference**: `python/src/server/api_routes/projects_api.py`
#### Projects
Standard REST patterns used:
- `GET /api/{resource}` - List all resources
- `POST /api/{resource}` - Create new resource
- `GET /api/{resource}/{id}` - Get single resource
- `PUT /api/{resource}/{id}` - Update resource
- `DELETE /api/{resource}/{id}` - Delete resource
Nested resource patterns:
- `GET /api/projects/{project_id}/tasks` - Tasks scoped to project
- `GET /api/projects/{project_id}/docs` - Documents scoped to project
- `POST /api/projects/{project_id}/versions` - Create version for project
### Actual Endpoint Examples
From `python/src/server/api_routes/`:
**Projects** (`projects_api.py`):
- `/api/projects` - Project CRUD
- `/api/projects/{project_id}/features` - Get project features
- `/api/projects/{project_id}/tasks` - Project-scoped tasks
- `/api/projects/{project_id}/docs` - Project documents
- `/api/projects/{project_id}/versions` - Version history
**Knowledge** (`knowledge_api.py`):
- `/api/knowledge/sources` - Knowledge sources
- `/api/knowledge/crawl` - Start web crawl
- `/api/knowledge/upload` - Upload document
- `/api/knowledge/search` - RAG search
- `/api/knowledge/code-search` - Code-specific search
**Progress** (`progress_api.py`):
- `/api/progress/active` - Active operations
- `/api/progress/{operation_id}` - Specific operation status
**MCP** (`mcp_api.py`):
- `/api/mcp/status` - MCP server status
- `/api/mcp/execute` - Execute MCP tool
## Frontend Service Methods
### Service Object Pattern
**Reference**: `archon-ui-main/src/features/projects/services/projectService.ts`
Services are exported as objects with async methods:
```typescript
export const serviceNameService = {
async methodName(): Promise<ReturnType> { ... }
}
```
### Standard Service Method Names
Actual patterns from service files:
**List Operations**:
- `listProjects()` - Get all projects
- `getProject(projectId)` - Get single project by ID
- `createProject(projectData)` - Create new project
- `updateProject(projectId, updates)` - Update project
- `deleteProject(projectId)` - Delete project
- `getTasksByProject(projectId)` - Get filtered list
- `getTasksByStatus(status)` - Get by specific criteria
#### Tasks
- `getTasksByProject(projectId)` - Get all tasks for a specific project
- `getTask(taskId)` - Get single task by ID
- `createTask(taskData)` - Create new task
- `updateTask(taskId, updates)` - Update task with partial data
- `updateTaskStatus(taskId, status)` - Update only task status
- `updateTaskOrder(taskId, newOrder, newStatus?)` - Update task position/order
- `deleteTask(taskId)` - Delete task (soft delete/archive)
- `getTasksByStatus(status)` - Get all tasks with specific status
**Single Item Operations**:
- `getProject(projectId)` - Get single item
- `getTask(taskId)` - Direct ID access
#### Documents
- `getDocuments(projectId)` - Get all documents for project
- `getDocument(projectId, docId)` - Get single document
- `createDocument(projectId, documentData)` - Create document
- `updateDocument(projectId, docId, updates)` - Update document
- `deleteDocument(projectId, docId)` - Delete document
**Create Operations**:
- `createProject(data)` - Returns created entity
- `createTask(data)` - Includes server-generated fields
#### Versions
- `createVersion(projectId, field, content)` - Create version snapshot
- `listVersions(projectId, fieldName?)` - List version history
- `getVersion(projectId, fieldName, versionNumber)` - Get specific version
- `restoreVersion(projectId, fieldName, versionNumber)` - Restore version
**Update Operations**:
- `updateProject(id, updates)` - Partial updates
- `updateTaskStatus(id, status)` - Specific field update
- `updateTaskOrder(id, order, status?)` - Complex updates
## API Endpoint Patterns
**Delete Operations**:
- `deleteProject(id)` - Returns void
- `deleteTask(id)` - Soft delete pattern
### RESTful Endpoints
```
GET /api/projects - List all projects
POST /api/projects - Create project
GET /api/projects/{project_id} - Get project
PUT /api/projects/{project_id} - Update project
DELETE /api/projects/{project_id} - Delete project
### Service File Locations
- **Projects**: `archon-ui-main/src/features/projects/services/projectService.ts`
- **Tasks**: `archon-ui-main/src/features/projects/tasks/services/taskService.ts`
- **Knowledge**: `archon-ui-main/src/features/knowledge/services/knowledgeService.ts`
- **Progress**: `archon-ui-main/src/features/progress/services/progressService.ts`
GET /api/projects/{project_id}/tasks - Get project tasks
POST /api/tasks - Create task (project_id in body)
GET /api/tasks/{task_id} - Get task
PUT /api/tasks/{task_id} - Update task
DELETE /api/tasks/{task_id} - Delete task
## React Hook Naming
GET /api/projects/{project_id}/docs - Get project documents
POST /api/projects/{project_id}/docs - Create document
GET /api/projects/{project_id}/docs/{doc_id} - Get document
PUT /api/projects/{project_id}/docs/{doc_id} - Update document
DELETE /api/projects/{project_id}/docs/{doc_id} - Delete document
```
### Query Hooks
**Reference**: `archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts`
### Progress/Polling Endpoints
```
GET /api/progress/{operation_id} - Generic operation progress
GET /api/knowledge/crawl-progress/{id} - Crawling progress
GET /api/agent-chat/sessions/{id}/messages - Chat messages
```
Standard patterns:
- `use[Resource]()` - List query (e.g., `useProjects`)
- `use[Resource]Detail(id)` - Single item query
- `use[Parent][Resource](parentId)` - Scoped query (e.g., `useProjectTasks`)
### Mutation Hooks
- `useCreate[Resource]()` - Creation mutation
- `useUpdate[Resource]()` - Update mutation
- `useDelete[Resource]()` - Deletion mutation
### Utility Hooks
**Reference**: `archon-ui-main/src/features/ui/hooks/`
- `useSmartPolling()` - Visibility-aware polling
- `useToast()` - Toast notifications
- `useDebounce()` - Debounced values
## Type Naming Conventions
### Type Definition Patterns
**Reference**: `archon-ui-main/src/features/projects/types/`
**Entity Types**:
- `Project` - Core entity type
- `Task` - Business object
- `Document` - Data model
**Request/Response Types**:
- `Create[Entity]Request` - Creation payload
- `Update[Entity]Request` - Update payload
- `[Entity]Response` - API response wrapper
**Database Types**:
- `DatabaseTaskStatus` - Exact database values
**Location**: `archon-ui-main/src/features/projects/tasks/types/task.ts`
Values: `"todo" | "doing" | "review" | "done"`
### Type File Organization
Following vertical slice architecture:
- Core types in `{feature}/types/`
- Sub-feature types in `{feature}/{subfeature}/types/`
- Shared types in `shared/types/`
## Query Key Factories
**Reference**: Each feature's `hooks/use{Feature}Queries.ts` file
Standard factory pattern:
- `{resource}Keys.all` - Base key for invalidation
- `{resource}Keys.lists()` - List queries
- `{resource}Keys.detail(id)` - Single item queries
- `{resource}Keys.byProject(projectId)` - Scoped queries
Examples:
- `projectKeys` - Projects domain
- `taskKeys` - Tasks (dual nature: global and project-scoped)
- `knowledgeKeys` - Knowledge base
- `progressKeys` - Progress tracking
- `documentKeys` - Document management
## Component Naming
### Hooks
- `use[Feature]` - Custom hooks (e.g., `usePolling`, `useProjectMutation`)
- Returns object with: `{ data, isLoading, error, refetch }`
### Page Components
**Location**: `archon-ui-main/src/pages/`
- `[Feature]Page.tsx` - Top-level pages
- `[Feature]View.tsx` - Main view components
### Services
- `[feature]Service` - Service modules (e.g., `projectService`, `crawlProgressService`)
- Methods return Promises with typed responses
### Feature Components
**Location**: `archon-ui-main/src/features/{feature}/components/`
- `[Entity]Card.tsx` - Card displays
- `[Entity]List.tsx` - List containers
- `[Entity]Form.tsx` - Form components
- `New[Entity]Modal.tsx` - Creation modals
- `Edit[Entity]Modal.tsx` - Edit modals
### Components
- `[Feature][Type]` - UI components (e.g., `TaskBoardView`, `EditTaskModal`)
- Props interfaces: `[Component]Props`
### Shared Components
**Location**: `archon-ui-main/src/features/ui/primitives/`
- Radix UI-based primitives
- Generic, reusable components
## State Variable Naming
### Loading States
- `isLoading[Feature]` - Boolean loading indicators
- `isSwitchingProject` - Specific operation states
- `movingTaskIds` - Set/Array of items being processed
**Examples from**: `archon-ui-main/src/features/projects/views/ProjectsView.tsx`
- `isLoading` - Generic loading
- `is[Action]ing` - Specific operations (e.g., `isSwitchingProject`)
- `[action]ingIds` - Sets of items being processed
### Error States
- `[feature]Error` - Error message strings
- `taskOperationError` - Specific operation errors
- `error` - Query errors
- `[operation]Error` - Specific operation errors
### Data States
- `[feature]s` - Plural for collections (e.g., `tasks`, `projects`)
- `selected[Feature]` - Currently selected item
- `[feature]Data` - Raw data from API
### Selection States
- `selected[Entity]` - Currently selected item
- `active[Entity]Id` - Active item ID
## Type Definitions
## Constants and Enums
### Database Types (from backend)
```typescript
type DatabaseTaskStatus = 'todo' | 'doing' | 'review' | 'done';
type Assignee = 'User' | 'Archon' | 'AI IDE Agent';
```
### Status Values
**Location**: `archon-ui-main/src/features/projects/tasks/types/task.ts`
Database values used directly - no mapping layers:
- Task statuses: `"todo"`, `"doing"`, `"review"`, `"done"`
- Operation statuses: `"pending"`, `"processing"`, `"completed"`, `"failed"`
### Request/Response Types
```typescript
Create[Feature]Request // e.g., CreateTaskRequest
Update[Feature]Request // e.g., UpdateTaskRequest
[Feature]Response // e.g., TaskResponse
```
### Time Constants
**Location**: `archon-ui-main/src/features/shared/queryPatterns.ts`
- `STALE_TIMES.instant` - 0ms
- `STALE_TIMES.realtime` - 3 seconds
- `STALE_TIMES.frequent` - 5 seconds
- `STALE_TIMES.normal` - 30 seconds
- `STALE_TIMES.rare` - 5 minutes
- `STALE_TIMES.static` - Infinity
## Function Naming Patterns
## File Naming Patterns
### Event Handlers
- `handle[Event]` - Generic handlers (e.g., `handleProjectSelect`)
- `on[Event]` - Props callbacks (e.g., `onTaskMove`, `onRefresh`)
### Service Layer
- `{feature}Service.ts` - Service modules
- Use lower camelCase with "Service" suffix (e.g., `projectService.ts`)
### Operations
- `load[Feature]` - Fetch data (e.g., `loadTasksForProject`)
- `save[Feature]` - Persist changes (e.g., `saveTask`)
- `delete[Feature]` - Remove items (e.g., `deleteTask`)
- `refresh[Feature]` - Reload data (e.g., `refreshTasks`)
### Hook Files
- `use{Feature}Queries.ts` - Query hooks and keys
- `use{Feature}.ts` - Feature-specific hooks
### Formatting/Transformation
- `format[Feature]` - Format for display (e.g., `formatTask`)
- `validate[Feature]` - Validate data (e.g., `validateUpdateTask`)
### Type Files
- `index.ts` - Barrel exports
- `{entity}.ts` - Specific entity types
### Test Files
- `{filename}.test.ts` - Unit tests
- Located in `tests/` subdirectories
## Best Practices
### Do Use
- `getTasksByProject(projectId)` - Clear scope with context
- `status` - Single source of truth from database
- Direct database values everywhere (no mapping)
- Polling with `usePolling` hook for data fetching
- Async/await with proper error handling
- ETag headers for efficient polling
- Loading indicators during operations
### Do Follow
- Use exact database values (no translation layers)
- Keep consistent patterns within features
- Use query key factories for all cache operations
- Follow vertical slice architecture
- Reference shared constants
## Current Architecture Patterns
### Don't Do
- Don't create mapping layers for database values
- Don't hardcode time values
- Don't mix query keys between features
- Don't use inconsistent naming within a feature
- Don't embed business logic in components
### Polling & Data Fetching
- HTTP polling with `usePolling` and `useCrawlProgressPolling` hooks
- ETag-based caching for bandwidth efficiency
- Loading state indicators (`isLoading`, `isSwitchingProject`)
- Error toast notifications for user feedback
- Manual refresh triggers via `refetch()`
- Immediate UI updates followed by API calls
## Common Patterns Reference
### Service Architecture
- Specialized services for different domains (`projectService`, `crawlProgressService`)
- Direct database value usage (no UI/DB mapping)
- Promise-based async operations
- Typed request/response interfaces
For implementation examples, see:
- Query patterns: Any `use{Feature}Queries.ts` file
- Service patterns: Any `{feature}Service.ts` file
- Type patterns: Any `{feature}/types/` directory
- Component patterns: Any `{feature}/components/` directory

View File

@@ -2,480 +2,194 @@
## Overview
Archon follows a **Vertical Slice Architecture** pattern where features are organized by business capability rather than technical layers. Each module is self-contained with its own API, business logic, and data access, making the system modular, maintainable, and ready for future microservice extraction if needed.
Archon is a knowledge management system with AI capabilities, built as a monolithic application with vertical slice organization. The frontend uses React with TanStack Query, while the backend runs FastAPI with multiple service components.
## Core Principles
## Tech Stack
1. **Feature Cohesion**: All code for a feature lives together
2. **Module Independence**: Modules communicate through well-defined interfaces
3. **Vertical Slices**: Each feature contains its complete stack (API → Service → Repository)
4. **Shared Minimal**: Only truly cross-cutting concerns go in shared
5. **Migration Ready**: Structure supports easy extraction to microservices
**Frontend**: React 18, TypeScript 5, TanStack Query v5, Tailwind CSS, Vite
**Backend**: Python 3.12, FastAPI, Supabase, PydanticAI
**Infrastructure**: Docker, PostgreSQL + pgvector
## Directory Structure
```
archon/
├── python/
│ ├── src/
│ │ ├── knowledge/ # Knowledge Management Module
├── __init__.py
│ │ │ ├── main.py # Knowledge module entry point
│ │ │ ├── shared/ # Shared within knowledge context
├── models.py
│ │ │ │ ├── exceptions.py
│ └── utils.py
│ │ │ └── features/ # Knowledge feature slices
│ │ │ ├── crawling/ # Web crawling feature
│ ├── __init__.py
│ │ │ │ ├── api.py # Crawl endpoints
│ │ │ │ ├── service.py # Crawling orchestration
│ │ │ │ ├── models.py # Crawl-specific models
│ │ │ │ ├── repository.py # Crawl data storage
│ │ │ │ └── tests/
│ │ │ ├── document_processing/ # Document upload & processing
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Upload endpoints
│ │ │ │ ├── service.py # PDF/DOCX processing
│ │ │ │ ├── extractors.py # Text extraction
│ │ │ │ └── tests/
│ │ │ ├── embeddings/ # Vector embeddings
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Embedding endpoints
│ │ │ │ ├── service.py # OpenAI/local embeddings
│ │ │ │ ├── models.py
│ │ │ │ └── repository.py # Vector storage
│ │ │ ├── search/ # RAG search
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Search endpoints
│ │ │ │ ├── service.py # Search algorithms
│ │ │ │ ├── reranker.py # Result reranking
│ │ │ │ └── tests/
│ │ │ ├── code_extraction/ # Code snippet extraction
│ │ │ │ ├── __init__.py
│ │ │ │ ├── service.py # Code parsing
│ │ │ │ ├── analyzers.py # Language detection
│ │ │ │ └── repository.py
│ │ │ └── source_management/ # Knowledge source CRUD
│ │ │ ├── __init__.py
│ │ │ ├── api.py
│ │ │ ├── service.py
│ │ │ └── repository.py
│ │ │
│ │ ├── projects/ # Project Management Module
│ │ │ ├── __init__.py
│ │ │ ├── main.py # Projects module entry point
│ │ │ ├── shared/ # Shared within projects context
│ │ │ │ ├── database.py # Project DB utilities
│ │ │ │ ├── models.py # Shared project models
│ │ │ │ └── exceptions.py # Project-specific exceptions
│ │ │ └── features/ # Project feature slices
│ │ │ ├── project_management/ # Project CRUD
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Project endpoints
│ │ │ │ ├── service.py # Project business logic
│ │ │ │ ├── models.py # Project models
│ │ │ │ ├── repository.py # Project DB operations
│ │ │ │ └── tests/
│ │ │ ├── task_management/ # Task CRUD
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Task endpoints
│ │ │ │ ├── service.py # Task business logic
│ │ │ │ ├── models.py # Task models
│ │ │ │ ├── repository.py # Task DB operations
│ │ │ │ └── tests/
│ │ │ ├── task_ordering/ # Drag-and-drop reordering
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Reorder endpoints
│ │ │ │ ├── service.py # Reordering algorithm
│ │ │ │ └── tests/
│ │ │ ├── document_management/ # Project documents
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Document endpoints
│ │ │ │ ├── service.py # Document logic
│ │ │ │ ├── models.py
│ │ │ │ └── repository.py
│ │ │ ├── document_versioning/ # Version control
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Version endpoints
│ │ │ │ ├── service.py # Versioning logic
│ │ │ │ ├── models.py # Version models
│ │ │ │ └── repository.py # Version storage
│ │ │ ├── ai_generation/ # AI project creation
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Generate endpoints
│ │ │ │ ├── service.py # AI orchestration
│ │ │ │ ├── agents.py # Agent interactions
│ │ │ │ ├── progress.py # Progress tracking
│ │ │ │ └── prompts.py # Generation prompts
│ │ │ ├── source_linking/ # Link to knowledge base
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Link endpoints
│ │ │ │ ├── service.py # Linking logic
│ │ │ │ └── repository.py # Junction table ops
│ │ │ └── bulk_operations/ # Batch updates
│ │ │ ├── __init__.py
│ │ │ ├── api.py # Bulk endpoints
│ │ │ ├── service.py # Batch processing
│ │ │ └── tests/
│ │ │
│ │ ├── mcp_server/ # MCP Protocol Server (IDE Integration)
│ │ │ ├── __init__.py
│ │ │ ├── main.py # MCP server entry point
│ │ │ ├── server.py # FastMCP server setup
│ │ │ ├── features/ # MCP tool implementations
│ │ │ │ ├── projects/ # Project tools for IDEs
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── project_tools.py
│ │ │ │ │ └── tests/
│ │ │ │ ├── tasks/ # Task tools for IDEs
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── task_tools.py
│ │ │ │ │ └── tests/
│ │ │ │ ├── documents/ # Document tools for IDEs
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── document_tools.py
│ │ │ │ │ ├── version_tools.py
│ │ │ │ │ └── tests/
│ │ │ │ └── feature_tools.py # Feature management
│ │ │ ├── modules/ # MCP modules
│ │ │ │ └── archon.py # Main Archon MCP module
│ │ │ └── utils/ # MCP utilities
│ │ │ └── tool_utils.py
│ │ │
│ │ ├── agents/ # AI Agents Module
│ │ │ ├── __init__.py
│ │ │ ├── main.py # Agents module entry point
│ │ │ ├── config.py # Agent configurations
│ │ │ ├── features/ # Agent capabilities
│ │ │ │ ├── document_agent/ # Document processing agent
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── agent.py # PydanticAI agent
│ │ │ │ │ ├── prompts.py # Agent prompts
│ │ │ │ │ └── tools.py # Agent tools
│ │ │ │ ├── code_agent/ # Code analysis agent
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── agent.py
│ │ │ │ │ └── analyzers.py
│ │ │ │ └── project_agent/ # Project creation agent
│ │ │ │ ├── __init__.py
│ │ │ │ ├── agent.py
│ │ │ │ ├── prp_generator.py
│ │ │ │ └── task_generator.py
│ │ │ └── shared/ # Shared agent utilities
│ │ │ ├── base_agent.py
│ │ │ ├── llm_client.py
│ │ │ └── response_models.py
│ │ │
│ │ ├── shared/ # Shared Across All Modules
│ │ │ ├── database/ # Database utilities
│ │ │ │ ├── __init__.py
│ │ │ │ ├── supabase.py # Supabase client
│ │ │ │ ├── migrations.py # DB migrations
│ │ │ │ └── connection_pool.py
│ │ │ ├── auth/ # Authentication
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api_keys.py
│ │ │ │ └── permissions.py
│ │ │ ├── config/ # Configuration
│ │ │ │ ├── __init__.py
│ │ │ │ ├── settings.py # Environment settings
│ │ │ │ └── logfire_config.py # Logging config
│ │ │ ├── middleware/ # HTTP middleware
│ │ │ │ ├── __init__.py
│ │ │ │ ├── cors.py
│ │ │ │ └── error_handler.py
│ │ │ └── utils/ # General utilities
│ │ │ ├── __init__.py
│ │ │ ├── datetime_utils.py
│ │ │ └── json_utils.py
│ │ │
│ │ └── main.py # Application orchestrator
│ │
│ └── tests/ # Integration tests
│ ├── test_api_essentials.py
│ ├── test_service_integration.py
│ └── fixtures/
├── archon-ui-main/ # Frontend Application
│ ├── src/
│ │ ├── pages/ # Page components
│ │ │ ├── KnowledgeBasePage.tsx
│ │ │ ├── ProjectPage.tsx
│ │ │ ├── SettingsPage.tsx
│ │ │ └── MCPPage.tsx
│ │ ├── components/ # Reusable components
│ │ │ ├── knowledge-base/ # Knowledge features
│ │ │ ├── project-tasks/ # Project features
│ │ │ └── ui/ # Shared UI components
│ │ ├── services/ # API services
│ │ │ ├── api.ts # Base API client
│ │ │ ├── knowledgeBaseService.ts
│ │ │ ├── projectService.ts
│ │ │ └── pollingService.ts # New polling utilities
│ │ ├── hooks/ # React hooks
│ │ │ ├── usePolling.ts # Polling hook
│ │ │ ├── useDatabaseMutation.ts # DB-first mutations
│ │ │ └── useAsyncAction.ts
│ │ └── contexts/ # React contexts
│ │ ├── ToastContext.tsx
│ │ └── ThemeContext.tsx
│ │
│ └── tests/ # Frontend tests
├── PRPs/ # Product Requirement Prompts
│ ├── templates/ # PRP templates
│ ├── ai_docs/ # AI context documentation
│ └── *.md # Feature PRPs
├── docs/ # Documentation
│ └── architecture/ # Architecture decisions
└── docker/ # Docker configurations
├── Dockerfile
└── docker-compose.yml
### Backend (`python/src/`)
```text
server/ # Main FastAPI application
├── api_routes/ # HTTP endpoints
├── services/ # Business logic
├── models/ # Data models
├── config/ # Configuration
├── middleware/ # Request processing
└── utils/ # Shared utilities
mcp_server/ # MCP server for IDE integration
└── features/ # MCP tool implementations
agents/ # AI agents (PydanticAI)
└── features/ # Agent capabilities
```
## Module Descriptions
### Frontend (`archon-ui-main/src/`)
```text
features/ # Vertical slice architecture
├── knowledge/ # Knowledge base feature
├── projects/ # Project management
│ ├── tasks/ # Task sub-feature
│ └── documents/ # Document sub-feature
├── progress/ # Operation tracking
├── mcp/ # MCP integration
├── shared/ # Cross-feature utilities
└── ui/ # UI components & hooks
### Knowledge Module (`src/knowledge/`)
Core knowledge management functionality including web crawling, document processing, embeddings, and RAG search. This is the heart of Archon's knowledge engine.
**Key Features:**
- Web crawling with JavaScript rendering
- Document upload and text extraction
- Vector embeddings and similarity search
- Code snippet extraction and indexing
- Source management and organization
### Projects Module (`src/projects/`)
Project and task management system with AI-powered project generation. Currently optional via feature flag.
**Key Features:**
- Project CRUD operations
- Task management with drag-and-drop ordering
- Document management with versioning
- AI-powered project generation
- Integration with knowledge base sources
### MCP Server Module (`src/mcp_server/`)
Model Context Protocol server that exposes Archon functionality to IDEs like Cursor and Windsurf.
**Key Features:**
- Tool-based API for IDE integration
- Project and task management tools
- Document operations
- Async operation support
### Agents Module (`src/agents/`)
AI agents powered by PydanticAI for intelligent document processing and project generation.
**Key Features:**
- Document analysis and summarization
- Code understanding and extraction
- Project requirement generation
- Task breakdown and planning
### Shared Module (`src/shared/`)
Cross-cutting concerns shared across all modules. Kept minimal to maintain module independence.
**Key Components:**
- Database connections and utilities
- Authentication and authorization
- Configuration management
- Logging and observability
- Common middleware
## Communication Patterns
### Inter-Module Communication
Modules communicate through:
1. **Direct HTTP API Calls** (current)
- Projects module calls Knowledge module APIs
- Simple and straightforward
- Works well for current scale
2. **Event Bus** (future consideration)
```python
# Example event-driven communication
await event_bus.publish("project.created", {
"project_id": "123",
"created_by": "user"
})
```
3. **Shared Database** (current reality)
- All modules use same Supabase instance
- Direct foreign keys between contexts
- Will need refactoring for true microservices
## Feature Flags
Features can be toggled via environment variables:
```python
# settings.py
PROJECTS_ENABLED = env.bool("PROJECTS_ENABLED", default=False)
TASK_ORDERING_ENABLED = env.bool("TASK_ORDERING_ENABLED", default=True)
AI_GENERATION_ENABLED = env.bool("AI_GENERATION_ENABLED", default=True)
pages/ # Route components
components/ # Legacy components (migrating)
```
## Database Architecture
## Core Modules
Currently using a shared Supabase (PostgreSQL) database:
### Knowledge Management
**Backend**: `python/src/server/services/knowledge_service.py`
**Frontend**: `archon-ui-main/src/features/knowledge/`
**Features**: Web crawling, document upload, embeddings, RAG search
```sql
-- Knowledge context tables
sources
documents
code_examples
### Project Management
**Backend**: `python/src/server/services/project_*_service.py`
**Frontend**: `archon-ui-main/src/features/projects/`
**Features**: Projects, tasks, documents, version history
-- Projects context tables
archon_projects
archon_tasks
archon_document_versions
### MCP Server
**Location**: `python/src/mcp_server/`
**Purpose**: Exposes tools to AI IDEs (Cursor, Windsurf)
**Port**: 8051
-- Cross-context junction tables
archon_project_sources -- Links projects to knowledge
```
### AI Agents
**Location**: `python/src/agents/`
**Purpose**: Document processing, code analysis, project generation
**Port**: 8052
## API Structure
Each feature exposes its own API routes:
### RESTful Endpoints
Pattern: `{METHOD} /api/{resource}/{id?}/{sub-resource?}`
```
/api/knowledge/
/crawl # Web crawling
/upload # Document upload
/search # RAG search
/sources # Source management
**Examples from** `python/src/server/api_routes/`:
- `/api/projects` - CRUD operations
- `/api/projects/{id}/tasks` - Nested resources
- `/api/knowledge/search` - RAG search
- `/api/progress/{id}` - Operation status
/api/projects/
/projects # Project CRUD
/tasks # Task management
/tasks/reorder # Task ordering
/documents # Document management
/generate # AI generation
### Service Layer
**Pattern**: `python/src/server/services/{feature}_service.py`
- Handles business logic
- Database operations via Supabase client
- Returns typed responses
## Frontend Architecture
### Data Fetching
**Core**: TanStack Query v5
**Configuration**: `archon-ui-main/src/features/shared/queryClient.ts`
**Patterns**: `archon-ui-main/src/features/shared/queryPatterns.ts`
### State Management
- **Server State**: TanStack Query
- **UI State**: React hooks & context
- **No Redux/Zustand**: Query cache handles all data
### Feature Organization
Each feature follows vertical slice pattern:
```text
features/{feature}/
├── components/ # UI components
├── hooks/ # Query hooks & keys
├── services/ # API calls
└── types/ # TypeScript types
```
## Deployment Architecture
### Smart Polling
**Implementation**: `archon-ui-main/src/features/ui/hooks/useSmartPolling.ts`
- Visibility-aware (pauses when tab hidden)
- Variable intervals based on focus state
### Current mixed
## Database
### Future (service modules)
**Provider**: Supabase (PostgreSQL + pgvector)
**Client**: `python/src/server/config/database.py`
Each module can become its own service:
### Main Tables
- `sources` - Knowledge sources
- `documents` - Document chunks with embeddings
- `code_examples` - Extracted code
- `archon_projects` - Projects
- `archon_tasks` - Tasks
- `archon_document_versions` - Version history
```yaml
# docker-compose.yml (future)
services:
knowledge:
image: archon-knowledge
ports: ["8001:8000"]
## Key Architectural Decisions
projects:
image: archon-projects
ports: ["8002:8000"]
### Vertical Slices
Features own their entire stack (UI → API → DB). See any `features/{feature}/` directory.
mcp-server:
image: archon-mcp
ports: ["8051:8051"]
### No WebSockets
HTTP polling with smart intervals. ETag caching reduces bandwidth by ~70%.
agents:
image: archon-agents
ports: ["8052:8052"]
### Query-First State
TanStack Query is the single source of truth. No separate state management needed.
### Direct Database Values
No translation layers. Database values (e.g., `"todo"`, `"doing"`) used directly in UI.
### Browser-Native Caching
ETags handled by browser, not JavaScript. See `archon-ui-main/src/features/shared/apiWithEtag.ts`.
## Deployment
### Development
```bash
# Backend
docker compose up -d
# or
cd python && uv run python -m src.server.main
# Frontend
cd archon-ui-main && npm run dev
```
## Migration Path
### Production
Single Docker Compose deployment with all services.
### Phase 1: Current State (Modules/service)
## Configuration
- All code in one repository
- Shared database
- Single deployment
### Environment Variables
**Required**: `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`
**Optional**: See `.env.example`
### Phase 2: Vertical Slices
### Feature Flags
Controlled via Settings UI. Projects feature can be disabled.
- Reorganize by feature
- Clear module boundaries
- Feature flags for control
## Recent Refactors (Phases 1-5)
## Development Guidelines
1. **Removed ETag cache layer** - Browser handles HTTP caching
2. **Standardized query keys** - Each feature owns its keys
3. **Fixed optimistic updates** - UUID-based with nanoid
4. **Configured deduplication** - Centralized QueryClient
5. **Removed manual invalidations** - Trust backend consistency
### Adding a New Feature
## Performance Optimizations
1. **Identify the Module**: Which bounded context does it belong to?
2. **Create Feature Slice**: New folder under `module/features/`
3. **Implement Vertical Slice**:
- `api.py` - HTTP endpoints
- `service.py` - Business logic
- `models.py` - Data models
- `repository.py` - Data access
- `tests/` - Feature tests
- **Request Deduplication**: Same query key = one request
- **Smart Polling**: Adapts to tab visibility
- **ETag Caching**: 70% bandwidth reduction
- **Optimistic Updates**: Instant UI feedback
### Testing Strategy
## Testing
- **Unit Tests**: Each feature has its own tests
- **Integration Tests**: Test module boundaries
- **E2E Tests**: Test complete user flows
### Code Organization Rules
1. **Features are Self-Contained**: All code for a feature lives together
2. **No Cross-Feature Imports**: Use module's shared or API calls
3. **Shared is Minimal**: Only truly cross-cutting concerns
4. **Dependencies Point Inward**: Features → Module Shared → Global Shared
## Technology Stack
### Backend
- **FastAPI**: Web framework
- **Supabase**: Database and auth
- **PydanticAI**: AI agents
- **OpenAI**: Embeddings and LLM
- **Crawl4AI**: Web crawling
### Frontend
- **React**: UI framework
- **TypeScript**: Type safety
- **TailwindCSS**: Styling
- **React Query**: Data fetching
- **Vite**: Build tool
### Infrastructure
- **Docker**: Containerization
- **PostgreSQL**: Database (via Supabase, desire to support any PostgreSQL)
- **pgvector**: Vector storage, Desire to support ChromaDB, Pinecone, Weaviate, etc.
**Frontend Tests**: `archon-ui-main/src/features/*/tests/`
**Backend Tests**: `python/tests/`
**Patterns**: Mock services and query patterns, not implementation
## Future Considerations
### Planned Improvements
1. **Remove Socket.IO**: Replace with polling (in progress)
2. **API Gateway**: Central entry point for all services
3. **Separate Databases**: One per bounded context
### Scalability Path
1. **Vertical Scaling**: Current approach, works for single-user
2. **Horizontal Scaling**: Add load balancer and multiple instances
---
This architecture provides a clear path from the current monolithic application to a more modular approach with vertical slicing, for easy potential to service separation if needed.
- Server-Sent Events for real-time updates
- GraphQL for selective field queries
- Separate databases per bounded context
- Multi-tenant support

View File

@@ -0,0 +1,192 @@
# Data Fetching Architecture
## Overview
Archon uses **TanStack Query v5** for all data fetching, caching, and synchronization. This replaces the former custom polling layer with a querycentric design that handles caching, deduplication, and smart refetching (including visibilityaware polling) automatically.
## Core Components
### 1. Query Client Configuration
**Location**: `archon-ui-main/src/features/shared/queryClient.ts`
Centralized QueryClient with:
- 30-second default stale time
- 10-minute garbage collection
- Smart retry logic (skips 4xx errors)
- Request deduplication enabled
- Structural sharing for optimized re-renders
### 2. Smart Polling Hook
**Location**: `archon-ui-main/src/features/ui/hooks/useSmartPolling.ts`
Visibility-aware polling that:
- Pauses when browser tab is hidden
- Slows down (1.5x interval) when tab is unfocused
- Returns `refetchInterval` for use with TanStack Query
### 3. Query Patterns
**Location**: `archon-ui-main/src/features/shared/queryPatterns.ts`
Shared constants:
- `DISABLED_QUERY_KEY` - For disabled queries
- `STALE_TIMES` - Standardized cache durations (instant, realtime, frequent, normal, rare, static)
## Feature Implementation Patterns
### Query Key Factories
Each feature maintains its own query keys:
- **Projects**: `archon-ui-main/src/features/projects/hooks/useProjectQueries.ts` (projectKeys)
- **Tasks**: `archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts` (taskKeys)
- **Knowledge**: `archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts` (knowledgeKeys)
- **Progress**: `archon-ui-main/src/features/progress/hooks/useProgressQueries.ts` (progressKeys)
- **MCP**: `archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts` (mcpKeys)
- **Documents**: `archon-ui-main/src/features/projects/documents/hooks/useDocumentQueries.ts` (documentKeys)
### Data Fetching Hooks
Standard pattern across all features:
- `use[Feature]()` - List queries
- `use[Feature]Detail(id)` - Single item queries
- `useCreate[Feature]()` - Creation mutations
- `useUpdate[Feature]()` - Update mutations
- `useDelete[Feature]()` - Deletion mutations
## Backend Integration
### ETag Support
**Location**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
ETag implementation:
- Browser handles ETag headers automatically
- 304 responses reduce bandwidth
- TanStack Query manages cache state
### API Structure
Backend endpoints follow RESTful patterns:
- **Knowledge**: `python/src/server/api_routes/knowledge_api.py`
- **Projects**: `python/src/server/api_routes/projects_api.py`
- **Progress**: `python/src/server/api_routes/progress_api.py`
- **MCP**: `python/src/server/api_routes/mcp_api.py`
## Optimistic Updates
**Utilities**: `archon-ui-main/src/features/shared/optimistic.ts`
All mutations use nanoid-based optimistic updates:
- Creates temporary entities with `_optimistic` flag
- Replaces with server data on success
- Rollback on error
- Visual indicators for pending state
## Refetch Strategies
### Smart Polling Usage
**Implementation**: `archon-ui-main/src/features/ui/hooks/useSmartPolling.ts`
Polling intervals are defined in each feature's query hooks. See actual implementations:
- **Projects**: `archon-ui-main/src/features/projects/hooks/useProjectQueries.ts`
- **Tasks**: `archon-ui-main/src/features/projects/tasks/hooks/useTaskQueries.ts`
- **Knowledge**: `archon-ui-main/src/features/knowledge/hooks/useKnowledgeQueries.ts`
- **Progress**: `archon-ui-main/src/features/progress/hooks/useProgressQueries.ts`
- **MCP**: `archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts`
Standard intervals from `archon-ui-main/src/features/shared/queryPatterns.ts`:
- `STALE_TIMES.instant`: 0ms (always fresh)
- `STALE_TIMES.frequent`: 5 seconds (frequently changing data)
- `STALE_TIMES.normal`: 30 seconds (standard cache)
### Manual Refetch
All queries expose `refetch()` for manual updates.
## Performance Optimizations
### Request Deduplication
Handled automatically by TanStack Query when same query key is used.
### Stale Time Configuration
Defined in `STALE_TIMES` and used consistently:
- Auth/Settings: `Infinity` (never stale)
- Active operations: `0` (always fresh)
- Normal data: `30_000` (30 seconds)
- Rare updates: `300_000` (5 minutes)
### Garbage Collection
Unused data removed after 10 minutes (configurable in queryClient).
## Migration from Polling
### What Changed (Phases 1-5)
1. **Phase 1**: Removed ETag cache layer
2. **Phase 2**: Standardized query keys
3. **Phase 3**: Fixed optimistic updates with UUIDs
4. **Phase 4**: Configured request deduplication
5. **Phase 5**: Removed manual invalidations
### Deprecated Patterns
- `usePolling` hook (removed)
- `useCrawlProgressPolling` (removed)
- Manual cache invalidation with setTimeout
- Socket.IO connections
- Double-layer caching
## Testing Patterns
### Hook Testing
**Example**: `archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts`
Standard mocking approach for:
- Service methods
- Query patterns (STALE_TIMES, DISABLED_QUERY_KEY)
- Smart polling behavior
### Integration Testing
Use React Testing Library with QueryClientProvider wrapper.
## Developer Guidelines
### Adding New Data Fetching
1. Create query key factory in `{feature}/hooks/use{Feature}Queries.ts`
2. Use `useQuery` with appropriate stale time from `STALE_TIMES`
3. Add smart polling if real-time updates needed
4. Implement optimistic updates for mutations
5. Follow existing patterns in similar features
### Common Patterns to Follow
- Always use query key factories
- Never hardcode stale times
- Use `DISABLED_QUERY_KEY` for conditional queries
- Implement optimistic updates for better UX
- Add loading and error states
## Future Considerations
- Server-Sent Events for true real-time (post-Phase 5)
- WebSocket fallback for critical updates
- GraphQL migration for selective field updates

View File

@@ -1,39 +1,149 @@
# ETag Implementation
## Current Implementation
## Overview
Our ETag implementation provides efficient HTTP caching for polling endpoints to reduce bandwidth usage.
Archon implements HTTP ETag caching to optimize bandwidth usage by reducing redundant data transfers. The implementation leverages browser-native HTTP caching combined with backend ETag generation for efficient cache validation.
### What It Does
- **Generates ETags**: Creates MD5 hashes of JSON response data
- **Checks ETags**: Simple string equality comparison between client's `If-None-Match` header and current data's ETag
- **Returns 304**: When ETags match, returns `304 Not Modified` with no body (saves bandwidth)
## How It Works
### How It Works
1. Server generates ETag from response data using MD5 hash
2. Client sends previous ETag in `If-None-Match` header
3. Server compares ETags:
- **Match**: Returns 304 (no body)
- **No match**: Returns 200 with new data and new ETag
### Backend ETag Generation
**Location**: `python/src/server/utils/etag_utils.py`
### Example
```python
# Server generates: ETag: "a3c2f1e4b5d6789"
# Client sends: If-None-Match: "a3c2f1e4b5d6789"
# Server returns: 304 Not Modified (no body)
```
The backend generates ETags for API responses:
- Creates MD5 hash of JSON-serialized response data
- Returns quoted ETag string (RFC 7232 format)
- Sets `Cache-Control: no-cache, must-revalidate` headers
- Compares client's `If-None-Match` header with current data's ETag
- Returns `304 Not Modified` when ETags match
## Limitations
### Frontend Handling
**Location**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
Our implementation is simplified and doesn't support full RFC 7232 features:
- ❌ Wildcard (`*`) matching
- ❌ Multiple ETags (`"etag1", "etag2"`)
- ❌ Weak validators (`W/"etag"`)
- ✅ Single ETag comparison only
The frontend relies on browser-native HTTP caching:
- Browser automatically sends `If-None-Match` headers with cached ETags
- Browser handles 304 responses by returning cached data from HTTP cache
- No manual ETag tracking or cache management needed
- TanStack Query manages data freshness through `staleTime` configuration
This works perfectly for our browser-to-API polling use case but may need enhancement for CDN/proxy support.
#### Browser vs Non-Browser Behavior
- **Standard Browsers**: Per the Fetch spec, a 304 response freshens the HTTP cache and returns the cached body to JavaScript
- **Non-Browser Runtimes** (React Native, custom fetch): May surface 304 with empty body to JavaScript
- **Client Fallback**: The `apiWithEtag.ts` implementation handles both scenarios, ensuring consistent behavior across environments
## Files
- Implementation: `python/src/server/utils/etag_utils.py`
- Tests: `python/tests/server/utils/test_etag_utils.py`
- Used in: Progress API, Projects API polling endpoints
## Implementation Details
### Backend API Integration
ETags are used in these API routes:
- **Projects**: `python/src/server/api_routes/projects_api.py`
- Project lists
- Task lists
- Task counts
- **Progress**: `python/src/server/api_routes/progress_api.py`
- Active operations tracking
### ETag Generation Process
1. **Data Serialization**: Response data is JSON-serialized with sorted keys for consistency
2. **Hash Creation**: MD5 hash generated from JSON string
3. **Format**: Returns quoted string per RFC 7232 (e.g., `"a3c2f1e4b5d6789"`)
### Cache Validation Flow
1. **Initial Request**: Server generates ETag and sends with response
2. **Subsequent Requests**: Browser sends `If-None-Match` header with cached ETag
3. **Server Validation**:
- ETags match → Returns `304 Not Modified` (no body)
- ETags differ → Returns `200 OK` with new data and new ETag
4. **Browser Behavior**: On 304, browser serves cached response to JavaScript
## Key Design Decisions
### Browser-Native Caching
The implementation leverages browser HTTP caching instead of manual cache management:
- Reduces code complexity
- Eliminates cache synchronization issues
- Works seamlessly with TanStack Query
- Maintains bandwidth optimization
### No Manual ETag Tracking
Unlike previous implementations, the current approach:
- Does NOT maintain ETag maps in JavaScript
- Does NOT manually handle 304 responses
- Lets browser and TanStack Query handle caching layers
## Integration with TanStack Query
### Cache Coordination
- **Browser Cache**: Handles HTTP-level caching (ETags/304s)
- **TanStack Query Cache**: Manages application-level data freshness
- **Separation of Concerns**: HTTP caching for bandwidth, TanStack for state
### Configuration
Cache behavior is controlled through TanStack Query's `staleTime`:
- See `archon-ui-main/src/features/shared/queryPatterns.ts` for standard times
- See `archon-ui-main/src/features/shared/queryClient.ts` for global configuration
## Performance Benefits
### Bandwidth Reduction
- ~70% reduction in data transfer for unchanged responses (based on internal measurements)
- Especially effective for polling patterns
- Significant improvement for mobile/slow connections
### Server Load
- Reduced JSON serialization for 304 responses
- Lower network I/O
- Faster response times for cached data
## Files and References
### Core Implementation
- **Backend Utilities**: `python/src/server/utils/etag_utils.py`
- **Frontend Client**: `archon-ui-main/src/features/shared/apiWithEtag.ts`
- **Tests**: `python/tests/server/utils/test_etag_utils.py`
### Usage Examples
- **Projects API**: `python/src/server/api_routes/projects_api.py` (lines with `generate_etag`, `check_etag`)
- **Progress API**: `python/src/server/api_routes/progress_api.py` (active operations tracking)
## Testing
### Backend Testing
Tests in `python/tests/server/utils/test_etag_utils.py` verify:
- Correct ETag generation format
- Consistent hashing for same data
- Different hashes for different data
- Proper quote formatting
### Frontend Testing
Browser DevTools verification:
1. Network tab shows `If-None-Match` headers on requests
2. 304 responses have no body
3. Response served from cache on 304
4. New ETag values when data changes
## Monitoring
### How to Verify ETags are Working
1. Open Chrome DevTools → Network tab
2. Make a request to a supported endpoint
3. Note the `ETag` response header
4. Refresh or re-request the same data
5. Observe:
- Request includes `If-None-Match` header
- Server returns `304 Not Modified` if unchanged
- Response body is empty on 304
- Browser serves cached data
### Metrics to Track
- Ratio of 304 vs 200 responses
- Bandwidth saved through 304 responses
- Cache hit rate in production
## Future Considerations
- Consider implementing strong vs weak ETags for more granular control
- Evaluate adding ETag support to more endpoints
- Monitor cache effectiveness in production
- Consider Last-Modified headers as supplementary validation

View File

@@ -1,194 +0,0 @@
# Polling Architecture Documentation
## Overview
Archon V2 uses HTTP polling instead of WebSockets for real-time updates. This simplifies the architecture, reduces complexity, and improves maintainability while providing adequate responsiveness for project management tasks.
## Core Components
### 1. usePolling Hook (`archon-ui-main/src/hooks/usePolling.ts`)
Generic polling hook that manages periodic data fetching with smart optimizations.
**Key Features:**
- Configurable polling intervals (default: 3 seconds)
- Automatic pause during browser tab inactivity
- ETag-based caching to reduce bandwidth
- Manual refresh capability
**Usage:**
```typescript
const { data, isLoading, error, refetch } = usePolling('/api/projects', {
interval: 5000,
enabled: true,
onSuccess: (data) => console.log('Projects updated:', data)
});
```
### 2. Specialized Progress Services
Individual services handle specific progress tracking needs:
**CrawlProgressService (`archon-ui-main/src/services/crawlProgressService.ts`)**
- Tracks website crawling operations
- Maps backend status to UI-friendly format
- Includes in-flight request guard to prevent overlapping fetches
- 1-second polling interval during active crawls
**Polling Endpoints:**
- `/api/projects` - Project list updates
- `/api/projects/{project_id}/tasks` - Task list for active project
- `/api/crawl-progress/{progress_id}` - Website crawling progress
- `/api/agent-chat/sessions/{session_id}/messages` - Chat messages
## Backend Support
### ETag Implementation (`python/src/server/utils/etag_utils.py`)
Server-side optimization to reduce unnecessary data transfer.
**How it works:**
1. Server generates ETag hash from response data
2. Client sends `If-None-Match` header with cached ETag
3. Server returns 304 Not Modified if data unchanged
4. Client uses cached data, reducing bandwidth by ~70%
### Progress API (`python/src/server/api_routes/progress_api.py`)
Dedicated endpoints for progress tracking:
- `GET /api/crawl-progress/{progress_id}` - Returns crawling status with ETag support
- Includes completion percentage, current step, and error details
## State Management
### Loading States
Visual feedback during operations:
- `movingTaskIds: Set<string>` - Tracks tasks being moved
- `isSwitchingProject: boolean` - Project transition state
- Loading overlays prevent concurrent operations
## Error Handling
### Retry Strategy
```typescript
retryCount: 3
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30000)
```
- Exponential backoff: 1s, 2s, 4s...
- Maximum retry delay: 30 seconds
- Automatic recovery after network issues
### User Feedback
- Toast notifications for errors
- Loading spinners during operations
- Clear error messages with recovery actions
## Performance Optimizations
### 1. Request Deduplication
Prevents multiple components from making identical requests:
```typescript
const cacheKey = `${endpoint}-${JSON.stringify(params)}`;
if (pendingRequests.has(cacheKey)) {
return pendingRequests.get(cacheKey);
}
```
### 2. Smart Polling Intervals
- Active operations: 1-2 second intervals
- Background data: 5-10 second intervals
- Paused when tab inactive (visibility API)
### 3. Selective Updates
Only polls active/relevant data:
- Tasks poll only for selected project
- Progress polls only during active operations
- Chat polls only for open sessions
## Architecture Benefits
### What We Have
- **Simple HTTP polling** - Standard request/response pattern
- **Automatic error recovery** - Built-in retry with exponential backoff
- **ETag caching** - 70% bandwidth reduction via 304 responses
- **Easy debugging** - Standard HTTP requests visible in DevTools
- **No connection limits** - Scales with standard HTTP infrastructure
- **Consolidated polling hooks** - Single pattern for all data fetching
### Trade-offs
- **Latency:** 1-5 second delay vs instant updates
- **Bandwidth:** More requests, but mitigated by ETags
- **Battery:** Slightly higher mobile battery usage
## Developer Guidelines
### Adding New Polling Endpoint
1. **Frontend - Use the usePolling hook:**
```typescript
// In your component or custom hook
const { data, isLoading, error, refetch } = usePolling('/api/new-endpoint', {
interval: 5000,
enabled: true,
staleTime: 2000
});
```
2. **Backend - Add ETag support:**
```python
from ..utils.etag_utils import generate_etag, check_etag
@router.get("/api/new-endpoint")
async def get_data(request: Request):
data = fetch_data()
etag = generate_etag(data)
if check_etag(request, etag):
return Response(status_code=304)
return JSONResponse(
content=data,
headers={"ETag": etag}
)
```
3. **For progress tracking, use useCrawlProgressPolling:**
```typescript
const { data, isLoading } = useCrawlProgressPolling(operationId, {
onSuccess: (data) => {
if (data.status === 'completed') {
// Handle completion
}
}
});
```
### Best Practices
1. **Always provide loading states** - Users should know when data is updating
2. **Handle errors gracefully** - Show toast notifications with clear messages
3. **Respect polling intervals** - Don't poll faster than necessary
4. **Clean up on unmount** - Cancel pending requests when components unmount
5. **Use ETag caching** - Reduce bandwidth with 304 responses
## Testing Polling Behavior
### Manual Testing
1. Open Network tab in DevTools
2. Look for requests with 304 status (cache hits)
3. Verify polling stops when switching tabs
4. Test error recovery by stopping backend
### Debugging Tips
- Check `localStorage` for cached ETags
- Monitor `console.log` for polling lifecycle events
- Use React DevTools to inspect hook states
- Watch for memory leaks in long-running sessions
## Future Improvements
### Planned Enhancements
- WebSocket fallback for critical updates
- Configurable per-user polling rates
- Smart polling based on user activity patterns
- GraphQL subscriptions for selective field updates
### Considered Alternatives
- Server-Sent Events (SSE) - One-way real-time updates
- Long polling - Reduced request frequency
- WebRTC data channels - P2P updates between clients

View File

@@ -0,0 +1,237 @@
# TanStack Query Patterns Guide
This guide documents the standardized patterns for using TanStack Query v5 in the Archon frontend.
## Core Principles
1. **Feature Ownership**: Each feature owns its query keys in `{feature}/hooks/use{Feature}Queries.ts`
2. **Consistent Patterns**: Always use shared patterns from `shared/queryPatterns.ts`
3. **No Hardcoded Values**: Never hardcode stale times or disabled keys
4. **Mirror Backend API**: Query keys should exactly match backend API structure
## Query Key Factory Pattern
Every feature MUST implement a query key factory following this pattern:
```typescript
// features/{feature}/hooks/use{Feature}Queries.ts
export const featureKeys = {
all: ["feature"] as const, // Base key for the domain
lists: () => [...featureKeys.all, "list"] as const, // For list endpoints
detail: (id: string) => [...featureKeys.all, "detail", id] as const, // For single item
// Add more as needed following backend routes
};
```
### Examples from Codebase
```typescript
// Projects - Simple hierarchy
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
detail: (id: string) => [...projectKeys.all, "detail", id] as const,
features: (id: string) => [...projectKeys.all, id, "features"] as const,
};
// Tasks - Dual nature (global and project-scoped)
export const taskKeys = {
all: ["tasks"] as const,
lists: () => [...taskKeys.all, "list"] as const, // /api/tasks
detail: (id: string) => [...taskKeys.all, "detail", id] as const,
byProject: (projectId: string) => ["projects", projectId, "tasks"] as const, // /api/projects/{id}/tasks
counts: () => [...taskKeys.all, "counts"] as const,
};
```
## Shared Patterns Usage
### Import Required Patterns
```typescript
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/queryPatterns";
```
### Disabled Queries
Always use `DISABLED_QUERY_KEY` when a query should not execute:
```typescript
// ✅ CORRECT
queryKey: projectId ? projectKeys.detail(projectId) : DISABLED_QUERY_KEY,
// ❌ WRONG - Don't create custom disabled keys
queryKey: projectId ? projectKeys.detail(projectId) : ["projects-undefined"],
```
### Stale Times
Always use `STALE_TIMES` constants for cache configuration:
```typescript
// ✅ CORRECT
staleTime: STALE_TIMES.normal, // 30 seconds
staleTime: STALE_TIMES.frequent, // 5 seconds
staleTime: STALE_TIMES.instant, // 0 - always fresh
// ❌ WRONG - Don't hardcode times
staleTime: 30000,
staleTime: 0,
```
#### STALE_TIMES Reference
- `instant: 0` - Always fresh (real-time data like active progress)
- `realtime: 3_000` - 3 seconds (near real-time updates)
- `frequent: 5_000` - 5 seconds (frequently changing data)
- `normal: 30_000` - 30 seconds (standard cache time)
- `rare: 300_000` - 5 minutes (rarely changing config)
- `static: Infinity` - Never stale (settings, auth)
## Complete Hook Pattern
```typescript
export function useFeatureDetail(id: string | undefined) {
return useQuery({
queryKey: id ? featureKeys.detail(id) : DISABLED_QUERY_KEY,
queryFn: () => id
? featureService.getFeatureById(id)
: Promise.reject("No ID provided"),
enabled: !!id,
staleTime: STALE_TIMES.normal,
});
}
```
## Mutations with Optimistic Updates
```typescript
import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/optimistic";
export function useCreateFeature() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateFeatureRequest) => featureService.create(data),
onMutate: async (newData) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: featureKeys.lists() });
// Snapshot for rollback
const previous = queryClient.getQueryData(featureKeys.lists());
// Optimistic update with nanoid for stable IDs
const optimisticEntity = createOptimisticEntity(newData);
queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) =>
[...old, optimisticEntity]
);
return { previous, localId: optimisticEntity._localId };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(featureKeys.lists(), context.previous);
}
},
onSuccess: (data, variables, context) => {
// Replace optimistic with real data
queryClient.setQueryData(featureKeys.lists(), (old: Feature[] = []) =>
replaceOptimisticEntity(old, context?.localId, data)
);
},
});
}
```
## Testing Query Hooks
Always mock both services and shared patterns:
```typescript
// Mock services
vi.mock("../../services", () => ({
featureService: {
getList: vi.fn(),
getById: vi.fn(),
},
}));
// Mock shared patterns with ALL values
vi.mock("../../../shared/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3_000,
frequent: 5_000,
normal: 30_000,
rare: 300_000,
static: Infinity,
},
}));
```
## Vertical Slice Architecture
Each feature is self-contained:
```text
src/features/projects/
├── components/ # UI components
├── hooks/
│ └── useProjectQueries.ts # Query hooks & keys
├── services/
│ └── projectService.ts # API calls
└── types/
└── index.ts # TypeScript types
```
Sub-features (like tasks under projects) follow the same structure:
```text
src/features/projects/tasks/
├── components/
├── hooks/
│ └── useTaskQueries.ts # Own query keys!
├── services/
└── types/
```
## Migration Checklist
When refactoring to these patterns:
- [ ] Create query key factory in `hooks/use{Feature}Queries.ts`
- [ ] Import `DISABLED_QUERY_KEY` and `STALE_TIMES` from shared
- [ ] Replace all hardcoded disabled keys with `DISABLED_QUERY_KEY`
- [ ] Replace all hardcoded stale times with `STALE_TIMES` constants
- [ ] Update all `queryKey` references to use factory
- [ ] Update all `invalidateQueries` to use factory
- [ ] Update all `setQueryData` to use factory
- [ ] Add comprehensive tests for query keys
- [ ] Remove any backward compatibility code
## Common Pitfalls to Avoid
1. **Don't create centralized query keys** - Each feature owns its keys
2. **Don't hardcode values** - Use shared constants
3. **Don't mix concerns** - Tasks shouldn't import projectKeys
4. **Don't skip mocking in tests** - Mock both services and patterns
5. **Don't use inconsistent patterns** - Follow the established conventions
## Completed Improvements (Phases 1-5)
- ✅ Phase 1: Removed manual frontend ETag cache layer (backend ETags remain; browser-managed)
- ✅ Phase 2: Standardized query keys with factories
- ✅ Phase 3: Implemented UUID-based optimistic updates using nanoid
- ✅ Phase 4: Configured request deduplication
- ✅ Phase 5: Removed manual cache invalidations
## Future Considerations
- Add Server-Sent Events for real-time updates
- Consider WebSocket fallback for critical updates
- Evaluate Zustand for complex client state management

View File

@@ -1,148 +1,135 @@
# Optimistic Updates Pattern (Future State)
# Optimistic Updates Pattern Guide
**⚠️ STATUS:** This is not currently implemented. There is a proofofconcept (POC) on the frontend Project page. This document describes the desired future state for handling optimistic updates in a simple, consistent way.
## Core Architecture
## Mental Model
### Shared Utilities Module
**Location**: `src/features/shared/optimistic.ts`
Think of optimistic updates as "assuming success" - update the UI immediately for instant feedback, then verify with the server. If something goes wrong, revert to the last known good state.
## The Pattern
Provides type-safe utilities for managing optimistic state across all features:
- `createOptimisticId()` - Generates stable UUIDs using nanoid
- `createOptimisticEntity<T>()` - Creates entities with `_optimistic` and `_localId` metadata
- `isOptimistic()` - Type guard for checking optimistic state
- `replaceOptimisticEntity()` - Replaces optimistic items by `_localId` (race-condition safe)
- `removeDuplicateEntities()` - Deduplicates after replacement
- `cleanOptimisticMetadata()` - Strips optimistic fields when needed
### TypeScript Interface
```typescript
// 1. Save current state (for rollback) — take an immutable snapshot
const previousState = structuredClone(currentState);
// 2. Update UI immediately
setState(newState);
// 3. Call API
try {
const serverState = await api.updateResource(newState);
// Success — use server as the source of truth
setState(serverState);
} catch (error) {
// 4. Rollback on failure
setState(previousState);
showToast("Failed to update. Reverted changes.", "error");
interface OptimisticEntity {
_optimistic: boolean;
_localId: string;
}
```
## Implementation Approach
## Implementation Patterns
### Simple Hook Pattern
### Mutation Hooks Pattern
**Reference**: `src/features/projects/tasks/hooks/useTaskQueries.ts:44-108`
```typescript
function useOptimistic<T>(initialValue: T, updateFn: (value: T) => Promise<T>) {
const [value, setValue] = useState(initialValue);
const [isUpdating, setIsUpdating] = useState(false);
const previousValueRef = useRef<T>(initialValue);
const opSeqRef = useRef(0); // monotonically increasing op id
const mountedRef = useRef(true); // avoid setState after unmount
useEffect(() => () => { mountedRef.current = false; }, []);
1. **onMutate**: Create optimistic entity with stable ID
- Use `createOptimisticEntity<T>()` for type-safe creation
- Store `optimisticId` in context for later replacement
const optimisticUpdate = async (newValue: T) => {
const opId = ++opSeqRef.current;
// Save for rollback
previousValueRef.current = value;
2. **onSuccess**: Replace optimistic with server response
- Use `replaceOptimisticEntity()` matching by `_localId`
- Apply `removeDuplicateEntities()` to prevent duplicates
// Update immediately
if (mountedRef.current) setValue(newValue);
if (mountedRef.current) setIsUpdating(true);
3. **onError**: Rollback to previous state
- Restore snapshot from context
try {
const result = await updateFn(newValue);
// Apply only if latest op and still mounted
if (mountedRef.current && opId === opSeqRef.current) {
setValue(result); // Server is source of truth
}
} catch (error) {
// Rollback
if (mountedRef.current && opId === opSeqRef.current) {
setValue(previousValueRef.current);
}
throw error;
} finally {
if (mountedRef.current && opId === opSeqRef.current) {
setIsUpdating(false);
}
}
};
### UI Component Pattern
**References**:
- `src/features/projects/tasks/components/TaskCard.tsx:39-40,160,186`
- `src/features/projects/components/ProjectCard.tsx:32-33,67,93`
- `src/features/knowledge/components/KnowledgeCard.tsx:49-50,176,244`
return { value, optimisticUpdate, isUpdating };
}
```
1. Check optimistic state: `const optimistic = isOptimistic(entity)`
2. Apply conditional styling: Add opacity and ring effect when optimistic
3. Display indicator: Use `<OptimisticIndicator>` component for visual feedback
### Usage Example
### Visual Indicator Component
**Location**: `src/features/ui/primitives/OptimisticIndicator.tsx`
```typescript
// In a component
const {
value: task,
optimisticUpdate,
isUpdating,
} = useOptimistic(initialTask, (task) =>
projectService.updateTask(task.id, task),
);
Reusable component showing:
- Spinning loader icon (Loader2 from lucide-react)
- "Saving..." text with pulse animation
- Configurable via props: `showSpinner`, `pulseAnimation`
// Handle user action
const handleStatusChange = (newStatus: string) => {
optimisticUpdate({ ...task, status: newStatus }).catch((error) =>
showToast("Failed to update task", "error"),
);
};
```
## Feature Integration
## Key Principles
### Tasks
- **Mutations**: `src/features/projects/tasks/hooks/useTaskQueries.ts`
- **UI**: `src/features/projects/tasks/components/TaskCard.tsx`
- Creates tasks with `priority: "medium"` default
1. **Keep it simple** — save, update, roll back.
2. **Server is the source of truth** — always use the server response as the final state.
3. **User feedback** — show loading states and clear error messages.
4. **Selective usage** — only where instant feedback matters:
- Draganddrop
- Status changes
- Toggle switches
- Quick edits
### Projects
- **Mutations**: `src/features/projects/hooks/useProjectQueries.ts`
- **UI**: `src/features/projects/components/ProjectCard.tsx`
- Handles `prd: null`, `data_schema: null` for new projects
## What NOT to Do
### Knowledge
- **Mutations**: `src/features/knowledge/hooks/useKnowledgeQueries.ts`
- **UI**: `src/features/knowledge/components/KnowledgeCard.tsx`
- Uses `createOptimisticId()` directly for progress tracking
- Don't track complex state histories
- Don't try to merge conflicts
- Use with caution for create/delete operations. If used, generate temporary client IDs, reconcile with serverassigned IDs, ensure idempotency, and define clear rollback/error states. Prefer nonoptimistic flows when side effects are complex.
- Don't over-engineer with queues or reconciliation
### Toasts
- **Location**: `src/features/ui/hooks/useToast.ts:43`
- Uses `createOptimisticId()` for unique toast IDs
## When to Implement
## Testing
Implement optimistic updates when:
### Unit Tests
**Location**: `src/features/shared/optimistic.test.ts`
- Users complain about UI feeling "slow"
- Drag-and-drop or reordering feels laggy
- Quick actions (like checkbox toggles) feel unresponsive
- Network latency is noticeable (> 200ms)
Covers all utility functions with 8 test cases:
- ID uniqueness and format validation
- Entity creation with metadata
- Type guard functionality
- Replacement logic
- Deduplication
- Metadata cleanup
## Success Metrics
### Manual Testing Checklist
1. **Rapid Creation**: Create 5+ items quickly - verify no duplicates
2. **Visual Feedback**: Check optimistic indicators appear immediately
3. **ID Stability**: Confirm nanoid-based IDs after server response
4. **Error Handling**: Stop backend, attempt creation - verify rollback
5. **Race Conditions**: Use browser console script for concurrent creates
When implemented correctly:
## Performance Characteristics
- UI feels instant (< 100ms response)
- Rollbacks are rare (< 1% of updates)
- Error messages are clear
- Users understand what happened when things fail
- **Bundle Impact**: ~130 bytes ([nanoid v5, minified+gzipped](https://bundlephobia.com/package/nanoid@5.0.9)) - build/environment dependent
- **Update Speed**: Typically snappy on modern devices; actual latency varies by device and workload
- **ID Generation**: Per [nanoid benchmarks](https://github.com/ai/nanoid#benchmark): secure sync ≈5M ops/s, non-secure ≈2.7M ops/s, async crypto ≈135k ops/s
- **Memory**: Minimal - only `_optimistic` and `_localId` metadata added per optimistic entity
## Production Considerations
## Migration Notes
The examples above are simplified for clarity. Production implementations should consider:
### From Timestamp-based IDs
**Before**: `const tempId = \`temp-\${Date.now()}\``
**After**: `const optimisticId = createOptimisticId()`
1. **Deep cloning**: Use `structuredClone()` or a deep clone utility for complex state
### Key Differences
- No timestamp collisions during rapid creation
- Stable IDs survive re-renders
- Type-safe with full TypeScript inference
- ~60% code reduction through shared utilities
```typescript
const previousState = structuredClone(currentState); // Proper deep clone
```
## Best Practices
2. **Race conditions**: Handle out-of-order responses with operation IDs
3. **Unmount safety**: Avoid setState after component unmount
4. **Debouncing**: For rapid updates (e.g., sliders), debounce API calls
5. **Conflict resolution**: For collaborative editing, consider operational transforms
6. **Polling/ETag interplay**: When polling, ignore stale responses (e.g., compare opId or Last-Modified) and rely on ETag/304 to prevent flicker overriding optimistic state.
7. **Idempotency & retries**: Use idempotency keys on write APIs so client retries (or duplicate submits) don't create duplicate effects.
1. **Always use shared utilities** - Don't implement custom optimistic logic
2. **Match by _localId** - Never match by the entity's `id` field
3. **Include deduplication** - Always call `removeDuplicateEntities()` after replacement
4. **Show visual feedback** - Users should see pending state clearly
5. **Handle errors gracefully** - Always implement rollback in `onError`
These complexities are why we recommend starting simple and only adding optimistic updates where the UX benefit is clear.
## Dependencies
- **nanoid**: v5.0.9 - UUID generation
- **@tanstack/react-query**: v5.x - Mutation state management
- **React**: v18.x - UI components
- **TypeScript**: v5.x - Type safety
---
*Last updated: Phase 3 implementation (PR #695)*

View File

@@ -54,11 +54,13 @@ This new vision for Archon replaces the old one (the agenteer). Archon used to b
1. **Clone Repository**:
```bash
git clone https://github.com/coleam00/archon.git
git clone -b stable https://github.com/coleam00/archon.git
```
```bash
cd archon
```
**Note:** The `stable` branch is recommended for using Archon. If you want to contribute or try the latest features, use the `main` branch with `git clone https://github.com/coleam00/archon.git`
2. **Environment Configuration**:
```bash

View File

@@ -24,6 +24,7 @@
"fractional-indexing": "^3.2.0",
"framer-motion": "^11.5.4",
"lucide-react": "^0.441.0",
"nanoid": "^5.0.9",
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
@@ -9030,10 +9031,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"funding": [
{
"type": "github",
@@ -9042,10 +9042,10 @@
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@@ -9651,6 +9651,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@@ -17,7 +17,9 @@
"biome:ci": "biome ci",
"preview": "npx vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:coverage": "npm run test:coverage:run && npm run test:coverage:summary",
"test:coverage:run": "vitest run --coverage --reporter=dot --reporter=json",
"test:coverage:stream": "vitest run --coverage --reporter=default --reporter=json --bail=false || true",
@@ -42,6 +44,7 @@
"fractional-indexing": "^3.2.0",
"framer-motion": "^11.5.4",
"lucide-react": "^0.441.0",
"nanoid": "^5.0.9",
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dnd": "^16.0.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L21 20H15L13.5 17H10.5L9 20H3L12 2ZM12 7L9.5 12H14.5L12 7Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 722 B

View File

@@ -1,15 +1,15 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './features/shared/queryClient';
import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
import { SettingsPage } from './pages/SettingsPage';
import { MCPPage } from './pages/MCPPage';
import { OnboardingPage } from './pages/OnboardingPage';
import { MainLayout } from './components/layout/MainLayout';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { ToastProvider as FeaturesToastProvider } from './features/ui/components/ToastProvider';
import { ToastProvider } from './features/ui/components/ToastProvider';
import { SettingsProvider, useSettings } from './contexts/SettingsContext';
import { TooltipProvider } from './features/ui/primitives/tooltip';
import { ProjectPage } from './pages/ProjectPage';
@@ -19,27 +19,6 @@ import { MigrationBanner } from './components/ui/MigrationBanner';
import { serverHealthService } from './services/serverHealthService';
import { useMigrationStatus } from './hooks/useMigrationStatus';
// Create a client with optimized settings for our polling use case
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Keep data fresh for 2 seconds by default
staleTime: 2000,
// Cache data for 5 minutes
gcTime: 5 * 60 * 1000,
// Retry failed requests 3 times
retry: 3,
// Refetch on window focus
refetchOnWindowFocus: true,
// Don't refetch on reconnect by default (we handle this manually)
refetchOnReconnect: false,
},
mutations: {
// Retry mutations once on failure
retry: 1,
},
},
});
const AppRoutes = () => {
const { projectsEnabled } = useSettings();
@@ -134,13 +113,11 @@ export function App() {
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<FeaturesToastProvider>
<TooltipProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</TooltipProvider>
</FeaturesToastProvider>
<TooltipProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</TooltipProvider>
</ToastProvider>
</ThemeProvider>
{import.meta.env.VITE_SHOW_DEVTOOLS === 'true' && (

View File

@@ -5,12 +5,13 @@ import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { Card } from "../ui/Card";
import { Select } from "../ui/Select";
import { useToast } from "../../contexts/ToastContext";
import { useToast } from "../../features/ui/hooks/useToast";
import {
bugReportService,
BugContext,
BugReportData,
} from "../../services/bugReportService";
import { copyToClipboard } from "../../features/shared/utils/clipboard";
interface BugReportModalProps {
isOpen: boolean;
@@ -99,13 +100,21 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
// Fallback: copy to clipboard
const formattedReport =
bugReportService.formatReportForClipboard(bugReportData);
await navigator.clipboard.writeText(formattedReport);
const clipboardResult = await copyToClipboard(formattedReport);
showToast(
"Failed to create GitHub issue, but bug report was copied to clipboard. Please paste it in a new GitHub issue.",
"warning",
10000,
);
if (clipboardResult.success) {
showToast(
"Failed to create GitHub issue, but bug report was copied to clipboard. Please paste it in a new GitHub issue.",
"warning",
10000,
);
} else {
showToast(
"Failed to create GitHub issue and could not copy to clipboard. Please report manually.",
"error",
10000,
);
}
}
} catch (error) {
console.error("Bug report submission failed:", error);
@@ -118,15 +127,15 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
}
};
const copyToClipboard = async () => {
const handleCopyToClipboard = async () => {
const bugReportData: BugReportData = { ...report, context };
const formattedReport =
bugReportService.formatReportForClipboard(bugReportData);
try {
await navigator.clipboard.writeText(formattedReport);
const result = await copyToClipboard(formattedReport);
if (result.success) {
showToast("Bug report copied to clipboard", "success");
} catch {
} else {
showToast("Failed to copy to clipboard", "error");
}
};
@@ -372,7 +381,7 @@ export const BugReportModal: React.FC<BugReportModalProps> = ({
<Button
type="button"
variant="ghost"
onClick={copyToClipboard}
onClick={handleCopyToClipboard}
className="sm:order-1"
>
<Copy className="w-4 h-4 mr-2" />

View File

@@ -30,6 +30,7 @@ import 'prismjs/components/prism-graphql'
import 'prismjs/themes/prism-tomorrow.css'
import { Button } from '../ui/Button'
import { Badge } from '../ui/Badge'
import { copyToClipboard } from '../../features/shared/utils/clipboard'
export interface CodeExample {
id: string
@@ -102,11 +103,15 @@ export const CodeViewerModal: React.FC<CodeViewerModalProps> = ({
setActiveExampleIndex(0)
}, [searchQuery])
const handleCopyCode = () => {
const handleCopyCode = async () => {
if (activeExample) {
navigator.clipboard.writeText(activeExample.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const result = await copyToClipboard(activeExample.code)
if (result.success) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} else {
console.error('Failed to copy to clipboard:', result.error)
}
}
}

View File

@@ -1,407 +0,0 @@
import { useState } from 'react';
import {
LinkIcon,
Upload,
BoxIcon,
Brain,
Plus
} from 'lucide-react';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Badge } from '../ui/Badge';
import { GlassCrawlDepthSelector } from '../ui/GlassCrawlDepthSelector';
import { useToast } from '../../contexts/ToastContext';
import { knowledgeBaseService } from '../../services/knowledgeBaseService';
import { CrawlProgressData } from '../../types/crawl';
interface AddKnowledgeModalProps {
onClose: () => void;
onSuccess: () => void;
onStartCrawl: (progressId: string, initialData: Partial<CrawlProgressData>) => void;
}
export const AddKnowledgeModal = ({
onClose,
onSuccess,
onStartCrawl
}: AddKnowledgeModalProps) => {
const [method, setMethod] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [newTag, setNewTag] = useState('');
const [knowledgeType, setKnowledgeType] = useState<'technical' | 'business'>('technical');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [crawlDepth, setCrawlDepth] = useState(2);
const [showDepthTooltip, setShowDepthTooltip] = useState(false);
const { showToast } = useToast();
// URL validation function
const validateUrl = async (url: string): Promise<{ isValid: boolean; error?: string; formattedUrl?: string }> => {
try {
let formattedUrl = url.trim();
if (!formattedUrl.startsWith('http://') && !formattedUrl.startsWith('https://')) {
formattedUrl = `https://${formattedUrl}`;
}
let urlObj;
try {
urlObj = new URL(formattedUrl);
} catch {
return { isValid: false, error: 'Please enter a valid URL format' };
}
const hostname = urlObj.hostname;
if (!hostname || hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return { isValid: true, formattedUrl };
}
if (!hostname.includes('.')) {
return { isValid: false, error: 'Please enter a valid domain name' };
}
const parts = hostname.split('.');
const tld = parts[parts.length - 1];
if (tld.length < 2) {
return { isValid: false, error: 'Please enter a valid domain with a proper extension' };
}
// Optional DNS check
try {
const response = await fetch(`https://dns.google/resolve?name=${hostname}&type=A`, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (response.ok) {
const dnsResult = await response.json();
if (dnsResult.Status === 0 && dnsResult.Answer?.length > 0) {
return { isValid: true, formattedUrl };
} else {
return { isValid: false, error: `Domain "${hostname}" could not be resolved` };
}
}
} catch {
// Allow URL even if DNS check fails
console.warn('DNS check failed, allowing URL anyway');
}
return { isValid: true, formattedUrl };
} catch {
return { isValid: false, error: 'URL validation failed' };
}
};
const handleSubmit = async () => {
try {
setLoading(true);
if (method === 'url') {
if (!url.trim()) {
showToast('Please enter a URL', 'error');
return;
}
showToast('Validating URL...', 'info');
const validation = await validateUrl(url);
if (!validation.isValid) {
showToast(validation.error || 'Invalid URL', 'error');
return;
}
const formattedUrl = validation.formattedUrl!;
setUrl(formattedUrl);
// Detect crawl type based on URL
const crawlType = detectCrawlType(formattedUrl);
const result = await knowledgeBaseService.crawlUrl({
url: formattedUrl,
knowledge_type: knowledgeType,
tags,
max_depth: crawlDepth
});
if ((result as any).progressId) {
onStartCrawl((result as any).progressId, {
status: 'initializing',
progress: 0,
currentStep: 'Starting crawl',
crawlType,
currentUrl: formattedUrl,
originalCrawlParams: {
url: formattedUrl,
knowledge_type: knowledgeType,
tags,
max_depth: crawlDepth
}
});
showToast(`Starting ${crawlType} crawl...`, 'success');
onClose();
} else {
showToast((result as any).message || 'Crawling started', 'success');
onSuccess();
}
} else {
if (!selectedFile) {
showToast('Please select a file', 'error');
return;
}
const result = await knowledgeBaseService.uploadDocument(selectedFile, {
knowledge_type: knowledgeType,
tags
});
if (result.success && result.progressId) {
onStartCrawl(result.progressId, {
currentUrl: `file://${selectedFile.name}`,
progress: 0,
status: 'starting',
uploadType: 'document',
fileName: selectedFile.name,
fileType: selectedFile.type,
originalUploadParams: {
file: selectedFile,
knowledge_type: knowledgeType,
tags
}
});
showToast('Document upload started', 'success');
onClose();
} else {
showToast(result.message || 'Document uploaded', 'success');
onSuccess();
}
}
} catch (error) {
console.error('Failed to add knowledge:', error);
showToast('Failed to add knowledge source', 'error');
} finally {
setLoading(false);
}
};
// Helper to detect crawl type
const detectCrawlType = (url: string): 'sitemap' | 'llms-txt' | 'normal' => {
if (url.includes('sitemap.xml')) return 'sitemap';
if (url.includes('llms') && url.endsWith('.txt')) return 'llms-txt';
return 'normal';
};
return (
<div className="fixed inset-0 bg-gray-500/50 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-2xl relative before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-[1px] before:bg-green-500 p-8">
<h2 className="text-xl font-bold text-gray-800 dark:text-white mb-8">
Add Knowledge Source
</h2>
{/* Knowledge Type Selection */}
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-2">
Knowledge Type
</label>
<div className="flex gap-4">
<label className={`
flex-1 p-4 rounded-md border cursor-pointer transition flex items-center justify-center gap-2
${knowledgeType === 'technical'
? 'border-blue-500 text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-blue-300 dark:hover:border-blue-500/30'}
`}>
<input
type="radio"
name="knowledgeType"
value="technical"
checked={knowledgeType === 'technical'}
onChange={() => setKnowledgeType('technical')}
className="sr-only"
/>
<BoxIcon className="w-5 h-5" />
<span>Technical/Coding</span>
</label>
<label className={`
flex-1 p-4 rounded-md border cursor-pointer transition flex items-center justify-center gap-2
${knowledgeType === 'business'
? 'border-purple-500 text-purple-600 dark:text-purple-500 bg-purple-50 dark:bg-purple-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-purple-300 dark:hover:border-purple-500/30'}
`}>
<input
type="radio"
name="knowledgeType"
value="business"
checked={knowledgeType === 'business'}
onChange={() => setKnowledgeType('business')}
className="sr-only"
/>
<Brain className="w-5 h-5" />
<span>Business/Project</span>
</label>
</div>
</div>
{/* Source Type Selection */}
<div className="flex gap-4 mb-6">
<button
onClick={() => setMethod('url')}
className={`flex-1 p-4 rounded-md border transition flex items-center justify-center gap-2
${method === 'url'
? 'border-blue-500 text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-blue-300 dark:hover:border-blue-500/30'}`}
>
<LinkIcon className="w-4 h-4" />
<span>URL / Website</span>
</button>
<button
onClick={() => setMethod('file')}
className={`flex-1 p-4 rounded-md border transition flex items-center justify-center gap-2
${method === 'file'
? 'border-pink-500 text-pink-600 dark:text-pink-500 bg-pink-50 dark:bg-pink-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-pink-300 dark:hover:border-pink-500/30'}`}
>
<Upload className="w-4 h-4" />
<span>Upload File</span>
</button>
</div>
{/* URL Input */}
{method === 'url' && (
<div className="mb-6">
<Input
label="URL to Scrape"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com or example.com"
accentColor="blue"
/>
{url && !url.startsWith('http://') && !url.startsWith('https://') && (
<p className="text-amber-600 dark:text-amber-400 text-sm mt-1">
Will automatically add https:// prefix
</p>
)}
</div>
)}
{/* File Upload */}
{method === 'file' && (
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-2">
Upload Document
</label>
<div className="relative">
<input
id="file-upload"
type="file"
accept=".pdf,.md,.doc,.docx,.txt"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="sr-only"
/>
<label
htmlFor="file-upload"
className="flex items-center justify-center gap-3 w-full p-6 rounded-md border-2 border-dashed cursor-pointer transition-all duration-300
bg-blue-500/10 hover:bg-blue-500/20
border-blue-500/30 hover:border-blue-500/50
text-blue-600 dark:text-blue-400
hover:shadow-[0_0_15px_rgba(59,130,246,0.3)]
backdrop-blur-sm"
>
<Upload className="w-6 h-6" />
<div className="text-center">
<div className="font-medium">
{selectedFile ? selectedFile.name : 'Choose File'}
</div>
<div className="text-sm opacity-75 mt-1">
{selectedFile
? `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB`
: 'Click to browse or drag and drop'}
</div>
</div>
</label>
</div>
<p className="text-gray-500 dark:text-zinc-600 text-sm mt-2">
Supports PDF, MD, DOC up to 10MB
</p>
</div>
)}
{/* Crawl Depth - Only for URLs */}
{method === 'url' && (
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-4">
Crawl Depth
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onMouseEnter={() => setShowDepthTooltip(true)}
onMouseLeave={() => setShowDepthTooltip(false)}
>
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</label>
<GlassCrawlDepthSelector
value={crawlDepth}
onChange={setCrawlDepth}
showTooltip={showDepthTooltip}
onTooltipToggle={setShowDepthTooltip}
/>
</div>
)}
{/* Tags */}
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-2">
Tags (AI will add recommended tags if left blank)
</label>
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((tag) => (
<Badge key={tag} color="purple" variant="outline">
{tag}
<button
onClick={() => setTags(tags.filter(t => t !== tag))}
className="ml-1 text-purple-600 hover:text-purple-800"
>
×
</button>
</Badge>
))}
</div>
<Input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newTag.trim()) {
setTags([...tags, newTag.trim()]);
setNewTag('');
}
}}
placeholder="Add tags..."
accentColor="purple"
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-4">
<Button onClick={onClose} variant="ghost" disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="primary"
accentColor={method === 'url' ? 'blue' : 'pink'}
disabled={loading}
>
{loading ? 'Adding...' : 'Add Source'}
</Button>
</div>
</Card>
</div>
);
};

View File

@@ -1,760 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Activity,
AlertTriangle,
CheckCircle,
ChevronDown,
ChevronUp,
Clock,
Globe,
FileText,
RotateCcw,
X,
FileCode,
Upload,
Search,
Cpu,
Database,
Code,
Zap,
Square,
Layers,
Download
} from 'lucide-react';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { CrawlProgressData } from '../../types/crawl';
import { useCrawlProgressPolling } from '../../hooks/useCrawlQueries';
import { useTerminalScroll } from '../../hooks/useTerminalScroll';
interface CrawlingProgressCardProps {
progressId: string;
initialData?: Partial<CrawlProgressData>;
onComplete?: (data: CrawlProgressData) => void;
onError?: (error: string) => void;
onRetry?: () => void;
onDismiss?: () => void;
onStop?: () => void;
}
// Simple mapping of backend status to UI display
const STATUS_CONFIG = {
// Common statuses
'starting': { label: 'Starting', icon: <Activity className="w-4 h-4" />, color: 'blue' },
'initializing': { label: 'Initializing', icon: <Activity className="w-4 h-4" />, color: 'blue' },
// Crawl statuses
'analyzing': { label: 'Analyzing URL', icon: <Search className="w-4 h-4" />, color: 'purple' },
'crawling': { label: 'Crawling Pages', icon: <Globe className="w-4 h-4" />, color: 'blue' },
'processing': { label: 'Processing Content', icon: <Cpu className="w-4 h-4" />, color: 'cyan' },
'source_creation': { label: 'Creating Source', icon: <FileText className="w-4 h-4" />, color: 'indigo' },
'document_storage': { label: 'Storing Documents', icon: <Database className="w-4 h-4" />, color: 'green' },
'code_extraction': { label: 'Extracting Code', icon: <Code className="w-4 h-4" />, color: 'yellow' },
'finalization': { label: 'Finalizing', icon: <Zap className="w-4 h-4" />, color: 'orange' },
// Upload statuses
'reading': { label: 'Reading File', icon: <Download className="w-4 h-4" />, color: 'blue' },
'extracting': { label: 'Extracting Text', icon: <FileText className="w-4 h-4" />, color: 'blue' },
'chunking': { label: 'Chunking Content', icon: <Cpu className="w-4 h-4" />, color: 'blue' },
'creating_source': { label: 'Creating Source', icon: <Database className="w-4 h-4" />, color: 'blue' },
'summarizing': { label: 'Generating Summary', icon: <Search className="w-4 h-4" />, color: 'purple' },
'storing': { label: 'Storing Chunks', icon: <Database className="w-4 h-4" />, color: 'green' },
// End states
'completed': { label: 'Completed', icon: <CheckCircle className="w-4 h-4" />, color: 'green' },
'error': { label: 'Error', icon: <AlertTriangle className="w-4 h-4" />, color: 'red' },
'failed': { label: 'Failed', icon: <AlertTriangle className="w-4 h-4" />, color: 'red' },
'cancelled': { label: 'Cancelled', icon: <X className="w-4 h-4" />, color: 'gray' },
'stopping': { label: 'Stopping', icon: <Square className="w-4 h-4" />, color: 'orange' },
} as const;
export const CrawlingProgressCard: React.FC<CrawlingProgressCardProps> = ({
progressId,
initialData,
onComplete,
onError,
onRetry,
onDismiss,
onStop
}) => {
const [showDetailedProgress, setShowDetailedProgress] = useState(true);
const [showLogs, setShowLogs] = useState(false);
const [isStopping, setIsStopping] = useState(false);
// Track completion/error handling
const [hasHandledCompletion, setHasHandledCompletion] = useState(false);
const [hasHandledError, setHasHandledError] = useState(false);
// Poll for progress updates
const { data: progressData } = useCrawlProgressPolling(progressId, {
onError: (error: Error) => {
if (error.message === 'Resource no longer exists') {
if (onDismiss) {
onDismiss();
}
}
}
});
// Merge polled data with initial data - preserve important fields
const displayData = progressData ? {
...initialData,
...progressData,
// Ensure we don't lose these fields during polling
currentUrl: progressData.currentUrl || progressData.current_url || initialData?.currentUrl,
crawlType: progressData.crawlType || progressData.crawl_type || initialData?.crawlType,
} : {
progressId,
status: 'starting',
progress: 0,
message: 'Initializing...',
...initialData
} as CrawlProgressData;
// Use terminal scroll hook for logs
const logsContainerRef = useTerminalScroll(
displayData?.logs || [],
showLogs
);
// Handle status changes
useEffect(() => {
if (!progressData) return;
if (progressData.status === 'completed' && !hasHandledCompletion && onComplete) {
setHasHandledCompletion(true);
onComplete(progressData);
} else if ((progressData.status === 'error' || progressData.status === 'failed') && !hasHandledError && onError) {
setHasHandledError(true);
onError(progressData.error || 'Unknown error');
}
}, [progressData?.status, hasHandledCompletion, hasHandledError, onComplete, onError]);
// Get current status config with better fallback
const statusConfig = (() => {
const config = STATUS_CONFIG[displayData.status as keyof typeof STATUS_CONFIG];
if (config) {
return config;
}
// Better fallbacks based on progress
if (displayData.progress >= 100) {
return STATUS_CONFIG.completed;
}
if (displayData.progress > 90) {
return STATUS_CONFIG.finalization;
}
// Log unknown statuses for debugging
console.warn(`Unknown status: ${displayData.status}, progress: ${displayData.progress}%, message: ${displayData.message}`);
return STATUS_CONFIG.processing;
})();
// Debug log for status transitions
useEffect(() => {
if (displayData.status === 'finalization' ||
(displayData.status === 'starting' && displayData.progress > 90)) {
console.log('Status transition debug:', {
status: displayData.status,
progress: displayData.progress,
message: displayData.message,
hasStatusConfig: !!STATUS_CONFIG[displayData.status as keyof typeof STATUS_CONFIG]
});
}
}, [displayData.status, displayData.progress]);
// Determine crawl type display
const getCrawlTypeDisplay = () => {
const crawlType = displayData.crawlType ||
(displayData.uploadType === 'document' ? 'upload' : 'normal');
switch (crawlType) {
case 'sitemap':
return { icon: <Layers className="w-4 h-4" />, label: 'Sitemap Crawl' };
case 'llms-txt':
case 'text_file':
return { icon: <FileCode className="w-4 h-4" />, label: 'LLMs.txt Import' };
case 'upload':
return { icon: <Upload className="w-4 h-4" />, label: 'Document Upload' };
default:
return { icon: <Globe className="w-4 h-4" />, label: 'Web Crawl' };
}
};
const crawlType = getCrawlTypeDisplay();
// Handle stop
const handleStop = async () => {
if (isStopping || !onStop) return;
setIsStopping(true);
try {
onStop();
} finally {
setIsStopping(false);
}
};
// Get progress steps based on type
const getProgressSteps = () => {
const isUpload = displayData.uploadType === 'document';
const steps = isUpload ? [
'reading', 'extracting', 'chunking', 'creating_source', 'summarizing', 'storing'
] : [
'analyzing', 'crawling', 'processing', 'source_creation', 'document_storage', 'code_extraction', 'finalization'
];
return steps.map(stepId => {
const config = STATUS_CONFIG[stepId as keyof typeof STATUS_CONFIG];
const currentIndex = steps.indexOf(displayData.status || '');
const stepIndex = steps.indexOf(stepId);
let status: 'pending' | 'active' | 'completed' | 'error' = 'pending';
if (displayData.status === 'completed') {
status = 'completed';
} else if (displayData.status === 'error' || displayData.status === 'failed') {
status = stepIndex <= currentIndex ? 'error' : 'pending';
} else if (stepIndex < currentIndex) {
status = 'completed';
} else if (stepIndex === currentIndex) {
status = 'active';
}
return {
id: stepId,
label: config.label,
icon: config.icon,
status
};
});
};
const progressSteps = getProgressSteps();
const isActive = !['completed', 'error', 'failed', 'cancelled'].includes(displayData.status || '');
return (
<Card className="relative overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<Badge color={crawlType.label.includes('Sitemap') ? 'purple' : 'blue'} variant="solid">
{crawlType.icon}
<span className="ml-1">{crawlType.label}</span>
</Badge>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`
${statusConfig.color === 'green' ? 'text-green-600 dark:text-green-400' :
statusConfig.color === 'red' ? 'text-red-600 dark:text-red-400' :
statusConfig.color === 'blue' ? 'text-blue-600 dark:text-blue-400' :
statusConfig.color === 'purple' ? 'text-purple-600 dark:text-purple-400' :
statusConfig.color === 'orange' ? 'text-orange-600 dark:text-orange-400' :
'text-gray-600 dark:text-gray-400'}
font-medium
`}>
{statusConfig.label}
</span>
{isActive && (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
{statusConfig.icon}
</motion.div>
)}
</div>
{displayData.currentUrl && (
<p className="text-sm text-gray-500 dark:text-zinc-400 truncate">
{displayData.currentUrl}
</p>
)}
</div>
{/* Stop button */}
{isActive && onStop && (
<Button
onClick={handleStop}
disabled={isStopping}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 dark:text-red-400"
>
<Square className="w-4 h-4" />
</Button>
)}
</div>
{/* Main Progress Bar */}
{isActive && (
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Overall Progress
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{Math.round(displayData.progress || 0)}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-zinc-700 rounded-full h-2">
<motion.div
className="h-2 rounded-full bg-gradient-to-r from-blue-500 to-blue-600"
initial={{ width: 0 }}
animate={{ width: `${Math.max(0, Math.min(100, displayData.progress || 0))}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
{/* Current message with numeric progress */}
{displayData.message && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{displayData.message}
{displayData.status === 'crawling' && displayData.totalPages !== undefined && displayData.totalPages > 0 && (
<span className="ml-2 font-medium">
({displayData.processedPages || 0}/{displayData.totalPages} pages)
</span>
)}
</p>
)}
</div>
)}
{/* Finalization Progress */}
{isActive && displayData.status === 'finalization' && (
<div className="mb-4 p-3 bg-orange-50 dark:bg-orange-500/10 border border-orange-200 dark:border-orange-500/20 rounded-md">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
<span className="text-sm font-medium text-orange-700 dark:text-orange-400">
Finalizing Results
</span>
</div>
<p className="text-xs text-orange-600 dark:text-orange-400/80 mt-1">
Completing crawl and saving final metadata...
</p>
</div>
)}
{/* Crawling Statistics - Show detailed crawl progress */}
{isActive && displayData.status === 'crawling' && (displayData.totalPages > 0 || displayData.processedPages > 0) && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-md">
<div className="flex items-center gap-2 mb-2">
<Globe className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
Crawling Progress
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-blue-600 dark:text-blue-400/80">Pages Discovered</div>
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">
{displayData.totalPages || 0}
</div>
</div>
<div>
<div className="text-xs text-blue-600 dark:text-blue-400/80">Pages Processed</div>
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">
{displayData.processedPages || 0}
</div>
</div>
</div>
{displayData.currentUrl && (
<div className="mt-2 pt-2 border-t border-blue-200/50 dark:border-blue-500/20">
<div className="text-xs text-blue-600 dark:text-blue-400/80">Currently crawling:</div>
<div className="text-xs text-blue-700 dark:text-blue-300 truncate mt-1">
{displayData.currentUrl}
</div>
</div>
)}
</div>
)}
{/* Code Extraction Progress - Special handling for long-running step */}
{isActive && displayData.status === 'code_extraction' && (
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-md">
<div className="flex items-center gap-2 mb-2">
<Code className="w-4 h-4 text-yellow-600 dark:text-yellow-400 animate-pulse" />
<span className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
Extracting Code Examples
</span>
</div>
{/* Show document scanning progress if available */}
{(displayData.completedDocuments !== undefined || displayData.totalDocuments !== undefined) &&
displayData.completedDocuments < displayData.totalDocuments && (
<div className="mb-2">
<div className="text-xs text-yellow-600 dark:text-yellow-400/80">
Scanning documents: {displayData.completedDocuments || 0} / {displayData.totalDocuments || 0}
</div>
<div className="w-full bg-yellow-200/50 dark:bg-yellow-700/30 rounded-full h-1.5 mt-1">
<div
className="h-1.5 rounded-full bg-yellow-500 dark:bg-yellow-400"
style={{
width: `${Math.round(((displayData.completedDocuments || 0) / Math.max(1, displayData.totalDocuments || 1)) * 100)}%`
}}
/>
</div>
</div>
)}
{/* Show summary generation progress */}
{(displayData.completedSummaries !== undefined || displayData.totalSummaries !== undefined) && displayData.totalSummaries > 0 && (
<div className="mb-2">
<div className="text-xs text-yellow-600 dark:text-yellow-400/80">
Generating summaries: {displayData.completedSummaries || 0} / {displayData.totalSummaries || 0}
</div>
<div className="w-full bg-yellow-200/50 dark:bg-yellow-700/30 rounded-full h-1.5 mt-1">
<div
className="h-1.5 rounded-full bg-yellow-500 dark:bg-yellow-400"
style={{
width: `${Math.round(((displayData.completedSummaries || 0) / Math.max(1, displayData.totalSummaries || 1)) * 100)}%`
}}
/>
</div>
</div>
)}
{/* Show code blocks found and stored */}
<div className="grid grid-cols-2 gap-3">
{displayData.codeBlocksFound !== undefined && (
<div>
<div className="text-xs text-yellow-600 dark:text-yellow-400/80">Code Blocks Found</div>
<div className="text-2xl font-bold text-yellow-700 dark:text-yellow-300">
{displayData.codeBlocksFound}
</div>
</div>
)}
{displayData.codeExamplesStored !== undefined && (
<div>
<div className="text-xs text-yellow-600 dark:text-yellow-400/80">Examples Stored</div>
<div className="text-2xl font-bold text-yellow-700 dark:text-yellow-300">
{displayData.codeExamplesStored}
</div>
</div>
)}
</div>
{/* Fallback to details if main fields not available */}
{!displayData.codeBlocksFound && displayData.details?.codeBlocksFound !== undefined && (
<div className="flex items-center gap-4">
<div>
<span className="text-2xl font-bold text-yellow-700 dark:text-yellow-300">
{displayData.details.codeBlocksFound}
</span>
<span className="text-sm text-yellow-600 dark:text-yellow-400 ml-2">
code blocks found
</span>
</div>
{displayData.details?.totalChunks && (
<div className="text-xs text-yellow-600 dark:text-yellow-400/60">
Scanning chunk {displayData.details.currentChunk || 0} of {displayData.details.totalChunks}
</div>
)}
</div>
)}
<p className="text-xs text-yellow-600 dark:text-yellow-400/60 mt-2">
{displayData.completedSummaries !== undefined && displayData.totalSummaries > 0
? `Generating AI summaries for ${displayData.totalSummaries} code examples...`
: displayData.completedDocuments !== undefined && displayData.totalDocuments > 0
? `Scanning ${displayData.totalDocuments} document(s) for code blocks...`
: 'Analyzing content for code examples...'}
</p>
</div>
)}
{/* Real-time Processing Stats */}
{isActive && displayData.status === 'document_storage' && (
<div className="mb-4 grid grid-cols-2 gap-3">
{displayData.details?.currentChunk !== undefined && displayData.details?.totalChunks && (
<div className="p-2 bg-blue-50 dark:bg-blue-500/10 rounded-md">
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium">Chunks Processing</div>
<div className="text-lg font-bold text-blue-700 dark:text-blue-300">
{displayData.details.currentChunk} / {displayData.details.totalChunks}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400/80">
{Math.round((displayData.details.currentChunk / displayData.details.totalChunks) * 100)}% complete
</div>
</div>
)}
{displayData.details?.embeddingsCreated !== undefined && (
<div className="p-2 bg-green-50 dark:bg-green-500/10 rounded-md">
<div className="text-xs text-green-600 dark:text-green-400 font-medium">Embeddings</div>
<div className="text-lg font-bold text-green-700 dark:text-green-300">
{displayData.details.embeddingsCreated}
</div>
<div className="text-xs text-green-600 dark:text-green-400/80">created</div>
</div>
)}
{displayData.details?.codeBlocksFound !== undefined && displayData.status === 'code_extraction' && (
<div className="p-2 bg-yellow-50 dark:bg-yellow-500/10 rounded-md">
<div className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">Code Blocks</div>
<div className="text-lg font-bold text-yellow-700 dark:text-yellow-300">
{displayData.details.codeBlocksFound}
</div>
<div className="text-xs text-yellow-600 dark:text-yellow-400/80">extracted</div>
</div>
)}
{displayData.details?.chunksPerSecond && (
<div className="p-2 bg-purple-50 dark:bg-purple-500/10 rounded-md">
<div className="text-xs text-purple-600 dark:text-purple-400 font-medium">Processing Speed</div>
<div className="text-lg font-bold text-purple-700 dark:text-purple-300">
{displayData.details.chunksPerSecond.toFixed(1)}
</div>
<div className="text-xs text-purple-600 dark:text-purple-400/80">chunks/sec</div>
</div>
)}
{displayData.details?.estimatedTimeRemaining && (
<div className="p-2 bg-orange-50 dark:bg-orange-500/10 rounded-md">
<div className="text-xs text-orange-600 dark:text-orange-400 font-medium">Time Remaining</div>
<div className="text-lg font-bold text-orange-700 dark:text-orange-300">
{Math.ceil(displayData.details.estimatedTimeRemaining / 60)}m
</div>
<div className="text-xs text-orange-600 dark:text-orange-400/80">estimated</div>
</div>
)}
</div>
)}
{/* Batch Processing Info - Enhanced */}
{(() => {
const shouldShowBatch = displayData.totalBatches && displayData.totalBatches > 0 && isActive && displayData.status === 'document_storage';
return shouldShowBatch;
})() && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-md">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-pulse" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-400">
Batch Processing
</span>
</div>
<span className="text-sm font-bold text-blue-600 dark:text-blue-400">
{displayData.completedBatches || 0}/{displayData.totalBatches} batches
</span>
</div>
{/* Batch progress bar */}
<div className="w-full bg-blue-200 dark:bg-blue-900/50 rounded-full h-1.5 mb-2">
<motion.div
className="h-1.5 rounded-full bg-blue-500 dark:bg-blue-400"
initial={{ width: 0 }}
animate={{
width: `${Math.round(((displayData.completedBatches || 0) / displayData.totalBatches) * 100)}%`
}}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{displayData.activeWorkers !== undefined && (
<div className="text-blue-600 dark:text-blue-400/80">
<span className="font-medium">{displayData.activeWorkers}</span> parallel {displayData.activeWorkers === 1 ? 'worker' : 'workers'}
</div>
)}
{displayData.currentBatch && displayData.totalChunksInBatch && (
<div className="text-blue-600 dark:text-blue-400/80">
Current: <span className="font-medium">{displayData.chunksInBatch || 0}/{displayData.totalChunksInBatch}</span> chunks
</div>
)}
{displayData.details?.totalChunks && (
<div className="text-blue-600 dark:text-blue-400/80 col-span-2">
Total progress: <span className="font-medium">{displayData.details.currentChunk || 0}/{displayData.details.totalChunks}</span> chunks processed
</div>
)}
</div>
</div>
)}
{/* Detailed Progress Steps */}
{isActive && (
<div className="mb-4">
<button
onClick={() => setShowDetailedProgress(!showDetailedProgress)}
className="flex items-center gap-2 text-sm text-gray-600 dark:text-zinc-400 hover:text-gray-800 dark:hover:text-white transition-colors"
>
<FileText className="w-4 h-4" />
<span>Detailed Progress</span>
{showDetailedProgress ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
)}
<AnimatePresence>
{showDetailedProgress && isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden mb-4"
>
<div className="space-y-2 p-3 bg-gray-50 dark:bg-zinc-900/50 rounded-md">
{progressSteps.map((step) => (
<div key={step.id} className="flex items-center gap-3">
<div className={`
p-1.5 rounded-md
${step.status === 'completed' ? 'bg-green-100 dark:bg-green-500/10 text-green-600 dark:text-green-400' :
step.status === 'active' ? 'bg-blue-100 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400' :
step.status === 'error' ? 'bg-red-100 dark:bg-red-500/10 text-red-600 dark:text-red-400' :
'bg-gray-100 dark:bg-gray-500/10 text-gray-400 dark:text-gray-600'}
`}>
{step.status === 'active' ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
{step.icon}
</motion.div>
) : (
step.icon
)}
</div>
<div className="flex-1">
<span className={`
text-sm
${step.status === 'active' ? 'font-medium text-gray-700 dark:text-gray-300' :
step.status === 'completed' ? 'text-gray-600 dark:text-gray-400' :
'text-gray-400 dark:text-gray-600'}
`}>
{step.label}
</span>
{/* Show detailed progress for active step */}
{step.status === 'active' && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{step.id === 'document_storage' && displayData.completedBatches !== undefined && displayData.totalBatches ? (
<span>Batch {displayData.completedBatches + 1} of {displayData.totalBatches}</span>
) : step.id === 'code_extraction' && displayData.details?.codeBlocksFound !== undefined ? (
<span>{displayData.details.codeBlocksFound} code blocks found</span>
) : step.id === 'crawling' && (displayData.processedPages !== undefined || displayData.totalPages !== undefined) ? (
<span>
{displayData.processedPages !== undefined ? displayData.processedPages : '?'} of {displayData.totalPages !== undefined ? displayData.totalPages : '?'} pages
</span>
) : displayData.message ? (
<span>{displayData.message}</span>
) : null}
</div>
)}
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Statistics */}
{(displayData.status === 'completed' || !isActive) && (
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
{displayData.totalPages && (
<div>
<span className="text-gray-500 dark:text-zinc-400">Pages:</span>
<span className="ml-2 font-medium text-gray-800 dark:text-white">
{displayData.processedPages || 0} / {displayData.totalPages}
</span>
</div>
)}
{displayData.chunksStored && (
<div>
<span className="text-gray-500 dark:text-zinc-400">Chunks:</span>
<span className="ml-2 font-medium text-gray-800 dark:text-white">
{displayData.chunksStored}
</span>
</div>
)}
{displayData.details?.embeddingsCreated && (
<div>
<span className="text-gray-500 dark:text-zinc-400">Embeddings:</span>
<span className="ml-2 font-medium text-gray-800 dark:text-white">
{displayData.details.embeddingsCreated}
</span>
</div>
)}
{displayData.details?.codeBlocksFound && (
<div>
<span className="text-gray-500 dark:text-zinc-400">Code Blocks:</span>
<span className="ml-2 font-medium text-gray-800 dark:text-white">
{displayData.details.codeBlocksFound}
</span>
</div>
)}
</div>
)}
{/* Error Message */}
{displayData.error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-md">
<p className="text-red-700 dark:text-red-400 text-sm">
{displayData.error}
</p>
</div>
)}
{/* Console Logs */}
{displayData.logs && displayData.logs.length > 0 && (
<div className="border-t border-gray-200 dark:border-zinc-800 pt-4">
<button
onClick={() => setShowLogs(!showLogs)}
className="flex items-center gap-2 text-sm text-gray-600 dark:text-zinc-400 hover:text-gray-800 dark:hover:text-white transition-colors mb-2"
>
<FileText className="w-4 h-4" />
<span>Console Output ({displayData.logs.length} lines)</span>
{showLogs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<AnimatePresence>
{showLogs && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
ref={logsContainerRef}
className="bg-gray-900 dark:bg-black rounded-md p-3 max-h-48 overflow-y-auto"
>
<div className="space-y-1 font-mono text-xs">
{displayData.logs.map((log, index) => (
<div key={index} className="text-green-400">
{log}
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Action Buttons */}
{(displayData.status === 'error' || displayData.status === 'failed' || displayData.status === 'cancelled') && (
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-zinc-800">
{onDismiss && (
<Button onClick={onDismiss} variant="ghost" size="sm">
<X className="w-4 h-4 mr-1" />
Dismiss
</Button>
)}
{onRetry && (
<Button onClick={onRetry} variant="primary" size="sm">
<RotateCcw className="w-4 h-4 mr-1" />
Retry
</Button>
)}
</div>
)}
</Card>
);
};

View File

@@ -1,112 +0,0 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { CrawlingProgressCard } from './CrawlingProgressCard';
import { CrawlProgressData } from '../../types/crawl';
import { AlertCircle } from 'lucide-react';
interface CrawlingTabProps {
progressItems: CrawlProgressData[];
onProgressComplete: (data: CrawlProgressData) => void;
onProgressError: (error: string, progressId?: string) => void;
onRetryProgress: (progressId: string) => void;
onStopProgress: (progressId: string) => void;
onDismissProgress: (progressId: string) => void;
}
export const CrawlingTab = ({
progressItems,
onProgressComplete,
onProgressError,
onRetryProgress,
onStopProgress,
onDismissProgress
}: CrawlingTabProps) => {
// Group progress items by type for better organization
const groupedItems = progressItems.reduce((acc, item) => {
const type = item.crawlType || (item.uploadType === 'document' ? 'upload' : 'normal');
if (!acc[type]) acc[type] = [];
acc[type].push(item);
return acc;
}, {} as Record<string, CrawlProgressData[]>);
const getSectionTitle = (type: string) => {
switch (type) {
case 'sitemap': return 'Sitemap Crawls';
case 'llms-txt': return 'LLMs.txt Crawls';
case 'upload': return 'Document Uploads';
case 'refresh': return 'Refreshing Sources';
default: return 'Web Crawls';
}
};
const getSectionDescription = (type: string) => {
switch (type) {
case 'sitemap':
return 'Processing sitemap.xml files to discover and crawl all listed pages';
case 'llms-txt':
return 'Extracting content from llms.txt files for AI model training';
case 'upload':
return 'Processing uploaded documents and extracting content';
case 'refresh':
return 'Re-crawling existing sources to update content';
default:
return 'Recursively crawling websites to extract knowledge';
}
};
if (progressItems.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 dark:text-zinc-600 mb-4" />
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-300 mb-2">
No Active Crawls
</h3>
<p className="text-gray-500 dark:text-zinc-500 max-w-md">
Start crawling a website or uploading a document to see progress here
</p>
</div>
);
}
return (
<div className="space-y-6">
<AnimatePresence mode="sync">
{Object.entries(groupedItems).map(([type, items]) => (
<motion.div
key={type}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-4"
>
{/* Section Header */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-300 uppercase tracking-wider">
{getSectionTitle(type)}
</h3>
<p className="text-xs text-gray-500 dark:text-zinc-500 mt-1">
{getSectionDescription(type)}
</p>
</div>
{/* Progress Cards */}
<div className="space-y-3">
{items.map((progressData) => (
<CrawlingProgressCard
key={progressData.progressId}
progressId={progressData.progressId}
initialData={progressData}
onComplete={onProgressComplete}
onError={(error) => onProgressError(error, progressData.progressId)}
onRetry={() => onRetryProgress(progressData.progressId)}
onDismiss={() => onDismissProgress(progressData.progressId)}
onStop={() => onStopProgress(progressData.progressId)}
/>
))}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
};

View File

@@ -1,319 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Search, Filter, FileText, Globe, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import { knowledgeBaseService } from '../../services/knowledgeBaseService';
interface DocumentChunk {
id: string;
source_id: string;
content: string;
metadata?: any;
url?: string;
}
interface DocumentBrowserProps {
sourceId: string;
isOpen: boolean;
onClose: () => void;
}
const extractDomain = (url: string): string => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
// Remove 'www.' prefix if present
const withoutWww = hostname.startsWith('www.') ? hostname.slice(4) : hostname;
// Keep full hostname (minus 'www.') to preserve subdomain-level filtering
return withoutWww;
} catch {
return url; // Return original if URL parsing fails
}
};
export const DocumentBrowser: React.FC<DocumentBrowserProps> = ({
sourceId,
isOpen,
onClose,
}) => {
const [chunks, setChunks] = useState<DocumentChunk[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedDomain, setSelectedDomain] = useState<string>('all');
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Extract unique domains from chunks
const domains = useMemo(() => {
const domainSet = new Set<string>();
chunks.forEach(chunk => {
if (chunk.url) {
domainSet.add(extractDomain(chunk.url));
}
});
return Array.from(domainSet).sort();
}, [chunks]);
// Filter chunks based on search and domain
const filteredChunks = useMemo(() => {
return chunks.filter(chunk => {
// Search filter
const searchLower = searchQuery.toLowerCase();
const searchMatch = !searchQuery ||
chunk.content.toLowerCase().includes(searchLower) ||
chunk.url?.toLowerCase().includes(searchLower);
// Domain filter
const domainMatch = selectedDomain === 'all' ||
(chunk.url && extractDomain(chunk.url) === selectedDomain);
return searchMatch && domainMatch;
});
}, [chunks, searchQuery, selectedDomain]);
// Get selected chunk
const selectedChunk = useMemo(() => {
return filteredChunks.find(chunk => chunk.id === selectedChunkId) || filteredChunks[0];
}, [filteredChunks, selectedChunkId]);
// Load chunks when component opens
useEffect(() => {
if (isOpen && sourceId) {
loadChunks();
}
}, [isOpen, sourceId]);
const loadChunks = async () => {
try {
setLoading(true);
setError(null);
const response = await knowledgeBaseService.getKnowledgeItemChunks(sourceId);
if (response.success) {
setChunks(response.chunks);
// Auto-select first chunk if none selected
if (response.chunks.length > 0 && !selectedChunkId) {
setSelectedChunkId(response.chunks[0].id);
}
} else {
setError('Failed to load document chunks');
}
} catch (error) {
console.error('Failed to load chunks:', error);
setError(error instanceof Error ? error.message : 'Failed to load document chunks');
} finally {
setLoading(false);
}
};
const loadChunksWithDomainFilter = async (domain: string) => {
try {
setLoading(true);
setError(null);
const domainFilter = domain === 'all' ? undefined : domain;
const response = await knowledgeBaseService.getKnowledgeItemChunks(sourceId, domainFilter);
if (response.success) {
setChunks(response.chunks);
} else {
setError('Failed to load document chunks');
}
} catch (error) {
console.error('Failed to load chunks with domain filter:', error);
setError(error instanceof Error ? error.message : 'Failed to load document chunks');
} finally {
setLoading(false);
}
};
const handleDomainChange = (domain: string) => {
setSelectedDomain(domain);
// Note: We could reload with server-side filtering, but for now we'll do client-side filtering
// loadChunksWithDomainFilter(domain);
};
if (!isOpen) return null;
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 flex items-center justify-center z-50 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative bg-gray-900/95 border border-gray-800 rounded-xl w-full max-w-7xl h-[85vh] flex overflow-hidden shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Blue accent line at the top */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 to-cyan-500 shadow-[0_0_20px_5px_rgba(59,130,246,0.5)]"></div>
{/* Sidebar */}
<div className="w-80 bg-gray-950/50 border-r border-gray-800 flex flex-col overflow-hidden">
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-800">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-blue-400">
Document Chunks ({(filteredChunks || []).length})
</h3>
</div>
{/* Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Search documents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 bg-gray-900/70 border border-gray-800 rounded-lg text-sm text-gray-300 placeholder-gray-600 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/20 transition-all"
/>
</div>
{/* Domain Filter */}
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-gray-500" />
<select
value={selectedDomain}
onChange={(e) => handleDomainChange(e.target.value)}
className="flex-1 bg-gray-900/70 border border-gray-800 rounded-lg text-sm text-gray-300 px-3 py-2 focus:outline-none focus:border-blue-500/50"
>
<option value="all">All Domains</option>
{domains?.map(domain => (
<option key={domain} value={domain}>{domain}</option>
)) || []}
</select>
</div>
</div>
{/* Document List */}
<div className="flex-1 overflow-y-auto p-2">
{filteredChunks.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-8">
No documents found
</div>
) : (
filteredChunks.map((chunk, index) => (
<button
key={chunk.id}
onClick={() => setSelectedChunkId(chunk.id)}
className={`w-full text-left p-3 mb-1 rounded-lg transition-all duration-200 ${
selectedChunk?.id === chunk.id
? 'bg-blue-500/20 border border-blue-500/40 shadow-[0_0_15px_rgba(59,130,246,0.2)]'
: 'hover:bg-gray-800/50 border border-transparent'
}`}
>
<div className="flex items-start gap-2">
<FileText className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
selectedChunk?.id === chunk.id ? 'text-blue-400' : 'text-gray-500'
}`} />
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium ${
selectedChunk?.id === chunk.id ? 'text-blue-300' : 'text-gray-300'
} line-clamp-1`}>
Chunk {index + 1}
</div>
<div className="text-xs text-gray-500 line-clamp-2 mt-0.5">
{chunk.content?.substring(0, 100) || 'No content'}...
</div>
{chunk.url && (
<div className="text-xs text-blue-400 mt-1 truncate">
{extractDomain(chunk.url)}
</div>
)}
</div>
</div>
</button>
))
)}
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-blue-400">
{selectedChunk ? `Document Chunk` : 'Document Browser'}
</h2>
{selectedChunk?.url && (
<Badge color="blue" className="flex items-center gap-1">
<Globe className="w-3 h-3" />
{extractDomain(selectedChunk.url)}
</Badge>
)}
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-white p-1 rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-400 mx-auto mb-4"></div>
<p className="text-gray-400">Loading document chunks...</p>
</div>
</div>
) : !selectedChunk || filteredChunks.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">Select a document chunk to view content</p>
</div>
</div>
) : (
<div className="h-full p-4">
<div className="bg-gray-900/70 rounded-lg border border-gray-800 h-full overflow-auto">
<div className="p-6">
{selectedChunk.url && (
<div className="text-sm text-blue-400 mb-4 font-mono">
{selectedChunk.url}
</div>
)}
<div className="prose prose-sm prose-invert max-w-none">
<div className="text-gray-300 whitespace-pre-wrap leading-relaxed">
{selectedChunk.content || 'No content available'}
</div>
</div>
{selectedChunk.metadata && (
<div className="mt-6 pt-4 border-t border-gray-700">
<details className="text-sm text-gray-400">
<summary className="cursor-pointer hover:text-gray-300 font-medium">
View Metadata
</summary>
<pre className="mt-3 bg-gray-800 p-3 rounded text-xs overflow-x-auto text-gray-300">
{JSON.stringify(selectedChunk.metadata, null, 2)}
</pre>
</details>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</motion.div>
</motion.div>,
document.body
);
};

View File

@@ -1,277 +0,0 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion';
import { X, Save, RefreshCw, Users, UserX } from 'lucide-react';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { KnowledgeItem } from '../../services/knowledgeBaseService';
import { knowledgeBaseService } from '../../services/knowledgeBaseService';
import { useToast } from '../../contexts/ToastContext';
interface EditKnowledgeItemModalProps {
item: KnowledgeItem;
onClose: () => void;
onUpdate: () => void;
}
export const EditKnowledgeItemModal: React.FC<EditKnowledgeItemModalProps> = ({
item,
onClose,
onUpdate,
}) => {
const { showToast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isRemovingFromGroup, setIsRemovingFromGroup] = useState(false);
const [formData, setFormData] = useState({
title: item.title,
description: item.metadata?.description || '',
});
const isInGroup = Boolean(item.metadata?.group_name);
// Handle escape key to close modal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim()) {
showToast('Title is required', 'error');
return;
}
setIsLoading(true);
try {
// Update the knowledge item
const updates: any = {};
// Only include title if it has changed
if (formData.title !== item.title) {
updates.title = formData.title;
}
// Only include description if it has changed
if (formData.description !== (item.metadata?.description || '')) {
updates.description = formData.description;
}
await knowledgeBaseService.updateKnowledgeItem(item.source_id, updates);
showToast('Knowledge item updated successfully', 'success');
onUpdate();
onClose();
} catch (error) {
console.error('Failed to update knowledge item:', error);
showToast(`Failed to update: ${(error as any)?.message || 'Unknown error'}`, 'error');
} finally {
setIsLoading(false);
}
};
const handleRemoveFromGroup = async () => {
if (!isInGroup) return;
setIsRemovingFromGroup(true);
try {
const currentGroupName = item.metadata?.group_name;
if (!currentGroupName) {
throw new Error('No group name found');
}
// Get all knowledge items to find other items in the same group
const allItemsResponse = await knowledgeBaseService.getKnowledgeItems({ per_page: 1000 });
const itemsInGroup = allItemsResponse.items.filter(
knowledgeItem => knowledgeItem.metadata?.group_name === currentGroupName
);
console.log(`Found ${itemsInGroup.length} items in group "${currentGroupName}"`);
if (itemsInGroup.length <= 2) {
// If there are only 2 items in the group, remove group_name from both
// This dissolves the group entirely
showToast('Dissolving group with 2 or fewer items...', 'info');
for (const groupItem of itemsInGroup) {
await knowledgeBaseService.updateKnowledgeItem(groupItem.source_id, {
group_name: ""
});
}
showToast('Group dissolved - all items are now individual', 'success');
} else {
// If there are 3+ items, only remove this item from the group
await knowledgeBaseService.updateKnowledgeItem(item.source_id, {
group_name: ""
});
showToast('Item removed from group successfully', 'success');
}
onUpdate();
onClose();
} catch (error) {
console.error('Failed to remove from group:', error);
showToast(`Failed to remove from group: ${(error as any)?.message || 'Unknown error'}`, 'error');
} finally {
setIsRemovingFromGroup(false);
}
};
// Using React Portal to render the modal at the root level
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 flex items-center justify-center z-50 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
{/* Pink accent line at the top */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-pink-500 to-purple-500 shadow-[0_0_20px_5px_rgba(236,72,153,0.5)] z-10 rounded-t-xl"></div>
<Card className="relative overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
Edit Knowledge Item
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Enter title"
accentColor="pink"
disabled={isLoading}
/>
{/* Description field */}
<div className="w-full">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-1.5">
Description
</label>
<div className="backdrop-blur-md bg-gradient-to-b dark:from-white/10 dark:to-black/30 from-white/80 to-white/60 border dark:border-zinc-800/80 border-gray-200 rounded-md px-3 py-2 transition-all duration-200 focus-within:border-pink-500 focus-within:shadow-[0_0_15px_rgba(236,72,153,0.5)]">
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Enter description (optional)"
disabled={isLoading}
rows={3}
className="w-full bg-transparent text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-zinc-600 focus:outline-none resize-none"
/>
</div>
</div>
{/* Group info and remove button */}
{isInGroup && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div>
<div className="text-sm font-medium text-blue-800 dark:text-blue-200">
Grouped Item
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">
Group: {item.metadata.group_name}
</div>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveFromGroup}
disabled={isRemovingFromGroup || isLoading}
className="text-red-600 border-red-300 hover:bg-red-50 dark:text-red-400 dark:border-red-800 dark:hover:bg-red-900/20"
>
{isRemovingFromGroup ? (
<>
<RefreshCw className="w-3 h-3 animate-spin mr-1" />
Removing...
</>
) : (
<>
<UserX className="w-3 h-3 mr-1" />
Remove from Group
</>
)}
</Button>
</div>
</div>
)}
{/* Additional info */}
<div className="bg-gray-100 dark:bg-zinc-800 rounded-lg p-3 space-y-1">
<div className="text-sm text-gray-600 dark:text-zinc-400">
<span className="font-medium">Source:</span> {item.url}
</div>
<div className="text-sm text-gray-600 dark:text-zinc-400">
<span className="font-medium">Type:</span> {item.metadata.source_type === 'url' ? 'URL' : 'File'}
</div>
<div className="text-sm text-gray-600 dark:text-zinc-400">
<span className="font-medium">Last Updated:</span> {new Date(item.updated_at).toLocaleString()}
</div>
</div>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading || isRemovingFromGroup}
>
Cancel
</Button>
<Button
type="submit"
accentColor="pink"
disabled={isLoading || isRemovingFromGroup}
className="flex items-center gap-2"
>
{isLoading ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</Button>
</div>
</form>
</Card>
</motion.div>
</motion.div>,
document.body
);
};

View File

@@ -1,158 +0,0 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Badge } from '../ui/Badge';
import { KnowledgeItem, knowledgeBaseService } from '../../services/knowledgeBaseService';
import { useToast } from '../../contexts/ToastContext';
interface GroupCreationModalProps {
selectedItems: KnowledgeItem[];
onClose: () => void;
onSuccess: () => void;
}
export const GroupCreationModal = ({ selectedItems, onClose, onSuccess }: GroupCreationModalProps) => {
const [groupName, setGroupName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { showToast } = useToast();
const handleCreateGroup = async () => {
if (!groupName.trim()) {
showToast('Please enter a group name', 'error');
return;
}
setIsLoading(true);
try {
// Update each selected item with the group name
const updatePromises = selectedItems.map(item =>
knowledgeBaseService.updateKnowledgeItem(item.source_id, {
...item.metadata,
group_name: groupName.trim()
})
);
await Promise.all(updatePromises);
showToast(`Successfully created group "${groupName}" with ${selectedItems.length} items`, 'success');
onSuccess();
} catch (error) {
console.error('Error creating group:', error);
showToast('Failed to create group', 'error');
} finally {
setIsLoading(false);
}
};
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", duration: 0.3 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-2xl"
>
<Card className="relative">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
Create Knowledge Group
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Group Name Input */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Group Name
</label>
<Input
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
placeholder="Enter group name..."
className="w-full"
onKeyDown={(e) => {
if (e.key === 'Enter' && !isLoading) {
handleCreateGroup();
}
}}
/>
</div>
{/* Selected Items Preview */}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-zinc-300 mb-3">
Items to be grouped ({selectedItems.length})
</h3>
<div className="max-h-60 overflow-y-auto space-y-2 pr-2">
{selectedItems.map((item) => (
<div
key={item.id}
className="p-3 bg-gray-50 dark:bg-zinc-800/50 rounded-lg"
>
<h4 className="font-medium text-gray-800 dark:text-white text-sm">
{item.title}
</h4>
<p className="text-xs text-gray-600 dark:text-zinc-400 mt-1 line-clamp-1">
{item.metadata.description || item.source_id}
</p>
{item.metadata.tags && item.metadata.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{item.metadata.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} accentColor="gray">
{tag}
</Badge>
))}
{item.metadata.tags.length > 3 && (
<Badge accentColor="gray">
+{item.metadata.tags.length - 3}
</Badge>
)}
</div>
)}
</div>
))}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button
variant="ghost"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
accentColor="blue"
onClick={handleCreateGroup}
disabled={isLoading || !groupName.trim()}
>
{isLoading ? 'Creating...' : 'Create Group'}
</Button>
</div>
</Card>
</motion.div>
</motion.div>
</AnimatePresence>
);
};

View File

@@ -1,665 +0,0 @@
import { useState, useMemo } from 'react';
import { Link as LinkIcon, Upload, Trash2, RefreshCw, Code, FileText, Brain, BoxIcon, Globe, ChevronRight, Pencil } from 'lucide-react';
import { Card } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { KnowledgeItem, KnowledgeItemMetadata } from '../../services/knowledgeBaseService';
import { useCardTilt } from '../../hooks/useCardTilt';
import { CodeViewerModal, CodeExample } from '../code/CodeViewerModal';
import { EditKnowledgeItemModal } from './EditKnowledgeItemModal';
import '../../styles/card-animations.css';
// Define GroupedKnowledgeItem interface locally
interface GroupedKnowledgeItem {
id: string;
title: string;
domain: string;
items: KnowledgeItem[];
metadata: KnowledgeItemMetadata;
created_at: string;
updated_at: string;
}
// Helper function to guess language from title
const guessLanguageFromTitle = (title: string = ''): string => {
const titleLower = title.toLowerCase();
if (titleLower.includes('javascript') || titleLower.includes('js')) return 'javascript';
if (titleLower.includes('typescript') || titleLower.includes('ts')) return 'typescript';
if (titleLower.includes('react')) return 'jsx';
if (titleLower.includes('html')) return 'html';
if (titleLower.includes('css')) return 'css';
if (titleLower.includes('python')) return 'python';
if (titleLower.includes('java')) return 'java';
return 'javascript'; // Default
};
// Tags display component
interface TagsDisplayProps {
tags: string[];
}
const TagsDisplay = ({ tags }: TagsDisplayProps) => {
const [showTooltip, setShowTooltip] = useState(false);
if (!tags || tags.length === 0) return null;
const visibleTags = tags.slice(0, 4);
const remainingTags = tags.slice(4);
const hasMoreTags = remainingTags.length > 0;
return (
<div className="w-full">
<div className="flex flex-wrap gap-2 h-full">
{visibleTags.map((tag, index) => (
<Badge
key={index}
color="purple"
variant="outline"
className="text-xs"
>
{tag}
</Badge>
))}
{hasMoreTags && (
<div
className="cursor-pointer relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<Badge
color="purple"
variant="outline"
className="bg-purple-100/50 dark:bg-purple-900/30 border-dashed text-xs"
>
+{remainingTags.length} more...
</Badge>
{showTooltip && (
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 whitespace-nowrap max-w-xs">
<div className="font-semibold text-purple-300 mb-1">
Additional Tags:
</div>
{remainingTags.map((tag, index) => (
<div key={index} className="text-gray-300">
{tag}
</div>
))}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-black dark:border-b-zinc-800"></div>
</div>
)}
</div>
)}
</div>
</div>
);
};
// Delete confirmation modal component
interface DeleteConfirmModalProps {
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
}
const DeleteConfirmModal = ({
onConfirm,
onCancel,
title,
message,
}: DeleteConfirmModalProps) => {
return (
<div className="fixed inset-0 bg-gray-500/50 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="w-full max-w-md">
<Card className="w-full">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
{title}
</h3>
<p className="text-gray-600 dark:text-zinc-400 mb-6">{message}</p>
<div className="flex justify-end gap-4">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-pink-500 text-white rounded-md hover:bg-pink-600 transition-colors"
>
Delete
</button>
</div>
</Card>
</div>
</div>
);
};
interface GroupedKnowledgeItemCardProps {
groupedItem: GroupedKnowledgeItem;
onDelete: (sourceId: string) => void;
onUpdate?: () => void;
onRefresh?: (sourceId: string) => void;
}
export const GroupedKnowledgeItemCard = ({
groupedItem,
onDelete,
onUpdate,
onRefresh
}: GroupedKnowledgeItemCardProps) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const [showCodeTooltip, setShowCodeTooltip] = useState(false);
const [showPageTooltip, setShowPageTooltip] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [activeCardIndex, setActiveCardIndex] = useState(0);
const [isShuffling, setIsShuffling] = useState(false);
const [showCodeModal, setShowCodeModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const isGrouped = groupedItem.items.length > 1;
const activeItem = groupedItem.items[activeCardIndex];
// Updated color logic based on individual item's source type and knowledge type
const getCardColor = (item: KnowledgeItem) => {
if (item.metadata.source_type === 'url') {
// Web documents
return item.metadata.knowledge_type === 'technical' ? 'blue' : 'cyan';
} else {
// Uploaded documents
return item.metadata.knowledge_type === 'technical' ? 'purple' : 'pink';
}
};
// Use active item for main card color
const accentColor = getCardColor(activeItem);
// Updated icon colors to match active card
const getSourceIconColor = (item: KnowledgeItem) => {
if (item.metadata.source_type === 'url') {
return item.metadata.knowledge_type === 'technical' ? 'text-blue-500' : 'text-cyan-500';
} else {
return item.metadata.knowledge_type === 'technical' ? 'text-purple-500' : 'text-pink-500';
}
};
const getTypeIconColor = (item: KnowledgeItem) => {
if (item.metadata.source_type === 'url') {
return item.metadata.knowledge_type === 'technical' ? 'text-blue-500' : 'text-cyan-500';
} else {
return item.metadata.knowledge_type === 'technical' ? 'text-purple-500' : 'text-pink-500';
}
};
// Use active item for icons
const TypeIcon = activeItem.metadata.knowledge_type === 'technical' ? BoxIcon : Brain;
const sourceIconColor = getSourceIconColor(activeItem);
const typeIconColor = getTypeIconColor(activeItem);
const statusColorMap = {
active: 'green',
processing: 'blue',
error: 'pink'
};
// Use the tilt effect hook - but only apply the handlers if not grouped
const { cardRef, tiltStyles, handlers } = useCardTilt({
max: 10,
scale: 1.02,
perspective: 1200,
});
// Only use tilt handlers if not grouped and modal is not open
const tiltHandlers = (isGrouped || showCodeModal) ? {} : handlers;
const handleDelete = () => {
setIsRemoving(true);
// Delay the actual deletion to allow for the animation
setTimeout(() => {
onDelete(groupedItem.id);
setShowDeleteConfirm(false);
}, 500);
};
const handleRefresh = () => {
if (onRefresh && activeItem) {
onRefresh(activeItem.source_id);
}
};
// Calculate total word count
const totalWordCount = groupedItem.metadata.word_count || groupedItem.items.reduce(
(sum, item) => sum + (item.metadata.word_count || 0), 0
);
// Calculate total code examples count from metadata
const totalCodeExamples = useMemo(() => {
return groupedItem.items.reduce(
(sum, item) => sum + (item.metadata.code_examples_count || 0),
0,
);
}, [groupedItem.items]);
// Calculate active item's code examples count from metadata
const activeCodeExamples = activeItem.metadata.code_examples_count || 0;
// Calculate active item's word count
const activeWordCount = activeItem.metadata.word_count || 0;
// Get code examples from all items in the group
const allCodeExamples = useMemo(() => {
return groupedItem.items.reduce(
(examples, item) => {
const itemExamples = item.code_examples || [];
return [...examples, ...itemExamples.map((ex: any, idx: number) => ({
title: ex.metadata?.example_name || ex.metadata?.title || ex.summary?.split('\n')[0] || 'Code Example',
description: ex.summary || '',
}))];
},
[] as Array<{
title: string;
description: string;
}>,
);
}, [groupedItem.items]);
// Format code examples for the modal with additional safety checks
const formattedCodeExamples = useMemo(() => {
return groupedItem.items.reduce((examples: CodeExample[], item) => {
if (!item || !item.code_examples) return examples;
const itemExamples = item.code_examples.map((example: any, index: number) => ({
id: example.id || `${item.id || 'unknown'}-example-${index}`,
title: example.metadata?.example_name || example.metadata?.title || example.summary?.split('\n')[0] || 'Code Example',
description: example.summary || 'No description available',
language: example.metadata?.language || guessLanguageFromTitle(example.metadata?.title || ''),
code: example.content || example.metadata?.code || '// Code example not available',
tags: example.metadata?.tags || [],
}));
return [...examples, ...itemExamples];
}, []);
}, [groupedItem.items]);
// Function to shuffle to the next card
const shuffleToNextCard = () => {
if (!isGrouped || isShuffling) return;
setIsShuffling(true);
const nextIndex = (activeCardIndex + 1) % groupedItem.items.length;
// Add a small delay to allow animation to complete
setTimeout(() => {
setActiveCardIndex(nextIndex);
setIsShuffling(false);
}, 300);
};
// Card content renderer - extracted to avoid duplication
const renderCardContent = (item = activeItem) => (
<div className="relative z-10 flex flex-col h-full">
{/* Header section - fixed height */}
<div className="flex items-center gap-2 mb-3 card-3d-layer-1">
{/* Source type icon */}
{item.metadata.source_type === 'url' ? (
<LinkIcon className={`w-4 h-4 ${getSourceIconColor(item)}`} />
) : (
<Upload className={`w-4 h-4 ${getSourceIconColor(item)}`} />
)}
{/* Knowledge type icon */}
{item.metadata.knowledge_type === 'technical' ? (
<BoxIcon className={`w-4 h-4 ${getTypeIconColor(item)}`} />
) : (
<Brain className={`w-4 h-4 ${getTypeIconColor(item)}`} />
)}
{/* Title with source count badge moved to header */}
<div className="flex items-center flex-1 gap-2 min-w-0">
<h3 className="text-gray-800 dark:text-white font-medium flex-1 line-clamp-1 truncate min-w-0">
{item.title || groupedItem.domain}
</h3>
{/* Sources badge - moved to header */}
{isGrouped && (
<button
onClick={shuffleToNextCard}
className="group flex items-center gap-1 px-2 py-1 bg-blue-500/20 border border-blue-500/40 rounded-full backdrop-blur-sm shadow-[0_0_15px_rgba(59,130,246,0.3)] hover:shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-300 card-3d-layer-3 flex-shrink-0"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<Globe className="w-3 h-3 text-blue-400" />
<span className="text-xs text-blue-400 font-medium">
{activeCardIndex + 1}/{groupedItem.items.length}
</span>
<ChevronRight className="w-3 h-3 text-blue-400 group-hover:translate-x-0.5 transition-transform" />
</button>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
setShowEditModal(true);
}}
className="p-1 text-gray-500 hover:text-blue-500"
title="Edit"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(true);
}}
className="p-1 text-gray-500 hover:text-red-500"
title="Delete"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{/* Description section - fixed height */}
<p className="text-gray-600 dark:text-zinc-400 text-sm mb-3 line-clamp-2 card-3d-layer-2">
{item.metadata.description ||
(groupedItem.items.length === 1
? `Content from ${groupedItem.domain}`
: `Source ${activeCardIndex + 1} of ${groupedItem.items.length} from ${groupedItem.domain}`)}
</p>
{/* Tags section - flexible height with flex-1 */}
<div className="flex-1 flex flex-col card-3d-layer-2 min-h-[4rem]">
<TagsDisplay tags={item.metadata.tags || []} />
</div>
{/* Footer section - anchored to bottom */}
<div className="flex items-end justify-between mt-auto card-3d-layer-1">
{/* Left side - refresh button and updated stacked */}
<div className="flex flex-col">
{item.metadata.source_type === 'url' && (
<button
onClick={handleRefresh}
className={`flex items-center gap-1 mb-1 px-2 py-1 transition-colors ${
item.metadata.knowledge_type === 'technical'
? 'text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
: 'text-cyan-500 hover:text-cyan-600 dark:text-cyan-400 dark:hover:text-cyan-300'
}`}
title={`Refresh from: ${item.metadata.original_url || item.url || 'URL not available'}`}
>
<RefreshCw className="w-3 h-3" />
<span className="text-sm font-medium">Recrawl</span>
</button>
)}
<span className="text-xs text-gray-500 dark:text-zinc-500">
Updated: {new Date(groupedItem.updated_at).toLocaleDateString()}
</span>
</div>
{/* Right side - code examples and status inline */}
<div className="flex items-center gap-2">
{/* Code examples badge - updated colors */}
{activeCodeExamples > 0 && (
<div
className="cursor-pointer relative card-3d-layer-3"
onClick={() => setShowCodeModal(true)}
onMouseEnter={() => setShowCodeTooltip(true)}
onMouseLeave={() => setShowCodeTooltip(false)}
>
<div className={`flex items-center gap-1 px-2 py-1 rounded-full backdrop-blur-sm transition-all duration-300 ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical'
? 'bg-blue-500/20 border border-blue-500/40 shadow-[0_0_15px_rgba(59,130,246,0.3)] hover:shadow-[0_0_20px_rgba(59,130,246,0.5)]'
: 'bg-cyan-500/20 border border-cyan-500/40 shadow-[0_0_15px_rgba(34,211,238,0.3)] hover:shadow-[0_0_20px_rgba(34,211,238,0.5)]'
: item.metadata.knowledge_type === 'technical'
? 'bg-purple-500/20 border border-purple-500/40 shadow-[0_0_15px_rgba(168,85,247,0.3)] hover:shadow-[0_0_20px_rgba(168,85,247,0.5)]'
: 'bg-pink-500/20 border border-pink-500/40 shadow-[0_0_15px_rgba(236,72,153,0.3)] hover:shadow-[0_0_20px_rgba(236,72,153,0.5)]'
}`}>
<Code className={`w-3 h-3 ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical' ? 'text-blue-400' : 'text-cyan-400'
: item.metadata.knowledge_type === 'technical' ? 'text-purple-400' : 'text-pink-400'
}`} />
<span className={`text-xs font-medium ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical' ? 'text-blue-400' : 'text-cyan-400'
: item.metadata.knowledge_type === 'technical' ? 'text-purple-400' : 'text-pink-400'
}`}>
{activeCodeExamples}
</span>
</div>
{/* Code Examples Tooltip - positioned relative to the badge */}
{showCodeTooltip && (
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 whitespace-nowrap">
<div className="font-medium">
Click to view Stored Code Examples
</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-zinc-800"></div>
</div>
)}
</div>
)}
{/* Page count - orange neon container */}
<div
className="relative card-3d-layer-3"
onMouseEnter={() => setShowPageTooltip(true)}
onMouseLeave={() => setShowPageTooltip(false)}
>
<div className="flex items-center gap-1 px-2 py-1 bg-orange-500/20 border border-orange-500/40 rounded-full backdrop-blur-sm shadow-[0_0_15px_rgba(251,146,60,0.3)] transition-all duration-300">
<FileText className="w-3 h-3 text-orange-400" />
<span className="text-xs text-orange-400 font-medium">
{Math.ceil(activeWordCount / 250).toLocaleString()}
</span>
</div>
{/* Page count tooltip - positioned relative to the badge */}
{showPageTooltip && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 bg-black dark:bg-zinc-800 text-white text-xs px-3 py-2 rounded-lg shadow-lg z-50 whitespace-nowrap">
<div className="font-medium mb-1">
{activeWordCount.toLocaleString()} words
</div>
<div className="text-gray-300 space-y-0.5">
<div>
= {Math.ceil(activeWordCount / 250).toLocaleString()} pages
</div>
<div>
= {(activeWordCount / 80000).toFixed(1)} average novels
</div>
</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-zinc-800"></div>
</div>
)}
</div>
<Badge
color={statusColorMap[item.metadata.status || 'active'] as any}
className="card-3d-layer-2"
>
{(item.metadata.status || 'active').charAt(0).toUpperCase() +
(item.metadata.status || 'active').slice(1)}
</Badge>
</div>
</div>
</div>
);
return (
<div
ref={cardRef}
className={`relative h-full ${isRemoving ? 'card-removing' : ''}`}
style={{
transform: isGrouped ? 'perspective(1200px)' : tiltStyles.transform,
transition: tiltStyles.transition,
transformStyle: 'preserve-3d',
}}
{...tiltHandlers}
>
{/* Stacked cards effect - background cards */}
{isGrouped && (
<>
{/* Third card (bottom of stack) */}
<div
className="absolute top-0 left-0 w-full h-full"
style={{
zIndex: 1,
transform:
'translateZ(-60px) translateY(-16px) translateX(-8px) rotateX(-2deg) rotateY(-2deg)',
transformStyle: 'preserve-3d',
filter: 'drop-shadow(0 10px 8px rgba(0, 0, 0, 0.15))',
}}
>
<Card
accentColor={getCardColor(groupedItem.items[(activeCardIndex + groupedItem.items.length - 2) % groupedItem.items.length])}
className="w-full h-full bg-white/60 dark:bg-zinc-900/60 backdrop-blur-md shadow-md opacity-60 overflow-hidden"
>
{/* Add a simplified version of the content for depth */}
<div className="p-4 opacity-30">
{renderCardContent(
groupedItem.items[
(activeCardIndex + groupedItem.items.length - 2) %
groupedItem.items.length
],
)}
</div>
</Card>
</div>
{/* Second card (middle of stack) */}
<div
className="absolute top-0 left-0 w-full h-full"
style={{
zIndex: 2,
transform:
'translateZ(-30px) translateY(-8px) translateX(-4px) rotateX(-1deg) rotateY(-1deg)',
transformStyle: 'preserve-3d',
filter: 'drop-shadow(0 8px 6px rgba(0, 0, 0, 0.1))',
}}
>
<Card
accentColor={getCardColor(groupedItem.items[(activeCardIndex + groupedItem.items.length - 1) % groupedItem.items.length])}
className="w-full h-full bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md shadow-md opacity-80 overflow-hidden"
>
{/* Add a simplified version of the content for depth */}
<div className="p-4 opacity-60">
{renderCardContent(
groupedItem.items[
(activeCardIndex + groupedItem.items.length - 1) %
groupedItem.items.length
],
)}
</div>
</Card>
</div>
</>
)}
{/* Main card (top of stack) - with animation for shuffling */}
<div
className={`relative z-10 transition-all duration-300 h-full ${isShuffling ? 'animate-card-shuffle-out' : 'opacity-100 scale-100'}`}
style={{
transform: 'translateZ(0)',
transformStyle: 'preserve-3d',
filter: 'drop-shadow(0 4px 3px rgba(0, 0, 0, 0.07))',
}}
>
<Card
accentColor={accentColor}
className="relative h-full flex flex-col backdrop-blur-lg bg-white/80 dark:bg-zinc-900/80"
>
{/* Reflection overlay */}
<div
className="card-reflection"
style={{
opacity: isGrouped ? 0 : tiltStyles.reflectionOpacity,
backgroundPosition: tiltStyles.reflectionPosition,
}}
></div>
{/* Card content */}
{renderCardContent()}
</Card>
</div>
{/* Incoming card animation - only visible during shuffle */}
{isShuffling && (
<div
className="absolute inset-0 z-20 animate-card-shuffle-in"
style={{
transform: 'translateZ(30px)',
transformStyle: 'preserve-3d',
filter: 'drop-shadow(0 4px 3px rgba(0, 0, 0, 0.07))',
}}
>
<Card
accentColor={accentColor}
className="relative h-full flex flex-col backdrop-blur-lg bg-white/80 dark:bg-zinc-900/80"
>
{/* Reflection overlay */}
<div
className="card-reflection"
style={{
opacity: isGrouped ? 0 : tiltStyles.reflectionOpacity,
backgroundPosition: tiltStyles.reflectionPosition,
}}
></div>
{/* Card content for next item */}
{renderCardContent(
groupedItem.items[
(activeCardIndex + 1) % groupedItem.items.length
],
)}
</Card>
</div>
)}
{/* Sources tooltip */}
{showTooltip && isGrouped && (
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black/90 dark:bg-zinc-800/90 backdrop-blur-md text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 whitespace-nowrap max-w-xs">
<div className="font-semibold text-blue-300 mb-1">
Grouped Sources:
</div>
{groupedItem.items.map((item, index) => (
<div
key={index}
className={`text-gray-300 ${activeCardIndex === index ? 'text-blue-300 font-medium' : ''}`}
>
{index + 1}. {item.title}
</div>
))}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-zinc-800"></div>
</div>
)}
{/* Code Examples Modal */}
{showCodeModal && formattedCodeExamples.length > 0 && (
<CodeViewerModal
examples={formattedCodeExamples}
onClose={() => setShowCodeModal(false)}
/>
)}
{/* Delete Confirm Modal */}
{showDeleteConfirm && (
<DeleteConfirmModal
onConfirm={handleDelete}
onCancel={() => setShowDeleteConfirm(false)}
title={isGrouped ? 'Delete Grouped Sources' : 'Delete Knowledge Item'}
message={
isGrouped
? `Are you sure you want to delete all ${groupedItem.items.length} sources from ${groupedItem.domain}? This action cannot be undone.`
: 'Are you sure you want to delete this knowledge item? This action cannot be undone.'
}
/>
)}
{/* Edit Modal - edits the active item */}
{showEditModal && activeItem && (
<EditKnowledgeItemModal
item={activeItem}
onClose={() => setShowEditModal(false)}
onUpdate={() => {
if (onUpdate) onUpdate();
}}
/>
)}
</div>
);
};

View File

@@ -1,544 +0,0 @@
import { useState } from 'react';
import { Link as LinkIcon, Upload, Trash2, RefreshCw, Code, FileText, Brain, BoxIcon, Pencil } from 'lucide-react';
import { Card } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { Checkbox } from '../ui/Checkbox';
import { KnowledgeItem, knowledgeBaseService } from '../../services/knowledgeBaseService';
import { useCardTilt } from '../../hooks/useCardTilt';
import { CodeViewerModal, CodeExample } from '../code/CodeViewerModal';
import { EditKnowledgeItemModal } from './EditKnowledgeItemModal';
import '../../styles/card-animations.css';
// Helper function to guess language from title
const guessLanguageFromTitle = (title: string = ''): string => {
const titleLower = title.toLowerCase();
if (titleLower.includes('javascript') || titleLower.includes('js')) return 'javascript';
if (titleLower.includes('typescript') || titleLower.includes('ts')) return 'typescript';
if (titleLower.includes('react')) return 'jsx';
if (titleLower.includes('html')) return 'html';
if (titleLower.includes('css')) return 'css';
if (titleLower.includes('python')) return 'python';
if (titleLower.includes('java')) return 'java';
return 'javascript'; // Default
};
// Tags display component
interface TagsDisplayProps {
tags: string[];
}
const TagsDisplay = ({ tags }: TagsDisplayProps) => {
const [showTooltip, setShowTooltip] = useState(false);
if (!tags || tags.length === 0) return null;
const visibleTags = tags.slice(0, 4);
const remainingTags = tags.slice(4);
const hasMoreTags = remainingTags.length > 0;
return (
<div className="w-full">
<div className="flex flex-wrap gap-2 h-full">
{visibleTags.map((tag, index) => (
<Badge
key={index}
color="purple"
variant="outline"
className="text-xs"
>
{tag}
</Badge>
))}
{hasMoreTags && (
<div
className="cursor-pointer relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<Badge
color="purple"
variant="outline"
className="bg-purple-100/50 dark:bg-purple-900/30 border-dashed text-xs"
>
+{remainingTags.length} more...
</Badge>
{showTooltip && (
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 whitespace-nowrap max-w-xs">
<div className="font-semibold text-purple-300 mb-1">
Additional Tags:
</div>
{remainingTags.map((tag, index) => (
<div key={index} className="text-gray-300">
{tag}
</div>
))}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-black dark:border-b-zinc-800"></div>
</div>
)}
</div>
)}
</div>
</div>
);
};
// Delete confirmation modal component
interface DeleteConfirmModalProps {
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
}
const DeleteConfirmModal = ({
onConfirm,
onCancel,
title,
message,
}: DeleteConfirmModalProps) => {
return (
<div className="fixed inset-0 bg-gray-500/50 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="w-full max-w-md">
<Card className="w-full">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">
{title}
</h3>
<p className="text-gray-600 dark:text-zinc-400 mb-6">{message}</p>
<div className="flex justify-end gap-4">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-pink-500 text-white rounded-md hover:bg-pink-600 transition-colors"
>
Delete
</button>
</div>
</Card>
</div>
</div>
);
};
interface KnowledgeItemCardProps {
item: KnowledgeItem;
onDelete: (sourceId: string) => void;
onUpdate?: () => void;
onRefresh?: (sourceId: string) => void;
onBrowseDocuments?: (sourceId: string) => void;
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelection?: (event: React.MouseEvent) => void;
}
export const KnowledgeItemCard = ({
item,
onDelete,
onUpdate,
onRefresh,
onBrowseDocuments,
isSelectionMode = false,
isSelected = false,
onToggleSelection
}: KnowledgeItemCardProps) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCodeModal, setShowCodeModal] = useState(false);
const [showCodeTooltip, setShowCodeTooltip] = useState(false);
const [showPageTooltip, setShowPageTooltip] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [loadedCodeExamples, setLoadedCodeExamples] = useState<any[] | null>(null);
const [isLoadingCodeExamples, setIsLoadingCodeExamples] = useState(false);
const [isRecrawling, setIsRecrawling] = useState(false);
const statusColorMap = {
active: 'green',
processing: 'blue',
error: 'pink'
};
// Updated color logic based on source type and knowledge type
const getCardColor = () => {
if (item.metadata.source_type === 'url') {
// Web documents
return item.metadata.knowledge_type === 'technical' ? 'blue' : 'cyan';
} else {
// Uploaded documents
return item.metadata.knowledge_type === 'technical' ? 'purple' : 'pink';
}
};
const accentColor = getCardColor();
// Updated icon colors to match card colors
const getSourceIconColor = () => {
if (item.metadata.source_type === 'url') {
return item.metadata.knowledge_type === 'technical' ? 'text-blue-500' : 'text-cyan-500';
} else {
return item.metadata.knowledge_type === 'technical' ? 'text-purple-500' : 'text-pink-500';
}
};
const getTypeIconColor = () => {
if (item.metadata.source_type === 'url') {
return item.metadata.knowledge_type === 'technical' ? 'text-blue-500' : 'text-cyan-500';
} else {
return item.metadata.knowledge_type === 'technical' ? 'text-purple-500' : 'text-pink-500';
}
};
// Get the type icon
const TypeIcon = item.metadata.knowledge_type === 'technical' ? BoxIcon : Brain;
const sourceIconColor = getSourceIconColor();
const typeIconColor = getTypeIconColor();
// Use the tilt effect hook - disable in selection mode
const { cardRef, tiltStyles, handlers } = useCardTilt({
max: isSelectionMode ? 0 : 10,
scale: isSelectionMode ? 1 : 1.02,
perspective: 1200,
});
const handleDelete = () => {
setIsRemoving(true);
// Delay the actual deletion to allow for the animation
setTimeout(() => {
onDelete(item.source_id);
setShowDeleteConfirm(false);
}, 500);
};
const handleRefresh = () => {
if (onRefresh && !isRecrawling) {
setIsRecrawling(true);
onRefresh(item.source_id);
// Temporary fix: Auto-reset after timeout
// TODO: Reset based on actual crawl completion status from polling
setTimeout(() => {
setIsRecrawling(false);
}, 60000); // Reset after 60 seconds as a fallback
}
};
// Get code examples count from metadata
const codeExamplesCount = item.metadata.code_examples_count || 0;
// Load code examples when modal opens
const handleOpenCodeModal = async () => {
setShowCodeModal(true);
// Only load if not already loaded
if (!loadedCodeExamples && !isLoadingCodeExamples && codeExamplesCount > 0) {
setIsLoadingCodeExamples(true);
try {
const response = await knowledgeBaseService.getCodeExamples(item.source_id);
if (response.success) {
setLoadedCodeExamples(response.code_examples);
}
} catch (error) {
console.error('Failed to load code examples:', error);
} finally {
setIsLoadingCodeExamples(false);
}
}
};
// Format code examples for the modal (use loaded examples if available)
const codeExamples: CodeExample[] =
(loadedCodeExamples || item.code_examples || []).map((example: any, index: number) => ({
id: example.id || `${item.id}-example-${index}`,
title: example.metadata?.example_name || example.metadata?.title || example.summary?.split('\n')[0] || 'Code Example',
description: example.summary || 'No description available',
language: example.metadata?.language || guessLanguageFromTitle(example.metadata?.title || ''),
code: example.content || example.metadata?.code || '// Code example not available',
tags: example.metadata?.tags || [],
}));
return (
<div
ref={cardRef}
className={`card-3d relative h-full ${isRemoving ? 'card-removing' : ''}`}
style={{
transform: tiltStyles.transform,
transition: tiltStyles.transition,
}}
{...(showCodeModal ? {} : handlers)}
>
<Card
accentColor={accentColor}
className={`relative h-full flex flex-col overflow-hidden ${
isSelected ? 'ring-2 ring-blue-500 dark:ring-blue-400' : ''
} ${isSelectionMode ? 'cursor-pointer' : ''}`}
onClick={(e) => {
if (isSelectionMode && onToggleSelection) {
e.stopPropagation();
onToggleSelection(e);
}
}}
>
{/* Checkbox for selection mode */}
{isSelectionMode && (
<div className="absolute top-3 right-3 z-20">
<Checkbox
checked={isSelected}
onChange={() => {}}
className="pointer-events-none"
/>
</div>
)}
{/* Reflection overlay */}
<div
className="card-reflection"
style={{
opacity: tiltStyles.reflectionOpacity,
backgroundPosition: tiltStyles.reflectionPosition,
}}
></div>
{/* Glow effect - updated for new colors */}
<div
className={`card-glow card-glow-${accentColor}`}
style={{
opacity: tiltStyles.glowIntensity * 0.3,
background: `radial-gradient(circle at ${tiltStyles.glowPosition.x}% ${tiltStyles.glowPosition.y}%,
rgba(${accentColor === 'blue' ? '59, 130, 246' :
accentColor === 'cyan' ? '34, 211, 238' :
accentColor === 'purple' ? '168, 85, 247' :
'236, 72, 153'}, 0.6) 0%,
rgba(${accentColor === 'blue' ? '59, 130, 246' :
accentColor === 'cyan' ? '34, 211, 238' :
accentColor === 'purple' ? '168, 85, 247' :
'236, 72, 153'}, 0) 70%)`,
}}
></div>
{/* Content container with proper z-index and flex layout */}
<div className="relative z-10 flex flex-col h-full">
{/* Header section - fixed height */}
<div className="flex items-center gap-2 mb-3 card-3d-layer-1">
{/* Source type icon */}
{item.metadata.source_type === 'url' ? (
<LinkIcon
className={`w-4 h-4 ${sourceIconColor}`}
title={item.metadata.original_url || item.url || 'URL not available'}
/>
) : (
<Upload className={`w-4 h-4 ${sourceIconColor}`} />
)}
{/* Knowledge type icon */}
<TypeIcon className={`w-4 h-4 ${typeIconColor}`} />
<h3 className="text-gray-800 dark:text-white font-medium flex-1 line-clamp-1 truncate min-w-0">
{item.title}
</h3>
{!isSelectionMode && (
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
setShowEditModal(true);
}}
className="p-1 text-gray-500 hover:text-blue-500"
title="Edit"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(true);
}}
className="p-1 text-gray-500 hover:text-red-500"
title="Delete"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
{/* Description section - fixed height */}
<p className="text-gray-600 dark:text-zinc-400 text-sm mb-3 line-clamp-2 card-3d-layer-2">
{item.metadata.description || 'No description available'}
</p>
{/* Tags section - flexible height with flex-1 */}
<div className="flex-1 flex flex-col card-3d-layer-2 min-h-[4rem]">
<TagsDisplay tags={item.metadata.tags || []} />
</div>
{/* Footer section - anchored to bottom */}
<div className="flex items-end justify-between mt-auto card-3d-layer-1">
{/* Left side - refresh button and updated stacked */}
<div className="flex flex-col">
{item.metadata.source_type === 'url' && (
<button
onClick={handleRefresh}
disabled={isRecrawling}
className={`flex items-center gap-1 mb-1 px-2 py-1 transition-colors ${
isRecrawling
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
: item.metadata.knowledge_type === 'technical'
? 'text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
: 'text-cyan-500 hover:text-cyan-600 dark:text-cyan-400 dark:hover:text-cyan-300'
}`}
title={isRecrawling ? 'Recrawl in progress...' : `Refresh from: ${item.metadata.original_url || item.url || 'URL not available'}`}
>
<RefreshCw className={`w-3 h-3 ${isRecrawling ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium">{isRecrawling ? 'Recrawling...' : 'Recrawl'}</span>
</button>
)}
<span className="text-xs text-gray-500 dark:text-zinc-500">
Updated: {new Date(item.updated_at).toLocaleDateString()}
</span>
</div>
{/* Right side - code examples, page count and status inline */}
<div className="flex items-center gap-2">
{/* Code examples badge - updated colors */}
{codeExamplesCount > 0 && (
<div
className="cursor-pointer relative card-3d-layer-3"
onClick={handleOpenCodeModal}
onMouseEnter={() => setShowCodeTooltip(true)}
onMouseLeave={() => setShowCodeTooltip(false)}
>
<div className={`flex items-center gap-1 px-2 py-1 rounded-full backdrop-blur-sm transition-all duration-300 ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical'
? 'bg-blue-500/20 border border-blue-500/40 shadow-[0_0_15px_rgba(59,130,246,0.3)] hover:shadow-[0_0_20px_rgba(59,130,246,0.5)]'
: 'bg-cyan-500/20 border border-cyan-500/40 shadow-[0_0_15px_rgba(34,211,238,0.3)] hover:shadow-[0_0_20px_rgba(34,211,238,0.5)]'
: item.metadata.knowledge_type === 'technical'
? 'bg-purple-500/20 border border-purple-500/40 shadow-[0_0_15px_rgba(168,85,247,0.3)] hover:shadow-[0_0_20px_rgba(168,85,247,0.5)]'
: 'bg-pink-500/20 border border-pink-500/40 shadow-[0_0_15px_rgba(236,72,153,0.3)] hover:shadow-[0_0_20px_rgba(236,72,153,0.5)]'
}`}>
<Code className={`w-3 h-3 ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical' ? 'text-blue-400' : 'text-cyan-400'
: item.metadata.knowledge_type === 'technical' ? 'text-purple-400' : 'text-pink-400'
}`} />
<span className={`text-xs font-medium ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical' ? 'text-blue-400' : 'text-cyan-400'
: item.metadata.knowledge_type === 'technical' ? 'text-purple-400' : 'text-pink-400'
}`}>
{codeExamplesCount}
</span>
</div>
{/* Code Examples Tooltip - positioned relative to the badge */}
{showCodeTooltip && (
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 max-w-xs">
<div className={`font-semibold mb-2 ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical' ? 'text-blue-300' : 'text-cyan-300'
: item.metadata.knowledge_type === 'technical' ? 'text-purple-300' : 'text-pink-300'
}`}>
Click for Code Browser
</div>
<div className="max-h-32 overflow-y-auto">
{codeExamples.map((example, index) => (
<div key={index} className={`mb-1 last:mb-0 ${
item.metadata.source_type === 'url'
? item.metadata.knowledge_type === 'technical' ? 'text-blue-200' : 'text-cyan-200'
: item.metadata.knowledge_type === 'technical' ? 'text-purple-200' : 'text-pink-200'
}`}>
{example.title}
</div>
))}
</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-zinc-800"></div>
</div>
)}
</div>
)}
{/* Page count - orange neon container (clickable for document browser) */}
<div
className="relative card-3d-layer-3 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onBrowseDocuments) {
onBrowseDocuments(item.source_id);
}
}}
onMouseEnter={() => setShowPageTooltip(true)}
onMouseLeave={() => setShowPageTooltip(false)}
title="Click to browse document chunks"
>
<div className="flex items-center gap-1 px-2 py-1 bg-orange-500/20 border border-orange-500/40 rounded-full backdrop-blur-sm shadow-[0_0_15px_rgba(251,146,60,0.3)] hover:shadow-[0_0_20px_rgba(251,146,60,0.5)] transition-all duration-300">
<FileText className="w-3 h-3 text-orange-400" />
<span className="text-xs text-orange-400 font-medium">
{Math.ceil(
(item.metadata.word_count || 0) / 250,
).toLocaleString()}
</span>
</div>
{/* Page count tooltip - positioned relative to the badge */}
{showPageTooltip && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 bg-black dark:bg-zinc-800 text-white text-xs px-3 py-2 rounded-lg shadow-lg z-50 whitespace-nowrap">
<div className="font-medium mb-1 text-orange-300">
Click to Browse Documents
</div>
<div className="text-gray-300 space-y-0.5">
<div>
{(item.metadata.word_count || 0).toLocaleString()} words
</div>
<div>
= {Math.ceil((item.metadata.word_count || 0) / 250).toLocaleString()} pages
</div>
<div>
= {((item.metadata.word_count || 0) / 80000).toFixed(1)} average novels
</div>
</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-zinc-800"></div>
</div>
)}
</div>
<Badge
color={statusColorMap[item.metadata.status || 'active'] as any}
className="card-3d-layer-2"
>
{(item.metadata.status || 'active').charAt(0).toUpperCase() +
(item.metadata.status || 'active').slice(1)}
</Badge>
</div>
</div>
</div>
</Card>
{/* Code Examples Modal */}
{showCodeModal && (
<CodeViewerModal
examples={codeExamples}
onClose={() => setShowCodeModal(false)}
isLoading={isLoadingCodeExamples}
/>
)}
{showDeleteConfirm && (
<DeleteConfirmModal
onConfirm={handleDelete}
onCancel={() => setShowDeleteConfirm(false)}
title="Delete Knowledge Item"
message="Are you sure you want to delete this knowledge item? This action cannot be undone."
/>
)}
{/* Edit Modal */}
{showEditModal && (
<EditKnowledgeItemModal
item={item}
onClose={() => setShowEditModal(false)}
onUpdate={() => {
if (onUpdate) onUpdate();
}}
/>
)}
</div>
);
};

View File

@@ -1,84 +0,0 @@
import React from 'react';
import { Card } from '../ui/Card';
export const KnowledgeItemSkeleton: React.FC = () => {
return (
<Card className="relative overflow-hidden">
{/* Shimmer effect overlay */}
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/10 to-transparent" />
{/* Icon skeleton */}
<div className="flex items-start gap-4 mb-4">
<div className="w-10 h-10 bg-gray-200 dark:bg-zinc-800 rounded-lg animate-pulse" />
{/* Title and metadata skeleton */}
<div className="flex-1">
<div className="h-6 bg-gray-200 dark:bg-zinc-800 rounded w-3/4 mb-2 animate-pulse" />
<div className="h-4 bg-gray-200 dark:bg-zinc-800 rounded w-1/2 animate-pulse" />
</div>
</div>
{/* Description skeleton */}
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 dark:bg-zinc-800 rounded animate-pulse" />
<div className="h-4 bg-gray-200 dark:bg-zinc-800 rounded w-5/6 animate-pulse" />
</div>
{/* Tags skeleton */}
<div className="flex gap-2 mb-4">
<div className="h-6 w-16 bg-gray-200 dark:bg-zinc-800 rounded-full animate-pulse" />
<div className="h-6 w-20 bg-gray-200 dark:bg-zinc-800 rounded-full animate-pulse" />
</div>
{/* Footer skeleton */}
<div className="flex justify-between items-center">
<div className="h-4 bg-gray-200 dark:bg-zinc-800 rounded w-32 animate-pulse" />
<div className="flex gap-2">
<div className="w-8 h-8 bg-gray-200 dark:bg-zinc-800 rounded animate-pulse" />
<div className="w-8 h-8 bg-gray-200 dark:bg-zinc-800 rounded animate-pulse" />
</div>
</div>
</Card>
);
};
export const KnowledgeGridSkeleton: React.FC = () => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, index) => (
<KnowledgeItemSkeleton key={index} />
))}
</div>
);
};
export const KnowledgeTableSkeleton: React.FC = () => {
return (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-zinc-800">
{[...Array(5)].map((_, index) => (
<th key={index} className="text-left p-4">
<div className="h-4 bg-gray-200 dark:bg-zinc-800 rounded w-20 animate-pulse" />
</th>
))}
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, rowIndex) => (
<tr key={rowIndex} className="border-b border-gray-100 dark:border-zinc-900">
{[...Array(5)].map((_, colIndex) => (
<td key={colIndex} className="p-4">
<div className="h-4 bg-gray-200 dark:bg-zinc-800 rounded animate-pulse" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
};

View File

@@ -1,335 +0,0 @@
import React, { useState } from 'react';
import { KnowledgeItem, KnowledgeItemMetadata } from '../../services/knowledgeBaseService';
import { Card } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { Link as LinkIcon, Upload, Trash2, RefreshCw, X, Globe, BoxIcon, Brain } from 'lucide-react';
import { format } from 'date-fns';
// Reuse the same grouping logic from KnowledgeBasePage
const extractDomain = (url: string): string => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
// Remove 'www.' prefix if present
const withoutWww = hostname.startsWith('www.') ? hostname.slice(4) : hostname;
// For domains with subdomains, extract the main domain (last 2 parts)
const parts = withoutWww.split('.');
if (parts.length > 2) {
// Return the main domain (last 2 parts: domain.tld)
return parts.slice(-2).join('.');
}
return withoutWww;
} catch {
return url; // Return original if URL parsing fails
}
};
interface GroupedKnowledgeItem {
id: string;
title: string;
domain: string;
items: KnowledgeItem[];
metadata: KnowledgeItemMetadata;
created_at: string;
updated_at: string;
}
const groupItemsByDomain = (items: KnowledgeItem[]): GroupedKnowledgeItem[] => {
const groups = new Map<string, KnowledgeItem[]>();
// Group items by domain
items.forEach(item => {
// Only group URL-based items, not file uploads
if (item.metadata.source_type === 'url') {
const domain = extractDomain(item.url);
const existing = groups.get(domain) || [];
groups.set(domain, [...existing, item]);
} else {
// File uploads remain ungrouped
groups.set(`file_${item.id}`, [item]);
}
});
// Convert groups to GroupedKnowledgeItem objects
return Array.from(groups.entries()).map(([domain, groupItems]) => {
const firstItem = groupItems[0];
const isFileGroup = domain.startsWith('file_');
// Find the latest update timestamp and convert it properly to ISO string
const latestTimestamp = Math.max(...groupItems.map(item => new Date(item.updated_at).getTime()));
const latestDate = new Date(latestTimestamp);
return {
id: isFileGroup ? firstItem.id : `group_${domain}`,
title: isFileGroup ? firstItem.title : `${domain}`,
domain: isFileGroup ? 'file' : domain,
items: groupItems,
metadata: {
...firstItem.metadata,
// Merge tags from all items in the group
tags: [...new Set(groupItems.flatMap(item => item.metadata.tags || []))],
// Sum up chunks count for grouped items
chunks_count: groupItems.reduce((sum, item) => sum + (item.metadata.chunks_count || 0), 0),
},
created_at: firstItem.created_at,
updated_at: latestDate.toISOString(),
};
});
};
interface KnowledgeTableProps {
items: KnowledgeItem[];
onDelete: (sourceId: string) => void;
}
export const KnowledgeTable: React.FC<KnowledgeTableProps> = ({ items, onDelete }) => {
const statusColorMap = {
active: 'green',
processing: 'blue',
error: 'pink'
};
// Group items by domain
const groupedItems = groupItemsByDomain(items);
// Get frequency display - based on update_frequency days
const getFrequencyDisplay = (frequency?: number) => {
if (!frequency || frequency === 0) {
return { icon: <X className="w-3 h-3" />, text: 'Never', color: 'text-gray-500 dark:text-zinc-500' };
} else if (frequency === 1) {
return { icon: <RefreshCw className="w-3 h-3" />, text: 'Daily', color: 'text-green-500' };
} else if (frequency === 7) {
return { icon: <RefreshCw className="w-3 h-3" />, text: 'Weekly', color: 'text-blue-500' };
} else if (frequency === 30) {
return { icon: <RefreshCw className="w-3 h-3" />, text: 'Monthly', color: 'text-purple-500' };
} else {
return { icon: <RefreshCw className="w-3 h-3" />, text: `Every ${frequency} days`, color: 'text-gray-500 dark:text-zinc-500' };
}
};
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-zinc-700">
<thead className="bg-gray-50 dark:bg-zinc-900/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Tags
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Sources
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Words
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Updated
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Frequency
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
Status
</th>
<th className="relative px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-zinc-900 divide-y divide-gray-200 dark:divide-zinc-700">
{groupedItems.map((groupedItem) => (
<GroupedKnowledgeTableRow
key={groupedItem.id}
groupedItem={groupedItem}
onDelete={onDelete}
statusColorMap={statusColorMap}
getFrequencyDisplay={getFrequencyDisplay}
/>
))}
</tbody>
</table>
</div>
);
};
interface GroupedKnowledgeTableRowProps {
groupedItem: GroupedKnowledgeItem;
onDelete: (sourceId: string) => void;
statusColorMap: Record<string, string>;
getFrequencyDisplay: (frequency?: number) => { icon: React.ReactNode; text: string; color: string };
}
const GroupedKnowledgeTableRow: React.FC<GroupedKnowledgeTableRowProps> = ({
groupedItem,
onDelete,
statusColorMap,
getFrequencyDisplay
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const [showTagsTooltip, setShowTagsTooltip] = useState(false);
const isGrouped = groupedItem.items.length > 1;
const firstItem = groupedItem.items[0];
const frequencyDisplay = getFrequencyDisplay(firstItem.metadata.update_frequency);
// Get the type icon
const TypeIcon = firstItem.metadata.knowledge_type === 'technical' ? BoxIcon : Brain;
const typeIconColor = firstItem.metadata.knowledge_type === 'technical' ? 'text-blue-500' : 'text-purple-500';
// Generate tooltip content for grouped items
const tooltipContent = isGrouped ? (
<div className="space-y-1">
<div className="font-medium text-white">Grouped Sources:</div>
{groupedItem.items.map((item, index) => (
<div key={item.id} className="text-sm text-gray-200">
{index + 1}. {item.source_id}
</div>
))}
</div>
) : null;
const handleDelete = async () => {
if (isGrouped) {
// Delete all items in the group
for (const item of groupedItem.items) {
await onDelete(item.source_id);
}
} else {
await onDelete(firstItem.source_id);
}
};
return (
<tr className="hover:bg-gray-50 dark:hover:bg-zinc-800/50">
<td className="px-6 py-4 max-w-xs">
<div className="flex items-center gap-2">
{firstItem.metadata.source_type === 'url' ? (
<LinkIcon className={`w-4 h-4 flex-shrink-0 ${
firstItem.metadata.knowledge_type === 'technical' ? 'text-blue-500' : 'text-cyan-500'
}`} />
) : (
<Upload className={`w-4 h-4 flex-shrink-0 ${
firstItem.metadata.knowledge_type === 'technical' ? 'text-purple-500' : 'text-pink-500'
}`} />
)}
<TypeIcon className={`w-4 h-4 flex-shrink-0 ${
firstItem.metadata.source_type === 'url'
? firstItem.metadata.knowledge_type === 'technical' ? 'text-blue-500' : 'text-cyan-500'
: firstItem.metadata.knowledge_type === 'technical' ? 'text-purple-500' : 'text-pink-500'
}`} />
<div className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-[200px]" title={isGrouped ? groupedItem.domain : firstItem.title}>
{isGrouped ? groupedItem.domain : firstItem.title}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-zinc-400">
<Badge color={firstItem.metadata.knowledge_type === 'technical' ? 'blue' : 'pink'}>
{firstItem.metadata.knowledge_type}
</Badge>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="relative">
<div
className="flex flex-wrap gap-1"
onMouseEnter={() => (groupedItem.metadata.tags?.length || 0) > 3 && setShowTagsTooltip(true)}
onMouseLeave={() => setShowTagsTooltip(false)}
>
{groupedItem.metadata.tags?.slice(0, 3).map(tag => (
<Badge key={tag} color="purple" variant="outline">
{tag}
</Badge>
))}
{(groupedItem.metadata.tags?.length || 0) > 3 && (
<Badge color="gray" variant="outline" className="cursor-pointer">
+{(groupedItem.metadata.tags?.length || 0) - 3}
</Badge>
)}
</div>
{/* Tags Tooltip */}
{showTagsTooltip && (groupedItem.metadata.tags?.length || 0) > 3 && (
<div className="absolute bottom-full mb-2 left-0 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 max-w-xs">
<div className="font-semibold text-purple-300 mb-1">All Tags:</div>
<div className="flex flex-wrap gap-1">
{groupedItem.metadata.tags?.map((tag, index) => (
<span key={index} className="bg-purple-500/20 text-purple-300 px-2 py-1 rounded text-xs">
{tag}
</span>
))}
</div>
<div className="absolute top-full left-4 border-4 border-transparent border-t-black dark:border-t-zinc-800"></div>
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{isGrouped ? (
<div
className="cursor-pointer relative inline-block"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<div className="flex items-center gap-1 px-2 py-1 bg-blue-500/20 border border-blue-500/40 rounded-full backdrop-blur-sm shadow-[0_0_15px_rgba(59,130,246,0.3)] hover:shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-300">
<Globe className="w-3 h-3 text-blue-400" />
<span className="text-xs text-blue-400 font-medium">{groupedItem.items.length}</span>
</div>
{/* Tooltip */}
{showTooltip && (
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black dark:bg-zinc-800 text-white text-xs rounded-lg py-2 px-3 shadow-lg z-50 whitespace-nowrap max-w-xs">
<div className="font-semibold text-blue-300 mb-1">Grouped Sources:</div>
{groupedItem.items.map((item, index) => (
<div key={index} className="text-gray-300">
{index + 1}. {item.source_id}
</div>
))}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-zinc-800"></div>
</div>
)}
</div>
) : (
<span className="text-sm text-gray-500 dark:text-zinc-400">1</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-zinc-400">
{groupedItem.metadata.chunks_count || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-zinc-400">
{(() => {
try {
const date = new Date(groupedItem.updated_at);
return isNaN(date.getTime()) ? 'Invalid date' : format(date, 'MMM dd, yyyy');
} catch (error) {
return 'Invalid date';
}
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`flex items-center gap-1 ${frequencyDisplay.color}`}>
{frequencyDisplay.icon}
<span className="text-sm">{frequencyDisplay.text}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Badge color={statusColorMap[firstItem.metadata.status || 'active'] as any}>
{(firstItem.metadata.status || 'active').charAt(0).toUpperCase() + (firstItem.metadata.status || 'active').slice(1)}
</Badge>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end gap-2">
<button onClick={handleDelete} className="p-2 text-gray-500 hover:text-red-500" title={isGrouped ? `Delete ${groupedItem.items.length} sources` : "Delete"}>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
};

View File

@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { callAPIWithETag } from "../../../features/projects/shared/apiWithEtag";
import { callAPIWithETag } from "../../../features/shared/apiWithEtag";
import { createRetryLogic, STALE_TIMES } from "../../../features/shared/queryPatterns";
import type { HealthResponse } from "../types";
/**
@@ -13,10 +14,10 @@ export function useBackendHealth() {
// Use existing ETag infrastructure with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
// Chain signals: React Query's signal + our timeout
if (signal) {
signal.addEventListener('abort', () => controller.abort());
signal.addEventListener("abort", () => controller.abort());
}
return callAPIWithETag<HealthResponse>("/api/health", {
@@ -25,23 +26,17 @@ export function useBackendHealth() {
clearTimeout(timeoutId);
});
},
// Retry configuration for startup scenarios
retry: (failureCount) => {
// Keep retrying during startup, up to 5 times
if (failureCount < 5) {
return true;
}
return false;
},
// Retry configuration for startup scenarios - respect 4xx but allow more attempts
retry: createRetryLogic(5),
retryDelay: (attemptIndex) => {
// Exponential backoff: 1.5s, 2.25s, 3.375s, etc.
return Math.min(1500 * 1.5 ** attemptIndex, 10000);
},
// Refetch every 30 seconds when healthy
refetchInterval: 30000,
refetchInterval: STALE_TIMES.normal,
// Keep trying to connect on window focus
refetchOnWindowFocus: true,
// Consider data fresh for 20 seconds
staleTime: 20000,
// Consider data fresh for 30 seconds
staleTime: STALE_TIMES.normal,
});
}

View File

@@ -3,7 +3,7 @@ import { Key, ExternalLink, Save, Loader } from "lucide-react";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { useToast } from "../../contexts/ToastContext";
import { useToast } from "../../features/ui/hooks/useToast";
import { credentialsService } from "../../services/credentialsService";
interface ProviderStepProps {

View File

@@ -4,7 +4,7 @@ import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { credentialsService, Credential } from '../../services/credentialsService';
import { useToast } from '../../contexts/ToastContext';
import { useToast } from '../../features/ui/hooks/useToast';
interface CustomCredential {
key: string;
@@ -16,6 +16,7 @@ interface CustomCredential {
is_encrypted?: boolean;
showValue?: boolean; // Track per-credential visibility
isNew?: boolean; // Track if this is a new unsaved credential
isFromBackend?: boolean; // Track if credential came from backend (write-only once encrypted)
}
export const APIKeysSection = () => {
@@ -51,17 +52,22 @@ export const APIKeysSection = () => {
});
// Convert to UI format
const uiCredentials = apiKeys.map(cred => ({
key: cred.key,
value: cred.value || '',
description: cred.description || '',
originalValue: cred.value || '',
originalKey: cred.key, // Track original key for updates
hasChanges: false,
is_encrypted: cred.is_encrypted || false,
showValue: false,
isNew: false
}));
const uiCredentials = apiKeys.map(cred => {
const isEncryptedFromBackend = cred.is_encrypted && cred.value === '[ENCRYPTED]';
return {
key: cred.key,
value: cred.value || '',
description: cred.description || '',
originalValue: cred.value || '',
originalKey: cred.key, // Track original key for updates
hasChanges: false,
is_encrypted: cred.is_encrypted || false,
showValue: false,
isNew: false,
isFromBackend: !cred.isNew, // Mark as from backend unless it's a new credential
};
});
setCustomCredentials(uiCredentials);
} catch (err) {
@@ -81,7 +87,8 @@ export const APIKeysSection = () => {
hasChanges: true,
is_encrypted: true, // Default to encrypted
showValue: true, // Show value for new entries
isNew: true
isNew: true,
isFromBackend: false // New credentials are not from backend
};
setCustomCredentials([...customCredentials, newCred]);
@@ -95,6 +102,12 @@ export const APIKeysSection = () => {
if (field === 'key' || field === 'value' || field === 'is_encrypted') {
updated.hasChanges = true;
}
// If user is editing the value of an encrypted credential from backend, make it editable
if (field === 'value' && cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]') {
updated.isFromBackend = false; // Now it's being edited, treat like new credential
updated.showValue = false; // Keep it hidden by default since it was encrypted
updated.value = ''; // Clear the [ENCRYPTED] placeholder so they can enter new value
}
return updated;
}
return cred;
@@ -102,11 +115,21 @@ export const APIKeysSection = () => {
};
const toggleValueVisibility = (index: number) => {
updateCredential(index, 'showValue', !customCredentials[index].showValue);
const cred = customCredentials[index];
if (cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]') {
showToast('Encrypted credentials cannot be viewed. Edit to make changes.', 'warning');
return;
}
updateCredential(index, 'showValue', !cred.showValue);
};
const toggleEncryption = (index: number) => {
updateCredential(index, 'is_encrypted', !customCredentials[index].is_encrypted);
const cred = customCredentials[index];
if (cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]') {
showToast('Edit the credential value to make changes.', 'warning');
return;
}
updateCredential(index, 'is_encrypted', !cred.is_encrypted);
};
const deleteCredential = async (index: number) => {
@@ -242,15 +265,31 @@ export const APIKeysSection = () => {
value={cred.value}
onChange={(e) => updateCredential(index, 'value', e.target.value)}
placeholder={cred.is_encrypted && !cred.value ? 'Enter new value (encrypted)' : 'Enter value'}
className="w-full px-3 py-2 pr-20 rounded-md bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-sm"
className={`w-full px-3 py-2 pr-20 rounded-md border text-sm ${
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-400'
: 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-700'
}`}
title={cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'Click to edit this encrypted credential'
: undefined}
/>
{/* Show/Hide value button */}
<button
type="button"
onClick={() => toggleValueVisibility(index)}
className="absolute right-10 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={cred.showValue ? 'Hide value' : 'Show value'}
disabled={cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'}
className={`absolute right-10 top-1/2 -translate-y-1/2 p-1.5 rounded transition-colors ${
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'cursor-not-allowed opacity-50'
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
title={
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'Edit credential to view and modify'
: cred.showValue ? 'Hide value' : 'Show value'
}
>
{cred.showValue ? (
<EyeOff className="w-4 h-4 text-gray-500" />
@@ -263,14 +302,21 @@ export const APIKeysSection = () => {
<button
type="button"
onClick={() => toggleEncryption(index)}
disabled={cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'}
className={`
absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded transition-colors
${cred.is_encrypted
? 'text-pink-600 dark:text-pink-400 hover:bg-pink-100 dark:hover:bg-pink-900/20'
: 'text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
${cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'cursor-not-allowed opacity-50 text-pink-400'
: cred.is_encrypted
? 'text-pink-600 dark:text-pink-400 hover:bg-pink-100 dark:hover:bg-pink-900/20'
: 'text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}
`}
title={cred.is_encrypted ? 'Encrypted' : 'Not encrypted'}
title={
cred.isFromBackend && cred.is_encrypted && cred.value === '[ENCRYPTED]'
? 'Edit credential to modify encryption'
: cred.is_encrypted ? 'Encrypted - click to decrypt' : 'Not encrypted - click to encrypt'
}
>
{cred.is_encrypted ? (
<Lock className="w-4 h-4" />
@@ -347,7 +393,7 @@ export const APIKeysSection = () => {
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<p>
Click the lock icon to toggle encryption for each credential. Encrypted values are stored securely and only decrypted when needed.
Encrypted credentials are masked after saving. Click on a masked credential to edit it - this allows you to change the value and encryption settings.
</p>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { Copy, Check, Link, Unlink } from 'lucide-react';
import { NeonButton, type CornerRadius, type GlowIntensity, type ColorOption } from '../ui/NeonButton';
import { motion } from 'framer-motion';
import { cn } from '../../lib/utils';
import { copyToClipboard } from '../../features/shared/utils/clipboard';
export const ButtonPlayground: React.FC = () => {
const [showLayer2, setShowLayer2] = useState(true);
@@ -279,10 +280,14 @@ export const ButtonPlayground: React.FC = () => {
return colors[color];
};
const copyToClipboard = () => {
navigator.clipboard.writeText(generateCSS());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const handleCopyToClipboard = async () => {
const result = await copyToClipboard(generateCSS());
if (result.success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error('Failed to copy to clipboard:', result.error);
}
};
// Corner input component
@@ -654,7 +659,7 @@ export const ButtonPlayground: React.FC = () => {
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">CSS Styles</h3>
<button
onClick={copyToClipboard}
onClick={handleCopyToClipboard}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center gap-2 shadow-lg shadow-purple-600/25"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}

View File

@@ -3,7 +3,7 @@ import { Code, Check, Save, Loader } from 'lucide-react';
import { Card } from '../ui/Card';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import { useToast } from '../../contexts/ToastContext';
import { useToast } from '../../features/ui/hooks/useToast';
import { credentialsService } from '../../services/credentialsService';
interface CodeExtractionSettingsProps {

View File

@@ -4,7 +4,7 @@ import { Toggle } from '../ui/Toggle';
import { Card } from '../ui/Card';
import { useTheme } from '../../contexts/ThemeContext';
import { credentialsService } from '../../services/credentialsService';
import { useToast } from '../../contexts/ToastContext';
import { useToast } from '../../features/ui/hooks/useToast';
import { serverHealthService } from '../../services/serverHealthService';
export const FeaturesSection = () => {

View File

@@ -2,7 +2,8 @@ import { useState } from 'react';
import { FileCode, Copy, Check } from 'lucide-react';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { useToast } from '../../contexts/ToastContext';
import { useToast } from '../../features/ui/hooks/useToast';
import { copyToClipboard } from '../../features/shared/utils/clipboard';
type RuleType = 'claude' | 'universal';
@@ -472,8 +473,9 @@ archon:manage_task(
};
const handleCopyToClipboard = async () => {
try {
await navigator.clipboard.writeText(currentRules);
const result = await copyToClipboard(currentRules);
if (result.success) {
setCopied(true);
showToast(`${selectedRuleType === 'claude' ? 'Claude Code' : 'Universal'} rules copied to clipboard!`, 'success');
@@ -481,8 +483,8 @@ archon:manage_task(
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
} else {
console.error('Failed to copy text:', result.error);
showToast('Failed to copy to clipboard', 'error');
}
};

View File

@@ -0,0 +1,877 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Badge } from '../ui/Badge';
import { useToast } from '../../features/ui/hooks/useToast';
import { cn } from '../../lib/utils';
import { credentialsService, OllamaInstance } from '../../services/credentialsService';
import { OllamaModelDiscoveryModal } from './OllamaModelDiscoveryModal';
import type { OllamaInstance as OllamaInstanceType } from './types/OllamaTypes';
interface OllamaConfigurationPanelProps {
isVisible: boolean;
onConfigChange: (instances: OllamaInstance[]) => void;
className?: string;
separateHosts?: boolean; // Enable separate LLM Chat and Embedding host configuration
}
interface ConnectionTestResult {
isHealthy: boolean;
responseTimeMs?: number;
modelsAvailable?: number;
error?: string;
}
const OllamaConfigurationPanel: React.FC<OllamaConfigurationPanelProps> = ({
isVisible,
onConfigChange,
className = '',
separateHosts = false
}) => {
const [instances, setInstances] = useState<OllamaInstance[]>([]);
const [loading, setLoading] = useState(true);
const [testingConnections, setTestingConnections] = useState<Set<string>>(new Set());
const [newInstanceUrl, setNewInstanceUrl] = useState('');
const [newInstanceName, setNewInstanceName] = useState('');
const [newInstanceType, setNewInstanceType] = useState<'chat' | 'embedding'>('chat');
const [showAddInstance, setShowAddInstance] = useState(false);
const [discoveringModels, setDiscoveringModels] = useState(false);
const [modelDiscoveryResults, setModelDiscoveryResults] = useState<any>(null);
const [showModelDiscoveryModal, setShowModelDiscoveryModal] = useState(false);
const [selectedChatModel, setSelectedChatModel] = useState<string | null>(null);
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string | null>(null);
// Track temporary URL values for each instance to prevent aggressive updates
const [tempUrls, setTempUrls] = useState<Record<string, string>>({});
const updateTimeouts = useRef<Record<string, NodeJS.Timeout>>({});
const { showToast } = useToast();
// Load instances from database
const loadInstances = async () => {
try {
setLoading(true);
// First try to migrate from localStorage if needed
const migrationResult = await credentialsService.migrateOllamaFromLocalStorage();
if (migrationResult.migrated) {
showToast(`Migrated ${migrationResult.instanceCount} Ollama instances to database`, 'success');
}
// Load instances from database
const databaseInstances = await credentialsService.getOllamaInstances();
setInstances(databaseInstances);
onConfigChange(databaseInstances);
} catch (error) {
console.error('Failed to load Ollama instances from database:', error);
showToast('Failed to load Ollama configuration from database', 'error');
// Fallback to localStorage
try {
const saved = localStorage.getItem('ollama-instances');
if (saved) {
const localInstances = JSON.parse(saved);
setInstances(localInstances);
onConfigChange(localInstances);
showToast('Loaded Ollama configuration from local backup', 'warning');
}
} catch (localError) {
console.error('Failed to load from localStorage as fallback:', localError);
}
} finally {
setLoading(false);
}
};
// Save instances to database
const saveInstances = async (newInstances: OllamaInstance[]) => {
try {
setLoading(true);
await credentialsService.setOllamaInstances(newInstances);
setInstances(newInstances);
onConfigChange(newInstances);
// Also backup to localStorage for fallback
try {
localStorage.setItem('ollama-instances', JSON.stringify(newInstances));
} catch (localError) {
console.warn('Failed to backup to localStorage:', localError);
}
} catch (error) {
console.error('Failed to save Ollama instances to database:', error);
showToast('Failed to save Ollama configuration to database', 'error');
} finally {
setLoading(false);
}
};
// Test connection to an Ollama instance with retry logic
const testConnection = async (baseUrl: string, retryCount = 3): Promise<ConnectionTestResult> => {
const maxRetries = retryCount;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('/api/providers/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: 'ollama',
base_url: baseUrl
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const result = {
isHealthy: data.health_status?.is_available || false,
responseTimeMs: data.health_status?.response_time_ms,
modelsAvailable: data.health_status?.models_available,
error: data.health_status?.error_message
};
// If successful, return immediately
if (result.isHealthy) {
return result;
}
// If not healthy but we got a valid response, still return (but might retry)
lastError = new Error(result.error || 'Instance not available');
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error');
}
// If this wasn't the last attempt, wait before retrying
if (attempt < maxRetries) {
const delayMs = Math.pow(2, attempt - 1) * 1000; // Exponential backoff: 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// All retries failed, return error result
return {
isHealthy: false,
error: lastError?.message || 'Connection failed after retries'
};
};
// Handle connection test for a specific instance
const handleTestConnection = async (instanceId: string) => {
const instance = instances.find(inst => inst.id === instanceId);
if (!instance) return;
setTestingConnections(prev => new Set(prev).add(instanceId));
try {
const result = await testConnection(instance.baseUrl);
// Update instance with test results
const updatedInstances = instances.map(inst =>
inst.id === instanceId
? {
...inst,
isHealthy: result.isHealthy,
responseTimeMs: result.responseTimeMs,
modelsAvailable: result.modelsAvailable,
lastHealthCheck: new Date().toISOString()
}
: inst
);
saveInstances(updatedInstances);
if (result.isHealthy) {
showToast(`Connected to ${instance.name} (${result.responseTimeMs?.toFixed(0)}ms, ${result.modelsAvailable} models)`, 'success');
} else {
showToast(result.error || 'Unable to connect to Ollama instance', 'error');
}
} catch (error) {
showToast(`Connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
} finally {
setTestingConnections(prev => {
const newSet = new Set(prev);
newSet.delete(instanceId);
return newSet;
});
}
};
// Add new instance
const handleAddInstance = async () => {
if (!newInstanceUrl.trim() || !newInstanceName.trim()) {
showToast('Please provide both URL and name for the new instance', 'error');
return;
}
// Validate URL format
try {
const url = new URL(newInstanceUrl);
if (!url.protocol.startsWith('http')) {
throw new Error('URL must use HTTP or HTTPS protocol');
}
} catch (error) {
showToast('Please provide a valid HTTP/HTTPS URL', 'error');
return;
}
// Check for duplicate URLs
const isDuplicate = instances.some(inst => inst.baseUrl === newInstanceUrl.trim());
if (isDuplicate) {
showToast('An instance with this URL already exists', 'error');
return;
}
const newInstance: OllamaInstance = {
id: `instance-${Date.now()}`,
name: newInstanceName.trim(),
baseUrl: newInstanceUrl.trim(),
isEnabled: true,
isPrimary: false,
loadBalancingWeight: 100,
instanceType: separateHosts ? newInstanceType : 'both'
};
try {
setLoading(true);
await credentialsService.addOllamaInstance(newInstance);
// Reload instances from database to get updated list
await loadInstances();
setNewInstanceUrl('');
setNewInstanceName('');
setNewInstanceType('chat');
setShowAddInstance(false);
showToast(`Added new Ollama instance: ${newInstance.name}`, 'success');
} catch (error) {
console.error('Failed to add Ollama instance:', error);
showToast(`Failed to add Ollama instance: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
} finally {
setLoading(false);
}
};
// Remove instance
const handleRemoveInstance = async (instanceId: string) => {
const instance = instances.find(inst => inst.id === instanceId);
if (!instance) return;
// Don't allow removing the last instance
if (instances.length <= 1) {
showToast('At least one Ollama instance must be configured', 'error');
return;
}
try {
setLoading(true);
await credentialsService.removeOllamaInstance(instanceId);
// Reload instances from database to get updated list
await loadInstances();
showToast(`Removed Ollama instance: ${instance.name}`, 'success');
} catch (error) {
console.error('Failed to remove Ollama instance:', error);
showToast(`Failed to remove Ollama instance: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
} finally {
setLoading(false);
}
};
// Debounced URL update - only update after user stops typing for 1 second
const debouncedUpdateInstanceUrl = useCallback(async (instanceId: string, newUrl: string) => {
try {
// Clear any existing timeout for this instance
if (updateTimeouts.current[instanceId]) {
clearTimeout(updateTimeouts.current[instanceId]);
}
// Set new timeout
updateTimeouts.current[instanceId] = setTimeout(async () => {
try {
await credentialsService.updateOllamaInstance(instanceId, {
baseUrl: newUrl,
isHealthy: undefined,
lastHealthCheck: undefined
});
await loadInstances(); // Reload to get updated data
// Clear the temporary URL after successful update
setTempUrls(prev => {
const updated = { ...prev };
delete updated[instanceId];
return updated;
});
// Connection test removed - only manual testing via "Test" button per user request
} catch (error) {
console.error('Failed to update Ollama instance URL:', error);
showToast('Failed to update instance URL', 'error');
}
}, 1000); // 1 second debounce
} catch (error) {
console.error('Failed to set up URL update timeout:', error);
}
}, [showToast]);
// Handle immediate URL change (for UI responsiveness) without triggering API calls
const handleUrlChange = (instanceId: string, newUrl: string) => {
// Update temporary URL state for immediate UI feedback
setTempUrls(prev => ({ ...prev, [instanceId]: newUrl }));
// Trigger debounced update
debouncedUpdateInstanceUrl(instanceId, newUrl);
};
// Handle URL blur - immediately save if there are pending changes
const handleUrlBlur = async (instanceId: string) => {
const tempUrl = tempUrls[instanceId];
const instance = instances.find(inst => inst.id === instanceId);
if (tempUrl && instance && tempUrl !== instance.baseUrl) {
// Clear the timeout since we're updating immediately
if (updateTimeouts.current[instanceId]) {
clearTimeout(updateTimeouts.current[instanceId]);
delete updateTimeouts.current[instanceId];
}
try {
await credentialsService.updateOllamaInstance(instanceId, {
baseUrl: tempUrl,
isHealthy: undefined,
lastHealthCheck: undefined
});
await loadInstances();
// Clear the temporary URL after successful update
setTempUrls(prev => {
const updated = { ...prev };
delete updated[instanceId];
return updated;
});
// Connection test removed - only manual testing via "Test" button per user request
} catch (error) {
console.error('Failed to update Ollama instance URL:', error);
showToast('Failed to update instance URL', 'error');
}
}
};
// Toggle instance enabled state
const handleToggleInstance = async (instanceId: string) => {
const instance = instances.find(inst => inst.id === instanceId);
if (!instance) return;
try {
await credentialsService.updateOllamaInstance(instanceId, {
isEnabled: !instance.isEnabled
});
await loadInstances(); // Reload to get updated data
} catch (error) {
console.error('Failed to toggle Ollama instance:', error);
showToast('Failed to toggle instance state', 'error');
}
};
// Set instance as primary
const handleSetPrimary = async (instanceId: string) => {
try {
// Update all instances - only the specified one should be primary
await saveInstances(instances.map(inst => ({
...inst,
isPrimary: inst.id === instanceId
})));
} catch (error) {
console.error('Failed to set primary Ollama instance:', error);
showToast('Failed to set primary instance', 'error');
}
};
// Open model discovery modal
const handleDiscoverModels = () => {
if (instances.length === 0) {
showToast('No Ollama instances configured', 'error');
return;
}
const enabledInstances = instances.filter(inst => inst.isEnabled);
if (enabledInstances.length === 0) {
showToast('No enabled Ollama instances found', 'error');
return;
}
setShowModelDiscoveryModal(true);
};
// Handle model selection from discovery modal
const handleModelSelection = async (models: { chatModel?: string; embeddingModel?: string }) => {
try {
setSelectedChatModel(models.chatModel || null);
setSelectedEmbeddingModel(models.embeddingModel || null);
// Store model preferences in localStorage for persistence
const modelPreferences = {
chatModel: models.chatModel,
embeddingModel: models.embeddingModel,
updatedAt: new Date().toISOString()
};
localStorage.setItem('ollama-selected-models', JSON.stringify(modelPreferences));
let successMessage = 'Model selection updated';
if (models.chatModel && models.embeddingModel) {
successMessage = `Selected models: ${models.chatModel} (chat), ${models.embeddingModel} (embedding)`;
} else if (models.chatModel) {
successMessage = `Selected chat model: ${models.chatModel}`;
} else if (models.embeddingModel) {
successMessage = `Selected embedding model: ${models.embeddingModel}`;
}
showToast(successMessage, 'success');
setShowModelDiscoveryModal(false);
} catch (error) {
console.error('Failed to save model selection:', error);
showToast('Failed to save model selection', 'error');
}
};
// Load instances from database on mount
useEffect(() => {
loadInstances();
}, []); // Empty dependency array - load only on mount
// Load saved model preferences on mount
useEffect(() => {
try {
const savedPreferences = localStorage.getItem('ollama-selected-models');
if (savedPreferences) {
const preferences = JSON.parse(savedPreferences);
setSelectedChatModel(preferences.chatModel || null);
setSelectedEmbeddingModel(preferences.embeddingModel || null);
}
} catch (error) {
console.warn('Failed to load saved model preferences:', error);
}
}, []);
// Notify parent of configuration changes
useEffect(() => {
onConfigChange(instances);
}, [instances, onConfigChange]);
// Note: Auto-testing completely removed to prevent API calls on every keystroke
// Connection testing now ONLY happens on manual "Test Connection" button clicks
// No automatic testing on URL changes, saves, or blur events per user request
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
// Clear all pending timeouts
Object.values(updateTimeouts.current).forEach(timeout => {
if (timeout) clearTimeout(timeout);
});
updateTimeouts.current = {};
};
}, []);
if (!isVisible) return null;
const getConnectionStatusBadge = (instance: OllamaInstance) => {
if (testingConnections.has(instance.id)) {
return <Badge variant="outline" color="gray" className="animate-pulse">Testing...</Badge>;
}
if (instance.isHealthy === true) {
return (
<Badge variant="solid" color="green" className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Online
{instance.responseTimeMs && (
<span className="text-xs opacity-75">
({instance.responseTimeMs.toFixed(0)}ms)
</span>
)}
</Badge>
);
}
if (instance.isHealthy === false) {
return (
<Badge variant="solid" color="pink" className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-red-500" />
Offline
</Badge>
);
}
// For instances that haven't been tested yet (isHealthy === undefined)
// Show a "checking" status until manually tested via "Test" button
return (
<Badge variant="outline" color="blue" className="animate-pulse">
<div className="w-2 h-2 rounded-full bg-blue-500 animate-ping mr-1" />
Checking...
</Badge>
);
};
return (
<Card
accentColor="green"
className={cn("mt-4 space-y-4", className)}
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Ollama Configuration
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure Ollama instances for distributed processing
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDiscoverModels}
disabled={instances.filter(inst => inst.isEnabled).length === 0}
className="text-xs"
>
{selectedChatModel || selectedEmbeddingModel ? 'Change Models' : 'Select Models'}
</Button>
<Badge variant="outline" color="gray" className="text-xs">
{instances.filter(inst => inst.isEnabled).length} Active
</Badge>
{(selectedChatModel || selectedEmbeddingModel) && (
<div className="flex gap-1">
{selectedChatModel && (
<Badge variant="solid" color="blue" className="text-xs">
Chat: {selectedChatModel.split(':')[0]}
</Badge>
)}
{selectedEmbeddingModel && (
<Badge variant="solid" color="purple" className="text-xs">
Embed: {selectedEmbeddingModel.split(':')[0]}
</Badge>
)}
</div>
)}
</div>
</div>
{/* Instance List */}
<div className="space-y-3">
{instances.map((instance) => (
<Card key={instance.id} className="p-4 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{instance.name}
</span>
{instance.isPrimary && (
<Badge variant="outline" color="gray" className="text-xs">Primary</Badge>
)}
{instance.instanceType && instance.instanceType !== 'both' && (
<Badge
variant="solid"
color={instance.instanceType === 'chat' ? 'blue' : 'purple'}
className="text-xs"
>
{instance.instanceType === 'chat' ? 'Chat' : 'Embedding'}
</Badge>
)}
{(!instance.instanceType || instance.instanceType === 'both') && separateHosts && (
<Badge variant="outline" color="gray" className="text-xs">
Both
</Badge>
)}
{getConnectionStatusBadge(instance)}
</div>
<div className="relative">
<Input
type="url"
value={tempUrls[instance.id] !== undefined ? tempUrls[instance.id] : instance.baseUrl}
onChange={(e) => handleUrlChange(instance.id, e.target.value)}
onBlur={() => handleUrlBlur(instance.id)}
placeholder="http://localhost:11434"
className={cn(
"text-sm",
tempUrls[instance.id] !== undefined && tempUrls[instance.id] !== instance.baseUrl
? "border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20"
: ""
)}
/>
{tempUrls[instance.id] !== undefined && tempUrls[instance.id] !== instance.baseUrl && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<div className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse" title="Changes will be saved after you stop typing" />
</div>
)}
</div>
{instance.modelsAvailable !== undefined && (
<div className="text-xs text-gray-600 dark:text-gray-400">
{instance.modelsAvailable} models available
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection(instance.id)}
disabled={testingConnections.has(instance.id)}
className="text-xs"
>
{testingConnections.has(instance.id) ? 'Testing...' : 'Test'}
</Button>
{!instance.isPrimary && (
<Button
variant="outline"
size="sm"
onClick={() => handleSetPrimary(instance.id)}
className="text-xs"
>
Set Primary
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleInstance(instance.id)}
className={cn(
"text-xs",
instance.isEnabled
? "text-green-600 hover:text-green-700"
: "text-gray-500 hover:text-gray-600"
)}
>
{instance.isEnabled ? 'Enabled' : 'Disabled'}
</Button>
{instances.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveInstance(instance.id)}
className="text-xs text-red-600 hover:text-red-700"
>
Remove
</Button>
)}
</div>
</div>
</Card>
))}
</div>
{/* Add Instance Section */}
{showAddInstance ? (
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div className="space-y-3">
<h4 className="font-medium text-blue-900 dark:text-blue-100">
Add New Ollama Instance
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
type="text"
placeholder="Instance Name"
value={newInstanceName}
onChange={(e) => setNewInstanceName(e.target.value)}
/>
<Input
type="url"
placeholder="http://localhost:11434"
value={newInstanceUrl}
onChange={(e) => setNewInstanceUrl(e.target.value)}
/>
</div>
{separateHosts && (
<div className="space-y-2">
<label className="text-sm font-medium text-blue-900 dark:text-blue-100">
Instance Type
</label>
<div className="flex gap-2">
<Button
variant={newInstanceType === 'chat' ? 'solid' : 'outline'}
size="sm"
onClick={() => setNewInstanceType('chat')}
className={cn(
newInstanceType === 'chat'
? 'bg-blue-600 text-white'
: 'text-blue-600 border-blue-600'
)}
>
LLM Chat
</Button>
<Button
variant={newInstanceType === 'embedding' ? 'solid' : 'outline'}
size="sm"
onClick={() => setNewInstanceType('embedding')}
className={cn(
newInstanceType === 'embedding'
? 'bg-blue-600 text-white'
: 'text-blue-600 border-blue-600'
)}
>
Embedding
</Button>
</div>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
onClick={handleAddInstance}
className="bg-blue-600 hover:bg-blue-700"
>
Add Instance
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowAddInstance(false);
setNewInstanceUrl('');
setNewInstanceName('');
setNewInstanceType('chat');
}}
>
Cancel
</Button>
</div>
</div>
</Card>
) : (
<Button
variant="outline"
onClick={() => setShowAddInstance(true)}
className="w-full border-dashed border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"
>
<span className="text-gray-600 dark:text-gray-400">+ Add Ollama Instance</span>
</Button>
)}
{/* Selected Models Summary for Dual-Host Mode */}
{separateHosts && (selectedChatModel || selectedEmbeddingModel) && (
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-3">
Model Assignment Summary
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedChatModel && (
<div className="flex items-center justify-between p-3 bg-blue-100 dark:bg-blue-800/30 rounded">
<div>
<div className="font-medium text-blue-900 dark:text-blue-100">
Chat Model
</div>
<div className="text-sm text-blue-700 dark:text-blue-300">
{selectedChatModel}
</div>
</div>
<Badge variant="solid" color="blue">
{instances.filter(inst => inst.instanceType === 'chat' || inst.instanceType === 'both').length} hosts
</Badge>
</div>
)}
{selectedEmbeddingModel && (
<div className="flex items-center justify-between p-3 bg-purple-100 dark:bg-purple-800/30 rounded">
<div>
<div className="font-medium text-purple-900 dark:text-purple-100">
Embedding Model
</div>
<div className="text-sm text-purple-700 dark:text-purple-300">
{selectedEmbeddingModel}
</div>
</div>
<Badge variant="solid" color="purple">
{instances.filter(inst => inst.instanceType === 'embedding' || inst.instanceType === 'both').length} hosts
</Badge>
</div>
)}
</div>
{(!selectedChatModel || !selectedEmbeddingModel) && (
<div className="mt-3 text-xs text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/30 p-2 rounded">
<strong>Tip:</strong> {!selectedChatModel && !selectedEmbeddingModel ? 'Select both chat and embedding models for optimal performance' : !selectedChatModel ? 'Consider selecting a chat model for LLM operations' : 'Consider selecting an embedding model for vector operations'}
</div>
)}
</Card>
)}
{/* Configuration Summary */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div className="flex justify-between">
<span>Total Instances:</span>
<span className="font-mono">{instances.length}</span>
</div>
<div className="flex justify-between">
<span>Active Instances:</span>
<span className="font-mono text-green-600 dark:text-green-400">
{instances.filter(inst => inst.isEnabled && inst.isHealthy).length}
</span>
</div>
<div className="flex justify-between">
<span>Load Balancing:</span>
<span className="font-mono">
{instances.filter(inst => inst.isEnabled).length > 1 ? 'Enabled' : 'Disabled'}
</span>
</div>
{(selectedChatModel || selectedEmbeddingModel) && (
<div className="flex justify-between">
<span>Selected Models:</span>
<span className="font-mono text-green-600 dark:text-green-400">
{[selectedChatModel, selectedEmbeddingModel].filter(Boolean).length}
</span>
</div>
)}
{separateHosts && (
<div className="flex justify-between">
<span>Dual-Host Mode:</span>
<span className="font-mono text-blue-600 dark:text-blue-400">
Enabled
</span>
</div>
)}
</div>
</div>
{/* Model Discovery Modal */}
<OllamaModelDiscoveryModal
isOpen={showModelDiscoveryModal}
onClose={() => setShowModelDiscoveryModal(false)}
onSelectModels={handleModelSelection}
instances={instances.filter(inst => inst.isEnabled).map(inst => ({
id: inst.id,
name: inst.name,
baseUrl: inst.baseUrl,
instanceType: inst.instanceType || 'both',
isEnabled: inst.isEnabled,
isPrimary: inst.isPrimary,
healthStatus: {
isHealthy: inst.isHealthy || false,
lastChecked: inst.lastHealthCheck ? new Date(inst.lastHealthCheck) : new Date(),
responseTimeMs: inst.responseTimeMs,
error: inst.isHealthy === false ? 'Connection failed' : undefined
},
loadBalancingWeight: inst.loadBalancingWeight,
lastHealthCheck: inst.lastHealthCheck,
modelsAvailable: inst.modelsAvailable,
responseTimeMs: inst.responseTimeMs
}))}
/>
</Card>
);
};
export default OllamaConfigurationPanel;

View File

@@ -0,0 +1,288 @@
import React, { useState } from 'react';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
import { cn } from '../../lib/utils';
import { useToast } from '../../features/ui/hooks/useToast';
import { ollamaService } from '../../services/ollamaService';
import type { HealthIndicatorProps } from './types/OllamaTypes';
/**
* Health indicator component for individual Ollama instances
*
* Displays real-time health status with refresh capabilities
* and detailed error information when instances are unhealthy.
*/
export const OllamaInstanceHealthIndicator: React.FC<HealthIndicatorProps> = ({
instance,
onRefresh,
showDetails = true
}) => {
const [isRefreshing, setIsRefreshing] = useState(false);
const { showToast } = useToast();
const handleRefresh = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
// Use the ollamaService to test the connection
const healthResult = await ollamaService.testConnection(instance.baseUrl);
// Notify parent component of the refresh result
onRefresh(instance.id);
if (healthResult.isHealthy) {
showToast(
`Health check successful for ${instance.name} (${healthResult.responseTime?.toFixed(0)}ms)`,
'success'
);
} else {
showToast(
`Health check failed for ${instance.name}: ${healthResult.error}`,
'error'
);
}
} catch (error) {
console.error('Health check failed:', error);
showToast(
`Failed to check health for ${instance.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
'error'
);
} finally {
setIsRefreshing(false);
}
};
const getHealthStatusBadge = () => {
if (isRefreshing) {
return (
<Badge variant="outline" className="animate-pulse">
<div className="w-2 h-2 rounded-full bg-gray-500 animate-ping mr-1" />
Checking...
</Badge>
);
}
if (instance.healthStatus.isHealthy === true) {
return (
<Badge
variant="solid"
className="flex items-center gap-1 bg-green-100 text-green-800 border-green-200 dark:bg-green-900 dark:text-green-100 dark:border-green-700"
>
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Online
</Badge>
);
}
if (instance.healthStatus.isHealthy === false) {
return (
<Badge
variant="solid"
className="flex items-center gap-1 bg-red-100 text-red-800 border-red-200 dark:bg-red-900 dark:text-red-100 dark:border-red-700"
>
<div className="w-2 h-2 rounded-full bg-red-500" />
Offline
</Badge>
);
}
// For instances that haven't been tested yet (isHealthy === undefined)
return (
<Badge
variant="outline"
className="animate-pulse flex items-center gap-1 bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-blue-700"
>
<div className="w-2 h-2 rounded-full bg-blue-500 animate-ping" />
Checking...
</Badge>
);
};
const getInstanceTypeIcon = () => {
switch (instance.instanceType) {
case 'chat':
return '💬';
case 'embedding':
return '🔢';
case 'both':
return '🔄';
default:
return '🤖';
}
};
const formatLastChecked = (date: Date) => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
if (!showDetails) {
// Compact mode - just the status badge and refresh button
return (
<div className="flex items-center gap-2">
{getHealthStatusBadge()}
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1 h-6 w-6"
title={`Refresh health status for ${instance.name}`}
>
<svg
className={cn("w-3 h-3", isRefreshing && "animate-spin")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</Button>
</div>
);
}
// Full detailed mode
return (
<Card className="p-3 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-lg" title={`Instance type: ${instance.instanceType}`}>
{getInstanceTypeIcon()}
</span>
<div>
<div className="font-medium text-gray-900 dark:text-white text-sm">
{instance.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{new URL(instance.baseUrl).host}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{getHealthStatusBadge()}
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1"
title={`Refresh health status for ${instance.name}`}
>
<svg
className={cn("w-4 h-4", isRefreshing && "animate-spin")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</Button>
</div>
</div>
{/* Health Details */}
<div className="space-y-2">
{instance.healthStatus.isHealthy && (
<div className="grid grid-cols-2 gap-4 text-xs">
{instance.healthStatus.responseTimeMs && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Response Time:</span>
<span className={cn(
"font-mono",
instance.healthStatus.responseTimeMs < 100
? "text-green-600 dark:text-green-400"
: instance.healthStatus.responseTimeMs < 500
? "text-yellow-600 dark:text-yellow-400"
: "text-red-600 dark:text-red-400"
)}>
{instance.healthStatus.responseTimeMs.toFixed(0)}ms
</span>
</div>
)}
{instance.modelsAvailable !== undefined && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Models:</span>
<span className="font-mono text-blue-600 dark:text-blue-400">
{instance.modelsAvailable}
</span>
</div>
)}
</div>
)}
{/* Error Details */}
{!instance.healthStatus.isHealthy && instance.healthStatus.error && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs">
<div className="font-medium text-red-800 dark:text-red-200 mb-1">
Connection Error:
</div>
<div className="text-red-600 dark:text-red-300 font-mono">
{instance.healthStatus.error}
</div>
</div>
)}
{/* Instance Configuration */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
{instance.isPrimary && (
<Badge variant="outline" className="text-xs">
Primary
</Badge>
)}
{instance.instanceType !== 'both' && (
<Badge
variant="solid"
className={cn(
"text-xs",
instance.instanceType === 'chat'
? "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900 dark:text-blue-100"
: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900 dark:text-purple-100"
)}
>
{instance.instanceType}
</Badge>
)}
</div>
<div className="text-gray-500 dark:text-gray-400">
Last checked: {formatLastChecked(instance.healthStatus.lastChecked)}
</div>
</div>
{/* Load Balancing Weight */}
{instance.loadBalancingWeight !== undefined && instance.loadBalancingWeight !== 100 && (
<div className="text-xs text-gray-600 dark:text-gray-400">
Load balancing weight: {instance.loadBalancingWeight}%
</div>
)}
</div>
</Card>
);
};
export default OllamaInstanceHealthIndicator;

View File

@@ -0,0 +1,893 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
// FORCE DEBUG - This should ALWAYS appear in console when this file loads
console.log('🚨 DEBUG: OllamaModelDiscoveryModal.tsx file loaded at', new Date().toISOString());
import {
X, Search, Activity, Database, Zap, Clock, Server,
Loader, CheckCircle, AlertCircle, Filter, Download,
MessageCircle, Layers, Cpu, HardDrive
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { createPortal } from 'react-dom';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Badge } from '../ui/Badge';
import { Card } from '../ui/Card';
import { useToast } from '../../features/ui/hooks/useToast';
import { ollamaService, type OllamaModel, type ModelDiscoveryResponse } from '../../services/ollamaService';
import type { OllamaInstance, ModelSelectionState } from './types/OllamaTypes';
interface OllamaModelDiscoveryModalProps {
isOpen: boolean;
onClose: () => void;
onSelectModels: (selection: { chatModel?: string; embeddingModel?: string }) => void;
instances: OllamaInstance[];
initialChatModel?: string;
initialEmbeddingModel?: string;
}
interface EnrichedModel extends OllamaModel {
instanceName?: string;
status: 'available' | 'testing' | 'error';
testResult?: {
chatWorks: boolean;
embeddingWorks: boolean;
dimensions?: number;
};
}
const OllamaModelDiscoveryModal: React.FC<OllamaModelDiscoveryModalProps> = ({
isOpen,
onClose,
onSelectModels,
instances,
initialChatModel,
initialEmbeddingModel
}) => {
console.log('🔴 COMPONENT DEBUG: OllamaModelDiscoveryModal component loaded/rendered', { isOpen });
const [models, setModels] = useState<EnrichedModel[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [discoveryComplete, setDiscoveryComplete] = useState(false);
const [discoveryProgress, setDiscoveryProgress] = useState<string>('');
const [lastDiscoveryTime, setLastDiscoveryTime] = useState<number | null>(null);
const [hasCache, setHasCache] = useState(false);
const [selectionState, setSelectionState] = useState<ModelSelectionState>({
selectedChatModel: initialChatModel || null,
selectedEmbeddingModel: initialEmbeddingModel || null,
filterText: '',
showOnlyEmbedding: false,
showOnlyChat: false,
sortBy: 'name'
});
const [testingModels, setTestingModels] = useState<Set<string>>(new Set());
const { showToast } = useToast();
// Get enabled instance URLs
const enabledInstanceUrls = useMemo(() => {
return instances
.filter(instance => instance.isEnabled)
.map(instance => instance.baseUrl);
}, [instances]);
// Create instance lookup map
const instanceLookup = useMemo(() => {
const lookup: Record<string, OllamaInstance> = {};
instances.forEach(instance => {
lookup[instance.baseUrl] = instance;
});
return lookup;
}, [instances]);
// Generate cache key based on enabled instances
const cacheKey = useMemo(() => {
const sortedUrls = [...enabledInstanceUrls].sort();
const key = `ollama-models-${sortedUrls.join('|')}`;
console.log('🟡 CACHE KEY DEBUG: Generated cache key', {
key,
enabledInstanceUrls,
sortedUrls
});
return key;
}, [enabledInstanceUrls]);
// Save models to localStorage
const saveModelsToCache = useCallback((modelsToCache: EnrichedModel[]) => {
try {
console.log('🟡 CACHE DEBUG: Attempting to save models to cache', {
cacheKey,
modelCount: modelsToCache.length,
instanceUrls: enabledInstanceUrls,
timestamp: Date.now()
});
const cacheData = {
models: modelsToCache,
timestamp: Date.now(),
instanceUrls: enabledInstanceUrls
};
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
setLastDiscoveryTime(Date.now());
setHasCache(true);
console.log('🟢 CACHE DEBUG: Successfully saved models to cache', {
cacheKey,
modelCount: modelsToCache.length,
cacheSize: JSON.stringify(cacheData).length,
storedInLocalStorage: !!localStorage.getItem(cacheKey)
});
} catch (error) {
console.error('🔴 CACHE DEBUG: Failed to save models to cache:', error);
}
}, [cacheKey, enabledInstanceUrls]);
// Load models from localStorage
const loadModelsFromCache = useCallback(() => {
console.log('🟡 CACHE DEBUG: Attempting to load models from cache', {
cacheKey,
enabledInstanceUrls,
hasLocalStorageItem: !!localStorage.getItem(cacheKey)
});
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
console.log('🟡 CACHE DEBUG: Found cached data', {
cacheKey,
cacheSize: cached.length
});
const cacheData = JSON.parse(cached);
const cacheAge = Date.now() - cacheData.timestamp;
const cacheAgeMinutes = Math.floor(cacheAge / (60 * 1000));
console.log('🟡 CACHE DEBUG: Cache data parsed', {
modelCount: cacheData.models?.length,
timestamp: cacheData.timestamp,
cacheAge,
cacheAgeMinutes,
cachedInstanceUrls: cacheData.instanceUrls,
currentInstanceUrls: enabledInstanceUrls
});
// Use cache if less than 10 minutes old and same instances
const instanceUrlsMatch = JSON.stringify(cacheData.instanceUrls?.sort()) === JSON.stringify([...enabledInstanceUrls].sort());
const isCacheValid = cacheAge < 10 * 60 * 1000 && instanceUrlsMatch;
console.log('🟡 CACHE DEBUG: Cache validation', {
isCacheValid,
cacheAge: cacheAge,
maxAge: 10 * 60 * 1000,
instanceUrlsMatch,
cachedUrls: JSON.stringify(cacheData.instanceUrls?.sort()),
currentUrls: JSON.stringify([...enabledInstanceUrls].sort())
});
if (isCacheValid) {
console.log('🟢 CACHE DEBUG: Using cached models', {
modelCount: cacheData.models.length,
timestamp: cacheData.timestamp
});
setModels(cacheData.models);
setDiscoveryComplete(true);
setLastDiscoveryTime(cacheData.timestamp);
setHasCache(true);
setDiscoveryProgress(`Loaded ${cacheData.models.length} cached models`);
return true;
} else {
console.log('🟠 CACHE DEBUG: Cache invalid - will refresh', {
reason: cacheAge >= 10 * 60 * 1000 ? 'expired' : 'different instances'
});
}
} else {
console.log('🟠 CACHE DEBUG: No cached data found for key:', cacheKey);
}
} catch (error) {
console.error('🔴 CACHE DEBUG: Failed to load cached models:', error);
}
return false;
}, [cacheKey, enabledInstanceUrls]);
// Test localStorage functionality (run once when component mounts)
useEffect(() => {
const testLocalStorage = () => {
try {
const testKey = 'ollama-test-key';
const testData = { test: 'localStorage working', timestamp: Date.now() };
console.log('🔧 LOCALSTORAGE DEBUG: Testing localStorage functionality');
localStorage.setItem(testKey, JSON.stringify(testData));
const retrieved = localStorage.getItem(testKey);
const parsed = retrieved ? JSON.parse(retrieved) : null;
console.log('🟢 LOCALSTORAGE DEBUG: localStorage test successful', {
saved: testData,
retrieved: parsed,
working: !!parsed && parsed.test === testData.test
});
localStorage.removeItem(testKey);
} catch (error) {
console.error('🔴 LOCALSTORAGE DEBUG: localStorage test failed', error);
}
};
testLocalStorage();
}, []); // Run once on mount
// Check cache when modal opens or instances change
useEffect(() => {
if (isOpen && enabledInstanceUrls.length > 0) {
console.log('🟡 MODAL DEBUG: Modal opened, checking cache', {
isOpen,
enabledInstanceUrls,
instanceUrlsCount: enabledInstanceUrls.length
});
loadModelsFromCache(); // Progress message is set inside this function
} else {
console.log('🟡 MODAL DEBUG: Modal state change', {
isOpen,
enabledInstanceUrlsCount: enabledInstanceUrls.length
});
}
}, [isOpen, enabledInstanceUrls, loadModelsFromCache]);
// Discover models when modal opens
const discoverModels = useCallback(async (forceRefresh: boolean = false) => {
console.log('🚨 DISCOVERY DEBUG: discoverModels FUNCTION CALLED', {
forceRefresh,
enabledInstanceUrls,
instanceUrlsCount: enabledInstanceUrls.length,
timestamp: new Date().toISOString(),
callStack: new Error().stack?.split('\n').slice(0, 3)
});
console.log('🟡 DISCOVERY DEBUG: Starting model discovery', {
forceRefresh,
enabledInstanceUrls,
instanceUrlsCount: enabledInstanceUrls.length,
timestamp: new Date().toISOString()
});
if (enabledInstanceUrls.length === 0) {
console.log('🔴 DISCOVERY DEBUG: No enabled instances');
setError('No enabled Ollama instances configured');
return;
}
// Check cache first if not forcing refresh
if (!forceRefresh) {
console.log('🟡 DISCOVERY DEBUG: Checking cache before discovery');
const loaded = loadModelsFromCache();
if (loaded) {
console.log('🟢 DISCOVERY DEBUG: Used cached models, skipping API call');
return; // Progress message already set by loadModelsFromCache
}
console.log('🟡 DISCOVERY DEBUG: No valid cache, proceeding with API discovery');
} else {
console.log('🟡 DISCOVERY DEBUG: Force refresh requested, skipping cache');
}
const discoveryStartTime = Date.now();
console.log('🟡 DISCOVERY DEBUG: Starting API discovery at', new Date(discoveryStartTime).toISOString());
setLoading(true);
setError(null);
setDiscoveryComplete(false);
setDiscoveryProgress(`Discovering models from ${enabledInstanceUrls.length} instance(s)...`);
try {
// Discover models (no timeout - let it complete naturally)
console.log('🚨 DISCOVERY DEBUG: About to call ollamaService.discoverModels', {
instanceUrls: enabledInstanceUrls,
includeCapabilities: true,
timestamp: new Date().toISOString()
});
const discoveryResult = await ollamaService.discoverModels({
instanceUrls: enabledInstanceUrls,
includeCapabilities: true
});
console.log('🚨 DISCOVERY DEBUG: ollamaService.discoverModels returned', {
totalModels: discoveryResult.total_models,
chatModelsCount: discoveryResult.chat_models?.length,
embeddingModelsCount: discoveryResult.embedding_models?.length,
hostStatusCount: Object.keys(discoveryResult.host_status || {}).length,
timestamp: new Date().toISOString()
});
const discoveryEndTime = Date.now();
const discoveryDuration = discoveryEndTime - discoveryStartTime;
console.log('🟢 DISCOVERY DEBUG: API discovery completed', {
duration: discoveryDuration,
durationSeconds: (discoveryDuration / 1000).toFixed(1),
totalModels: discoveryResult.total_models,
chatModels: discoveryResult.chat_models.length,
embeddingModels: discoveryResult.embedding_models.length,
hostStatus: Object.keys(discoveryResult.host_status).length,
errors: discoveryResult.discovery_errors.length
});
// Enrich models with instance information and status
const enrichedModels: EnrichedModel[] = [];
// Process chat models
discoveryResult.chat_models.forEach(chatModel => {
const instance = instanceLookup[chatModel.instance_url];
const enriched: EnrichedModel = {
name: chatModel.name,
tag: chatModel.name,
size: chatModel.size,
digest: '',
capabilities: ['chat'],
instance_url: chatModel.instance_url,
instanceName: instance?.name || 'Unknown',
status: 'available',
parameters: chatModel.parameters
};
enrichedModels.push(enriched);
});
// Process embedding models
discoveryResult.embedding_models.forEach(embeddingModel => {
const instance = instanceLookup[embeddingModel.instance_url];
// Check if we already have this model (might support both chat and embedding)
const existingModel = enrichedModels.find(m =>
m.name === embeddingModel.name && m.instance_url === embeddingModel.instance_url
);
if (existingModel) {
// Add embedding capability
existingModel.capabilities.push('embedding');
existingModel.embedding_dimensions = embeddingModel.dimensions;
} else {
// Create new model entry
const enriched: EnrichedModel = {
name: embeddingModel.name,
tag: embeddingModel.name,
size: embeddingModel.size,
digest: '',
capabilities: ['embedding'],
embedding_dimensions: embeddingModel.dimensions,
instance_url: embeddingModel.instance_url,
instanceName: instance?.name || 'Unknown',
status: 'available'
};
enrichedModels.push(enriched);
}
});
console.log('🚨 DISCOVERY DEBUG: About to call setModels', {
enrichedModelsCount: enrichedModels.length,
enrichedModels: enrichedModels.map(m => ({ name: m.name, capabilities: m.capabilities })),
timestamp: new Date().toISOString()
});
setModels(enrichedModels);
setDiscoveryComplete(true);
console.log('🚨 DISCOVERY DEBUG: Called setModels and setDiscoveryComplete', {
enrichedModelsCount: enrichedModels.length,
timestamp: new Date().toISOString()
});
// Cache the discovered models
saveModelsToCache(enrichedModels);
showToast(
`Discovery complete: Found ${discoveryResult.total_models} models across ${Object.keys(discoveryResult.host_status).length} instances`,
'success'
);
if (discoveryResult.discovery_errors.length > 0) {
showToast(`Some hosts had errors: ${discoveryResult.discovery_errors.length} issues`, 'warning');
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMsg);
showToast(`Model discovery failed: ${errorMsg}`, 'error');
} finally {
setLoading(false);
}
}, [enabledInstanceUrls, instanceLookup, showToast, loadModelsFromCache, saveModelsToCache]);
// Test model capabilities
const testModelCapabilities = useCallback(async (model: EnrichedModel) => {
const modelKey = `${model.name}@${model.instance_url}`;
setTestingModels(prev => new Set(prev).add(modelKey));
try {
const capabilities = await ollamaService.getModelCapabilities(model.name, model.instance_url);
const testResult = {
chatWorks: capabilities.supports_chat,
embeddingWorks: capabilities.supports_embedding,
dimensions: capabilities.embedding_dimensions
};
setModels(prevModels =>
prevModels.map(m =>
m.name === model.name && m.instance_url === model.instance_url
? { ...m, testResult, status: 'available' as const }
: m
)
);
if (capabilities.error) {
showToast(`Model test completed with warnings: ${capabilities.error}`, 'warning');
} else {
showToast(`Model ${model.name} tested successfully`, 'success');
}
} catch (error) {
setModels(prevModels =>
prevModels.map(m =>
m.name === model.name && m.instance_url === model.instance_url
? { ...m, status: 'error' as const }
: m
)
);
showToast(`Failed to test ${model.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
} finally {
setTestingModels(prev => {
const newSet = new Set(prev);
newSet.delete(modelKey);
return newSet;
});
}
}, [showToast]);
// Filter and sort models
const filteredAndSortedModels = useMemo(() => {
console.log('🚨 FILTERING DEBUG: filteredAndSortedModels useMemo running', {
modelsLength: models.length,
models: models.map(m => ({ name: m.name, capabilities: m.capabilities })),
selectionState,
timestamp: new Date().toISOString()
});
let filtered = models.filter(model => {
// Text filter
if (selectionState.filterText && !model.name.toLowerCase().includes(selectionState.filterText.toLowerCase())) {
return false;
}
// Capability filters
if (selectionState.showOnlyChat && !model.capabilities.includes('chat')) {
return false;
}
if (selectionState.showOnlyEmbedding && !model.capabilities.includes('embedding')) {
return false;
}
return true;
});
// Sort models
filtered.sort((a, b) => {
switch (selectionState.sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'size':
return b.size - a.size;
case 'instance':
return (a.instanceName || '').localeCompare(b.instanceName || '');
default:
return 0;
}
});
console.log('🚨 FILTERING DEBUG: filteredAndSortedModels result', {
originalCount: models.length,
filteredCount: filtered.length,
filtered: filtered.map(m => ({ name: m.name, capabilities: m.capabilities })),
timestamp: new Date().toISOString()
});
return filtered;
}, [models, selectionState]);
// Handle model selection
const handleModelSelect = (model: EnrichedModel, type: 'chat' | 'embedding') => {
if (type === 'chat' && !model.capabilities.includes('chat')) {
showToast(`Model ${model.name} does not support chat functionality`, 'error');
return;
}
if (type === 'embedding' && !model.capabilities.includes('embedding')) {
showToast(`Model ${model.name} does not support embedding functionality`, 'error');
return;
}
setSelectionState(prev => ({
...prev,
[type === 'chat' ? 'selectedChatModel' : 'selectedEmbeddingModel']: model.name
}));
};
// Apply selections and close modal
const handleApplySelection = () => {
onSelectModels({
chatModel: selectionState.selectedChatModel || undefined,
embeddingModel: selectionState.selectedEmbeddingModel || undefined
});
onClose();
};
// Reset modal state when closed
const handleClose = () => {
setSelectionState({
selectedChatModel: initialChatModel || null,
selectedEmbeddingModel: initialEmbeddingModel || null,
filterText: '',
showOnlyEmbedding: false,
showOnlyChat: false,
sortBy: 'name'
});
setError(null);
onClose();
};
// Auto-discover when modal opens (only if no cache available)
useEffect(() => {
console.log('🟡 AUTO-DISCOVERY DEBUG: useEffect triggered', {
isOpen,
discoveryComplete,
loading,
hasCache,
willAutoDiscover: isOpen && !discoveryComplete && !loading && !hasCache
});
if (isOpen && !discoveryComplete && !loading && !hasCache) {
console.log('🟢 AUTO-DISCOVERY DEBUG: Starting auto-discovery');
discoverModels();
} else {
console.log('🟠 AUTO-DISCOVERY DEBUG: Skipping auto-discovery', {
reason: !isOpen ? 'modal closed' :
discoveryComplete ? 'already complete' :
loading ? 'already loading' :
hasCache ? 'has cache' : 'unknown'
});
}
}, [isOpen, discoveryComplete, loading, hasCache, discoverModels]);
if (!isOpen) return null;
const modalContent = (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-4xl max-h-[85vh] mx-4 bg-white dark:bg-gray-900 rounded-xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="border-b border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Database className="w-6 h-6 text-green-500" />
Ollama Model Discovery
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Discover and select models from your Ollama instances
{hasCache && lastDiscoveryTime && (
<span className="ml-2 text-green-600 dark:text-green-400">
(Cached {new Date(lastDiscoveryTime).toLocaleTimeString()})
</span>
)}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClose}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Controls */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<Input
type="text"
placeholder="Search models..."
value={selectionState.filterText}
onChange={(e) => setSelectionState(prev => ({ ...prev, filterText: e.target.value }))}
className="w-full"
icon={<Search className="w-4 h-4" />}
/>
</div>
{/* Filters */}
<div className="flex gap-2">
<Button
variant={selectionState.showOnlyChat ? "solid" : "outline"}
size="sm"
onClick={() => setSelectionState(prev => ({
...prev,
showOnlyChat: !prev.showOnlyChat,
showOnlyEmbedding: false
}))}
className="flex items-center gap-1"
>
<MessageCircle className="w-4 h-4" />
Chat Only
</Button>
<Button
variant={selectionState.showOnlyEmbedding ? "solid" : "outline"}
size="sm"
onClick={() => setSelectionState(prev => ({
...prev,
showOnlyEmbedding: !prev.showOnlyEmbedding,
showOnlyChat: false
}))}
className="flex items-center gap-1"
>
<Layers className="w-4 h-4" />
Embedding Only
</Button>
</div>
{/* Refresh */}
<Button
variant="outline"
size="sm"
onClick={() => {
console.log('🚨 REFRESH BUTTON CLICKED - About to call discoverModels(true)', {
timestamp: new Date().toISOString(),
loading,
enabledInstanceUrls,
instanceUrlsCount: enabledInstanceUrls.length
});
discoverModels(true); // Force refresh
}}
disabled={loading}
className="flex items-center gap-1"
>
{loading ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Activity className="w-4 h-4" />
)}
{loading ? 'Discovering...' : 'Refresh'}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden">
{error ? (
<div className="p-6 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Discovery Failed</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">{error}</p>
<Button onClick={() => discoverModels(true)}>Try Again</Button>
</div>
) : loading ? (
<div className="p-6 text-center">
<Loader className="w-12 h-12 text-green-500 mx-auto mb-4 animate-spin" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Discovering Models</h3>
<p className="text-gray-600 dark:text-gray-400 mb-2">
{discoveryProgress || `Scanning ${enabledInstanceUrls.length} Ollama instances...`}
</p>
<div className="mt-4">
<div className="bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div className="bg-green-500 h-full animate-pulse" style={{width: '100%'}}></div>
</div>
</div>
</div>
) : (
<div className="h-96 overflow-y-auto p-6">
{(() => {
console.log('🚨 RENDERING DEBUG: About to render models list', {
filteredAndSortedModelsLength: filteredAndSortedModels.length,
modelsLength: models.length,
loading,
error,
discoveryComplete,
timestamp: new Date().toISOString()
});
return null;
})()}
{filteredAndSortedModels.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400">
<Database className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No models found</p>
<p className="text-sm">
{models.length === 0
? "Try refreshing to discover models from your Ollama instances"
: "Adjust your filters to see more models"
}
</p>
</div>
) : (
<div className="grid gap-4">
{filteredAndSortedModels.map((model) => {
const modelKey = `${model.name}@${model.instance_url}`;
const isTesting = testingModels.has(modelKey);
const isChatSelected = selectionState.selectedChatModel === model.name;
const isEmbeddingSelected = selectionState.selectedEmbeddingModel === model.name;
return (
<Card
key={modelKey}
className={`p-4 hover:shadow-md transition-shadow ${
isChatSelected || isEmbeddingSelected
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: ''
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-semibold text-gray-900 dark:text-white">{model.name}</h4>
{/* Capability badges */}
<div className="flex gap-1">
{model.capabilities.includes('chat') && (
<Badge variant="solid" className="bg-blue-100 text-blue-800 text-xs">
<MessageCircle className="w-3 h-3 mr-1" />
Chat
</Badge>
)}
{model.capabilities.includes('embedding') && (
<Badge variant="solid" className="bg-purple-100 text-purple-800 text-xs">
<Layers className="w-3 h-3 mr-1" />
{model.embedding_dimensions}D
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 mb-3">
<span className="flex items-center gap-1">
<Server className="w-4 h-4" />
{model.instanceName}
</span>
<span className="flex items-center gap-1">
<HardDrive className="w-4 h-4" />
{(model.size / (1024 ** 3)).toFixed(1)} GB
</span>
{model.parameters?.family && (
<span className="flex items-center gap-1">
<Cpu className="w-4 h-4" />
{model.parameters.family}
</span>
)}
</div>
{/* Test result display */}
{model.testResult && (
<div className="flex gap-2 mb-2">
{model.testResult.chatWorks && (
<Badge variant="solid" className="bg-green-100 text-green-800 text-xs">
Chat Verified
</Badge>
)}
{model.testResult.embeddingWorks && (
<Badge variant="solid" className="bg-green-100 text-green-800 text-xs">
Embedding Verified ({model.testResult.dimensions}D)
</Badge>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{/* Action buttons */}
<div className="flex gap-2">
{model.capabilities.includes('chat') && (
<Button
size="sm"
variant={isChatSelected ? "solid" : "outline"}
onClick={() => handleModelSelect(model, 'chat')}
className="text-xs"
>
{isChatSelected ? '✓ Selected for Chat' : 'Select for Chat'}
</Button>
)}
{model.capabilities.includes('embedding') && (
<Button
size="sm"
variant={isEmbeddingSelected ? "solid" : "outline"}
onClick={() => handleModelSelect(model, 'embedding')}
className="text-xs"
>
{isEmbeddingSelected ? '✓ Selected for Embedding' : 'Select for Embedding'}
</Button>
)}
</div>
{/* Test button */}
<Button
size="sm"
variant="ghost"
onClick={() => testModelCapabilities(model)}
disabled={isTesting}
className="text-xs"
>
{isTesting ? (
<>
<Loader className="w-3 h-3 mr-1 animate-spin" />
Testing...
</>
) : (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Test Model
</>
)}
</Button>
</div>
</div>
</Card>
);
})}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{selectionState.selectedChatModel && (
<span className="mr-4">Chat: <strong>{selectionState.selectedChatModel}</strong></span>
)}
{selectionState.selectedEmbeddingModel && (
<span>Embedding: <strong>{selectionState.selectedEmbeddingModel}</strong></span>
)}
{!selectionState.selectedChatModel && !selectionState.selectedEmbeddingModel && (
<span>No models selected</span>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleApplySelection}
disabled={!selectionState.selectedChatModel && !selectionState.selectedEmbeddingModel}
>
Apply Selection
</Button>
</div>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
return createPortal(modalContent, document.body);
};
export default OllamaModelDiscoveryModal;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
/**
* TypeScript type definitions for Ollama components and services
*
* Provides comprehensive type definitions for Ollama multi-instance management,
* model discovery, and health monitoring across the frontend application.
*/
// Core Ollama instance configuration
export interface OllamaInstance {
id: string;
name: string;
baseUrl: string;
instanceType: 'chat' | 'embedding' | 'both';
isEnabled: boolean;
isPrimary: boolean;
healthStatus: {
isHealthy?: boolean;
lastChecked: Date;
responseTimeMs?: number;
error?: string;
};
loadBalancingWeight?: number;
lastHealthCheck?: string;
modelsAvailable?: number;
responseTimeMs?: number;
}
// Configuration for dual-host setups
export interface OllamaConfiguration {
chatInstance: OllamaInstance;
embeddingInstance: OllamaInstance;
selectedChatModel?: string;
selectedEmbeddingModel?: string;
fallbackToChatInstance: boolean;
}
// Model information from discovery
export interface OllamaModel {
name: string;
tag: string;
size: number;
digest: string;
capabilities: ('chat' | 'embedding')[];
embeddingDimensions?: number;
parameters?: {
family: string;
parameterSize: string;
quantization: string;
};
instanceUrl: string;
}
// Health status for instances
export interface InstanceHealth {
instanceUrl: string;
isHealthy: boolean;
responseTimeMs?: number;
modelsAvailable?: number;
errorMessage?: string;
lastChecked?: string;
}
// Model discovery results
export interface ModelDiscoveryResults {
totalModels: number;
chatModels: OllamaModel[];
embeddingModels: OllamaModel[];
hostStatus: Record<string, {
status: 'online' | 'error';
modelsCount?: number;
error?: string;
}>;
discoveryErrors: string[];
}
// Props for modal components
export interface ModelDiscoveryModalProps {
isOpen: boolean;
onClose: () => void;
onSelectModels: (models: { chatModel?: string; embeddingModel?: string }) => void;
instances: OllamaInstance[];
}
// Props for health indicator component
export interface HealthIndicatorProps {
instance: OllamaInstance;
onRefresh: (instanceId: string) => void;
showDetails?: boolean;
}
// Props for configuration panel
export interface ConfigurationPanelProps {
isVisible: boolean;
onConfigChange: (instances: OllamaInstance[]) => void;
className?: string;
separateHosts?: boolean;
}
// Validation and error types
export interface ValidationResult {
isValid: boolean;
message: string;
details?: string;
suggestedAction?: string;
}
export interface ConnectionTestResult {
isHealthy: boolean;
responseTimeMs?: number;
modelsAvailable?: number;
error?: string;
}
// UI State types
export interface ModelSelectionState {
selectedChatModel: string | null;
selectedEmbeddingModel: string | null;
filterText: string;
showOnlyEmbedding: boolean;
showOnlyChat: boolean;
sortBy: 'name' | 'size' | 'instance';
}
// Form data types
export interface AddInstanceFormData {
name: string;
baseUrl: string;
instanceType: 'chat' | 'embedding' | 'both';
}
// Embedding routing information
export interface EmbeddingRoute {
modelName: string;
instanceUrl: string;
dimensions: number;
targetColumn: string;
performanceScore: number;
confidence: number;
}
// Statistics and monitoring
export interface InstanceStatistics {
totalInstances: number;
activeInstances: number;
averageResponseTime?: number;
totalModels: number;
healthyInstancesCount: number;
}
// Event types for component communication
export type OllamaEvent =
| { type: 'INSTANCE_ADDED'; payload: OllamaInstance }
| { type: 'INSTANCE_REMOVED'; payload: string }
| { type: 'INSTANCE_UPDATED'; payload: OllamaInstance }
| { type: 'HEALTH_CHECK_COMPLETED'; payload: { instanceId: string; result: ConnectionTestResult } }
| { type: 'MODEL_DISCOVERY_COMPLETED'; payload: ModelDiscoveryResults }
| { type: 'CONFIGURATION_CHANGED'; payload: OllamaConfiguration };
// API Response types (re-export from service for convenience)
export type {
ModelDiscoveryResponse,
InstanceHealthResponse,
InstanceValidationResponse,
EmbeddingRouteResponse,
EmbeddingRoutesResponse
} from '../../services/ollamaService';
// Error handling types
export interface OllamaError {
code: string;
message: string;
context?: string;
retryable?: boolean;
}
// Settings integration
export interface OllamaSettings {
enableHealthMonitoring: boolean;
healthCheckInterval: number;
autoDiscoveryEnabled: boolean;
modelCacheTtl: number;
connectionTimeout: number;
maxConcurrentHealthChecks: number;
}

View File

@@ -4,13 +4,13 @@ interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
color?: 'purple' | 'green' | 'pink' | 'blue' | 'gray' | 'orange';
variant?: 'solid' | 'outline';
}
export const Badge: React.FC<BadgeProps> = ({
export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(({
children,
color = 'gray',
variant = 'outline',
className = '',
...props
}) => {
}, ref) => {
const colorMap = {
solid: {
purple: 'bg-purple-500/10 text-purple-500 dark:bg-purple-500/10 dark:text-purple-500',
@@ -29,11 +29,17 @@ export const Badge: React.FC<BadgeProps> = ({
orange: 'border border-orange-500 text-orange-500 dark:border-orange-500 dark:text-orange-500 shadow-[0_0_10px_rgba(251,146,60,0.3)]'
}
};
return <span className={`
return <span
ref={ref}
className={`
inline-flex items-center text-xs px-2 py-1 rounded
${colorMap[variant][color]}
${className}
`} {...props}>
{children}
</span>;
};
`}
{...props}
>
{children}
</span>;
});
Badge.displayName = 'Badge';

View File

@@ -5,29 +5,17 @@
* and handles different environments (development, Docker, production)
*/
// Get the API URL from environment or construct it
// Get the API URL from environment or use relative URLs for proxy
export function getApiUrl(): string {
// For relative URLs in production (goes through proxy)
if (import.meta.env.PROD) {
return '';
// Check if VITE_API_URL is explicitly provided (for absolute URL mode)
const viteApiUrl = (import.meta.env as any).VITE_API_URL as string | undefined;
if (viteApiUrl) {
return viteApiUrl;
}
// Check if VITE_API_URL is provided (set by docker-compose)
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
// For development, construct from window location
const protocol = window.location.protocol;
const host = window.location.hostname;
// Use configured port or default to 8181
const port = import.meta.env.VITE_ARCHON_SERVER_PORT || '8181';
if (!import.meta.env.VITE_ARCHON_SERVER_PORT) {
console.info('[Archon] Using default ARCHON_SERVER_PORT: 8181');
}
return `${protocol}//${host}:${port}`;
// Default to relative URLs to use Vite proxy in development
// or direct proxy in production - this ensures all requests go through proxy
return '';
}
// Get the base path for API endpoints

View File

@@ -1,121 +0,0 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react';
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info' | 'warning';
duration?: number;
}
interface ToastContextType {
showToast: (message: string, type?: Toast['type'], duration?: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: Toast['type'] = 'info', duration = 4000) => {
// Use timestamp + random number to prevent duplicate keys
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newToast: Toast = { id, message, type, duration };
setToasts(prev => [...prev, newToast]);
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, duration);
}
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const getIcon = (type: Toast['type']) => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'error':
return <XCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
case 'info':
default:
return <Info className="w-5 h-5 text-blue-500" />;
}
};
const getGlassmorphismStyles = (type: Toast['type']) => {
switch (type) {
case 'success':
return {
container: 'backdrop-blur-xl bg-gradient-to-r from-green-50/95 to-emerald-50/95 dark:from-green-950/90 dark:to-emerald-950/90 border border-green-300/60 dark:border-green-500/40 shadow-[0_20px_25px_-5px_rgba(0,0,0,0.1),0_10px_10px_-5px_rgba(0,0,0,0.04)] dark:shadow-[0_20px_25px_-5px_rgba(0,0,0,0.6),0_10px_10px_-5px_rgba(0,0,0,0.3)]',
textColor: 'text-green-800 dark:text-green-100',
buttonColor: 'text-green-600 hover:text-green-800 dark:text-green-300 dark:hover:text-green-100'
};
case 'error':
return {
container: 'backdrop-blur-xl bg-gradient-to-r from-red-50/95 to-pink-50/95 dark:from-red-950/90 dark:to-pink-950/90 border border-red-300/60 dark:border-red-500/40 shadow-[0_20px_25px_-5px_rgba(0,0,0,0.1),0_10px_10px_-5px_rgba(0,0,0,0.04)] dark:shadow-[0_20px_25px_-5px_rgba(0,0,0,0.6),0_10px_10px_-5px_rgba(0,0,0,0.3)]',
textColor: 'text-red-800 dark:text-red-100',
buttonColor: 'text-red-600 hover:text-red-800 dark:text-red-300 dark:hover:text-red-100'
};
case 'warning':
return {
container: 'backdrop-blur-xl bg-gradient-to-r from-yellow-50/95 to-orange-50/95 dark:from-yellow-950/90 dark:to-orange-950/90 border border-yellow-300/60 dark:border-yellow-500/40 shadow-[0_20px_25px_-5px_rgba(0,0,0,0.1),0_10px_10px_-5px_rgba(0,0,0,0.04)] dark:shadow-[0_20px_25px_-5px_rgba(0,0,0,0.6),0_10px_10px_-5px_rgba(0,0,0,0.3)]',
textColor: 'text-yellow-800 dark:text-yellow-100',
buttonColor: 'text-yellow-600 hover:text-yellow-800 dark:text-yellow-300 dark:hover:text-yellow-100'
};
case 'info':
default:
return {
container: 'backdrop-blur-xl bg-gradient-to-r from-blue-50/95 to-cyan-50/95 dark:from-blue-950/90 dark:to-cyan-950/90 border border-blue-300/60 dark:border-blue-500/40 shadow-[0_20px_25px_-5px_rgba(0,0,0,0.1),0_10px_10px_-5px_rgba(0,0,0,0.04)] dark:shadow-[0_20px_25px_-5px_rgba(0,0,0,0.6),0_10px_10px_-5px_rgba(0,0,0,0.3)]',
textColor: 'text-blue-800 dark:text-blue-100',
buttonColor: 'text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-100'
};
}
};
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed top-4 right-4 z-50 space-y-2">
<AnimatePresence>
{toasts.map(toast => (
<motion.div
key={toast.id}
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ duration: 0.3, type: "spring", stiffness: 300, damping: 25 }}
className={`flex items-center gap-3 p-4 rounded-lg min-w-[300px] max-w-[500px] ${getGlassmorphismStyles(toast.type).container}`}
>
{getIcon(toast.type)}
<p className={`flex-1 text-sm font-medium ${getGlassmorphismStyles(toast.type).textColor}`}>
{toast.message}
</p>
<button
onClick={() => removeToast(toast.id)}
className={`${getGlassmorphismStyles(toast.type).buttonColor} transition-colors duration-200`}
>
<X className="w-4 h-4" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,334 @@
/**
* Add Knowledge Dialog Component
* Modal for crawling URLs or uploading documents
*/
import { Globe, Loader2, Upload } from "lucide-react";
import { useId, useState } from "react";
import { useToast } from "../../ui/hooks/useToast";
import { Button, Input, Label } from "../../ui/primitives";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../ui/primitives/dialog";
import { cn } from "../../ui/primitives/styles";
import { Tabs, TabsContent } from "../../ui/primitives/tabs";
import { useCrawlUrl, useUploadDocument } from "../hooks";
import type { CrawlRequest, UploadMetadata } from "../types";
import { KnowledgeTypeSelector } from "./KnowledgeTypeSelector";
import { LevelSelector } from "./LevelSelector";
import { TagInput } from "./TagInput";
interface AddKnowledgeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
onCrawlStarted?: (progressId: string) => void;
}
export const AddKnowledgeDialog: React.FC<AddKnowledgeDialogProps> = ({
open,
onOpenChange,
onSuccess,
onCrawlStarted,
}) => {
const [activeTab, setActiveTab] = useState<"crawl" | "upload">("crawl");
const { showToast } = useToast();
const crawlMutation = useCrawlUrl();
const uploadMutation = useUploadDocument();
// Generate unique IDs for form elements
const urlId = useId();
const fileId = useId();
// Crawl form state
const [crawlUrl, setCrawlUrl] = useState("");
const [crawlType, setCrawlType] = useState<"technical" | "business">("technical");
const [maxDepth, setMaxDepth] = useState("2");
const [tags, setTags] = useState<string[]>([]);
// Upload form state
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadType, setUploadType] = useState<"technical" | "business">("technical");
const [uploadTags, setUploadTags] = useState<string[]>([]);
const resetForm = () => {
setCrawlUrl("");
setCrawlType("technical");
setMaxDepth("2");
setTags([]);
setSelectedFile(null);
setUploadType("technical");
setUploadTags([]);
};
const handleCrawl = async () => {
if (!crawlUrl) {
showToast("Please enter a URL to crawl", "error");
return;
}
try {
const request: CrawlRequest = {
url: crawlUrl,
knowledge_type: crawlType,
max_depth: parseInt(maxDepth, 10),
tags: tags.length > 0 ? tags : undefined,
};
const response = await crawlMutation.mutateAsync(request);
// Notify parent about the new crawl operation
if (response?.progressId && onCrawlStarted) {
onCrawlStarted(response.progressId);
}
showToast("Crawl started successfully", "success");
resetForm();
onSuccess();
onOpenChange(false);
} catch (error) {
// Display the actual error message from backend
const message = error instanceof Error ? error.message : "Failed to start crawl";
showToast(message, "error");
}
};
const handleUpload = async () => {
if (!selectedFile) {
showToast("Please select a file to upload", "error");
return;
}
try {
const metadata: UploadMetadata = {
knowledge_type: uploadType,
tags: uploadTags.length > 0 ? uploadTags : undefined,
};
const response = await uploadMutation.mutateAsync({ file: selectedFile, metadata });
// Notify parent about the new upload operation if it has a progressId
if (response?.progressId && onCrawlStarted) {
onCrawlStarted(response.progressId);
}
// Upload happens in background - show appropriate message
showToast(`Upload started for ${selectedFile.name}. Processing in background...`, "info");
resetForm();
// Don't call onSuccess here - the upload hasn't actually succeeded yet
// onSuccess should be called when polling shows completion
onOpenChange(false);
} catch (error) {
// Display the actual error message from backend
const message = error instanceof Error ? error.message : "Failed to upload document";
showToast(message, "error");
}
};
const isProcessing = crawlMutation.isPending || uploadMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Knowledge</DialogTitle>
<DialogDescription>Crawl websites or upload documents to expand your knowledge base.</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "crawl" | "upload")}>
{/* Enhanced Tab Buttons */}
<div className="grid grid-cols-2 gap-3 p-2 rounded-xl backdrop-blur-md bg-gradient-to-b from-gray-100/30 via-gray-50/20 to-white/40 dark:from-gray-900/30 dark:via-gray-800/20 dark:to-black/40 border border-gray-200/40 dark:border-gray-700/40">
{/* Crawl Website Tab */}
<button
type="button"
onClick={() => setActiveTab("crawl")}
className={cn(
"relative flex items-center justify-center gap-3 px-6 py-4 rounded-lg transition-all duration-300",
"backdrop-blur-md border-2 font-medium text-sm",
activeTab === "crawl"
? "bg-gradient-to-b from-cyan-100/70 via-cyan-50/40 to-white/80 dark:from-cyan-900/40 dark:via-cyan-800/25 dark:to-black/50 border-cyan-400/60 text-cyan-700 dark:text-cyan-300 shadow-[0_0_20px_rgba(34,211,238,0.25)]"
: "bg-gradient-to-b from-white/40 via-white/30 to-white/60 dark:from-gray-800/40 dark:via-gray-800/30 dark:to-black/60 border-gray-300/40 dark:border-gray-600/40 text-gray-600 dark:text-gray-300 hover:border-cyan-300/50 hover:text-cyan-600 dark:hover:text-cyan-400 hover:shadow-[0_0_15px_rgba(34,211,238,0.15)]",
)}
>
{/* Top accent glow for active state */}
{activeTab === "crawl" && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className="mx-2 mt-0.5 h-[2px] rounded-full bg-cyan-500" />
<div className="-mt-1 h-8 w-full bg-gradient-to-b from-cyan-500/30 to-transparent blur-md" />
</div>
)}
<Globe className={cn("w-5 h-5", activeTab === "crawl" ? "text-cyan-500" : "text-current")} />
<div className="flex flex-col items-start gap-0.5">
<span className="font-semibold">Crawl Website</span>
<span className="text-xs opacity-80">Scan web pages</span>
</div>
</button>
{/* Upload Document Tab */}
<button
type="button"
onClick={() => setActiveTab("upload")}
className={cn(
"relative flex items-center justify-center gap-3 px-6 py-4 rounded-lg transition-all duration-300",
"backdrop-blur-md border-2 font-medium text-sm",
activeTab === "upload"
? "bg-gradient-to-b from-purple-100/70 via-purple-50/40 to-white/80 dark:from-purple-900/40 dark:via-purple-800/25 dark:to-black/50 border-purple-400/60 text-purple-700 dark:text-purple-300 shadow-[0_0_20px_rgba(147,51,234,0.25)]"
: "bg-gradient-to-b from-white/40 via-white/30 to-white/60 dark:from-gray-800/40 dark:via-gray-800/30 dark:to-black/60 border-gray-300/40 dark:border-gray-600/40 text-gray-600 dark:text-gray-300 hover:border-purple-300/50 hover:text-purple-600 dark:hover:text-purple-400 hover:shadow-[0_0_15px_rgba(147,51,234,0.15)]",
)}
>
{/* Top accent glow for active state */}
{activeTab === "upload" && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className="mx-2 mt-0.5 h-[2px] rounded-full bg-purple-500" />
<div className="-mt-1 h-8 w-full bg-gradient-to-b from-purple-500/30 to-transparent blur-md" />
</div>
)}
<Upload className={cn("w-5 h-5", activeTab === "upload" ? "text-purple-500" : "text-current")} />
<div className="flex flex-col items-start gap-0.5">
<span className="font-semibold">Upload Document</span>
<span className="text-xs opacity-80">Add local files</span>
</div>
</button>
</div>
{/* Crawl Tab */}
<TabsContent value="crawl" className="space-y-6 mt-6">
{/* Enhanced URL Input Section */}
<div className="space-y-3">
<Label htmlFor={urlId} className="text-sm font-medium text-gray-900 dark:text-white/90">
Website URL
</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Globe className="h-5 w-5" style={{ color: "#0891b2" }} />
</div>
<Input
id={urlId}
type="url"
placeholder="https://docs.example.com or https://github.com/..."
value={crawlUrl}
onChange={(e) => setCrawlUrl(e.target.value)}
disabled={isProcessing}
className="pl-10 h-12 backdrop-blur-md bg-gradient-to-r from-white/60 to-white/50 dark:from-black/60 dark:to-black/50 border-gray-300/60 dark:border-gray-600/60 focus:border-cyan-400/70 focus:shadow-[0_0_20px_rgba(34,211,238,0.15)]"
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enter the URL of a website you want to crawl for knowledge
</p>
</div>
<div className="space-y-6">
<KnowledgeTypeSelector value={crawlType} onValueChange={setCrawlType} disabled={isProcessing} />
<LevelSelector value={maxDepth} onValueChange={setMaxDepth} disabled={isProcessing} />
</div>
<TagInput
tags={tags}
onTagsChange={setTags}
disabled={isProcessing}
placeholder="Add tags like 'api', 'documentation', 'guide'..."
/>
<Button
onClick={handleCrawl}
disabled={isProcessing || !crawlUrl}
className="w-full bg-gradient-to-r from-cyan-500 to-cyan-600 hover:from-cyan-600 hover:to-cyan-700 backdrop-blur-md border border-cyan-400/50 shadow-[0_0_20px_rgba(6,182,212,0.25)] hover:shadow-[0_0_30px_rgba(6,182,212,0.35)] transition-all duration-200"
>
{crawlMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Starting Crawl...
</>
) : (
<>
<Globe className="w-4 h-4 mr-2" />
Start Crawling
</>
)}
</Button>
</TabsContent>
{/* Upload Tab */}
<TabsContent value="upload" className="space-y-6 mt-6">
{/* Enhanced File Input Section */}
<div className="space-y-3">
<Label htmlFor={fileId} className="text-sm font-medium text-gray-900 dark:text-white/90">
Document File
</Label>
{/* Custom File Upload Area */}
<div className="relative">
<input
id={fileId}
type="file"
accept=".txt,.md,.pdf,.doc,.docx,.html,.htm"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={isProcessing}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
/>
<div
className={cn(
"relative h-20 rounded-xl border-2 border-dashed transition-all duration-200",
"backdrop-blur-md bg-gradient-to-b from-white/60 via-white/40 to-white/50 dark:from-black/60 dark:via-black/40 dark:to-black/50",
"flex flex-col items-center justify-center gap-2 text-center p-4",
selectedFile
? "border-purple-400/70 bg-gradient-to-b from-purple-50/60 to-white/60 dark:from-purple-900/20 dark:to-black/50"
: "border-gray-300/60 dark:border-gray-600/60 hover:border-purple-400/50 hover:bg-gradient-to-b hover:from-purple-50/40 hover:to-white/60 dark:hover:from-purple-900/10 dark:hover:to-black/50",
isProcessing && "opacity-50 cursor-not-allowed",
)}
>
<Upload
className={cn("w-6 h-6", selectedFile ? "text-purple-500" : "text-gray-400 dark:text-gray-500")}
/>
<div className="text-sm">
{selectedFile ? (
<div className="space-y-1">
<p className="font-medium text-purple-700 dark:text-purple-400">{selectedFile.name}</p>
<p className="text-xs text-purple-600 dark:text-purple-400">
{Math.round(selectedFile.size / 1024)} KB
</p>
</div>
) : (
<div className="space-y-1">
<p className="font-medium text-gray-700 dark:text-gray-300">Click to browse or drag & drop</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
PDF, DOC, DOCX, TXT, MD files supported
</p>
</div>
)}
</div>
</div>
</div>
</div>
<KnowledgeTypeSelector value={uploadType} onValueChange={setUploadType} disabled={isProcessing} />
<TagInput
tags={uploadTags}
onTagsChange={setUploadTags}
disabled={isProcessing}
placeholder="Add tags like 'manual', 'reference', 'guide'..."
/>
<Button
onClick={handleUpload}
disabled={isProcessing || !selectedFile}
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 backdrop-blur-md border border-purple-400/50 shadow-[0_0_20px_rgba(147,51,234,0.25)] hover:shadow-[0_0_30px_rgba(147,51,234,0.35)] transition-all duration-200"
>
{uploadMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
Upload Document
</>
)}
</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,239 @@
/**
* Document Browser Component
* Shows document chunks and code examples for a knowledge item
*/
import { ChevronDown, ChevronRight, Code, FileText, Search } from "lucide-react";
import { useState } from "react";
import { Input } from "../../ui/primitives";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../ui/primitives/dialog";
import { cn } from "../../ui/primitives/styles";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives/tabs";
import { useCodeExamples, useKnowledgeItemChunks } from "../hooks";
interface DocumentBrowserProps {
sourceId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DocumentBrowser: React.FC<DocumentBrowserProps> = ({ sourceId, open, onOpenChange }) => {
const [activeTab, setActiveTab] = useState<"documents" | "code">("documents");
const [searchQuery, setSearchQuery] = useState("");
const [expandedChunks, setExpandedChunks] = useState<Set<string>>(new Set());
const {
data: chunksData,
isLoading: chunksLoading,
isError: chunksError,
error: chunksErrorObj,
} = useKnowledgeItemChunks(sourceId);
const { data: codeData, isLoading: codeLoading, isError: codeError, error: codeErrorObj } = useCodeExamples(sourceId);
const chunks = chunksData?.chunks || [];
const codeExamples = codeData?.code_examples || [];
// Filter chunks based on search
const filteredChunks = chunks.filter(
(chunk) =>
chunk.content.toLowerCase().includes(searchQuery.toLowerCase()) ||
chunk.metadata?.title?.toLowerCase().includes(searchQuery.toLowerCase()),
);
// Filter code examples based on search
const filteredCode = codeExamples.filter((example) => {
const codeContent = example.code || example.content || "";
return (
codeContent.toLowerCase().includes(searchQuery.toLowerCase()) ||
example.summary?.toLowerCase().includes(searchQuery.toLowerCase()) ||
example.language?.toLowerCase().includes(searchQuery.toLowerCase())
);
});
const toggleChunk = (chunkId: string) => {
setExpandedChunks((prev) => {
const next = new Set(prev);
if (next.has(chunkId)) {
next.delete(chunkId);
} else {
next.add(chunkId);
}
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Document Browser</DialogTitle>
<div className="flex items-center gap-2 mt-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="text"
placeholder="Search documents and code..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-black/30 border-white/10 focus:border-cyan-500/50"
/>
</div>
</div>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "documents" | "code")}
className="flex-1 flex flex-col"
>
<TabsList className="">
<TabsTrigger value="documents" className="data-[state=active]:bg-cyan-500/20">
<FileText className="w-4 h-4 mr-2" />
Documents ({filteredChunks.length})
</TabsTrigger>
<TabsTrigger value="code" className="data-[state=active]:bg-cyan-500/20">
<Code className="w-4 h-4 mr-2" />
Code Examples ({filteredCode.length})
</TabsTrigger>
</TabsList>
{/* Documents Tab */}
<TabsContent value="documents" className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
{chunksLoading ? (
<div className="text-center py-8 text-gray-400">Loading documents...</div>
) : chunksError ? (
<div className="text-center py-8 text-red-400">
Failed to load documents for source {sourceId}.
{chunksErrorObj?.message && ` ${chunksErrorObj.message}`}
</div>
) : filteredChunks.length === 0 ? (
<div className="text-center py-8 text-gray-400">
{searchQuery ? "No documents match your search" : "No documents available"}
</div>
) : (
<div className="space-y-3 p-4">
{filteredChunks.map((chunk) => {
const isExpanded = expandedChunks.has(chunk.id);
const preview = chunk.content.substring(0, 200);
const needsExpansion = chunk.content.length > 200;
return (
<div
key={chunk.id}
className="bg-black/30 rounded-lg border border-white/10 p-4 hover:border-cyan-500/30 transition-colors"
>
{chunk.metadata?.title && (
<h4 className="font-medium text-white/90 mb-2 flex items-center gap-2">
{needsExpansion && (
<button
type="button"
onClick={() => toggleChunk(chunk.id)}
className="text-gray-400 hover:text-white transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{chunk.metadata.title}
</h4>
)}
<div className="text-sm text-gray-300 whitespace-pre-wrap">
{isExpanded || !needsExpansion ? (
chunk.content
) : (
<>
{preview}...
<button
type="button"
onClick={() => toggleChunk(chunk.id)}
className="ml-2 text-cyan-400 hover:text-cyan-300"
>
Show more
</button>
</>
)}
</div>
{chunk.metadata?.tags && chunk.metadata.tags.length > 0 && (
<div className="flex items-center gap-2 mt-3 flex-wrap">
{chunk.metadata.tags.map((tag: string) => (
<span key={tag} className="px-2 py-1 text-xs border border-white/20 rounded bg-black/20">
{tag}
</span>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</TabsContent>
{/* Code Examples Tab */}
<TabsContent value="code" className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
{codeLoading ? (
<div className="text-center py-8 text-gray-400">Loading code examples...</div>
) : codeError ? (
<div className="text-center py-8 text-red-400">
Failed to load code examples for source {sourceId}.
{codeErrorObj?.message && ` ${codeErrorObj.message}`}
</div>
) : filteredCode.length === 0 ? (
<div className="text-center py-8 text-gray-400">
{searchQuery ? "No code examples match your search" : "No code examples available"}
</div>
) : (
<div className="space-y-3 p-4">
{filteredCode.map((example) => (
<div
key={example.id}
className="bg-black/30 rounded-lg border border-white/10 overflow-hidden hover:border-cyan-500/30 transition-colors"
>
<div className="flex items-center justify-between p-3 border-b border-white/10 bg-black/20">
<div className="flex items-center gap-2">
<Code className="w-4 h-4 text-cyan-400" />
{example.language && (
<span className="px-2 py-1 text-xs bg-cyan-500/20 text-cyan-400 rounded">
{example.language}
</span>
)}
</div>
{example.file_path && <span className="text-xs text-gray-400">{example.file_path}</span>}
</div>
{example.summary && (
<div className="p-3 text-sm text-gray-300 border-b border-white/10">{example.summary}</div>
)}
<pre className="p-4 text-sm overflow-x-auto">
<code
className={cn(
"text-gray-300",
example.language === "javascript" && "language-javascript",
example.language === "typescript" && "language-typescript",
example.language === "python" && "language-python",
example.language === "java" && "language-java",
)}
>
{example.code || example.content || ""}
</code>
</pre>
</div>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,348 @@
/**
* Enhanced Knowledge Card Component
* Individual knowledge item card with excellent UX and inline progress
* Following the pattern from ProjectCard
*/
import { format } from "date-fns";
import { motion } from "framer-motion";
import { Clock, Code, ExternalLink, File, FileText, Globe } from "lucide-react";
import { useState } from "react";
import { KnowledgeCardProgress } from "../../progress/components/KnowledgeCardProgress";
import type { ActiveOperation } from "../../progress/types";
import { isOptimistic } from "../../shared/optimistic";
import { StatPill } from "../../ui/primitives";
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip } from "../../ui/primitives/tooltip";
import { useDeleteKnowledgeItem, useRefreshKnowledgeItem } from "../hooks";
import type { KnowledgeItem } from "../types";
import { extractDomain } from "../utils/knowledge-utils";
import { KnowledgeCardActions } from "./KnowledgeCardActions";
import { KnowledgeCardTags } from "./KnowledgeCardTags";
import { KnowledgeCardTitle } from "./KnowledgeCardTitle";
import { KnowledgeCardType } from "./KnowledgeCardType";
interface KnowledgeCardProps {
item: KnowledgeItem;
onViewDocument: () => void;
onViewCodeExamples?: () => void;
onExport?: () => void;
onDeleteSuccess: () => void;
activeOperation?: ActiveOperation;
onRefreshStarted?: (progressId: string) => void;
}
export const KnowledgeCard: React.FC<KnowledgeCardProps> = ({
item,
onViewDocument,
onViewCodeExamples,
onExport,
onDeleteSuccess,
activeOperation,
onRefreshStarted,
}) => {
const [isHovered, setIsHovered] = useState(false);
const deleteMutation = useDeleteKnowledgeItem();
const refreshMutation = useRefreshKnowledgeItem();
// Check if item is optimistic
const optimistic = isOptimistic(item);
// Determine card styling based on type and status
// Check if it's a real URL (not a file:// URL)
// Prioritize top-level source_type over metadata source_type
const sourceType = item.source_type || item.metadata?.source_type;
const isUrl = sourceType === "url" && !item.url?.startsWith("file://");
// const isFile = item.metadata?.source_type === "file" || item.url?.startsWith('file://'); // Currently unused
// Check both top-level and metadata for knowledge_type (for compatibility)
const isTechnical = item.knowledge_type === "technical" || item.metadata?.knowledge_type === "technical";
const isProcessing = item.status === "processing";
const hasError = item.status === "error";
const codeExamplesCount = item.code_examples_count || item.metadata?.code_examples_count || 0;
const documentCount = item.document_count || item.metadata?.document_count || 0;
const handleDelete = async () => {
await deleteMutation.mutateAsync(item.source_id);
onDeleteSuccess();
};
const handleRefresh = async () => {
// Prevent double-clicking refresh while a refresh is already in progress
if (refreshMutation.isPending) return;
const response = await refreshMutation.mutateAsync(item.source_id);
// Notify parent about the new refresh operation
if (response?.progressId && onRefreshStarted) {
onRefreshStarted(response.progressId);
}
};
const getCardGradient = () => {
if (activeOperation) {
return "from-cyan-100/60 via-cyan-50/30 to-white/70 dark:from-cyan-900/30 dark:via-cyan-900/15 dark:to-black/40";
}
if (hasError) {
return "from-red-100/50 via-red-50/25 to-white/60 dark:from-red-900/20 dark:via-red-900/10 dark:to-black/30";
}
if (isProcessing) {
return "from-yellow-100/50 via-yellow-50/25 to-white/60 dark:from-yellow-900/20 dark:via-yellow-900/10 dark:to-black/30";
}
if (isTechnical) {
return isUrl
? "from-cyan-100/50 via-cyan-50/25 to-white/60 dark:from-cyan-900/20 dark:via-cyan-900/10 dark:to-black/30"
: "from-purple-100/50 via-purple-50/25 to-white/60 dark:from-purple-900/20 dark:via-purple-900/10 dark:to-black/30";
}
return isUrl
? "from-blue-100/50 via-blue-50/25 to-white/60 dark:from-blue-900/20 dark:via-blue-900/10 dark:to-black/30"
: "from-pink-100/50 via-pink-50/25 to-white/60 dark:from-pink-900/20 dark:via-pink-900/10 dark:to-black/30";
};
const getBorderColor = () => {
if (activeOperation) return "border-cyan-600/40 dark:border-cyan-500/50";
if (hasError) return "border-red-600/30 dark:border-red-500/30";
if (isProcessing) return "border-yellow-600/30 dark:border-yellow-500/30";
if (isTechnical) {
return isUrl ? "border-cyan-600/30 dark:border-cyan-500/30" : "border-purple-600/30 dark:border-purple-500/30";
}
return isUrl ? "border-blue-600/30 dark:border-blue-500/30" : "border-pink-600/30 dark:border-pink-500/30";
};
// Accent color used for the top glow bar
const getAccentColorName = () => {
if (activeOperation) return "cyan" as const;
if (hasError) return "red" as const;
if (isProcessing) return "yellow" as const;
if (isTechnical) return isUrl ? ("cyan" as const) : ("purple" as const);
return isUrl ? ("blue" as const) : ("pink" as const);
};
const accent = (() => {
const name = getAccentColorName();
switch (name) {
case "cyan":
return { bar: "bg-cyan-500", smear: "from-cyan-500/25" };
case "purple":
return { bar: "bg-purple-500", smear: "from-purple-500/25" };
case "blue":
return { bar: "bg-blue-500", smear: "from-blue-500/25" };
case "pink":
return { bar: "bg-pink-500", smear: "from-pink-500/25" };
case "red":
return { bar: "bg-red-500", smear: "from-red-500/25" };
case "yellow":
return { bar: "bg-yellow-400", smear: "from-yellow-400/25" };
default:
return { bar: "bg-cyan-500", smear: "from-cyan-500/25" };
}
})();
const getSourceIcon = () => {
if (isUrl) return <Globe className="w-5 h-5" />;
return <File className="w-5 h-5" />;
};
return (
<motion.div
className="relative group cursor-pointer"
role="button"
tabIndex={0}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onViewDocument}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onViewDocument();
}
}}
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
<div
className={cn(
"relative overflow-hidden transition-all duration-300 rounded-xl",
"bg-gradient-to-b backdrop-blur-md border",
getCardGradient(),
getBorderColor(),
isHovered && "shadow-[0_0_30px_rgba(6,182,212,0.2)]",
"min-h-[240px] flex flex-col",
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
)}
>
{/* Top accent glow tied to type (does not change size) */}
<div className="pointer-events-none absolute inset-x-0 top-0">
{/* Hairline highlight */}
<div className={cn("mx-1 mt-0.5 h-[2px] rounded-full", accent.bar)} />
{/* Soft glow smear fading downward */}
<div className={cn("-mt-1 h-8 w-full bg-gradient-to-b to-transparent blur-md", accent.smear)} />
</div>
{/* Glow effect on hover */}
{isHovered && (
<div className="absolute inset-0 opacity-20 pointer-events-none">
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(6,182,212,0.4)_0%,transparent_70%)] blur-3xl" />
</div>
)}
{/* Header with Type Badge */}
<div className="relative p-4 pb-2">
<div className="flex items-start justify-between gap-2 mb-2">
{/* Type and Source Badge */}
<div className="flex items-center gap-2">
<SimpleTooltip content={isUrl ? "Content from a web page" : "Uploaded document"}>
<div
className={cn(
"flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium",
isUrl
? "bg-cyan-100 text-cyan-700 dark:bg-cyan-500/10 dark:text-cyan-400"
: "bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400",
)}
>
{getSourceIcon()}
<span>{isUrl ? "Web Page" : "Document"}</span>
</div>
</SimpleTooltip>
<KnowledgeCardType sourceId={item.source_id} knowledgeType={item.knowledge_type} />
</div>
{/* Actions */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") e.stopPropagation();
}}
role="none"
>
<KnowledgeCardActions
sourceId={item.source_id}
itemTitle={item.title}
isUrl={isUrl}
hasCodeExamples={codeExamplesCount > 0}
onViewDocuments={onViewDocument}
onViewCodeExamples={codeExamplesCount > 0 ? onViewCodeExamples : undefined}
onRefresh={isUrl ? handleRefresh : undefined}
onDelete={handleDelete}
onExport={onExport}
/>
</div>
</div>
{/* Title */}
<div className="mb-2">
<KnowledgeCardTitle
sourceId={item.source_id}
title={item.title}
description={item.metadata?.description}
accentColor={getAccentColorName()}
/>
<OptimisticIndicator isOptimistic={optimistic} className="mt-2" />
</div>
{/* URL/Source */}
{item.url &&
(isUrl ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors mt-2"
>
<ExternalLink className="w-3 h-3" />
<span className="truncate">{extractDomain(item.url)}</span>
</a>
) : (
<div className="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 mt-2">
<FileText className="w-3 h-3" />
<span className="truncate">{item.url.replace("file://", "")}</span>
</div>
))}
{/* Tags */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}}
role="none"
className="mt-2"
>
<KnowledgeCardTags sourceId={item.source_id} tags={item.metadata?.tags || []} />
</div>
</div>
{/* Spacer to push footer to bottom */}
<div className="flex-1" />
{/* Progress tracking for active operations - using simplified component */}
{activeOperation && <KnowledgeCardProgress operation={activeOperation} />}
{/* Fixed Footer with Stats */}
<div className="px-4 py-3 bg-gray-100/50 dark:bg-black/30 border-t border-gray-200/50 dark:border-white/10">
<div className="flex items-center justify-between text-xs">
{/* Left: date */}
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-400">
<Clock className="w-3 h-3" />
<span className="text-xs">
{(() => {
const updated = item.updated_at || item.created_at;
try {
return `Updated: ${format(new Date(updated), "M/d/yyyy")}`;
} catch {
return `Updated: ${new Date(updated).toLocaleDateString()}`;
}
})()}
</span>
</div>
{/* Right: pills */}
<div className="flex items-center gap-2">
<SimpleTooltip
content={`${documentCount} document${documentCount !== 1 ? "s" : ""} indexed - Click to view`}
>
<div
className="cursor-pointer hover:scale-105 transition-transform"
onClick={(e) => {
e.stopPropagation();
onViewDocument();
}}
>
<StatPill
color="orange"
value={documentCount}
size="sm"
aria-label="Documents count"
icon={<FileText className="w-3.5 h-3.5" />}
/>
</div>
</SimpleTooltip>
<SimpleTooltip
content={`${codeExamplesCount} code example${codeExamplesCount !== 1 ? "s" : ""} extracted - ${onViewCodeExamples ? "Click to view" : "No examples available"}`}
>
<div
className={cn("transition-transform", onViewCodeExamples && "cursor-pointer hover:scale-105")}
onClick={(e) => {
e.stopPropagation();
if (onViewCodeExamples) {
onViewCodeExamples();
}
}}
>
<StatPill
color="blue"
value={codeExamplesCount}
size="sm"
aria-label="Code examples count"
icon={<Code className="w-3.5 h-3.5" />}
/>
</div>
</SimpleTooltip>
</div>
</div>
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,172 @@
/**
* Knowledge Card Actions Component
* Handles actions for knowledge items (recrawl, delete, etc.)
* Following the pattern from ProjectCardActions
*/
import { Code, Download, Eye, MoreHorizontal, RefreshCw, Trash2 } from "lucide-react";
import { useState } from "react";
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
import { Button } from "../../ui/primitives/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../ui/primitives/dropdown-menu";
import { cn } from "../../ui/primitives/styles";
interface KnowledgeCardActionsProps {
sourceId: string; // Source ID for API calls
itemTitle?: string; // Title for delete confirmation
isUrl: boolean;
hasCodeExamples: boolean;
onViewDocuments: () => void;
onViewCodeExamples?: () => void;
onRefresh?: () => Promise<void>;
onDelete?: () => Promise<void>;
onExport?: () => void;
}
export const KnowledgeCardActions: React.FC<KnowledgeCardActionsProps> = ({
sourceId: _sourceId, // Currently unused, may be needed for future features
itemTitle = "this knowledge item",
isUrl,
hasCodeExamples,
onViewDocuments,
onViewCodeExamples,
onRefresh,
onDelete,
onExport,
}) => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleRefresh = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onRefresh || !isUrl) return;
setIsRefreshing(true);
try {
await onRefresh();
} finally {
// Always reset the refreshing state
setIsRefreshing(false);
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onDelete) return;
setShowDeleteModal(true);
};
const handleConfirmDelete = async () => {
if (!onDelete) return;
setIsDeleting(true);
setShowDeleteModal(false);
try {
await onDelete();
} finally {
// Ensures state is reset even if parent removes the card
setIsDeleting(false);
}
};
const handleViewDocuments = (e: React.MouseEvent) => {
e.stopPropagation();
onViewDocuments();
};
const handleViewCodeExamples = (e: React.MouseEvent) => {
e.stopPropagation();
onViewCodeExamples?.();
};
const handleExport = (e: React.MouseEvent) => {
e.stopPropagation();
onExport?.();
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-gray-400 hover:text-white hover:bg-white/10",
// Always visible for clearer affordance
"opacity-100",
(isRefreshing || isDeleting) && "opacity-100",
)}
disabled={isDeleting}
title={isRefreshing ? "Recrawling..." : "More actions"}
>
{isRefreshing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <MoreHorizontal className="w-4 h-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleViewDocuments}>
<Eye className="w-4 h-4 mr-2" />
View Documents
</DropdownMenuItem>
{hasCodeExamples && onViewCodeExamples && (
<DropdownMenuItem onClick={handleViewCodeExamples}>
<Code className="w-4 h-4 mr-2" />
View Code Examples
</DropdownMenuItem>
)}
{isUrl && onRefresh && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={cn("w-4 h-4 mr-2", isRefreshing && "animate-spin")} />
{isRefreshing ? "Recrawling..." : "Recrawl"}
</DropdownMenuItem>
</>
)}
{onExport && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export
</DropdownMenuItem>
</>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
disabled={isDeleting}
className="text-red-400 focus:text-red-400"
>
<Trash2 className="w-4 h-4 mr-2" />
{isDeleting ? "Deleting..." : "Delete"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteConfirmModal
itemName={itemTitle}
type="knowledge"
open={showDeleteModal}
onOpenChange={setShowDeleteModal}
onConfirm={handleConfirmDelete}
onCancel={() => setShowDeleteModal(false)}
/>
</>
);
};

View File

@@ -0,0 +1,357 @@
/**
* Knowledge Card Tags Component
* Displays and allows inline editing of tags for knowledge items
*/
import { ChevronDown, ChevronUp, Plus, Tag, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Badge } from "../../../components/ui/Badge";
import { Input } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip } from "../../ui/primitives/tooltip";
import { useUpdateKnowledgeItem } from "../hooks";
interface KnowledgeCardTagsProps {
sourceId: string;
tags: string[];
}
export const KnowledgeCardTags: React.FC<KnowledgeCardTagsProps> = ({ sourceId, tags }) => {
const [isEditing, setIsEditing] = useState(false);
const [editingTags, setEditingTags] = useState<string[]>(tags);
const [newTagValue, setNewTagValue] = useState("");
const [originalTagBeingEdited, setOriginalTagBeingEdited] = useState<string | null>(null);
const [showAllTags, setShowAllTags] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const updateMutation = useUpdateKnowledgeItem();
// Determine how many tags to show (2 rows worth, approximately 6-8 tags depending on length)
const MAX_TAGS_COLLAPSED = 6;
// Update local state when props change, but only when not editing to avoid overwriting user input
useEffect(() => {
if (!isEditing) {
setEditingTags(tags);
}
}, [tags, isEditing]);
// Focus input when starting to add a new tag
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
const handleSaveTags = async () => {
const updatedTags = editingTags.filter((tag) => tag.trim().length > 0);
try {
await updateMutation.mutateAsync({
sourceId,
updates: {
tags: updatedTags,
},
});
setIsEditing(false);
setNewTagValue("");
} catch (_error) {
// Reset on error
setEditingTags(tags);
setNewTagValue("");
}
};
const handleCancelEdit = () => {
setEditingTags(tags);
setNewTagValue("");
setOriginalTagBeingEdited(null);
setIsEditing(false);
};
const handleAddTagAndSave = async () => {
const trimmed = newTagValue.trim();
if (trimmed) {
let newTags = [...editingTags];
// If we're editing an existing tag, remove the original first
if (originalTagBeingEdited) {
newTags = newTags.filter((tag) => tag !== originalTagBeingEdited);
}
// Add the new/modified tag if it doesn't already exist
if (!newTags.includes(trimmed)) {
newTags.push(trimmed);
}
// Save directly without updating local state first
const updatedTags = newTags.filter((tag) => tag.trim().length > 0);
try {
await updateMutation.mutateAsync({
sourceId,
updates: {
tags: updatedTags,
},
});
setIsEditing(false);
setNewTagValue("");
setOriginalTagBeingEdited(null);
} catch (_error) {
// Reset on error
setEditingTags(tags);
setNewTagValue("");
setOriginalTagBeingEdited(null);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (newTagValue.trim()) {
// Add tag and save immediately
handleAddTagAndSave();
} else {
// If no tag in input, just save current state
handleSaveTags();
}
} else if (e.key === "Escape") {
e.preventDefault();
handleCancelEdit();
}
};
const handleAddTag = () => {
const trimmed = newTagValue.trim();
if (trimmed) {
let newTags = [...editingTags];
// If we're editing an existing tag, remove the original first
if (originalTagBeingEdited) {
newTags = newTags.filter((tag) => tag !== originalTagBeingEdited);
}
// Add the new/modified tag if it doesn't already exist
if (!newTags.includes(trimmed)) {
newTags.push(trimmed);
}
setEditingTags(newTags);
setNewTagValue("");
setOriginalTagBeingEdited(null);
}
};
const handleRemoveTag = (tagToRemove: string) => {
setEditingTags(editingTags.filter((tag) => tag !== tagToRemove));
};
const handleDeleteTag = async (tagToDelete: string) => {
// Remove the tag and save immediately
const updatedTags = tags.filter((tag) => tag !== tagToDelete);
try {
await updateMutation.mutateAsync({
sourceId,
updates: {
tags: updatedTags,
},
});
} catch (_error) {
// Error handling is done by the mutation hook
}
};
const handleEditTag = (tagToEdit: string) => {
// When clicking an existing tag in edit mode, put it in the input for editing
if (isEditing) {
setNewTagValue(tagToEdit);
setOriginalTagBeingEdited(tagToEdit);
// Focus the input
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select(); // Select all text for easy editing
}
}, 0);
}
};
const displayTags = isEditing ? editingTags : tags;
const visibleTags = showAllTags || isEditing ? displayTags : displayTags.slice(0, MAX_TAGS_COLLAPSED);
const hasMoreTags = displayTags.length > MAX_TAGS_COLLAPSED;
return (
<div className="flex items-center gap-1 flex-wrap">
{/* Display tags */}
{visibleTags.map((tag) => (
<div key={tag} className="relative">
{isEditing ? (
<SimpleTooltip content={`Click to edit "${tag}"`}>
<Badge
color="gray"
variant="outline"
className="flex items-center gap-1 text-[10px] cursor-pointer group pr-0.5 px-1.5 py-0.5 h-5"
onClick={() => handleEditTag(tag)}
>
<Tag className="w-2.5 h-2.5" />
<span>{tag}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation(); // Prevent triggering the edit when clicking remove
handleRemoveTag(tag);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity ml-0.5 hover:text-red-500"
aria-label={`Remove ${tag} tag`}
>
<X className="w-2.5 h-2.5" />
</button>
</Badge>
</SimpleTooltip>
) : (
<div className="relative group">
<SimpleTooltip content={`Click to edit "${tag}"`}>
<Badge
color="gray"
variant="outline"
className="flex items-center gap-1 text-[10px] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors group pr-0.5 px-1.5 py-0.5 h-5"
onClick={() => {
setIsEditing(true);
// Load this specific tag for editing
setNewTagValue(tag);
setOriginalTagBeingEdited(tag);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 0);
}}
>
<Tag className="w-2.5 h-2.5" />
<span>{tag}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation(); // Prevent triggering the edit when clicking delete
handleDeleteTag(tag);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity ml-0.5 hover:text-red-500"
aria-label={`Delete ${tag} tag`}
disabled={updateMutation.isPending}
>
<X className="w-2.5 h-2.5" />
</button>
</Badge>
</SimpleTooltip>
</div>
)}
</div>
))}
{/* Show more/less button */}
{!isEditing && hasMoreTags && (
<button
type="button"
onClick={() => setShowAllTags(!showAllTags)}
className="flex items-center gap-0.5 text-[10px] text-gray-500 dark:text-gray-400 hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors px-1 py-0.5 rounded"
>
{showAllTags ? (
<>
<span>Show less</span>
<ChevronUp className="w-2.5 h-2.5" />
</>
) : (
<>
<span>+{displayTags.length - MAX_TAGS_COLLAPSED} more</span>
<ChevronDown className="w-2.5 h-2.5" />
</>
)}
</button>
)}
{/* Add tag input */}
{isEditing && (
<div className="flex items-center gap-1">
<Input
ref={inputRef}
value={newTagValue}
onChange={(e) => setNewTagValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (newTagValue.trim()) {
handleAddTag();
}
}}
placeholder={originalTagBeingEdited ? "Edit tag..." : "Add tag..."}
className={cn(
"h-6 text-xs px-2 w-20 min-w-0",
"border-cyan-400 dark:border-cyan-600",
"focus:ring-1 focus:ring-cyan-400",
)}
disabled={updateMutation.isPending}
/>
<button
type="button"
onClick={() => {
if (newTagValue.trim()) {
handleAddTag();
}
}}
className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300"
disabled={!newTagValue.trim() || updateMutation.isPending}
aria-label="Add tag"
>
<Plus className="w-2.5 h-2.5" />
</button>
</div>
)}
{/* Add tag button when not editing */}
{!isEditing && (
<SimpleTooltip content="Click to add or edit tags">
<button
type="button"
onClick={() => {
setIsEditing(true);
setOriginalTagBeingEdited(null); // Clear any existing edit state
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
}}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:text-cyan-600 dark:hover:text-cyan-400 hover:border-cyan-400 dark:hover:border-cyan-600 transition-colors h-5"
aria-label="Add tags"
>
<Plus className="w-2.5 h-2.5" />
<span>Tags</span>
</button>
</SimpleTooltip>
)}
{/* Save/Cancel buttons when editing */}
{isEditing && (
<div className="flex items-center gap-1 ml-2">
<button
type="button"
onClick={handleSaveTags}
disabled={updateMutation.isPending}
className="px-2 py-1 text-xs bg-cyan-600 text-white rounded hover:bg-cyan-700 disabled:opacity-50 transition-colors"
>
Save
</button>
<button
type="button"
onClick={handleCancelEdit}
disabled={updateMutation.isPending}
className="px-2 py-1 text-xs bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,203 @@
/**
* Knowledge Card Title Component
* Displays and allows inline editing of knowledge item titles
*/
import { Info } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Input } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip, Tooltip, TooltipContent, TooltipTrigger } from "../../ui/primitives/tooltip";
import { useUpdateKnowledgeItem } from "../hooks";
// Centralized color class mappings
const ICON_COLOR_CLASSES: Record<string, string> = {
cyan: "text-gray-400 hover:!text-cyan-600 dark:text-gray-500 dark:hover:!text-cyan-400",
purple: "text-gray-400 hover:!text-purple-600 dark:text-gray-500 dark:hover:!text-purple-400",
blue: "text-gray-400 hover:!text-blue-600 dark:text-gray-500 dark:hover:!text-blue-400",
pink: "text-gray-400 hover:!text-pink-600 dark:text-gray-500 dark:hover:!text-pink-400",
red: "text-gray-400 hover:!text-red-600 dark:text-gray-500 dark:hover:!text-red-400",
yellow: "text-gray-400 hover:!text-yellow-600 dark:text-gray-500 dark:hover:!text-yellow-400",
default: "text-gray-400 hover:!text-blue-600 dark:text-gray-500 dark:hover:!text-blue-400",
};
const TOOLTIP_COLOR_CLASSES: Record<string, string> = {
cyan: "border-cyan-500/50 shadow-[0_0_15px_rgba(34,211,238,0.5)] dark:border-cyan-400/50 dark:shadow-[0_0_15px_rgba(34,211,238,0.7)]",
purple:
"border-purple-500/50 shadow-[0_0_15px_rgba(168,85,247,0.5)] dark:border-purple-400/50 dark:shadow-[0_0_15px_rgba(168,85,247,0.7)]",
blue: "border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.5)] dark:border-blue-400/50 dark:shadow-[0_0_15px_rgba(59,130,246,0.7)]",
pink: "border-pink-500/50 shadow-[0_0_15px_rgba(236,72,153,0.5)] dark:border-pink-400/50 dark:shadow-[0_0_15px_rgba(236,72,153,0.7)]",
red: "border-red-500/50 shadow-[0_0_15px_rgba(239,68,68,0.5)] dark:border-red-400/50 dark:shadow-[0_0_15px_rgba(239,68,68,0.7)]",
yellow:
"border-yellow-500/50 shadow-[0_0_15px_rgba(234,179,8,0.5)] dark:border-yellow-400/50 dark:shadow-[0_0_15px_rgba(234,179,8,0.7)]",
default:
"border-cyan-500/50 shadow-[0_0_15px_rgba(34,211,238,0.5)] dark:border-cyan-400/50 dark:shadow-[0_0_15px_rgba(34,211,238,0.7)]",
};
interface KnowledgeCardTitleProps {
sourceId: string;
title: string;
description?: string;
accentColor: "cyan" | "purple" | "blue" | "pink" | "red" | "yellow";
}
export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
sourceId,
title,
description,
accentColor,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const updateMutation = useUpdateKnowledgeItem();
// Simple lookups using centralized color mappings
const getIconColorClass = () => ICON_COLOR_CLASSES[accentColor] ?? ICON_COLOR_CLASSES.default;
const getTooltipColorClass = () => TOOLTIP_COLOR_CLASSES[accentColor] ?? TOOLTIP_COLOR_CLASSES.default;
// Update local state when props change, but only when not editing to avoid overwriting user input
useEffect(() => {
if (!isEditing) {
setEditValue(title);
}
}, [title, isEditing]);
// Focus input when editing starts
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleSave = async () => {
const trimmedValue = editValue.trim();
if (trimmedValue === title) {
setIsEditing(false);
return;
}
if (!trimmedValue) {
// Don't allow empty titles, revert to original
setEditValue(title);
setIsEditing(false);
return;
}
try {
await updateMutation.mutateAsync({
sourceId,
updates: {
title: trimmedValue,
},
});
setIsEditing(false);
} catch (_error) {
// Reset on error
setEditValue(title);
setIsEditing(false);
}
};
const handleCancel = () => {
setEditValue(title);
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Stop all key events from bubbling to prevent card interactions
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
// For all other keys (including space), let them work normally in the input
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click
if (!isEditing && !updateMutation.isPending) {
setIsEditing(true);
}
};
if (isEditing) {
return (
<div
className="flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onInput={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
disabled={updateMutation.isPending}
className={cn(
"text-base font-semibold bg-transparent border-cyan-400 dark:border-cyan-600",
"focus:ring-1 focus:ring-cyan-400 px-2 py-1",
)}
/>
{description && description.trim() && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Info
className={cn(
"w-3.5 h-3.5 transition-colors flex-shrink-0 opacity-70 hover:opacity-100 cursor-help",
getIconColorClass(),
)}
/>
</TooltipTrigger>
<TooltipContent side="top" className={cn("max-w-xs whitespace-pre-wrap", getTooltipColorClass())}>
{description}
</TooltipContent>
</Tooltip>
)}
</div>
);
}
return (
<div className="flex items-center gap-1.5">
<SimpleTooltip content="Click to edit title">
<h3
className={cn(
"text-base font-semibold text-gray-900 dark:text-white/90 line-clamp-2 cursor-pointer",
"hover:text-gray-700 dark:hover:text-white transition-colors",
updateMutation.isPending && "opacity-50",
)}
onClick={handleClick}
>
{title}
</h3>
</SimpleTooltip>
{description && description.trim() && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Info
className={cn(
"w-3.5 h-3.5 transition-colors flex-shrink-0 opacity-70 hover:opacity-100 cursor-help",
getIconColorClass(),
)}
/>
</TooltipTrigger>
<TooltipContent side="top" className={cn("max-w-xs whitespace-pre-wrap", getTooltipColorClass())}>
{description}
</TooltipContent>
</Tooltip>
)}
</div>
);
};

View File

@@ -0,0 +1,125 @@
/**
* Knowledge Card Type Component
* Displays and allows inline editing of knowledge item type (technical/business)
*/
import { Briefcase, Terminal } from "lucide-react";
import { useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip } from "../../ui/primitives/tooltip";
import { useUpdateKnowledgeItem } from "../hooks";
interface KnowledgeCardTypeProps {
sourceId: string;
knowledgeType: "technical" | "business";
}
export const KnowledgeCardType: React.FC<KnowledgeCardTypeProps> = ({ sourceId, knowledgeType }) => {
const [isEditing, setIsEditing] = useState(false);
const updateMutation = useUpdateKnowledgeItem();
const isTechnical = knowledgeType === "technical";
const handleTypeChange = async (newType: "technical" | "business") => {
if (newType === knowledgeType) {
setIsEditing(false);
return;
}
try {
await updateMutation.mutateAsync({
sourceId,
updates: {
knowledge_type: newType,
},
});
} finally {
// Always exit editing mode regardless of success or failure
// The mutation's onError handler will show error toasts if needed
setIsEditing(false);
}
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click
if (!isEditing && !updateMutation.isPending) {
setIsEditing(true);
}
};
const getTypeLabel = () => {
return isTechnical ? "Technical" : "Business";
};
const getTypeIcon = () => {
return isTechnical ? <Terminal className="w-3.5 h-3.5" /> : <Briefcase className="w-3.5 h-3.5" />;
};
if (isEditing) {
return (
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
<Select
open={isEditing}
onOpenChange={(open) => setIsEditing(open)}
value={knowledgeType}
onValueChange={(value) => handleTypeChange(value as "technical" | "business")}
disabled={updateMutation.isPending}
>
<SelectTrigger
className={cn(
"w-auto h-auto text-xs font-medium px-2 py-1 rounded-md",
"border-cyan-400 dark:border-cyan-600",
"focus:ring-1 focus:ring-cyan-400",
isTechnical
? "bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"
: "bg-pink-100 text-pink-700 dark:bg-pink-500/10 dark:text-pink-400",
)}
>
<SelectValue>
<div className="flex items-center gap-1.5">
{getTypeIcon()}
<span>{getTypeLabel()}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="technical">
<div className="flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5" />
<span>Technical</span>
</div>
</SelectItem>
<SelectItem value="business">
<div className="flex items-center gap-1.5">
<Briefcase className="w-3.5 h-3.5" />
<span>Business</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
);
}
return (
<SimpleTooltip
content={`${isTechnical ? "Technical documentation" : "Business/general content"} - Click to change`}
>
<div
className={cn(
"flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium cursor-pointer",
"hover:ring-1 hover:ring-cyan-400/50 transition-all",
isTechnical
? "bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"
: "bg-pink-100 text-pink-700 dark:bg-pink-500/10 dark:text-pink-400",
updateMutation.isPending && "opacity-50 cursor-not-allowed",
)}
onClick={handleClick}
>
{getTypeIcon()}
<span>{getTypeLabel()}</span>
</div>
</SimpleTooltip>
);
};

View File

@@ -0,0 +1,129 @@
/**
* Knowledge Base Header Component
* Contains search, filters, and view controls
*/
import { Asterisk, BookOpen, Briefcase, Grid, List, Plus, Search, Terminal } from "lucide-react";
import { Button, Input, ToggleGroup, ToggleGroupItem } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
interface KnowledgeHeaderProps {
totalItems: number;
isLoading: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
typeFilter: "all" | "technical" | "business";
onTypeFilterChange: (type: "all" | "technical" | "business") => void;
viewMode: "grid" | "table";
onViewModeChange: (mode: "grid" | "table") => void;
onAddKnowledge: () => void;
}
export const KnowledgeHeader: React.FC<KnowledgeHeaderProps> = ({
totalItems,
isLoading,
searchQuery,
onSearchChange,
typeFilter,
onTypeFilterChange,
viewMode,
onViewModeChange,
onAddKnowledge,
}) => {
return (
<div className="flex flex-col gap-4 px-6 py-4 border-b border-white/10">
<div className="flex items-center gap-4">
{/* Left: Title */}
<div className="flex items-center gap-3 flex-shrink-0">
<BookOpen className="h-7 w-7 text-purple-500 filter drop-shadow-[0_0_8px_rgba(168,85,247,0.8)]" />
<h1 className="text-2xl font-bold text-white">Knowledge Base</h1>
<span className="px-3 py-1 text-sm bg-black/30 border border-white/10 rounded">
{isLoading ? "Loading..." : `${totalItems} items`}
</span>
</div>
{/* Right: Search, Filters, View toggle, CTA */}
<div className="ml-auto flex items-center gap-3">
{/* Search on title row */}
<div className="relative w-[320px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="text"
placeholder="Search knowledge base..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 bg-black/30 border-white/10 focus:border-cyan-500/50"
/>
</div>
{/* Segmented type filters */}
<ToggleGroup
type="single"
size="sm"
value={typeFilter}
onValueChange={(v) => v && onTypeFilterChange(v as "all" | "technical" | "business")}
aria-label="Filter knowledge type"
>
<ToggleGroupItem value="all" aria-label="All" title="All" className="flex items-center justify-center">
<Asterisk className="w-4 h-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem
value="technical"
aria-label="Technical"
title="Technical"
className="flex items-center justify-center"
>
<Terminal className="w-4 h-4" aria-hidden="true" />
</ToggleGroupItem>
<ToggleGroupItem
value="business"
aria-label="Business"
title="Business"
className="flex items-center justify-center"
>
<Briefcase className="w-4 h-4" aria-hidden="true" />
</ToggleGroupItem>
</ToggleGroup>
{/* View Mode Toggle */}
<div className="flex gap-1 p-1 bg-black/30 rounded-lg border border-white/10">
<Button
variant="ghost"
size="sm"
onClick={() => onViewModeChange("grid")}
aria-label="Grid view"
aria-pressed={viewMode === "grid"}
title="Grid view"
className={cn(
"px-3",
viewMode === "grid" ? "bg-cyan-500/20 text-cyan-400" : "text-gray-400 hover:text-white",
)}
>
<Grid className="w-4 h-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onViewModeChange("table")}
aria-label="Table view"
aria-pressed={viewMode === "table"}
title="Table view"
className={cn(
"px-3",
viewMode === "table" ? "bg-cyan-500/20 text-cyan-400" : "text-gray-400 hover:text-white",
)}
>
<List className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
{/* Add knowledge */}
<Button variant="knowledge" onClick={onAddKnowledge} className="shadow-lg shadow-purple-500/30">
<Plus className="w-4 h-4 mr-2" />
Add Knowledge
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,184 @@
/**
* Knowledge List Component
* Displays knowledge items in grid or table view
*/
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, Loader2 } from "lucide-react";
import type { ActiveOperation } from "../../progress/types";
import { Button } from "../../ui/primitives";
import type { KnowledgeItem } from "../types";
import { KnowledgeCard } from "./KnowledgeCard";
import { KnowledgeTable } from "./KnowledgeTable";
interface KnowledgeListProps {
items: KnowledgeItem[];
viewMode: "grid" | "table";
isLoading: boolean;
error: Error | null;
onRetry: () => void;
onViewDocument: (sourceId: string) => void;
onViewCodeExamples?: (sourceId: string) => void;
onDeleteSuccess: () => void;
activeOperations?: ActiveOperation[];
onRefreshStarted?: (progressId: string) => void;
}
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.23, 1, 0.32, 1] },
},
exit: {
opacity: 0,
scale: 0.95,
transition: { duration: 0.3 },
},
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
export const KnowledgeList: React.FC<KnowledgeListProps> = ({
items,
viewMode,
isLoading,
error,
onRetry,
onViewDocument,
onViewCodeExamples,
onDeleteSuccess,
activeOperations = [],
onRefreshStarted,
}) => {
// Helper to check if an item is being recrawled
const getActiveOperationForItem = (item: KnowledgeItem): ActiveOperation | undefined => {
// First try to match by source_id (most reliable for refresh operations)
const matchBySourceId = activeOperations.find((op) => op.source_id === item.source_id);
if (matchBySourceId) {
return matchBySourceId;
}
// Fallback: Check if any active operation is for this item's URL
const itemUrl = item.metadata?.original_url || item.url;
return activeOperations.find((op) => {
// Check various URL fields in the operation
return (
op.url === itemUrl ||
op.current_url === itemUrl ||
op.message?.includes(itemUrl) ||
(op.operation_type === "crawl" && op.message?.includes(item.title))
);
});
};
// Loading state
if (isLoading && items.length === 0) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={itemVariants}
className="flex items-center justify-center py-12"
>
<div className="text-center" aria-live="polite" aria-busy="true">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading knowledge base...</p>
</div>
</motion.div>
);
}
// Error state
if (error) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={itemVariants}
className="flex items-center justify-center py-12"
>
<div className="text-center max-w-md" role="alert">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-500/10 mb-4">
<AlertCircle className="w-6 h-6 text-red-400" />
</div>
<h3 className="text-lg font-semibold mb-2">Failed to Load Knowledge Base</h3>
<p className="text-gray-400 mb-4">{error.message}</p>
<Button onClick={onRetry} variant="outline">
Try Again
</Button>
</div>
</motion.div>
);
}
// Empty state
if (items.length === 0) {
return (
<motion.div
initial="hidden"
animate="visible"
variants={itemVariants}
className="flex items-center justify-center py-12"
>
<div className="text-center max-w-md">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-cyan-500/10 mb-4">
<AlertCircle className="w-6 h-6 text-cyan-400" />
</div>
<h3 className="text-lg font-semibold mb-2">No Knowledge Items</h3>
<p className="text-gray-400">Start by adding documents or crawling websites to build your knowledge base.</p>
</div>
</motion.div>
);
}
// Table view
if (viewMode === "table") {
return (
<motion.div
initial="hidden"
animate="visible"
variants={itemVariants}
className="bg-black/30 rounded-lg border border-white/10 overflow-hidden"
>
<KnowledgeTable items={items} onViewDocument={onViewDocument} onDeleteSuccess={onDeleteSuccess} />
</motion.div>
);
}
// Grid view
return (
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
<AnimatePresence mode="popLayout">
{items.map((item) => {
const activeOperation = getActiveOperationForItem(item);
return (
<motion.div key={item.source_id} layout variants={itemVariants} exit="exit">
<KnowledgeCard
item={item}
onViewDocument={() => onViewDocument(item.source_id)}
onViewCodeExamples={onViewCodeExamples ? () => onViewCodeExamples(item.source_id) : undefined}
onDeleteSuccess={onDeleteSuccess}
activeOperation={activeOperation}
onRefreshStarted={onRefreshStarted}
/>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
);
};

View File

@@ -0,0 +1,209 @@
/**
* Knowledge Table Component
* Table view for knowledge items with Tron styling
*/
import { formatDistanceToNowStrict } from "date-fns";
import { Code, ExternalLink, Eye, FileText, MoreHorizontal, Trash2 } from "lucide-react";
import { useState } from "react";
import { useToast } from "../../ui/hooks/useToast";
import { Button } from "../../ui/primitives";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../ui/primitives/dropdown-menu";
import { cn } from "../../ui/primitives/styles";
import { useDeleteKnowledgeItem } from "../hooks";
import type { KnowledgeItem } from "../types";
interface KnowledgeTableProps {
items: KnowledgeItem[];
onViewDocument: (sourceId: string) => void;
onDeleteSuccess: () => void;
}
export const KnowledgeTable: React.FC<KnowledgeTableProps> = ({ items, onViewDocument, onDeleteSuccess }) => {
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const { showToast } = useToast();
const deleteMutation = useDeleteKnowledgeItem();
const handleDelete = async (item: KnowledgeItem) => {
if (!confirm(`Delete "${item.title}"? This action cannot be undone.`)) {
return;
}
setDeletingIds((prev) => new Set(prev).add(item.source_id));
try {
await deleteMutation.mutateAsync(item.source_id);
showToast("Knowledge item deleted successfully", "success");
onDeleteSuccess();
} catch (_error) {
showToast("Failed to delete knowledge item", "error");
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(item.source_id);
return next;
});
}
};
const getTypeIcon = (type?: string) => {
if (type === "technical") {
return <Code className="w-4 h-4" />;
}
return <FileText className="w-4 h-4" />;
};
const getTypeColor = (type?: string) => {
if (type === "technical") {
return "text-cyan-400 bg-cyan-500/10 border-cyan-500/20";
}
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
};
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Title</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Type</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Source</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Docs</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Examples</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">Created</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const isDeleting = deletingIds.has(item.source_id);
return (
<tr
key={item.source_id}
className={cn(
"border-b border-white/5 transition-colors",
"hover:bg-white/5",
isDeleting && "opacity-50 pointer-events-none",
)}
>
{/* Title */}
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="text-white/90 font-medium truncate max-w-xs">{item.title}</span>
</div>
</td>
{/* Type */}
<td className="py-3 px-4">
<span
className={cn(
"px-2 py-1 text-xs rounded inline-flex items-center",
getTypeColor(item.metadata?.knowledge_type),
)}
>
{getTypeIcon(item.metadata?.knowledge_type)}
<span className="ml-1">{item.metadata?.knowledge_type || "general"}</span>
</span>
</td>
{/* Source URL */}
<td className="py-3 px-4">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
<span className="truncate max-w-xs">
{(() => {
try {
return new URL(item.url).hostname;
} catch {
return item.url;
}
})()}
</span>
</a>
</td>
{/* Document Count */}
<td className="py-3 px-4">
<div className="flex items-center gap-1 text-sm text-gray-400">
<FileText className="w-3.5 h-3.5" />
<span className="font-medium text-white/80">
{item.document_count || item.metadata?.document_count || 0}
</span>
</div>
</td>
{/* Code Examples Count */}
<td className="py-3 px-4">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Code className="w-3.5 h-3.5 text-green-400" />
<span className="font-medium text-white/80">
{item.code_examples_count || item.metadata?.code_examples_count || 0}
</span>
</div>
</td>
{/* Created Date */}
<td className="py-3 px-4">
<span className="text-sm text-gray-400">
{formatDistanceToNowStrict(new Date(item.created_at), { addSuffix: true })}
</span>
</td>
{/* Actions */}
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onViewDocument(item.source_id)}
className="text-gray-400 hover:text-white hover:bg-white/10"
>
<Eye className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-gray-400 hover:text-white hover:bg-white/10"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onViewDocument(item.source_id)}>
<Eye className="w-4 h-4 mr-2" />
View Documents
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDelete(item)}
className="text-red-400 focus:text-red-400"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,163 @@
/**
* Knowledge Type Selection Component
* Radio cards for Technical vs Business knowledge type selection
*/
import { motion } from "framer-motion";
import { Briefcase, Check, Terminal } from "lucide-react";
import { cn } from "../../ui/primitives/styles";
interface KnowledgeTypeSelectorProps {
value: "technical" | "business";
onValueChange: (value: "technical" | "business") => void;
disabled?: boolean;
}
const TYPES = [
{
value: "technical" as const,
label: "Technical",
description: "Code, APIs, dev docs",
icon: Terminal,
gradient: {
selected:
"from-cyan-100/60 via-cyan-50/30 to-white/70 dark:from-cyan-900/30 dark:via-cyan-900/15 dark:to-black/40",
unselected:
"from-gray-50/50 via-gray-25/25 to-white/60 dark:from-gray-800/20 dark:via-gray-800/10 dark:to-black/30",
},
border: {
selected: "border-cyan-500/60",
unselected: "border-gray-300/50 dark:border-gray-700/50",
hover: "hover:border-cyan-400/50",
},
colors: {
selected: "text-cyan-700 dark:text-cyan-400",
unselected: "text-gray-700 dark:text-gray-300",
description: {
selected: "text-cyan-600 dark:text-cyan-400",
unselected: "text-gray-500 dark:text-gray-400",
},
},
accent: "bg-cyan-500",
smear: "from-cyan-500/25",
},
{
value: "business" as const,
label: "Business",
description: "Guides, policies, general",
icon: Briefcase,
gradient: {
selected:
"from-pink-100/60 via-pink-50/30 to-white/70 dark:from-pink-900/30 dark:via-pink-900/15 dark:to-black/40",
unselected:
"from-gray-50/50 via-gray-25/25 to-white/60 dark:from-gray-800/20 dark:via-gray-800/10 dark:to-black/30",
},
border: {
selected: "border-pink-500/60",
unselected: "border-gray-300/50 dark:border-gray-700/50",
hover: "hover:border-pink-400/50",
},
colors: {
selected: "text-pink-700 dark:text-pink-400",
unselected: "text-gray-700 dark:text-gray-300",
description: {
selected: "text-pink-600 dark:text-pink-400",
unselected: "text-gray-500 dark:text-gray-400",
},
},
accent: "bg-pink-500",
smear: "from-pink-500/25",
},
];
export const KnowledgeTypeSelector: React.FC<KnowledgeTypeSelectorProps> = ({
value,
onValueChange,
disabled = false,
}) => {
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-900 dark:text-white/90">Knowledge Type</div>
<div className="grid grid-cols-2 gap-4">
{TYPES.map((type) => {
const isSelected = value === type.value;
const Icon = type.icon;
return (
<motion.div
key={type.value}
whileHover={!disabled ? { scale: 1.02 } : {}}
whileTap={!disabled ? { scale: 0.98 } : {}}
>
<button
type="button"
onClick={() => !disabled && onValueChange(type.value)}
disabled={disabled}
className={cn(
"relative w-full h-24 rounded-xl transition-all duration-200 border-2",
"flex flex-col items-center justify-center gap-2 p-4",
"backdrop-blur-md",
isSelected
? `${type.border.selected} bg-gradient-to-b ${type.gradient.selected}`
: `${type.border.unselected} bg-gradient-to-b ${type.gradient.unselected}`,
!disabled && !isSelected && type.border.hover,
!disabled &&
!isSelected &&
"hover:shadow-[0_0_15px_rgba(0,0,0,0.05)] dark:hover:shadow-[0_0_15px_rgba(255,255,255,0.05)]",
isSelected && "shadow-[0_0_20px_rgba(6,182,212,0.15)]",
disabled && "opacity-50 cursor-not-allowed",
)}
aria-label={`Select ${type.label}: ${type.description}`}
>
{/* Top accent glow for selected state */}
{isSelected && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className={cn("mx-1 mt-0.5 h-[2px] rounded-full", type.accent)} />
<div className={cn("-mt-1 h-6 w-full bg-gradient-to-b to-transparent blur-md", type.smear)} />
</div>
)}
{/* Selection indicator */}
{isSelected && (
<div
className={cn(
"absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center",
type.accent,
)}
>
<Check className="w-3 h-3 text-white" />
</div>
)}
{/* Icon */}
<Icon className={cn("w-6 h-6", isSelected ? type.colors.selected : type.colors.unselected)} />
{/* Label */}
<div
className={cn("text-sm font-semibold", isSelected ? type.colors.selected : type.colors.unselected)}
>
{type.label}
</div>
{/* Description */}
<div
className={cn(
"text-xs text-center leading-tight",
isSelected ? type.colors.description.selected : type.colors.description.unselected,
)}
>
{type.description}
</div>
</button>
</motion.div>
);
})}
</div>
{/* Help text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center">
Choose the type that best describes your content
</div>
</div>
);
};

View File

@@ -0,0 +1,158 @@
/**
* Level Selection Component
* Circular level selector for crawl depth using radio-like selection
*/
import { motion } from "framer-motion";
import { Check, Info } from "lucide-react";
import { cn } from "../../ui/primitives/styles";
import { SimpleTooltip } from "../../ui/primitives/tooltip";
interface LevelSelectorProps {
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
}
const LEVELS = [
{
value: "1",
label: "1",
description: "Single page only",
details: "1-50 pages • Best for: Single articles, specific pages",
},
{
value: "2",
label: "2",
description: "Page + immediate links",
details: "10-200 pages • Best for: Documentation sections, blogs",
},
{
value: "3",
label: "3",
description: "2 levels deep",
details: "50-500 pages • Best for: Entire sites, comprehensive docs",
},
{
value: "5",
label: "5",
description: "Very deep crawling",
details: "100-1000+ pages • Warning: May include irrelevant content",
},
];
export const LevelSelector: React.FC<LevelSelectorProps> = ({ value, onValueChange, disabled = false }) => {
const tooltipContent = (
<div className="space-y-2 text-xs">
<div className="font-semibold mb-2">Crawl Depth Level Explanations:</div>
{LEVELS.map((level) => (
<div key={level.value} className="space-y-1">
<div className="font-medium">
Level {level.value}: "{level.description}"
</div>
<div className="text-gray-300 dark:text-gray-400 pl-2">{level.details}</div>
</div>
))}
<div className="mt-3 pt-2 border-t border-gray-600 dark:border-gray-400">
<div className="flex items-center gap-1">
<span>💡</span>
<span className="font-medium">More data isn't always better. Choose based on your needs.</span>
</div>
</div>
</div>
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white/90" id="crawl-depth-label">
Crawl Depth
</div>
<SimpleTooltip content={tooltipContent}>
<Info className="w-4 h-4 text-gray-400 hover:text-cyan-500 transition-colors cursor-help" />
</SimpleTooltip>
</div>
<div className="grid grid-cols-4 gap-3" role="radiogroup" aria-labelledby="crawl-depth-label">
{LEVELS.map((level) => {
const isSelected = value === level.value;
return (
<motion.div
key={level.value}
whileHover={!disabled ? { scale: 1.05 } : {}}
whileTap={!disabled ? { scale: 0.95 } : {}}
>
<SimpleTooltip content={level.details}>
<button
type="button"
role="radio"
aria-checked={isSelected}
aria-label={`Level ${level.value}: ${level.description}`}
tabIndex={isSelected ? 0 : -1}
onClick={() => !disabled && onValueChange(level.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled) onValueChange(level.value);
}
}}
disabled={disabled}
className={cn(
"relative w-full h-16 rounded-xl transition-all duration-200 border-2",
"flex flex-col items-center justify-center gap-1",
"backdrop-blur-md focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2",
isSelected
? "border-cyan-500/60 bg-gradient-to-b from-cyan-100/60 via-cyan-50/30 to-white/70 dark:from-cyan-900/30 dark:via-cyan-900/15 dark:to-black/40"
: "border-gray-300/50 dark:border-gray-700/50 bg-gradient-to-b from-gray-50/50 via-gray-25/25 to-white/60 dark:from-gray-800/20 dark:via-gray-800/10 dark:to-black/30",
!disabled && "hover:border-cyan-400/50 hover:shadow-[0_0_15px_rgba(6,182,212,0.15)]",
disabled && "opacity-50 cursor-not-allowed",
)}
>
{/* Top accent glow for selected state */}
{isSelected && (
<div className="pointer-events-none absolute inset-x-0 top-0">
<div className="mx-1 mt-0.5 h-[2px] rounded-full bg-cyan-500" />
<div className="-mt-1 h-6 w-full bg-gradient-to-b from-cyan-500/25 to-transparent blur-md" />
</div>
)}
{/* Selection indicator */}
{isSelected && (
<div className="absolute -top-1 -right-1 w-5 h-5 bg-cyan-500 rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
)}
{/* Level number */}
<div
className={cn(
"text-lg font-bold",
isSelected ? "text-cyan-700 dark:text-cyan-400" : "text-gray-700 dark:text-gray-300",
)}
>
{level.label}
</div>
{/* Level description */}
<div
className={cn(
"text-xs text-center leading-tight",
isSelected ? "text-cyan-600 dark:text-cyan-400" : "text-gray-500 dark:text-gray-400",
)}
>
{level.value === "1" ? "level" : "levels"}
</div>
</button>
</SimpleTooltip>
</motion.div>
);
})}
</div>
{/* Help text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center">
Higher levels crawl deeper into the website structure
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
/**
* Tag Input Component
* Visual tag management with add/remove functionality
*/
import { motion } from "framer-motion";
import { Plus, X } from "lucide-react";
import { useState } from "react";
import { Input } from "../../ui/primitives";
import { cn } from "../../ui/primitives/styles";
interface TagInputProps {
tags: string[];
onTagsChange: (tags: string[]) => void;
placeholder?: string;
disabled?: boolean;
maxTags?: number;
}
export const TagInput: React.FC<TagInputProps> = ({
tags,
onTagsChange,
placeholder = "Enter a tag and press Enter",
disabled = false,
maxTags = 10,
}) => {
const [inputValue, setInputValue] = useState("");
const addTag = (tag: string) => {
const trimmedTag = tag.trim();
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
onTagsChange([...tags, trimmedTag]);
setInputValue("");
}
};
const removeTag = (tagToRemove: string) => {
onTagsChange(tags.filter((tag) => tag !== tagToRemove));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
// Remove last tag when backspace is pressed on empty input
removeTag(tags[tags.length - 1]);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Handle comma-separated input for backwards compatibility
if (value.includes(",")) {
// Collect pasted candidates, trim and filter them
const newCandidates = value
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
// Merge with current tags using Set to dedupe
const combinedTags = new Set([...tags, ...newCandidates]);
const combinedArray = Array.from(combinedTags);
// Enforce maxTags limit by taking only the first N allowed tags
const finalTags = combinedArray.slice(0, maxTags);
// Single batched update
onTagsChange(finalTags);
setInputValue("");
} else {
setInputValue(value);
}
};
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-900 dark:text-white/90">Tags</div>
{/* Tag Display */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<motion.div
key={tag}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium",
"backdrop-blur-md bg-gradient-to-r from-blue-100/80 to-blue-50/60 dark:from-blue-900/40 dark:to-blue-800/30",
"border border-blue-300/50 dark:border-blue-700/50",
"text-blue-700 dark:text-blue-300",
"transition-all duration-200",
)}
>
<span className="max-w-24 truncate">{tag}</span>
{!disabled && (
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 transition-colors"
aria-label={`Remove ${tag} tag`}
>
<X className="w-3 h-3" />
</button>
)}
</motion.div>
))}
</div>
)}
{/* Tag Input */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Plus className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={tags.length >= maxTags ? "Maximum tags reached" : placeholder}
disabled={disabled || tags.length >= maxTags}
className="pl-9 backdrop-blur-md bg-gradient-to-r from-white/60 to-white/50 dark:from-black/60 dark:to-black/50 border-gray-300/60 dark:border-gray-600/60 focus:border-blue-400/70 focus:shadow-[0_0_15px_rgba(59,130,246,0.15)]"
/>
</div>
{/* Help Text */}
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>Press Enter or comma to add tags Backspace to remove last tag</p>
{maxTags && (
<p>
{tags.length}/{maxTags} tags used
</p>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./AddKnowledgeDialog";
export * from "./DocumentBrowser";
export * from "./KnowledgeCard";
export * from "./KnowledgeList";
export * from "./KnowledgeTypeSelector";
export * from "./LevelSelector";
export * from "./TagInput";

View File

@@ -0,0 +1 @@
export * from "./useKnowledgeQueries";

View File

@@ -0,0 +1,246 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { KnowledgeItemsResponse } from "../../types";
import { knowledgeKeys, useCrawlUrl, useDeleteKnowledgeItem, useUploadDocument } from "../useKnowledgeQueries";
// Mock the services
vi.mock("../../services", () => ({
knowledgeService: {
getKnowledgeItem: vi.fn(),
deleteKnowledgeItem: vi.fn(),
updateKnowledgeItem: vi.fn(),
crawlUrl: vi.fn(),
refreshKnowledgeItem: vi.fn(),
uploadDocument: vi.fn(),
stopCrawl: vi.fn(),
getKnowledgeItemChunks: vi.fn(),
getCodeExamples: vi.fn(),
searchKnowledgeBase: vi.fn(),
getKnowledgeSources: vi.fn(),
},
}));
// Mock the toast hook
vi.mock("../../../ui/hooks/useToast", () => ({
useToast: () => ({
showToast: vi.fn(),
}),
}));
// Mock smart polling
vi.mock("../../../ui/hooks", () => ({
useSmartPolling: () => ({
refetchInterval: 30000,
isPaused: false,
}),
}));
// Test wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe("useKnowledgeQueries", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("knowledgeKeys", () => {
it("should generate correct query keys", () => {
expect(knowledgeKeys.all).toEqual(["knowledge"]);
expect(knowledgeKeys.lists()).toEqual(["knowledge", "list"]);
expect(knowledgeKeys.detail("source-123")).toEqual(["knowledge", "detail", "source-123"]);
expect(knowledgeKeys.chunks("source-123", { domain: "example.com" })).toEqual([
"knowledge",
"source-123",
"chunks",
{ domain: "example.com", limit: undefined, offset: undefined },
]);
expect(knowledgeKeys.codeExamples("source-123")).toEqual([
"knowledge",
"source-123",
"code-examples",
{ limit: undefined, offset: undefined },
]);
expect(knowledgeKeys.search("test query")).toEqual(["knowledge", "search", "test query"]);
expect(knowledgeKeys.sources()).toEqual(["knowledge", "sources"]);
});
it("should handle filter in summaries key", () => {
const filter = { knowledge_type: "technical" as const, page: 2 };
expect(knowledgeKeys.summaries(filter)).toEqual(["knowledge", "summaries", filter]);
});
});
describe("useDeleteKnowledgeItem", () => {
it("should optimistically remove item and handle success", async () => {
const initialData: KnowledgeItemsResponse = {
items: [
{
id: "1",
source_id: "source-1",
title: "Item 1",
url: "https://example.com/1",
source_type: "url" as const,
knowledge_type: "technical" as const,
status: "active" as const,
document_count: 5,
code_examples_count: 2,
metadata: {},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "2",
source_id: "source-2",
title: "Item 2",
url: "https://example.com/2",
source_type: "url" as const,
knowledge_type: "business" as const,
status: "active" as const,
document_count: 3,
code_examples_count: 0,
metadata: {},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
total: 2,
page: 1,
per_page: 20,
};
const { knowledgeService } = await import("../../services");
vi.mocked(knowledgeService.deleteKnowledgeItem).mockResolvedValue({
success: true,
message: "Item deleted",
});
// Create QueryClient instance that will be used by the test
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// Pre-populate cache with the same client instance
queryClient.setQueryData(knowledgeKeys.lists(), initialData);
// Create wrapper with the pre-populated QueryClient
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper });
await result.current.mutateAsync("source-1");
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(knowledgeService.deleteKnowledgeItem).toHaveBeenCalledWith("source-1");
});
});
it("should handle deletion error", async () => {
const { knowledgeService } = await import("../../services");
vi.mocked(knowledgeService.deleteKnowledgeItem).mockRejectedValue(new Error("Deletion failed"));
const wrapper = createWrapper();
const { result } = renderHook(() => useDeleteKnowledgeItem(), { wrapper });
await expect(result.current.mutateAsync("source-1")).rejects.toThrow("Deletion failed");
});
});
describe("useCrawlUrl", () => {
it("should start crawl and return progress ID", async () => {
const crawlRequest = {
url: "https://example.com",
knowledge_type: "technical" as const,
tags: ["docs"],
max_depth: 2,
};
const mockResponse = {
success: true,
progressId: "progress-123",
message: "Crawling started",
estimatedDuration: "3-5 minutes",
};
const { knowledgeService } = await import("../../services");
vi.mocked(knowledgeService.crawlUrl).mockResolvedValue(mockResponse);
const wrapper = createWrapper();
const { result } = renderHook(() => useCrawlUrl(), { wrapper });
const response = await result.current.mutateAsync(crawlRequest);
expect(response).toEqual(mockResponse);
expect(knowledgeService.crawlUrl).toHaveBeenCalledWith(crawlRequest);
});
it("should handle crawl error", async () => {
const { knowledgeService } = await import("../../services");
vi.mocked(knowledgeService.crawlUrl).mockRejectedValue(new Error("Invalid URL"));
const wrapper = createWrapper();
const { result } = renderHook(() => useCrawlUrl(), { wrapper });
await expect(
result.current.mutateAsync({
url: "invalid-url",
}),
).rejects.toThrow("Invalid URL");
});
});
describe("useUploadDocument", () => {
it("should upload document with metadata", async () => {
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
const metadata = {
knowledge_type: "business" as const,
tags: ["report"],
};
const mockResponse = {
success: true,
progressId: "upload-456",
message: "Upload started",
filename: "test.pdf",
};
const { knowledgeService } = await import("../../services");
vi.mocked(knowledgeService.uploadDocument).mockResolvedValue(mockResponse);
const wrapper = createWrapper();
const { result } = renderHook(() => useUploadDocument(), { wrapper });
const response = await result.current.mutateAsync({ file, metadata });
expect(response).toEqual(mockResponse);
expect(knowledgeService.uploadDocument).toHaveBeenCalledWith(file, metadata);
});
it("should handle upload error", async () => {
const file = new File(["test"], "test.txt", { type: "text/plain" });
const { knowledgeService } = await import("../../services");
vi.mocked(knowledgeService.uploadDocument).mockRejectedValue(new Error("File too large"));
const wrapper = createWrapper();
const { result } = renderHook(() => useUploadDocument(), { wrapper });
await expect(result.current.mutateAsync({ file, metadata: {} })).rejects.toThrow("File too large");
});
});
});

View File

@@ -0,0 +1,812 @@
/**
* Knowledge Base Query Hooks
* Following TanStack Query best practices with query key factories
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { createOptimisticEntity, createOptimisticId } from "@/features/shared/optimistic";
import { useActiveOperations } from "../../progress/hooks";
import { progressKeys } from "../../progress/hooks/useProgressQueries";
import type { ActiveOperation, ActiveOperationsResponse } from "../../progress/types";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "../../ui/hooks";
import { useToast } from "../../ui/hooks/useToast";
import { knowledgeService } from "../services";
import type {
CrawlRequest,
CrawlStartResponse,
KnowledgeItem,
KnowledgeItemsFilter,
KnowledgeItemsResponse,
UploadMetadata,
} from "../types";
import { getProviderErrorMessage } from "../utils/providerErrorHandler";
// Query keys factory for better organization and type safety
export const knowledgeKeys = {
all: ["knowledge"] as const,
lists: () => [...knowledgeKeys.all, "list"] as const,
detail: (id: string) => [...knowledgeKeys.all, "detail", id] as const,
// Include domain + pagination to avoid cache collisions
chunks: (id: string, opts?: { domain?: string; limit?: number; offset?: number }) =>
[
...knowledgeKeys.all,
id,
"chunks",
{ domain: opts?.domain ?? "all", limit: opts?.limit, offset: opts?.offset },
] as const,
// Include pagination in the key
codeExamples: (id: string, opts?: { limit?: number; offset?: number }) =>
[...knowledgeKeys.all, id, "code-examples", { limit: opts?.limit, offset: opts?.offset }] as const,
// Prefix helper for targeting all summaries queries
summariesPrefix: () => [...knowledgeKeys.all, "summaries"] as const,
summaries: (filter?: KnowledgeItemsFilter) => [...knowledgeKeys.all, "summaries", filter] as const,
sources: () => [...knowledgeKeys.all, "sources"] as const,
search: (query: string) => [...knowledgeKeys.all, "search", query] as const,
};
/**
* Fetch a specific knowledge item
*/
export function useKnowledgeItem(sourceId: string | null) {
return useQuery<KnowledgeItem>({
queryKey: sourceId ? knowledgeKeys.detail(sourceId) : DISABLED_QUERY_KEY,
queryFn: () => (sourceId ? knowledgeService.getKnowledgeItem(sourceId) : Promise.reject("No source ID")),
enabled: !!sourceId,
staleTime: STALE_TIMES.normal,
});
}
/**
* Fetch document chunks for a knowledge item
*/
export function useKnowledgeItemChunks(
sourceId: string | null,
opts?: { domain?: string; limit?: number; offset?: number },
) {
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
return useQuery({
queryKey: sourceId ? knowledgeKeys.chunks(sourceId, opts) : DISABLED_QUERY_KEY,
queryFn: () =>
sourceId
? knowledgeService.getKnowledgeItemChunks(sourceId, {
domainFilter: opts?.domain,
limit: opts?.limit,
offset: opts?.offset,
})
: Promise.reject("No source ID"),
enabled: !!sourceId,
staleTime: STALE_TIMES.normal,
});
}
/**
* Fetch code examples for a knowledge item
*/
export function useCodeExamples(sourceId: string | null) {
return useQuery({
queryKey: sourceId ? knowledgeKeys.codeExamples(sourceId) : DISABLED_QUERY_KEY,
queryFn: () => (sourceId ? knowledgeService.getCodeExamples(sourceId) : Promise.reject("No source ID")),
enabled: !!sourceId,
staleTime: STALE_TIMES.normal,
});
}
/**
* Crawl URL mutation with optimistic updates
* Returns the progressId that can be used to track crawl progress
*/
export function useCrawlUrl() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation<
CrawlStartResponse,
Error,
CrawlRequest,
{
previousKnowledge?: KnowledgeItem[];
previousSummaries?: Array<[readonly unknown[], KnowledgeItemsResponse | undefined]>;
previousOperations?: ActiveOperationsResponse;
tempProgressId: string;
tempItemId: string;
}
>({
mutationFn: (request: CrawlRequest) => knowledgeService.crawlUrl(request),
onMutate: async (request) => {
// Cancel any outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
await queryClient.cancelQueries({ queryKey: progressKeys.active() });
// TODO: Fix invisible optimistic updates
// ISSUE: Optimistic updates are applied to knowledgeKeys.summaries(filter) queries,
// but the UI component (KnowledgeView) queries with dynamic filters that we don't have access to here.
// This means optimistic updates only work if the filter happens to match what's being viewed.
//
// CURRENT BEHAVIOR:
// - We update all cached summaries queries (lines 158-179 below)
// - BUT if the user changes filters after mutation starts, they won't see the optimistic update
// - AND we have no way to know what filter the user is currently viewing
//
// PROPER FIX requires one of:
// 1. Pass current filter from KnowledgeView to mutation hooks (prop drilling)
// 2. Create KnowledgeFilterContext to share filter state
// 3. Restructure to have a single source of truth query key like other features
//
// IMPACT: Users don't see immediate feedback when adding knowledge items - items only
// appear after the server responds (usually 1-3 seconds later)
// Snapshot the previous values for rollback
const previousSummaries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summariesPrefix(),
});
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.active());
// Generate temporary progress ID and optimistic entity
const tempProgressId = createOptimisticId();
const optimisticItem = createOptimisticEntity<KnowledgeItem>({
title: (() => {
try {
return new URL(request.url).hostname || "New crawl";
} catch {
return "New crawl";
}
})(),
url: request.url,
source_id: tempProgressId,
source_type: "url",
knowledge_type: request.knowledge_type || "technical",
status: "processing",
document_count: 0,
code_examples_count: 0,
metadata: {
knowledge_type: request.knowledge_type || "technical",
tags: request.tags || [],
source_type: "url",
status: "processing",
description: `Crawling ${request.url}`,
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Omit<KnowledgeItem, "id">);
const tempItemId = optimisticItem.id;
// Update all summaries caches with optimistic data, respecting each cache's filter
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summariesPrefix(),
});
for (const [qk, old] of entries) {
const filter = qk[qk.length - 1] as KnowledgeItemsFilter | undefined;
const matchesType = !filter?.knowledge_type || optimisticItem.knowledge_type === filter.knowledge_type;
const matchesTags =
!filter?.tags || filter.tags.every((t) => (optimisticItem.metadata?.tags ?? []).includes(t));
if (!(matchesType && matchesTags)) continue;
if (!old) {
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
items: [optimisticItem],
total: 1,
page: 1,
per_page: 100,
});
} else {
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
...old,
items: [optimisticItem, ...old.items],
total: (old.total ?? old.items.length) + 1,
});
}
}
// Create optimistic progress operation
const optimisticOperation: ActiveOperation = {
operation_id: tempProgressId,
operation_type: "crawl",
status: "starting",
progress: 0,
message: `Initializing crawl for ${request.url}`,
started_at: new Date().toISOString(),
progressId: tempProgressId,
type: "crawl",
url: request.url,
source_id: tempProgressId,
};
// Add optimistic operation to active operations
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) {
return {
operations: [optimisticOperation],
count: 1,
timestamp: new Date().toISOString(),
};
}
return {
...old,
operations: [optimisticOperation, ...old.operations],
count: old.count + 1,
};
});
// Return context for rollback and replacement
return { previousSummaries, previousOperations, tempProgressId, tempItemId };
},
onSuccess: (response, _variables, context) => {
// Replace temporary IDs with real ones from the server
if (context) {
// Update summaries cache with real progress ID
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((item) => {
if (item.source_id === context.tempProgressId) {
return {
...item,
source_id: response.progressId,
};
}
return item;
}),
};
});
// Update progress operation with real progress ID
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) return old;
return {
...old,
operations: old.operations.map((op) => {
if (op.operation_id === context.tempProgressId) {
return {
...op,
operation_id: response.progressId,
progressId: response.progressId,
source_id: response.progressId,
message: response.message || op.message,
};
}
return op;
}),
};
});
}
// Invalidate to get fresh data
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
showToast(`Crawl started: ${response.message}`, "success");
// Return the response so caller can access progressId
return response;
},
onError: (error, _variables, context) => {
// Rollback optimistic updates on error
if (context?.previousSummaries) {
// Rollback all summary queries
for (const [queryKey, data] of context.previousSummaries) {
queryClient.setQueryData(queryKey, data);
}
}
if (context?.previousOperations) {
queryClient.setQueryData(progressKeys.active(), context.previousOperations);
}
const errorMessage = getProviderErrorMessage(error) || "Failed to start crawl";
showToast(errorMessage, "error");
},
});
}
/**
* Upload document mutation with optimistic updates
*/
export function useUploadDocument() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation<
{ progressId: string; message: string },
Error,
{ file: File; metadata: UploadMetadata },
{
previousSummaries?: Array<[readonly unknown[], KnowledgeItemsResponse | undefined]>;
previousOperations?: ActiveOperationsResponse;
tempProgressId: string;
tempItemId: string;
}
>({
mutationFn: ({ file, metadata }: { file: File; metadata: UploadMetadata }) =>
knowledgeService.uploadDocument(file, metadata),
onMutate: async ({ file, metadata }) => {
// Cancel any outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
await queryClient.cancelQueries({ queryKey: progressKeys.active() });
// Snapshot the previous values for rollback
const previousSummaries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summariesPrefix(),
});
const previousOperations = queryClient.getQueryData<ActiveOperationsResponse>(progressKeys.active());
const tempProgressId = createOptimisticId();
// Create optimistic knowledge item for the upload
const optimisticItem = createOptimisticEntity<KnowledgeItem>({
title: file.name,
url: `file://${file.name}`,
source_id: tempProgressId,
source_type: "file",
knowledge_type: metadata.knowledge_type || "technical",
status: "processing",
document_count: 0,
code_examples_count: 0,
metadata: {
knowledge_type: metadata.knowledge_type || "technical",
tags: metadata.tags || [],
source_type: "file",
status: "processing",
description: `Uploading ${file.name}`,
file_name: file.name,
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Omit<KnowledgeItem, "id">);
const tempItemId = optimisticItem.id;
// Respect each cache's filter (knowledge_type, tags, etc.)
const entries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summariesPrefix(),
});
for (const [qk, old] of entries) {
const filter = qk[qk.length - 1] as KnowledgeItemsFilter | undefined;
const matchesType = !filter?.knowledge_type || optimisticItem.knowledge_type === filter.knowledge_type;
const matchesTags =
!filter?.tags || filter.tags.every((t) => (optimisticItem.metadata?.tags ?? []).includes(t));
if (!(matchesType && matchesTags)) continue;
if (!old) {
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
items: [optimisticItem],
total: 1,
page: 1,
per_page: 100,
});
} else {
queryClient.setQueryData<KnowledgeItemsResponse>(qk, {
...old,
items: [optimisticItem, ...old.items],
total: (old.total ?? old.items.length) + 1,
});
}
}
// Create optimistic progress operation for upload
const optimisticOperation: ActiveOperation = {
operation_id: tempProgressId,
operation_type: "upload",
status: "starting",
progress: 0,
message: `Uploading ${file.name}`,
started_at: new Date().toISOString(),
progressId: tempProgressId,
type: "upload",
url: `file://${file.name}`,
source_id: tempProgressId,
};
// Add optimistic operation to active operations
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) {
return {
operations: [optimisticOperation],
count: 1,
timestamp: new Date().toISOString(),
};
}
return {
...old,
operations: [optimisticOperation, ...old.operations],
count: old.count + 1,
};
});
return { previousSummaries, previousOperations, tempProgressId, tempItemId };
},
onSuccess: (response, _variables, context) => {
// Replace temporary IDs with real ones from the server
if (context && response?.progressId) {
// Update summaries cache with real progress ID
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((item) => {
if (item.id === context.tempItemId) {
return {
...item,
source_id: response.progressId,
};
}
return item;
}),
};
});
// Update progress operation with real progress ID
queryClient.setQueryData<ActiveOperationsResponse>(progressKeys.active(), (old) => {
if (!old) return old;
return {
...old,
operations: old.operations.map((op) => {
if (op.operation_id === context.tempProgressId) {
return {
...op,
operation_id: response.progressId,
progressId: response.progressId,
source_id: response.progressId,
message: response.message || op.message,
};
}
return op;
}),
};
});
}
// Only invalidate progress to start tracking the new operation
// The lists/summaries will refresh automatically via polling when operations are active
queryClient.invalidateQueries({ queryKey: progressKeys.active() });
// Don't show success here - upload is just starting in background
// Success/failure will be shown via progress polling
},
onError: (error, _variables, context) => {
// Rollback optimistic updates on error
if (context?.previousSummaries) {
for (const [queryKey, data] of context.previousSummaries) {
queryClient.setQueryData(queryKey, data);
}
}
if (context?.previousOperations) {
queryClient.setQueryData(progressKeys.active(), context.previousOperations);
}
// Display the actual error message from backend
const message = error instanceof Error ? error.message : "Failed to upload document";
showToast(message, "error");
},
});
}
/**
* Stop crawl mutation
*/
export function useStopCrawl() {
const { showToast } = useToast();
return useMutation({
mutationFn: (progressId: string) => knowledgeService.stopCrawl(progressId),
onSuccess: (_data, progressId) => {
showToast(`Stop requested (${progressId}). Operation will end shortly.`, "info");
},
onError: (error, progressId) => {
// If it's a 404, the operation might have already completed or been cancelled
// See PRPs/local/frontend-state-management-refactor.md Phase 4: Configure Request Deduplication
const is404Error =
(error as any)?.statusCode === 404 ||
(error instanceof Error && (error.message.includes("404") || error.message.includes("not found")));
if (is404Error) {
// Don't show error for 404s - the operation is likely already gone
return;
}
const errorMessage = error instanceof Error ? error.message : "Unknown error";
showToast(`Failed to stop crawl (${progressId}): ${errorMessage}`, "error");
},
});
}
/**
* Delete knowledge item mutation
*/
export function useDeleteKnowledgeItem() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (sourceId: string) => knowledgeService.deleteKnowledgeItem(sourceId),
onMutate: async (sourceId) => {
// Cancel summary queries (all filters)
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
// Snapshot all summary caches (for all filters)
const summariesPrefix = knowledgeKeys.summariesPrefix();
const previousEntries = queryClient.getQueriesData<KnowledgeItemsResponse>({
queryKey: summariesPrefix,
});
// Optimistically remove the item from each cached summary
for (const [queryKey, data] of previousEntries) {
if (!data) continue;
const nextItems = data.items.filter((item) => item.source_id !== sourceId);
const removed = data.items.length - nextItems.length;
queryClient.setQueryData<KnowledgeItemsResponse>(queryKey, {
...data,
items: nextItems,
total: Math.max(0, (data.total ?? data.items.length) - removed),
});
}
return { previousEntries };
},
onError: (error, _sourceId, context) => {
// Roll back all summaries
for (const [queryKey, data] of context?.previousEntries ?? []) {
queryClient.setQueryData(queryKey, data);
}
const errorMessage = error instanceof Error ? error.message : "Failed to delete item";
showToast(errorMessage, "error");
},
onSuccess: (data) => {
showToast(data.message || "Item deleted successfully", "success");
// Invalidate summaries to reconcile with server
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
// Also invalidate detail views
queryClient.invalidateQueries({ queryKey: knowledgeKeys.all });
},
});
}
/**
* Update knowledge item mutation
*/
export function useUpdateKnowledgeItem() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: ({ sourceId, updates }: { sourceId: string; updates: Partial<KnowledgeItem> & { tags?: string[] } }) =>
knowledgeService.updateKnowledgeItem(sourceId, updates),
onMutate: async ({ sourceId, updates }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: knowledgeKeys.detail(sourceId) });
await queryClient.cancelQueries({ queryKey: knowledgeKeys.summariesPrefix() });
// Snapshot the previous values
const previousItem = queryClient.getQueryData<KnowledgeItem>(knowledgeKeys.detail(sourceId));
const previousSummaries = queryClient.getQueriesData({ queryKey: knowledgeKeys.summariesPrefix() });
// Optimistically update the detail item
if (previousItem) {
const updatedItem = { ...previousItem };
// Initialize metadata if missing
const currentMetadata = updatedItem.metadata || {};
// Handle title updates
if ("title" in updates && typeof updates.title === "string") {
updatedItem.title = updates.title;
}
// Handle tags updates - update in metadata only
if ("tags" in updates && Array.isArray(updates.tags)) {
const newTags = updates.tags as string[];
updatedItem.metadata = {
...currentMetadata,
tags: newTags,
};
}
// Handle knowledge_type updates
if ("knowledge_type" in updates && typeof updates.knowledge_type === "string") {
const newType = updates.knowledge_type as "technical" | "business";
updatedItem.knowledge_type = newType;
// Also update in metadata for consistency
updatedItem.metadata = {
...updatedItem.metadata,
knowledge_type: newType,
};
}
queryClient.setQueryData<KnowledgeItem>(knowledgeKeys.detail(sourceId), updatedItem);
}
// Optimistically update summaries cache
queryClient.setQueriesData<KnowledgeItemsResponse>({ queryKey: knowledgeKeys.summariesPrefix() }, (old) => {
if (!old?.items) return old;
return {
...old,
items: old.items.map((item) => {
if (item.source_id === sourceId) {
const updatedItem = { ...item };
// Initialize metadata if missing
const currentMetadata = updatedItem.metadata || {};
// Update title if provided
if ("title" in updates && typeof updates.title === "string") {
updatedItem.title = updates.title;
}
// Update tags if provided - update in metadata only
if ("tags" in updates && Array.isArray(updates.tags)) {
const newTags = updates.tags as string[];
updatedItem.metadata = {
...currentMetadata,
tags: newTags,
};
}
// Update knowledge_type if provided
if ("knowledge_type" in updates && typeof updates.knowledge_type === "string") {
const newType = updates.knowledge_type as "technical" | "business";
updatedItem.knowledge_type = newType;
// Also update in metadata for consistency
updatedItem.metadata = {
...updatedItem.metadata,
knowledge_type: newType,
};
}
return updatedItem;
}
return item;
}),
};
});
return { previousItem, previousSummaries };
},
onError: (error, variables, context) => {
// Rollback on error
if (context?.previousItem) {
queryClient.setQueryData(knowledgeKeys.detail(variables.sourceId), context.previousItem);
}
if (context?.previousSummaries) {
// Rollback all summary queries
for (const [queryKey, data] of context.previousSummaries) {
queryClient.setQueryData(queryKey, data);
}
}
const errorMessage = error instanceof Error ? error.message : "Failed to update item";
showToast(errorMessage, "error");
},
onSuccess: (_data, { sourceId }) => {
showToast("Item updated successfully", "success");
// Invalidate all related queries
queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(sourceId) });
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
},
});
}
/**
* Refresh knowledge item mutation
*/
export function useRefreshKnowledgeItem() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (sourceId: string) => knowledgeService.refreshKnowledgeItem(sourceId),
onSuccess: (data, sourceId) => {
showToast("Refresh started", "success");
// Remove the item from cache as it's being refreshed
queryClient.removeQueries({ queryKey: knowledgeKeys.detail(sourceId) });
// Invalidate summaries immediately - backend is consistent after refresh initiation
queryClient.invalidateQueries({ queryKey: knowledgeKeys.summariesPrefix() });
return data;
},
onError: (error) => {
const errorMessage = error instanceof Error ? error.message : "Failed to refresh item";
showToast(errorMessage, "error");
},
});
}
/**
* Knowledge Summaries Hook with Active Operations Tracking
* Fetches lightweight summaries and tracks active crawl operations
* Only polls when there are active operations that we started
*/
export function useKnowledgeSummaries(filter?: KnowledgeItemsFilter) {
// Track active crawl IDs locally - only set when we start a crawl/refresh
const [activeCrawlIds, setActiveCrawlIds] = useState<string[]>([]);
// ALWAYS poll for active operations to catch pre-existing ones
// This ensures we discover operations that were started before page load
const { data: activeOperationsData } = useActiveOperations(true);
// Check if we have any active operations (either tracked or discovered)
const hasActiveOperations = (activeOperationsData?.operations?.length || 0) > 0;
// Convert to the format expected by components
const activeOperations: ActiveOperation[] = useMemo(() => {
if (!activeOperationsData?.operations) return [];
// Include ALL active operations (not just tracked ones) to catch pre-existing operations
// This ensures operations started before page load are still shown
return activeOperationsData.operations.map((op) => ({
...op,
progressId: op.operation_id,
type: op.operation_type,
}));
}, [activeOperationsData]);
// Fetch summaries with smart polling when there are active operations
const { refetchInterval } = useSmartPolling(hasActiveOperations ? STALE_TIMES.frequent : STALE_TIMES.normal);
const summaryQuery = useQuery<KnowledgeItemsResponse>({
queryKey: knowledgeKeys.summaries(filter),
queryFn: () => knowledgeService.getKnowledgeSummaries(filter),
refetchInterval: hasActiveOperations ? refetchInterval : false, // Poll when ANY operations are active
refetchOnWindowFocus: true,
staleTime: STALE_TIMES.normal, // Consider data stale after 30 seconds
});
// When operations complete, remove them from tracking
// Trust smart polling to handle eventual consistency - no manual invalidation needed
// Active operations are already tracked and polling handles updates when operations complete
return {
...summaryQuery,
activeCrawlIds,
setActiveCrawlIds, // Export this so components can add IDs when starting operations
activeOperations,
};
}
/**
* Fetch document chunks with pagination
*/
export function useKnowledgeChunks(
sourceId: string | null,
options?: { limit?: number; offset?: number; enabled?: boolean },
) {
return useQuery({
queryKey: sourceId
? knowledgeKeys.chunks(sourceId, { limit: options?.limit, offset: options?.offset })
: DISABLED_QUERY_KEY,
queryFn: () =>
sourceId
? knowledgeService.getKnowledgeItemChunks(sourceId, {
limit: options?.limit,
offset: options?.offset,
})
: Promise.reject("No source ID"),
enabled: options?.enabled !== false && !!sourceId,
staleTime: STALE_TIMES.normal,
});
}
/**
* Fetch code examples with pagination
*/
export function useKnowledgeCodeExamples(
sourceId: string | null,
options?: { limit?: number; offset?: number; enabled?: boolean },
) {
return useQuery({
queryKey: sourceId
? knowledgeKeys.codeExamples(sourceId, { limit: options?.limit, offset: options?.offset })
: DISABLED_QUERY_KEY,
queryFn: () =>
sourceId
? knowledgeService.getCodeExamples(sourceId, {
limit: options?.limit,
offset: options?.offset,
})
: Promise.reject("No source ID"),
enabled: options?.enabled !== false && !!sourceId,
staleTime: STALE_TIMES.normal,
});
}

View File

@@ -0,0 +1,20 @@
/**
* Knowledge Feature Module
*
* Vertical slice containing all knowledge base functionality:
* - Knowledge item management (CRUD, search)
* - Crawling and URL processing
* - Document upload and processing
* - Document browsing and viewing
*/
// Components
export * from "./components";
// Hooks
export * from "./hooks";
// Services
export * from "./services";
// Types
export * from "./types";
// Views with error boundary
export { KnowledgeViewWithBoundary } from "./views/KnowledgeViewWithBoundary";

View File

@@ -0,0 +1,150 @@
/**
* Content Viewer Component
* Displays the selected document or code content
*/
import { Check, Code, Copy, FileText, Layers } from "lucide-react";
import { Button } from "../../../ui/primitives";
import type { InspectorSelectedItem } from "../../types";
interface ContentViewerProps {
selectedItem: InspectorSelectedItem | null;
onCopy: (text: string, id: string) => void;
copiedId: string | null;
}
export const ContentViewer: React.FC<ContentViewerProps> = ({ selectedItem, onCopy, copiedId }) => {
if (!selectedItem) {
return (
<div className="flex-1 flex items-center justify-center text-gray-500">
<div className="text-center">
<Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Select an item to view</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Content Header - Fixed with proper overflow handling */}
<div className="p-4 border-b border-white/10 flex items-center gap-3 flex-shrink-0">
{/* Icon and Metadata - Allow to grow and shrink with min-w-0 for proper truncation */}
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Icon - Fixed size, no shrink */}
<div className="flex-shrink-0">
{selectedItem.type === "document" ? (
<FileText className="w-5 h-5 text-cyan-400" />
) : (
<Code className="w-5 h-5 text-green-400" />
)}
</div>
{/* Metadata Content - Can shrink with proper overflow */}
<div className="min-w-0 flex-1">
{selectedItem.type === "document" ? (
<>
<h4 className="text-sm font-medium text-white/90 truncate">
{selectedItem.metadata && "title" in selectedItem.metadata
? selectedItem.metadata.title || "Document"
: "Document"}
</h4>
{selectedItem.metadata && "section" in selectedItem.metadata && selectedItem.metadata.section && (
<p className="text-xs text-gray-500 truncate">{selectedItem.metadata.section}</p>
)}
</>
) : (
<>
<div className="flex items-center gap-2 min-w-0">
<span className="px-2 py-0.5 bg-green-500/10 text-green-400 text-xs font-mono rounded flex-shrink-0">
{selectedItem.type === "code" && selectedItem.metadata && "language" in selectedItem.metadata
? selectedItem.metadata.language || "unknown"
: "unknown"}
</span>
{selectedItem.type === "code" &&
selectedItem.metadata &&
"file_path" in selectedItem.metadata &&
selectedItem.metadata.file_path && (
<span className="text-xs text-gray-500 font-mono truncate min-w-0">
{selectedItem.metadata.file_path}
</span>
)}
</div>
{selectedItem.type === "code" &&
selectedItem.metadata &&
"summary" in selectedItem.metadata &&
selectedItem.metadata.summary && (
<p className="text-xs text-gray-400 mt-1 line-clamp-2">{selectedItem.metadata.summary}</p>
)}
</>
)}
</div>
</div>
{/* Copy Button - Never shrinks, always visible */}
<Button
size="sm"
variant="ghost"
onClick={() => onCopy(selectedItem.content, selectedItem.id)}
className="text-gray-400 hover:text-white flex-shrink-0"
>
{copiedId === selectedItem.id ? (
<>
<Check className="w-4 h-4 text-green-400 mr-1.5" />
<span className="text-xs">Copied!</span>
</>
) : (
<>
<Copy className="w-4 h-4 mr-1.5" />
<span className="text-xs">Copy</span>
</>
)}
</Button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto min-h-0 p-6 scrollbar-thin">
{selectedItem.type === "document" ? (
<div className="prose prose-invert max-w-none">
<pre className="whitespace-pre-wrap text-sm text-gray-300 font-sans leading-relaxed">
{selectedItem.content || "No content available"}
</pre>
</div>
) : (
<div className="relative">
<pre className="bg-black/30 border border-white/10 rounded-lg p-4 overflow-x-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
<code className="text-sm text-gray-300 font-mono">
{selectedItem.content || "// No code content available"}
</code>
</pre>
</div>
)}
</div>
{/* Content Footer - Show metadata */}
<div className="border-t border-white/10 flex-shrink-0">
<div className="px-4 py-3 flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-4">
{selectedItem.metadata?.relevance_score != null && (
<span>
Relevance:{" "}
<span className="text-cyan-400">{(selectedItem.metadata.relevance_score * 100).toFixed(0)}%</span>
</span>
)}
{selectedItem.type === "document" && "url" in selectedItem.metadata && selectedItem.metadata.url && (
<a
href={selectedItem.metadata.url}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 transition-colors underline"
>
View Source
</a>
)}
</div>
<span className="text-gray-600">{selectedItem.type === "document" ? "Document Chunk" : "Code Example"}</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,137 @@
/**
* Inspector Header Component
* Displays item metadata and badges
*/
import { formatDistanceToNow } from "date-fns";
import { Briefcase, Calendar, File, Globe, Terminal } from "lucide-react";
import { cn } from "../../../ui/primitives/styles";
import type { KnowledgeItem } from "../../types";
interface InspectorHeaderProps {
item: KnowledgeItem;
viewMode: "documents" | "code";
onViewModeChange: (mode: "documents" | "code") => void;
documentCount: number;
codeCount: number;
filteredDocumentCount: number;
filteredCodeCount: number;
}
export const InspectorHeader: React.FC<InspectorHeaderProps> = ({
item,
viewMode,
onViewModeChange,
documentCount,
codeCount,
filteredDocumentCount,
filteredCodeCount,
}) => {
return (
<div className="px-6 py-4 border-b border-white/10">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h2 className="text-xl font-semibold text-white mb-2">{item.title}</h2>
<div className="flex flex-wrap items-center gap-3">
{/* Source Type Badge */}
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
item.source_type === "url"
? "bg-blue-500/10 text-blue-400 border border-blue-500/20"
: "bg-purple-500/10 text-purple-400 border border-purple-500/20",
)}
>
{item.source_type === "url" ? (
<>
<Globe className="w-3.5 h-3.5" />
Web
</>
) : (
<>
<File className="w-3.5 h-3.5" />
File
</>
)}
</span>
{/* Knowledge Type Badge */}
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
item.knowledge_type === "technical"
? "bg-green-500/10 text-green-400 border border-green-500/20"
: "bg-orange-500/10 text-orange-400 border border-orange-500/20",
)}
>
{item.knowledge_type === "technical" ? (
<>
<Terminal className="w-3.5 h-3.5" />
Technical
</>
) : (
<>
<Briefcase className="w-3.5 h-3.5" />
Business
</>
)}
</span>
{/* URL */}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 truncate max-w-xs"
>
{item.url}
</a>
)}
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => onViewModeChange("documents")}
className={cn(
"pb-2 px-1 text-sm font-medium border-b-2 transition-colors",
viewMode === "documents"
? "text-cyan-400 border-cyan-400"
: "text-gray-500 border-transparent hover:text-gray-300",
)}
>
Documents ({documentCount})
</button>
<button
type="button"
onClick={() => onViewModeChange("code")}
className={cn(
"pb-2 px-1 text-sm font-medium border-b-2 transition-colors",
viewMode === "code"
? "text-cyan-400 border-cyan-400"
: "text-gray-500 border-transparent hover:text-gray-300",
)}
>
Code Examples ({codeCount})
</button>
<div className="flex-1" />
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>
Showing{" "}
{viewMode === "documents"
? `${filteredDocumentCount} of ${documentCount}`
: `${filteredCodeCount} of ${codeCount}`}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,182 @@
/**
* Inspector Sidebar Component
* Displays list of documents or code examples with search
*/
import { motion } from "framer-motion";
import { Code, FileText, Hash, Loader2, Search } from "lucide-react";
import { Button, Input } from "../../../ui/primitives";
import { cn } from "../../../ui/primitives/styles";
import type { CodeExample, DocumentChunk } from "../../types";
interface InspectorSidebarProps {
viewMode: "documents" | "code";
searchQuery: string;
onSearchChange: (query: string) => void;
items: DocumentChunk[] | CodeExample[];
selectedItemId: string | null;
onItemSelect: (item: DocumentChunk | CodeExample) => void;
isLoading: boolean;
hasNextPage: boolean;
onLoadMore: () => void;
isFetchingNextPage: boolean;
}
export const InspectorSidebar: React.FC<InspectorSidebarProps> = ({
viewMode,
searchQuery,
onSearchChange,
items,
selectedItemId,
onItemSelect,
isLoading,
hasNextPage,
onLoadMore,
isFetchingNextPage,
}) => {
const getItemTitle = (item: DocumentChunk | CodeExample) => {
const idSuffix = String(item.id).slice(-6);
if (viewMode === "documents") {
const doc = item as DocumentChunk;
// Use top-level title (from filename/headers), fallback to metadata, then generic
return doc.title || doc.metadata?.title || doc.metadata?.section || `Document ${idSuffix}`;
}
const code = item as CodeExample;
// Use AI-generated title first, fallback to filename, then summary, then generic
return (
code.title || code.example_name || code.file_path?.split("/").pop() || code.summary || `Code Example ${idSuffix}`
);
};
const getItemDescription = (item: DocumentChunk | CodeExample) => {
if (viewMode === "documents") {
const doc = item as DocumentChunk;
// Use formatted section, fallback to metadata section, then content preview
const preview = doc.content ? `${doc.content.substring(0, 100)}...` : "No preview available";
return doc.section || doc.metadata?.section || preview;
}
const code = item as CodeExample;
// Summary is most descriptive, then language
return code.summary || (code.language ? `${code.language} code snippet` : "Code snippet");
};
return (
<aside className="w-80 border-r border-white/10 flex flex-col bg-black/40" aria-label="Document and code browser">
{/* Search */}
<div className="p-4 border-b border-white/10 flex-shrink-0">
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none"
aria-hidden="true"
/>
<Input
placeholder={`Search ${viewMode}...`}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 bg-black/30"
aria-label={`Search ${viewMode}`}
/>
</div>
</div>
{/* Item List */}
<div className="flex-1 overflow-y-auto min-h-0 scrollbar-thin">
{isLoading ? (
<div className="p-4 text-center text-gray-500" aria-live="polite">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" aria-hidden="true" />
<span>Loading {viewMode}...</span>
</div>
) : items.length === 0 ? (
<div className="p-4 text-center text-gray-500">
No {viewMode} found
{searchQuery && <p className="text-xs mt-1">Try adjusting your search</p>}
</div>
) : (
<div className="p-2">
{items.map((item) => (
<motion.button
type="button"
key={item.id}
whileHover={{ x: 2 }}
whileTap={{ scale: 0.98 }}
onClick={() => onItemSelect(item)}
className={cn(
"w-full text-left p-3 rounded-lg mb-1 transition-all",
"hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-cyan-500/50",
selectedItemId === item.id
? "bg-cyan-500/10 border border-cyan-500/30 ring-1 ring-cyan-500/20"
: "border border-transparent",
)}
role="option"
aria-selected={selectedItemId === item.id}
aria-label={`${getItemTitle(item)}. ${getItemDescription(item)}`}
>
<div className="flex items-start gap-3">
{/* Icon - Fixed size */}
<div className="mt-0.5 flex-shrink-0" aria-hidden="true">
{viewMode === "documents" ? (
<FileText className="w-4 h-4 text-cyan-400" />
) : (
<Code className="w-4 h-4 text-green-400" />
)}
</div>
{/* Content - Can shrink with proper overflow */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 min-w-0">
<span className="text-sm font-medium text-white/90 truncate flex-1" title={getItemTitle(item)}>
{getItemTitle(item)}
</span>
{viewMode === "code" && (item as CodeExample).language && (
<span className="px-1.5 py-0.5 bg-green-500/10 text-green-400 text-xs rounded flex-shrink-0">
{(item as CodeExample).language}
</span>
)}
</div>
<p className="text-xs text-gray-500 line-clamp-2" title={getItemDescription(item)}>
{getItemDescription(item)}
</p>
{item.metadata?.relevance_score != null && (
<div className="flex items-center gap-1 mt-1">
<Hash className="w-3 h-3 text-gray-600" aria-hidden="true" />
<span className="text-xs text-gray-600">
{(item.metadata.relevance_score * 100).toFixed(0)}%
</span>
</div>
)}
</div>
</div>
</motion.button>
))}
{/* Load More Button */}
{hasNextPage && !isLoading && (
<div className="p-3 mt-2">
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
disabled={isFetchingNextPage}
className="w-full text-cyan-400 hover:text-white hover:bg-cyan-500/10 transition-all"
aria-label={`Load more ${viewMode}`}
>
{isFetchingNextPage ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
<span>Loading...</span>
</>
) : (
<>
<span>Load More {viewMode}</span>
<span className="sr-only">. Press to load additional items.</span>
</>
)}
</Button>
</div>
)}
</div>
)}
</div>
</aside>
);
};

View File

@@ -0,0 +1,188 @@
/**
* Knowledge Inspector Modal
* Orchestrates split-view design with sidebar navigation and content viewer
*/
import { useCallback, useEffect, useState } from "react";
import { InspectorDialog, InspectorDialogContent, InspectorDialogTitle } from "../../../ui/primitives";
import type { CodeExample, DocumentChunk, InspectorSelectedItem, KnowledgeItem } from "../../types";
import { useInspectorPagination } from "../hooks/useInspectorPagination";
import { ContentViewer } from "./ContentViewer";
import { InspectorHeader } from "./InspectorHeader";
import { InspectorSidebar } from "./InspectorSidebar";
import { copyToClipboard } from "../../../shared/utils/clipboard";
interface KnowledgeInspectorProps {
item: KnowledgeItem;
open: boolean;
onOpenChange: (open: boolean) => void;
initialTab?: "documents" | "code";
}
type ViewMode = "documents" | "code";
export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({
item,
open,
onOpenChange,
initialTab = "documents",
}) => {
const [viewMode, setViewMode] = useState<ViewMode>(initialTab);
const [searchQuery, setSearchQuery] = useState("");
const [selectedItem, setSelectedItem] = useState<InspectorSelectedItem | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
// Reset view mode when item or initialTab changes
useEffect(() => {
setViewMode(initialTab);
setSelectedItem(null); // Clear selected item when switching tabs
}, [item.source_id, initialTab]);
// Use pagination hook for current view mode
const paginationData = useInspectorPagination({
sourceId: item.source_id,
viewMode,
searchQuery,
});
// Get current items based on view mode
const currentItems = paginationData.items;
const isLoading = paginationData.isLoading;
const hasNextPage = paginationData.hasNextPage;
const fetchNextPage = paginationData.fetchNextPage;
const isFetchingNextPage = paginationData.isFetchingNextPage;
// Use metadata counts like KnowledgeCard does - don't rely on loaded data length
const totalDocumentCount = item.document_count ?? item.metadata?.document_count ?? 0;
const totalCodeCount = item.code_examples_count ?? item.metadata?.code_examples_count ?? 0;
// Auto-select first item when data loads
useEffect(() => {
if (selectedItem || currentItems.length === 0) return;
const firstItem = currentItems[0];
if (viewMode === "documents") {
const firstDoc = firstItem as DocumentChunk;
setSelectedItem({
type: "document",
id: firstDoc.id,
content: firstDoc.content || "",
metadata: {
title: firstDoc.title || firstDoc.metadata?.title,
section: firstDoc.section || firstDoc.metadata?.section,
relevance_score: firstDoc.metadata?.relevance_score,
url: firstDoc.url || firstDoc.metadata?.url,
tags: firstDoc.metadata?.tags,
},
});
} else {
const firstCode = firstItem as CodeExample;
setSelectedItem({
type: "code",
id: String(firstCode.id || ""),
content: firstCode.content || firstCode.code || "",
metadata: {
language: firstCode.language,
file_path: firstCode.file_path,
summary: firstCode.summary,
relevance_score: firstCode.metadata?.relevance_score,
title: firstCode.title || firstCode.example_name,
},
});
}
}, [viewMode, currentItems, selectedItem]);
const handleCopy = useCallback(async (text: string, id: string) => {
const result = await copyToClipboard(text);
if (result.success) {
setCopiedId(id);
setTimeout(() => setCopiedId((v) => (v === id ? null : v)), 2000);
} else {
console.error("Failed to copy to clipboard:", result.error);
}
}, []);
const handleItemSelect = useCallback(
(item: DocumentChunk | CodeExample) => {
if (viewMode === "documents") {
const doc = item as DocumentChunk;
setSelectedItem({
type: "document",
id: doc.id || "",
content: doc.content || "",
metadata: {
title: doc.title || doc.metadata?.title,
section: doc.section || doc.metadata?.section,
relevance_score: doc.metadata?.relevance_score,
url: doc.url || doc.metadata?.url,
tags: doc.metadata?.tags,
},
});
} else {
const code = item as CodeExample;
setSelectedItem({
type: "code",
id: String(code.id),
content: code.content || code.code || "",
metadata: {
language: code.language,
file_path: code.file_path,
summary: code.summary,
relevance_score: code.metadata?.relevance_score,
title: code.title || code.example_name,
},
});
}
},
[viewMode],
);
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
setSelectedItem(null);
setSearchQuery("");
}, []);
return (
<InspectorDialog open={open} onOpenChange={onOpenChange}>
<InspectorDialogContent>
<InspectorDialogTitle>Knowledge Inspector - {item.title}</InspectorDialogTitle>
{/* Header - Fixed */}
<div className="flex-shrink-0">
<InspectorHeader
item={item}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
documentCount={totalDocumentCount}
codeCount={totalCodeCount}
filteredDocumentCount={viewMode === "documents" ? currentItems.length : 0}
filteredCodeCount={viewMode === "code" ? currentItems.length : 0}
/>
</div>
{/* Main Content Area - Scrollable */}
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<InspectorSidebar
viewMode={viewMode}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
items={currentItems as DocumentChunk[] | CodeExample[]}
selectedItemId={selectedItem?.id || null}
onItemSelect={handleItemSelect}
isLoading={isLoading}
hasNextPage={hasNextPage}
onLoadMore={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
{/* Content Viewer */}
<div className="flex-1 min-h-0 bg-black/20 flex flex-col">
<ContentViewer selectedItem={selectedItem} onCopy={handleCopy} copiedId={copiedId} />
</div>
</div>
</InspectorDialogContent>
</InspectorDialog>
);
};

View File

@@ -0,0 +1,4 @@
export * from "./ContentViewer";
export * from "./InspectorHeader";
export * from "./InspectorSidebar";
export * from "./KnowledgeInspector";

View File

@@ -0,0 +1 @@
export * from "./useInspectorData";

View File

@@ -0,0 +1,77 @@
/**
* Inspector Data Hook
* Encapsulates data fetching and filtering logic for the inspector
*/
import { useMemo } from "react";
import { useKnowledgeChunks, useKnowledgeCodeExamples } from "../../hooks";
import type { CodeExample, DocumentChunk } from "../../types";
export interface UseInspectorDataProps {
sourceId: string;
searchQuery: string;
}
export interface UseInspectorDataResult {
documents: {
data: DocumentChunk[];
filtered: DocumentChunk[];
isLoading: boolean;
};
codeExamples: {
data: CodeExample[];
filtered: CodeExample[];
isLoading: boolean;
};
}
export function useInspectorData({ sourceId, searchQuery }: UseInspectorDataProps): UseInspectorDataResult {
// Fetch documents and code examples with pagination (load first batch for initial display)
const { data: documentsResponse, isLoading: docsLoading } = useKnowledgeChunks(sourceId, { limit: 100 });
const { data: codeResponse, isLoading: codeLoading } = useKnowledgeCodeExamples(sourceId, { limit: 100 });
const documentChunks = documentsResponse?.chunks || [];
const codeList = codeResponse?.code_examples || [];
// Filter documents based on search
const filteredDocuments = useMemo(() => {
if (!searchQuery) return documentChunks;
const query = searchQuery.toLowerCase();
return documentChunks.filter(
(doc) =>
doc.content?.toLowerCase().includes(query) ||
doc.title?.toLowerCase().includes(query) ||
doc.metadata?.title?.toLowerCase().includes(query) ||
doc.metadata?.section?.toLowerCase().includes(query),
);
}, [documentChunks, searchQuery]);
// Filter code examples based on search
const filteredCode = useMemo(() => {
if (!searchQuery) return codeList;
const query = searchQuery.toLowerCase();
return codeList.filter(
(code) =>
code.content?.toLowerCase().includes(query) ||
code.summary?.toLowerCase().includes(query) ||
code.language?.toLowerCase().includes(query) ||
code.file_path?.toLowerCase().includes(query) ||
code.title?.toLowerCase().includes(query),
);
}, [codeList, searchQuery]);
return {
documents: {
data: documentChunks,
filtered: filteredDocuments,
isLoading: docsLoading,
},
codeExamples: {
data: codeList,
filtered: filteredCode,
isLoading: codeLoading,
},
};
}

View File

@@ -0,0 +1,121 @@
/**
* Inspector Pagination Hook
* Handles pagination for the Knowledge Inspector with "Load More" functionality
*/
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { STALE_TIMES } from "@/features/shared/queryPatterns";
import { knowledgeKeys } from "../../hooks/useKnowledgeQueries";
import { knowledgeService } from "../../services";
import type { ChunksResponse, CodeExample, CodeExamplesResponse, DocumentChunk } from "../../types";
export interface UseInspectorPaginationProps {
sourceId: string;
viewMode: "documents" | "code";
searchQuery: string;
}
export interface UseInspectorPaginationResult {
items: (DocumentChunk | CodeExample)[];
isLoading: boolean;
hasNextPage: boolean;
fetchNextPage: (options?: any) => Promise<any>;
isFetchingNextPage: boolean;
totalCount: number;
loadedCount: number;
}
export function useInspectorPagination({
sourceId,
viewMode,
searchQuery,
}: UseInspectorPaginationProps): UseInspectorPaginationResult {
const PAGE_SIZE = 100;
// Use infinite query for the current view mode
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<
ChunksResponse | CodeExamplesResponse,
Error
>({
queryKey: [
...knowledgeKeys.detail(sourceId),
viewMode === "documents" ? "chunks-infinite" : "code-examples-infinite",
],
queryFn: ({ pageParam }: { pageParam: unknown }) => {
const page = Number(pageParam) || 0;
const service =
viewMode === "documents" ? knowledgeService.getKnowledgeItemChunks : knowledgeService.getCodeExamples;
return service(sourceId, {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
});
},
getNextPageParam: (lastPage, allPages) => {
const hasMore = (lastPage as ChunksResponse | CodeExamplesResponse)?.has_more;
return hasMore ? allPages.length : undefined;
},
enabled: !!sourceId,
staleTime: STALE_TIMES.normal,
initialPageParam: 0,
});
// Flatten the paginated data and apply search filtering
const { items, totalCount, loadedCount } = useMemo(() => {
type Page = ChunksResponse | CodeExamplesResponse;
if (!data || !data.pages) {
return { items: [], totalCount: 0, loadedCount: 0 };
}
// Flatten all pages - data has 'pages' property from useInfiniteQuery
const pages = data.pages as Page[];
const allItems = pages.flatMap((page): (DocumentChunk | CodeExample)[] =>
"chunks" in page ? (page.chunks ?? []) : "code_examples" in page ? (page.code_examples ?? []) : [],
);
// Get total from first page (fallback to loadedCount)
const first = pages[0];
const totalCount = first && "total" in first && typeof first.total === "number" ? first.total : allItems.length;
const loadedCount = allItems.length;
// Apply search filtering
if (!searchQuery) {
return { items: allItems, totalCount, loadedCount };
}
const query = searchQuery.toLowerCase();
const filteredItems = allItems.filter((item: DocumentChunk | CodeExample) => {
if (viewMode === "documents") {
const doc = item as DocumentChunk;
return (
doc.content?.toLowerCase().includes(query) ||
doc.title?.toLowerCase().includes(query) ||
doc.metadata?.title?.toLowerCase().includes(query) ||
doc.metadata?.section?.toLowerCase().includes(query)
);
} else {
const code = item as CodeExample;
return (
code.content?.toLowerCase().includes(query) ||
code.summary?.toLowerCase().includes(query) ||
code.language?.toLowerCase().includes(query) ||
code.file_path?.toLowerCase().includes(query) ||
code.title?.toLowerCase().includes(query)
);
}
});
return { items: filteredItems, totalCount, loadedCount };
}, [data, viewMode, searchQuery]);
return {
items,
isLoading,
hasNextPage: !!hasNextPage,
fetchNextPage,
isFetchingNextPage,
totalCount,
loadedCount,
};
}

View File

@@ -0,0 +1,178 @@
/**
* Paginated Inspector Data Hook
* Implements progressive loading for documents and code examples
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useKnowledgeChunks, useKnowledgeCodeExamples } from "../../hooks/useKnowledgeQueries";
import type { CodeExample, DocumentChunk } from "../../types";
const PAGE_SIZE = 20;
export interface UsePaginatedInspectorDataProps {
sourceId: string;
searchQuery: string;
enabled?: boolean;
}
export interface PaginatedData<T> {
items: T[];
isLoading: boolean;
hasMore: boolean;
total: number;
loadMore: () => void;
reset: () => void;
}
export interface UsePaginatedInspectorDataResult {
documents: PaginatedData<DocumentChunk>;
codeExamples: PaginatedData<CodeExample>;
}
export function usePaginatedInspectorData({
sourceId,
searchQuery,
enabled = true,
}: UsePaginatedInspectorDataProps): UsePaginatedInspectorDataResult {
// Pagination state for documents
const [docsOffset, setDocsOffset] = useState(0);
const [allDocs, setAllDocs] = useState<DocumentChunk[]>([]);
// Pagination state for code examples
const [codeOffset, setCodeOffset] = useState(0);
const [allCode, setAllCode] = useState<CodeExample[]>([]);
// Fetch documents with pagination
const {
data: docsResponse,
isLoading: docsLoading,
isFetching: docsFetching,
} = useKnowledgeChunks(sourceId, {
limit: PAGE_SIZE,
offset: docsOffset,
enabled,
});
// Fetch code examples with pagination
const {
data: codeResponse,
isLoading: codeLoading,
isFetching: codeFetching,
} = useKnowledgeCodeExamples(sourceId, {
limit: PAGE_SIZE,
offset: codeOffset,
enabled,
});
// Update accumulated documents when new data arrives
useEffect(() => {
if (!docsResponse?.chunks) return;
if (docsOffset === 0) {
// First page - replace all
setAllDocs(docsResponse.chunks);
} else {
// Append new chunks, deduplicating by id
setAllDocs((prev) => {
const existingIds = new Set(prev.map((d) => d.id));
const newChunks = docsResponse.chunks.filter((chunk) => !existingIds.has(chunk.id));
return [...prev, ...newChunks];
});
}
}, [docsResponse, docsOffset]);
// Update accumulated code examples when new data arrives
useEffect(() => {
if (!codeResponse?.code_examples) return;
if (codeOffset === 0) {
// First page - replace all
setAllCode(codeResponse.code_examples);
} else {
// Append new examples, deduplicating by id
setAllCode((prev) => {
const existingIds = new Set(prev.map((c) => c.id));
const newExamples = codeResponse.code_examples.filter((example) => !existingIds.has(example.id));
return [...prev, ...newExamples];
});
}
}, [codeResponse, codeOffset]);
// Filter documents based on search
const filteredDocuments = useMemo(() => {
if (!searchQuery) return allDocs;
const query = searchQuery.toLowerCase();
return allDocs.filter(
(doc) =>
doc.content?.toLowerCase().includes(query) ||
doc.metadata?.title?.toLowerCase().includes(query) ||
doc.metadata?.section?.toLowerCase().includes(query) ||
doc.url?.toLowerCase().includes(query),
);
}, [allDocs, searchQuery]);
// Filter code examples based on search
const filteredCode = useMemo(() => {
if (!searchQuery) return allCode;
const query = searchQuery.toLowerCase();
return allCode.filter(
(code) =>
code.content?.toLowerCase().includes(query) ||
code.summary?.toLowerCase().includes(query) ||
code.metadata?.language?.toLowerCase().includes(query),
);
}, [allCode, searchQuery]);
// Load more documents
const loadMoreDocs = useCallback(() => {
if (docsResponse?.has_more && !docsFetching) {
setDocsOffset((prev) => prev + PAGE_SIZE);
}
}, [docsResponse?.has_more, docsFetching]);
// Load more code examples
const loadMoreCode = useCallback(() => {
if (codeResponse?.has_more && !codeFetching) {
setCodeOffset((prev) => prev + PAGE_SIZE);
}
}, [codeResponse?.has_more, codeFetching]);
// Reset documents pagination
const resetDocs = useCallback(() => {
setDocsOffset(0);
setAllDocs([]);
}, []);
// Reset code pagination
const resetCode = useCallback(() => {
setCodeOffset(0);
setAllCode([]);
}, []);
// Reset when source changes or becomes enabled
useEffect(() => {
resetDocs();
resetCode();
}, [sourceId, enabled, resetDocs, resetCode]);
return {
documents: {
items: filteredDocuments,
isLoading: docsLoading,
hasMore: docsResponse?.has_more || false,
total: docsResponse?.total || 0,
loadMore: loadMoreDocs,
reset: resetDocs,
},
codeExamples: {
items: filteredCode,
isLoading: codeLoading,
hasMore: codeResponse?.has_more || false,
total: codeResponse?.total || 0,
loadMore: loadMoreCode,
reset: resetCode,
},
};
}

View File

@@ -0,0 +1 @@
export * from "./components";

View File

@@ -0,0 +1 @@
export * from "./knowledgeService";

View File

@@ -0,0 +1,220 @@
/**
* Knowledge Base Service
* Handles all knowledge-related API operations using TanStack Query patterns
*/
import { callAPIWithETag } from "../../shared/apiWithEtag";
import { APIServiceError } from "../../shared/errors";
import type {
ChunksResponse,
CodeExamplesResponse,
CrawlRequest,
CrawlStartResponse,
KnowledgeItem,
KnowledgeItemsFilter,
KnowledgeItemsResponse,
KnowledgeSource,
RefreshResponse,
SearchOptions,
SearchResultsResponse,
UploadMetadata,
} from "../types";
export const knowledgeService = {
/**
* Get lightweight summaries of knowledge items
* Use this for card displays and frequent updates
*/
async getKnowledgeSummaries(filter?: KnowledgeItemsFilter): Promise<KnowledgeItemsResponse> {
const params = new URLSearchParams();
if (filter?.page) params.append("page", filter.page.toString());
if (filter?.per_page) params.append("per_page", filter.per_page.toString());
if (filter?.knowledge_type) params.append("knowledge_type", filter.knowledge_type);
if (filter?.search) params.append("search", filter.search);
if (filter?.tags?.length) {
for (const tag of filter.tags) {
params.append("tags", tag);
}
}
const queryString = params.toString();
const endpoint = `/api/knowledge-items/summary${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<KnowledgeItemsResponse>(endpoint);
},
/**
* Get a specific knowledge item
*/
async getKnowledgeItem(sourceId: string): Promise<KnowledgeItem> {
return callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`);
},
/**
* Delete a knowledge item
*/
async deleteKnowledgeItem(sourceId: string): Promise<{ success: boolean; message: string }> {
const response = await callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/${sourceId}`, {
method: "DELETE",
});
return response;
},
/**
* Update a knowledge item
*/
async updateKnowledgeItem(
sourceId: string,
updates: Partial<KnowledgeItem> & { tags?: string[] },
): Promise<KnowledgeItem> {
const response = await callAPIWithETag<KnowledgeItem>(`/api/knowledge-items/${sourceId}`, {
method: "PUT",
body: JSON.stringify(updates),
});
return response;
},
/**
* Start crawling a URL
*/
async crawlUrl(request: CrawlRequest): Promise<CrawlStartResponse> {
const response = await callAPIWithETag<CrawlStartResponse>("/api/knowledge-items/crawl", {
method: "POST",
body: JSON.stringify(request),
});
return response;
},
/**
* Refresh an existing knowledge item
*/
async refreshKnowledgeItem(sourceId: string): Promise<RefreshResponse> {
const response = await callAPIWithETag<RefreshResponse>(`/api/knowledge-items/${sourceId}/refresh`, {
method: "POST",
});
return response;
},
/**
* Upload a document
*/
async uploadDocument(
file: File,
metadata: UploadMetadata,
): Promise<{ success: boolean; progressId: string; message: string; filename: string }> {
const formData = new FormData();
formData.append("file", file);
if (metadata.knowledge_type) {
formData.append("knowledge_type", metadata.knowledge_type);
}
if (metadata.tags?.length) {
formData.append("tags", JSON.stringify(metadata.tags));
}
// Use fetch directly for file upload (FormData doesn't work well with our ETag wrapper)
// In test environment, we need absolute URLs
let uploadUrl = "/api/documents/upload";
if (typeof process !== "undefined" && process.env?.NODE_ENV === "test") {
const testHost = process.env?.VITE_HOST || "localhost";
const testPort = process.env?.ARCHON_SERVER_PORT || "8181";
uploadUrl = `http://${testHost}:${testPort}${uploadUrl}`;
}
const response = await fetch(uploadUrl, {
method: "POST",
body: formData,
signal: AbortSignal.timeout(30000), // 30 second timeout for file uploads
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new APIServiceError(err.error || `HTTP ${response.status}`, "HTTP_ERROR", response.status);
}
return response.json();
},
/**
* Stop a running crawl
*/
async stopCrawl(progressId: string): Promise<{ success: boolean; message: string }> {
return callAPIWithETag<{ success: boolean; message: string }>(`/api/knowledge-items/stop/${progressId}`, {
method: "POST",
});
},
/**
* Get document chunks for a knowledge item with pagination
*/
async getKnowledgeItemChunks(
sourceId: string,
options?: {
domainFilter?: string;
limit?: number;
offset?: number;
},
): Promise<ChunksResponse> {
const params = new URLSearchParams();
if (options?.domainFilter) {
params.append("domain_filter", options.domainFilter);
}
if (options?.limit !== undefined) {
params.append("limit", options.limit.toString());
}
if (options?.offset !== undefined) {
params.append("offset", options.offset.toString());
}
const queryString = params.toString();
const endpoint = `/api/knowledge-items/${sourceId}/chunks${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<ChunksResponse>(endpoint);
},
/**
* Get code examples for a knowledge item with pagination
*/
async getCodeExamples(
sourceId: string,
options?: {
limit?: number;
offset?: number;
},
): Promise<CodeExamplesResponse> {
const params = new URLSearchParams();
if (options?.limit !== undefined) {
params.append("limit", options.limit.toString());
}
if (options?.offset !== undefined) {
params.append("offset", options.offset.toString());
}
const queryString = params.toString();
const endpoint = `/api/knowledge-items/${sourceId}/code-examples${queryString ? `?${queryString}` : ""}`;
return callAPIWithETag<CodeExamplesResponse>(endpoint);
},
/**
* Search the knowledge base
*/
async searchKnowledgeBase(options: SearchOptions): Promise<SearchResultsResponse> {
return callAPIWithETag<SearchResultsResponse>("/api/knowledge-items/search", {
method: "POST",
body: JSON.stringify(options),
});
},
/**
* Get available knowledge sources
*/
async getKnowledgeSources(): Promise<KnowledgeSource[]> {
return callAPIWithETag<KnowledgeSource[]>("/api/knowledge-items/sources");
},
};

View File

@@ -0,0 +1 @@
export * from "./knowledge";

View File

@@ -0,0 +1,200 @@
/**
* Knowledge Base Types
* Matches backend models from knowledge_api.py
*/
export interface KnowledgeItemMetadata {
knowledge_type?: "technical" | "business";
tags?: string[];
source_type?: "url" | "file" | "group";
status?: "active" | "processing" | "error";
description?: string;
last_scraped?: string;
chunks_count?: number;
word_count?: number;
file_name?: string;
file_type?: string;
page_count?: number;
update_frequency?: number;
next_update?: string;
group_name?: string;
original_url?: string;
document_count?: number; // Number of documents in this knowledge item
code_examples_count?: number; // Number of code examples found
}
export interface KnowledgeItem {
id: string;
title: string;
url: string;
source_id: string;
source_type: "url" | "file";
knowledge_type: "technical" | "business";
status: "active" | "processing" | "error" | "completed";
document_count: number;
code_examples_count: number;
metadata: KnowledgeItemMetadata;
created_at: string;
updated_at: string;
}
export interface CodeExampleMetadata {
language?: string;
file_path?: string;
summary?: string;
relevance_score?: number;
// No additional flexible properties - use strict typing
}
export interface CodeExample {
id: number;
source_id: string;
content: string; // The actual code content (primary field from backend)
code?: string; // Alternative field name for backward compatibility
summary?: string;
// Fields extracted from metadata by backend API
title?: string; // AI-generated descriptive name (e.g. "Prepare Multiple Tool Definitions")
example_name?: string; // Same as title, kept for backend compatibility
language?: string; // Programming language
file_path?: string; // Path to the original file
// Original metadata field (for backward compatibility)
metadata?: CodeExampleMetadata;
}
export interface DocumentChunkMetadata {
title?: string;
section?: string;
relevance_score?: number;
url?: string;
tags?: string[];
// No additional flexible properties - use strict typing
}
export interface DocumentChunk {
id: string;
source_id: string;
content: string;
url?: string;
// Fields extracted from metadata by backend API
title?: string; // filename or first header
section?: string; // formatted headers for display
source_type?: string;
knowledge_type?: string;
// Original metadata field (for backward compatibility)
metadata?: DocumentChunkMetadata;
}
export interface GroupedKnowledgeItem {
id: string;
title: string;
domain: string;
items: KnowledgeItem[];
metadata: KnowledgeItemMetadata;
created_at: string;
updated_at: string;
}
// API Response types
export interface KnowledgeItemsResponse {
items: KnowledgeItem[];
total: number;
page: number;
per_page: number;
}
export interface ChunksResponse {
success: boolean;
source_id: string;
domain_filter?: string | null;
chunks: DocumentChunk[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface CodeExamplesResponse {
success: boolean;
source_id: string;
code_examples: CodeExample[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
// Request types
export interface KnowledgeItemsFilter {
knowledge_type?: "technical" | "business";
tags?: string[];
source_type?: "url" | "file";
search?: string;
page?: number;
per_page?: number;
}
export interface CrawlRequest {
url: string;
knowledge_type?: "technical" | "business";
tags?: string[];
update_frequency?: number;
max_depth?: number;
extract_code_examples?: boolean;
}
export interface UploadMetadata {
knowledge_type?: "technical" | "business";
tags?: string[];
}
export interface SearchOptions {
query: string;
knowledge_type?: "technical" | "business";
sources?: string[];
limit?: number;
}
// UI-specific types
export type KnowledgeViewMode = "grid" | "table";
// Inspector specific types
export interface InspectorSelectedItem {
type: "document" | "code";
id: string;
content: string;
metadata?: DocumentChunkMetadata | CodeExampleMetadata;
}
// Response from crawl/upload start
export interface CrawlStartResponse {
success: boolean;
progressId: string;
message: string;
estimatedDuration?: string;
}
export interface RefreshResponse {
progressId: string;
message: string;
}
// Search response types
export interface SearchResultsResponse {
results: DocumentChunk[];
total: number;
query: string;
knowledge_type?: "technical" | "business";
}
// Knowledge sources response
export interface KnowledgeSource {
id: string;
name: string;
domain?: string;
source_type: "url" | "file";
knowledge_type: "technical" | "business";
status: "active" | "processing" | "error";
document_count: number;
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,2 @@
export * from "./knowledge-utils";
export * from "./providerErrorHandler";

View File

@@ -0,0 +1,129 @@
/**
* Knowledge Base Utility Functions
*/
import type { KnowledgeItem, KnowledgeItemMetadata } from "../types";
/**
* Group knowledge items by their group_name metadata
*/
export function groupKnowledgeItems(items: KnowledgeItem[]) {
const grouped = new Map<string, KnowledgeItem[]>();
const ungrouped: KnowledgeItem[] = [];
items.forEach((item) => {
const groupName = item.metadata?.group_name;
if (groupName) {
const existing = grouped.get(groupName) || [];
existing.push(item);
grouped.set(groupName, existing);
} else {
ungrouped.push(item);
}
});
return {
grouped: Array.from(grouped.entries()).map(([name, items]) => ({
name,
items,
count: items.length,
})),
ungrouped,
};
}
/**
* Get display type for a knowledge item
*/
export function getKnowledgeItemType(item: KnowledgeItem): string {
if (item.metadata?.source_type === "file") {
return item.metadata.file_type || "document";
}
if (item.metadata?.source_type === "group") {
return "group";
}
return item.metadata?.knowledge_type || "general";
}
/**
* Format file size for display
*/
export function formatFileSize(bytes?: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Get status color for knowledge item
*/
export function getStatusColor(status?: KnowledgeItemMetadata["status"]) {
switch (status) {
case "active":
return "green";
case "processing":
return "blue";
case "error":
return "red";
default:
return "gray";
}
}
/**
* Check if a knowledge item needs refresh based on update frequency
*/
export function needsRefresh(item: KnowledgeItem): boolean {
const updateFrequency = item.metadata?.update_frequency;
if (!updateFrequency) return false;
const lastScraped = item.metadata?.last_scraped;
if (!lastScraped) return true;
const lastScrapedDate = new Date(lastScraped);
const time = lastScrapedDate.getTime();
// If date is invalid, force a refresh
if (Number.isNaN(time)) return true;
const daysSinceLastScrape = (Date.now() - time) / (1000 * 60 * 60 * 24);
return daysSinceLastScrape >= updateFrequency;
}
/**
* Extract domain from URL
*/
export function extractDomain(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace("www.", "");
} catch {
return url;
}
}
/**
* Get icon for file type
*/
export function getFileTypeIcon(fileType?: string): string {
if (!fileType) return "📄";
const lowerType = fileType.toLowerCase();
if (lowerType.includes("pdf")) return "📕";
if (lowerType.includes("doc")) return "📘";
if (lowerType.includes("txt")) return "📝";
if (lowerType.includes("md")) return "📋";
if (lowerType.includes("code") || lowerType.includes("json")) return "💻";
return "📄";
}

View File

@@ -0,0 +1,71 @@
/**
* Provider-agnostic error handler for LLM operations
* Supports OpenAI, Google AI, Anthropic, and other providers
*/
export interface ProviderError extends Error {
statusCode?: number;
provider?: string;
errorType?: string;
isProviderError?: boolean;
}
/**
* Parse backend error responses into provider-aware error objects
*/
export function parseProviderError(error: unknown): ProviderError {
const providerError = error as ProviderError;
// Check if this is a structured provider error from backend
if (error && typeof error === "object") {
if (error.statusCode || error.status) {
providerError.statusCode = error.statusCode || error.status;
}
// Parse backend error structure
if (error.message && error.message.includes("detail")) {
try {
const parsed = JSON.parse(error.message);
if (parsed.detail && parsed.detail.error_type) {
providerError.isProviderError = true;
providerError.provider = parsed.detail.provider || "LLM";
providerError.errorType = parsed.detail.error_type;
providerError.message = parsed.detail.message || error.message;
}
} catch {
// If parsing fails, use message as-is
}
}
}
return providerError;
}
/**
* Get user-friendly error message for any LLM provider
*/
export function getProviderErrorMessage(error: unknown): string {
const parsed = parseProviderError(error);
if (parsed.isProviderError) {
const provider = parsed.provider || "LLM";
switch (parsed.errorType) {
case "authentication_failed":
return `Please verify your ${provider} API key in Settings.`;
case "quota_exhausted":
return `${provider} quota exhausted. Please check your billing settings.`;
case "rate_limit":
return `${provider} rate limit exceeded. Please wait and try again.`;
default:
return `${provider} API error. Please check your configuration.`;
}
}
// Handle status codes for non-structured errors
if (parsed.statusCode === 401) {
return "Please verify your API key in Settings.";
}
return parsed.message || "An error occurred.";
}

View File

@@ -0,0 +1,197 @@
/**
* Main Knowledge Base View Component
* Orchestrates the knowledge base UI using vertical slice architecture
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { CrawlingProgress } from "../../progress/components/CrawlingProgress";
import type { ActiveOperation } from "../../progress/types";
import { useToast } from "../../ui/hooks/useToast";
import { AddKnowledgeDialog } from "../components/AddKnowledgeDialog";
import { KnowledgeHeader } from "../components/KnowledgeHeader";
import { KnowledgeList } from "../components/KnowledgeList";
import { useKnowledgeSummaries } from "../hooks/useKnowledgeQueries";
import { KnowledgeInspector } from "../inspector/components/KnowledgeInspector";
import type { KnowledgeItem, KnowledgeItemsFilter } from "../types";
export const KnowledgeView = () => {
// View state
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<"all" | "technical" | "business">("all");
// Dialog state
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [inspectorItem, setInspectorItem] = useState<KnowledgeItem | null>(null);
const [inspectorInitialTab, setInspectorInitialTab] = useState<"documents" | "code">("documents");
// Build filter object for API - memoize to prevent recreating on every render
const filter = useMemo<KnowledgeItemsFilter>(() => {
const f: KnowledgeItemsFilter = {
page: 1,
per_page: 100,
};
if (searchQuery) {
f.search = searchQuery;
}
if (typeFilter !== "all") {
f.knowledge_type = typeFilter;
}
return f;
}, [searchQuery, typeFilter]);
// Fetch knowledge summaries (no automatic polling!)
const { data, isLoading, error, refetch, setActiveCrawlIds, activeOperations } = useKnowledgeSummaries(filter);
const knowledgeItems = data?.items || [];
const totalItems = data?.total || 0;
const hasActiveOperations = activeOperations.length > 0;
// Toast notifications
const { showToast } = useToast();
const previousOperations = useRef<ActiveOperation[]>([]);
// Track crawl completions and errors for toast notifications
useEffect(() => {
// Find operations that just completed or failed
const finishedOps = previousOperations.current.filter((prevOp) => {
const currentOp = activeOperations.find((op) => op.operation_id === prevOp.operation_id);
// Operation disappeared from active list - check its final status
return (
!currentOp &&
["crawling", "processing", "storing", "document_storage", "completed", "error", "failed"].includes(
prevOp.status,
)
);
});
// Show toast for each finished operation
finishedOps.forEach((op) => {
// Check if it was an error or success
if (op.status === "error" || op.status === "failed") {
// Show error message with details
const errorMessage = op.message || op.error || "Operation failed";
showToast(`${errorMessage}`, "error", 7000);
} else if (op.status === "completed") {
// Show success message
const message = op.message || "Operation completed";
showToast(`${message}`, "success", 5000);
}
// Remove from active crawl IDs
setActiveCrawlIds((prev) => prev.filter((id) => id !== op.operation_id));
// Refetch summaries after any completion
refetch();
});
// Update previous operations
previousOperations.current = [...activeOperations];
}, [activeOperations, showToast, refetch, setActiveCrawlIds]);
const handleAddKnowledge = () => {
setIsAddDialogOpen(true);
};
const handleViewDocument = (sourceId: string) => {
// Find the item and open inspector to documents tab
const item = knowledgeItems.find((k) => k.source_id === sourceId);
if (item) {
setInspectorInitialTab("documents");
setInspectorItem(item);
}
};
const handleViewCodeExamples = (sourceId: string) => {
// Open the inspector to code examples tab
const item = knowledgeItems.find((k) => k.source_id === sourceId);
if (item) {
setInspectorInitialTab("code");
setInspectorItem(item);
}
};
const handleDeleteSuccess = () => {
// TanStack Query will automatically refetch
};
return (
<div className="h-full flex flex-col">
{/* Header */}
<KnowledgeHeader
totalItems={totalItems}
isLoading={isLoading}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
viewMode={viewMode}
onViewModeChange={setViewMode}
onAddKnowledge={handleAddKnowledge}
/>
{/* Main Content */}
<div className="flex-1 overflow-auto px-6 pb-6">
{/* Active Operations - Show at top when present */}
{hasActiveOperations && (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white/90">Active Operations ({activeOperations.length})</h3>
<div className="flex items-center gap-2 text-sm text-gray-400">
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-pulse" />
Live Updates
</div>
</div>
<CrawlingProgress onSwitchToBrowse={() => {}} />
</div>
)}
{/* Knowledge Items List */}
<KnowledgeList
items={knowledgeItems}
viewMode={viewMode}
isLoading={isLoading}
error={error}
onRetry={refetch}
onViewDocument={handleViewDocument}
onViewCodeExamples={handleViewCodeExamples}
onDeleteSuccess={handleDeleteSuccess}
activeOperations={activeOperations}
onRefreshStarted={(progressId) => {
// Add the progress ID to track it
setActiveCrawlIds((prev) => [...prev, progressId]);
}}
/>
</div>
{/* Dialogs */}
<AddKnowledgeDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
onSuccess={() => {
setIsAddDialogOpen(false);
refetch();
}}
onCrawlStarted={(progressId) => {
// Add the progress ID to track it
setActiveCrawlIds((prev) => [...prev, progressId]);
}}
/>
{/* Knowledge Inspector Modal */}
{inspectorItem && (
<KnowledgeInspector
item={inspectorItem}
open={!!inspectorItem}
onOpenChange={(open) => {
if (!open) setInspectorItem(null);
}}
initialTab={inspectorInitialTab}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { FeatureErrorBoundary } from "../../ui/components";
import { KnowledgeView } from "./KnowledgeView";
export const KnowledgeViewWithBoundary = () => {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<FeatureErrorBoundary featureName="Knowledge Base" onReset={reset}>
<KnowledgeView />
</FeatureErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};

View File

@@ -0,0 +1 @@
export * from "./KnowledgeView";

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { cn, glassmorphism, compoundStyles } from '../../ui/primitives';
import { Monitor, Clock, Activity } from 'lucide-react';
import { motion } from 'framer-motion';
import type { McpClient } from '../types';
import { motion } from "framer-motion";
import { Activity, Clock, Monitor } from "lucide-react";
import type React from "react";
import { cn, compoundStyles, glassmorphism } from "../../ui/primitives";
import type { McpClient } from "../types";
interface McpClientListProps {
clients: McpClient[];
@@ -10,20 +10,17 @@ interface McpClientListProps {
}
const clientIcons: Record<string, string> = {
'Claude': '🤖',
'Cursor': '💻',
'Windsurf': '🏄',
'Cline': '🔧',
'KiRo': '🚀',
'Augment': '⚡',
'Gemini': '🌐',
'Unknown': '❓'
Claude: "🤖",
Cursor: "💻",
Windsurf: "🏄",
Cline: "🔧",
KiRo: "🚀",
Augment: "⚡",
Gemini: "🌐",
Unknown: "❓",
};
export const McpClientList: React.FC<McpClientListProps> = ({
clients,
className
}) => {
export const McpClientList: React.FC<McpClientListProps> = ({ clients, className }) => {
const formatDuration = (connectedAt: string): string => {
const now = new Date();
const connected = new Date(connectedAt);
@@ -39,10 +36,10 @@ export const McpClientList: React.FC<McpClientListProps> = ({
const activity = new Date(lastActivity);
const seconds = Math.floor((now.getTime() - activity.getTime()) / 1000);
if (seconds < 5) return 'Active';
if (seconds < 5) return "Active";
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
return 'Idle';
return "Idle";
};
if (clients.length === 0) {
@@ -72,13 +69,11 @@ export const McpClientList: React.FC<McpClientListProps> = ({
"flex items-center justify-between p-4 rounded-lg",
glassmorphism.background.card,
glassmorphism.border.default,
client.status === 'active'
? "border-green-500/50 shadow-[0_0_15px_rgba(34,197,94,0.2)]"
: ""
client.status === "active" ? "border-green-500/50 shadow-[0_0_15px_rgba(34,197,94,0.2)]" : "",
)}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{clientIcons[client.client_type] || ''}</span>
<span className="text-2xl">{clientIcons[client.client_type] || "❓"}</span>
<div>
<p className="font-medium text-white">{client.client_type}</p>
<p className="text-xs text-zinc-400">Session: {client.session_id.slice(0, 8)}</p>
@@ -93,10 +88,7 @@ export const McpClientList: React.FC<McpClientListProps> = ({
<div className="flex items-center gap-1">
<Activity className="w-3 h-3 text-green-400" />
<span className={cn(
"text-zinc-400",
client.status === 'active' && "text-green-400"
)}>
<span className={cn("text-zinc-400", client.status === "active" && "text-green-400")}>
{formatLastActivity(client.last_activity)}
</span>
</div>
@@ -105,4 +97,4 @@ export const McpClientList: React.FC<McpClientListProps> = ({
))}
</div>
);
};
};

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useToast } from "../../ui/hooks";
import { Button, cn, glassmorphism, Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
import type { McpServerConfig, McpServerStatus, SupportedIDE } from "../types";
import { copyToClipboard } from "../../shared/utils/clipboard";
interface McpConfigSectionProps {
config?: McpServerConfig;
@@ -185,10 +186,16 @@ export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, stat
);
}
const handleCopyConfig = () => {
const handleCopyConfig = async () => {
const configText = ideConfigurations[selectedIDE].configGenerator(config);
navigator.clipboard.writeText(configText);
showToast("Configuration copied to clipboard", "success");
const result = await copyToClipboard(configText);
if (result.success) {
showToast("Configuration copied to clipboard", "success");
} else {
console.error("Failed to copy config:", result.error);
showToast("Failed to copy configuration", "error");
}
};
const handleCursorOneClick = () => {
@@ -202,10 +209,16 @@ export const McpConfigSection: React.FC<McpConfigSectionProps> = ({ config, stat
showToast("Opening Cursor with Archon MCP configuration...", "info");
};
const handleClaudeCodeCommand = () => {
const handleClaudeCodeCommand = async () => {
const command = `claude mcp add --transport http archon http://${config.host}:${config.port}/mcp`;
navigator.clipboard.writeText(command);
showToast("Command copied to clipboard", "success");
const result = await copyToClipboard(command);
if (result.success) {
showToast("Command copied to clipboard", "success");
} else {
console.error("Failed to copy command:", result.error);
showToast("Failed to copy command", "error");
}
};
const selectedConfig = ideConfigurations[selectedIDE];

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { cn, glassmorphism } from '../../ui/primitives';
import { CheckCircle, AlertCircle, Clock, Server, Users } from 'lucide-react';
import type { McpServerStatus, McpSessionInfo, McpServerConfig } from '../types';
import { AlertCircle, CheckCircle, Clock, Server, Users } from "lucide-react";
import type React from "react";
import { cn, glassmorphism } from "../../ui/primitives";
import type { McpServerConfig, McpServerStatus, McpSessionInfo } from "../types";
interface McpStatusBarProps {
status: McpServerStatus;
@@ -10,12 +10,7 @@ interface McpStatusBarProps {
className?: string;
}
export const McpStatusBar: React.FC<McpStatusBarProps> = ({
status,
sessionInfo,
config,
className
}) => {
export const McpStatusBar: React.FC<McpStatusBarProps> = ({ status, sessionInfo, config, className }) => {
const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -29,33 +24,33 @@ export const McpStatusBar: React.FC<McpStatusBarProps> = ({
};
const getStatusIcon = () => {
if (status.status === 'running') {
if (status.status === "running") {
return <CheckCircle className="w-4 h-4 text-green-500" />;
}
return <AlertCircle className="w-4 h-4 text-red-500" />;
};
const getStatusColor = () => {
if (status.status === 'running') {
return 'text-green-500 shadow-[0_0_10px_rgba(34,197,94,0.5)]';
if (status.status === "running") {
return "text-green-500 shadow-[0_0_10px_rgba(34,197,94,0.5)]";
}
return 'text-red-500';
return "text-red-500";
};
return (
<div className={cn(
"flex items-center gap-6 px-4 py-2 rounded-lg",
glassmorphism.background.subtle,
glassmorphism.border.default,
"font-mono text-sm",
className
)}>
<div
className={cn(
"flex items-center gap-6 px-4 py-2 rounded-lg",
glassmorphism.background.subtle,
glassmorphism.border.default,
"font-mono text-sm",
className,
)}
>
{/* Status Indicator */}
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className={cn("font-semibold", getStatusColor())}>
{status.status.toUpperCase()}
</span>
<span className={cn("font-semibold", getStatusColor())}>{status.status.toUpperCase()}</span>
</div>
{/* Separator */}
@@ -97,11 +92,13 @@ export const McpStatusBar: React.FC<McpStatusBarProps> = ({
<div className="flex items-center gap-2">
<span className="text-zinc-400">TRANSPORT</span>
<span className="text-cyan-400">
{config?.transport === 'streamable-http' ? 'HTTP' :
config?.transport === 'sse' ? 'SSE' :
config?.transport || 'HTTP'}
{config?.transport === "streamable-http"
? "HTTP"
: config?.transport === "sse"
? "SSE"
: config?.transport || "HTTP"}
</span>
</div>
</div>
);
};
};

View File

@@ -1,3 +1,3 @@
export * from "./McpStatusBar";
export * from "./McpClientList";
export * from "./McpConfigSection";
export * from "./McpConfigSection";
export * from "./McpStatusBar";

View File

@@ -1 +1 @@
export * from "./useMcpQueries";
export * from "./useMcpQueries";

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { STALE_TIMES } from "../../shared/queryPatterns";
import { useSmartPolling } from "../../ui/hooks";
import { mcpApi } from "../services";
@@ -9,6 +10,7 @@ export const mcpKeys = {
config: () => [...mcpKeys.all, "config"] as const,
sessions: () => [...mcpKeys.all, "sessions"] as const,
clients: () => [...mcpKeys.all, "clients"] as const,
health: () => [...mcpKeys.all, "health"] as const,
};
export function useMcpStatus() {
@@ -19,7 +21,7 @@ export function useMcpStatus() {
queryFn: () => mcpApi.getStatus(),
refetchInterval,
refetchOnWindowFocus: false,
staleTime: 3000,
staleTime: STALE_TIMES.frequent,
throwOnError: true,
});
}
@@ -28,7 +30,7 @@ export function useMcpConfig() {
return useQuery({
queryKey: mcpKeys.config(),
queryFn: () => mcpApi.getConfig(),
staleTime: Infinity, // Config rarely changes
staleTime: STALE_TIMES.static, // Config rarely changes
throwOnError: true,
});
}
@@ -41,7 +43,7 @@ export function useMcpClients() {
queryFn: () => mcpApi.getClients(),
refetchInterval,
refetchOnWindowFocus: false,
staleTime: 8000,
staleTime: STALE_TIMES.frequent,
throwOnError: true,
});
}
@@ -54,7 +56,7 @@ export function useMcpSessionInfo() {
queryFn: () => mcpApi.getSessionInfo(),
refetchInterval,
refetchOnWindowFocus: false,
staleTime: 8000,
staleTime: STALE_TIMES.frequent,
throwOnError: true,
});
}
}

View File

@@ -3,4 +3,4 @@ export * from "./hooks";
export * from "./services";
export * from "./types";
export { McpView } from "./views/McpView";
export { McpViewWithBoundary } from "./views/McpViewWithBoundary";
export { McpViewWithBoundary } from "./views/McpViewWithBoundary";

View File

@@ -1 +1 @@
export * from "./mcpApi";
export * from "./mcpApi";

View File

@@ -1,16 +1,10 @@
import { callAPIWithETag } from "../../projects/shared/apiWithEtag";
import type {
McpServerStatus,
McpServerConfig,
McpSessionInfo,
McpClient
} from "../types";
import { callAPIWithETag } from "../../shared/apiWithEtag";
import type { McpClient, McpServerConfig, McpServerStatus, McpSessionInfo } from "../types";
export const mcpApi = {
async getStatus(): Promise<McpServerStatus> {
try {
const response =
await callAPIWithETag<McpServerStatus>("/api/mcp/status");
const response = await callAPIWithETag<McpServerStatus>("/api/mcp/status");
return response;
} catch (error) {
console.error("Failed to get MCP status:", error);
@@ -20,8 +14,7 @@ export const mcpApi = {
async getConfig(): Promise<McpServerConfig> {
try {
const response =
await callAPIWithETag<McpServerConfig>("/api/mcp/config");
const response = await callAPIWithETag<McpServerConfig>("/api/mcp/config");
return response;
} catch (error) {
console.error("Failed to get MCP config:", error);
@@ -31,8 +24,7 @@ export const mcpApi = {
async getSessionInfo(): Promise<McpSessionInfo> {
try {
const response =
await callAPIWithETag<McpSessionInfo>("/api/mcp/sessions");
const response = await callAPIWithETag<McpSessionInfo>("/api/mcp/sessions");
return response;
} catch (error) {
console.error("Failed to get session info:", error);
@@ -42,13 +34,11 @@ export const mcpApi = {
async getClients(): Promise<McpClient[]> {
try {
const response = await callAPIWithETag<{ clients: McpClient[] }>(
"/api/mcp/clients",
);
const response = await callAPIWithETag<{ clients: McpClient[] }>("/api/mcp/clients");
return response.clients || [];
} catch (error) {
console.error("Failed to get MCP clients:", error);
throw error;
}
},
};
};

View File

@@ -1 +1 @@
export * from "./mcp";
export * from "./mcp";

View File

@@ -14,15 +14,7 @@ export interface McpServerConfig {
export interface McpClient {
session_id: string;
client_type:
| "Claude"
| "Cursor"
| "Windsurf"
| "Cline"
| "KiRo"
| "Augment"
| "Gemini"
| "Unknown";
client_type: "Claude" | "Cursor" | "Windsurf" | "Cline" | "KiRo" | "Augment" | "Gemini" | "Unknown";
connected_at: string;
last_activity: string;
status: "active" | "idle";
@@ -36,14 +28,7 @@ export interface McpSessionInfo {
}
// we actually support all ides and mcp clients
export type SupportedIDE =
| "windsurf"
| "cursor"
| "claudecode"
| "cline"
| "kiro"
| "augment"
| "gemini";
export type SupportedIDE = "windsurf" | "cursor" | "claudecode" | "cline" | "kiro" | "augment" | "gemini";
export interface IdeConfiguration {
ide: SupportedIDE;
@@ -51,4 +36,4 @@ export interface IdeConfiguration {
steps: string[];
config: string;
supportsOneClick?: boolean;
}
}

View File

@@ -12,4 +12,4 @@ export const McpViewWithBoundary = () => {
)}
</QueryErrorResetBoundary>
);
};
};

Some files were not shown because too many files have changed in this diff Show More