mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
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:
@@ -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
4
.gitignore
vendored
@@ -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
408
AGENTS.md
@@ -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 fix‑forward 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
272
CLAUDE.md
@@ -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 fix‑forward 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; type‑safe end‑to‑end 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
192
PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md
Normal file
192
PRPs/ai_docs/DATA_FETCHING_ARCHITECTURE.md
Normal 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 query‑centric design that handles caching, deduplication, and smart refetching (including visibility‑aware 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
237
PRPs/ai_docs/QUERY_PATTERNS.md
Normal file
237
PRPs/ai_docs/QUERY_PATTERNS.md
Normal 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
|
||||
@@ -1,148 +1,135 @@
|
||||
# Optimistic Updates Pattern (Future State)
|
||||
# Optimistic Updates Pattern Guide
|
||||
|
||||
**⚠️ STATUS:** This is not currently implemented. There is a proof‑of‑concept (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:
|
||||
- Drag‑and‑drop
|
||||
- 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 server‑assigned IDs, ensure idempotency, and define clear rollback/error states. Prefer non‑optimistic 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)*
|
||||
@@ -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
|
||||
|
||||
31
archon-ui-main/package-lock.json
generated
31
archon-ui-main/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
archon-ui-main/public/img/Grok.png
Normal file
BIN
archon-ui-main/public/img/Grok.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
archon-ui-main/public/img/Ollama.png
Normal file
BIN
archon-ui-main/public/img/Ollama.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
archon-ui-main/public/img/OpenAI.png
Normal file
BIN
archon-ui-main/public/img/OpenAI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 354 KiB |
BIN
archon-ui-main/public/img/OpenRouter.png
Normal file
BIN
archon-ui-main/public/img/OpenRouter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
3
archon-ui-main/public/img/anthropic-logo.svg
Normal file
3
archon-ui-main/public/img/anthropic-logo.svg
Normal 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 |
6
archon-ui-main/public/img/google-logo.svg
Normal file
6
archon-ui-main/public/img/google-logo.svg
Normal 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 |
@@ -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' && (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
1141
archon-ui-main/src/components/settings/OllamaModelSelectionModal.tsx
Normal file
1141
archon-ui-main/src/components/settings/OllamaModelSelectionModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
184
archon-ui-main/src/components/settings/types/OllamaTypes.ts
Normal file
184
archon-ui-main/src/components/settings/types/OllamaTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
140
archon-ui-main/src/features/knowledge/components/TagInput.tsx
Normal file
140
archon-ui-main/src/features/knowledge/components/TagInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
1
archon-ui-main/src/features/knowledge/hooks/index.ts
Normal file
1
archon-ui-main/src/features/knowledge/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useKnowledgeQueries";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
20
archon-ui-main/src/features/knowledge/index.ts
Normal file
20
archon-ui-main/src/features/knowledge/index.ts
Normal 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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./ContentViewer";
|
||||
export * from "./InspectorHeader";
|
||||
export * from "./InspectorSidebar";
|
||||
export * from "./KnowledgeInspector";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useInspectorData";
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
1
archon-ui-main/src/features/knowledge/inspector/index.ts
Normal file
1
archon-ui-main/src/features/knowledge/inspector/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./components";
|
||||
1
archon-ui-main/src/features/knowledge/services/index.ts
Normal file
1
archon-ui-main/src/features/knowledge/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./knowledgeService";
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
1
archon-ui-main/src/features/knowledge/types/index.ts
Normal file
1
archon-ui-main/src/features/knowledge/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./knowledge";
|
||||
200
archon-ui-main/src/features/knowledge/types/knowledge.ts
Normal file
200
archon-ui-main/src/features/knowledge/types/knowledge.ts
Normal 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;
|
||||
}
|
||||
2
archon-ui-main/src/features/knowledge/utils/index.ts
Normal file
2
archon-ui-main/src/features/knowledge/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./knowledge-utils";
|
||||
export * from "./providerErrorHandler";
|
||||
129
archon-ui-main/src/features/knowledge/utils/knowledge-utils.ts
Normal file
129
archon-ui-main/src/features/knowledge/utils/knowledge-utils.ts
Normal 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 "📄";
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
197
archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx
Normal file
197
archon-ui-main/src/features/knowledge/views/KnowledgeView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
1
archon-ui-main/src/features/knowledge/views/index.ts
Normal file
1
archon-ui-main/src/features/knowledge/views/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./KnowledgeView";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./McpStatusBar";
|
||||
export * from "./McpClientList";
|
||||
export * from "./McpConfigSection";
|
||||
export * from "./McpConfigSection";
|
||||
export * from "./McpStatusBar";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./useMcpQueries";
|
||||
export * from "./useMcpQueries";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./mcpApi";
|
||||
export * from "./mcpApi";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./mcp";
|
||||
export * from "./mcp";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,4 @@ export const McpViewWithBoundary = () => {
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user