Compare commits
7 Commits
48a04969bc
...
1152abd4a5
Author | SHA1 | Date | |
---|---|---|---|
1152abd4a5 | |||
fc403142c2 | |||
![]() |
3111f56571 | ||
![]() |
a07d1c7d39 | ||
![]() |
f75be32123 | ||
![]() |
5e201cc033 | ||
![]() |
b72331288a |
@ -11,6 +11,13 @@ JWT_SECRET=sdfj94mfm430f72m3487rdsjiy7834n9rnf934n8r3n490fn4u83fh894hr9nf0
|
||||
# SERVER_BASEPATH_API=v1/
|
||||
# TIMEZONE=Europe/Amsterdam
|
||||
|
||||
|
||||
# Default Admin User
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_EMAIL=darren@fusero.nl
|
||||
DEFAULT_ADMIN_PASSWORD=admin123
|
||||
|
||||
|
||||
FASTIFY_PORT=14000
|
||||
|
||||
# [ Database ]
|
||||
|
3
.gitignore
vendored
@ -3,8 +3,7 @@ node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
503
README.md
@ -1,346 +1,211 @@
|
||||
# Fusero Boilerplate App
|
||||
# Fusero App Boilerplate
|
||||
|
||||
This is a Fastify + MikroORM + PostgreSQL boilerplate, designed to run in Docker for local development.
|
||||
It is set up to run alongside other projects without port or database conflicts.
|
||||
A full-stack application boilerplate with React frontend and Node.js backend.
|
||||
|
||||
---
|
||||
## Project Structure
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v18+ recommended)
|
||||
- [Docker](https://www.docker.com/get-started)
|
||||
- [npm](https://www.npmjs.com/)
|
||||
|
||||
---
|
||||
|
||||
## 2. Clone the Repo
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd fusero-app-boilerplate
|
||||
```
|
||||
fusero-app-boilerplate/
|
||||
├── frontend/ # React frontend application
|
||||
├── backend/ # Node.js backend application
|
||||
├── docker-compose.yml # Production Docker configuration
|
||||
└── docker-compose.dev.yml # Development Docker configuration
|
||||
```
|
||||
|
||||
---
|
||||
## Prerequisites
|
||||
|
||||
## 3. Setup the `.env` File
|
||||
- Node.js (v18 or higher)
|
||||
- npm (v9 or higher)
|
||||
- Docker and Docker Compose
|
||||
- Git
|
||||
|
||||
Copy `.env.example` to `.env` (or create `.env` if not present):
|
||||
## Development Setup
|
||||
|
||||
```env
|
||||
# Database connection for Docker
|
||||
POSTGRES_NAME=fusero-boilerplate-db
|
||||
POSTGRES_HOSTNAME=localhost
|
||||
POSTGRES_PORT=19095
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root123
|
||||
### Important Note: Database Must Run in Docker
|
||||
The PostgreSQL database must always run in Docker, regardless of your development setup choice. This ensures consistent database behavior across all environments.
|
||||
|
||||
# Test Database connection
|
||||
POSTGRES_TEST_NAME=test-db
|
||||
POSTGRES_TEST_PORT=19096
|
||||
|
||||
# Default admin user for seeding
|
||||
DEFAULT_ADMIN_USERNAME=darren
|
||||
DEFAULT_ADMIN_EMAIL=darren@fusero.nl
|
||||
DEFAULT_ADMIN_PASSWORD=admin123
|
||||
|
||||
# JWT secret
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
To start the database:
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up db
|
||||
```
|
||||
|
||||
---
|
||||
### Option 1: Running Everything in Docker (Recommended for Development)
|
||||
|
||||
## 4. Start the Database (Docker)
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
This will start two Postgres instances:
|
||||
- Main database on port 19095
|
||||
- Test database on port 19096
|
||||
|
||||
Both with dedicated Docker volumes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Install Dependencies
|
||||
1. **Start the Development Environment**
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
This will start:
|
||||
- Frontend on http://localhost:3000
|
||||
- Backend on http://localhost:14000
|
||||
- PostgreSQL database on port 19090
|
||||
|
||||
### Option 2: Running Services Separately (Recommended for Debugging)
|
||||
|
||||
For better debugging experience, you can run the frontend and backend in separate terminal windows, while keeping the database in Docker:
|
||||
|
||||
1. **First, ensure the database is running in Docker**
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up db
|
||||
```
|
||||
|
||||
2. **Then, in separate terminal windows:**
|
||||
|
||||
#### Terminal 1: Backend Service
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Run Migrations
|
||||
|
||||
```bash
|
||||
npm run migration:create # Create a new migration
|
||||
npm run migration:up # Run migrations
|
||||
```
|
||||
|
||||
This will create all tables in the database.
|
||||
|
||||
---
|
||||
|
||||
## 7. Seed the Database
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
This will create the default admin user and roles as specified in your `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Start the App
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
The backend will be available at http://localhost:14000
|
||||
|
||||
The app will be available at [http://localhost:14000](http://localhost:14000).
|
||||
|
||||
---
|
||||
|
||||
## 9. API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
#### Login
|
||||
#### Terminal 2: Frontend Service
|
||||
```bash
|
||||
curl -X POST http://localhost:14000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "darren",
|
||||
"password": "admin123"
|
||||
}'
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
The frontend will be available at http://localhost:3000
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. **Backend Environment**
|
||||
- Copy `.env.example` to `.env` in the backend directory
|
||||
- Configure your environment variables:
|
||||
```
|
||||
PORT=14000
|
||||
DB_HOST=localhost
|
||||
DB_PORT=19090
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=fusero
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
```
|
||||
|
||||
2. **Frontend Environment**
|
||||
- Copy `.env.example` to `.env` in the frontend directory
|
||||
- Set the API base URL:
|
||||
```
|
||||
VITE_API_BASE_URL=http://localhost:14000/api/v1
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
1. **Build and Run with Docker**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
2. **Environment Variables**
|
||||
- Ensure all environment variables are properly set in your production environment
|
||||
- Never commit `.env` files to version control
|
||||
|
||||
## Development Best Practices
|
||||
|
||||
1. **Database Management**
|
||||
- Always run the database in Docker
|
||||
- Use `docker-compose.dev.yml` for development
|
||||
- Never run PostgreSQL directly on your host machine
|
||||
|
||||
2. **Running Services Separately**
|
||||
- For development, it's recommended to run frontend and backend in separate terminal windows
|
||||
- This allows for better debugging and hot-reloading
|
||||
- You can see logs from each service clearly
|
||||
|
||||
3. **Code Organization**
|
||||
- Frontend code should be in the `frontend/` directory
|
||||
- Backend code should be in the `backend/` directory
|
||||
- Shared types and utilities should be in their respective directories
|
||||
|
||||
4. **Version Control**
|
||||
- Commit `package-lock.json` files
|
||||
- Don't commit `.env` files
|
||||
- Use meaningful commit messages
|
||||
|
||||
## API Documentation
|
||||
|
||||
The backend API is documented using Swagger/OpenAPI. After starting the backend service, you can access the API documentation at:
|
||||
- Development: http://localhost:14000/api-docs
|
||||
- Production: http://your-domain/api-docs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Port Conflicts**
|
||||
- If you encounter port conflicts, check which services are running:
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
- Or check for processes using the ports:
|
||||
```bash
|
||||
lsof -i :3000
|
||||
lsof -i :14000
|
||||
```
|
||||
|
||||
2. **Database Issues**
|
||||
- Ensure PostgreSQL is running in Docker
|
||||
- Check database connection settings in `.env`
|
||||
- Verify database migrations are up to date
|
||||
- If database issues persist, try:
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
docker-compose -f docker-compose.dev.yml up db
|
||||
```
|
||||
|
||||
3. **CORS Issues**
|
||||
- If you see CORS errors, verify the frontend's API base URL
|
||||
- Check backend CORS configuration
|
||||
- Ensure both services are running on the correct ports
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Create a new branch for your feature
|
||||
2. Make your changes
|
||||
3. Submit a pull request
|
||||
4. Ensure all tests pass
|
||||
5. Update documentation as needed
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Technical Documentation: ChatGPT-Powered Endpoint Creation
|
||||
|
||||
### Overview
|
||||
Developers can leverage the ChatGPT modal in the Canvas Endpoints UI to create new Canvas API endpoints using natural language prompts. When a user enters a prompt like "Create a course endpoint for Canvas", the system uses ChatGPT to:
|
||||
|
||||
1. Interpret the intent and generate a JSON object with the required fields for the endpoint (name, method, path, description, etc.).
|
||||
2. Automatically submit this JSON to the backend endpoint creation API (`/api/v1/canvas-api/endpoints`).
|
||||
3. Refresh the endpoint list in the UI and display a success message.
|
||||
|
||||
### How it Works
|
||||
- **Prompt Handling:**
|
||||
- The frontend sends the user's prompt to `/api/v1/canvas-api/chatgpt/completions`.
|
||||
- ChatGPT is instructed to return only a JSON object suitable for the endpoint creation form.
|
||||
- **Auto-Creation:**
|
||||
- If the response is a valid endpoint JSON (with `name`, `method`, and `path`), the frontend posts it to `/api/v1/canvas-api/endpoints`.
|
||||
- The endpoint list is refreshed and a toast notification is shown.
|
||||
- **Fallback:**
|
||||
- If the response is not a valid endpoint JSON, it is displayed as a normal chat message.
|
||||
|
||||
### Example Prompt
|
||||
```
|
||||
Create a course endpoint for Canvas. Use the Canvas API docs to determine the correct path and required fields.
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
### Example ChatGPT Response
|
||||
```
|
||||
{
|
||||
"success": true,
|
||||
"message": "Authentication successful",
|
||||
"data": {
|
||||
"token": "your.jwt.token",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "darren",
|
||||
"email": "darren@fusero.nl",
|
||||
"roles": ["admin"]
|
||||
}
|
||||
}
|
||||
"name": "Create Course",
|
||||
"method": "POST",
|
||||
"path": "/courses",
|
||||
"description": "Creates a new course in Canvas."
|
||||
}
|
||||
```
|
||||
|
||||
### User Management
|
||||
|
||||
#### Create User
|
||||
```bash
|
||||
curl -X POST http://localhost:14000/api/v1/app/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your.jwt.token" \
|
||||
-d '{
|
||||
"username": "newuser",
|
||||
"password": "userpass123",
|
||||
"email": "user@example.com",
|
||||
"roleName": "user"
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "User created successfully",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"username": "newuser",
|
||||
"email": "user@example.com",
|
||||
"roles": ["user"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Get All Users (Requires admin role)
|
||||
```bash
|
||||
curl -X GET http://localhost:14000/api/v1/app/users \
|
||||
-H "Authorization: Bearer your.jwt.token"
|
||||
```
|
||||
|
||||
#### Get User by ID
|
||||
```bash
|
||||
curl -X GET http://localhost:14000/api/v1/app/users/1 \
|
||||
-H "Authorization: Bearer your.jwt.token"
|
||||
```
|
||||
|
||||
### Canvas Dummy Grades API
|
||||
|
||||
The canvas API provides endpoints for managing dummy grades. All endpoints are prefixed with `/api/v1/canvas-api/`.
|
||||
|
||||
#### Get All Dummy Grades
|
||||
```bash
|
||||
GET /api/v1/canvas-api/
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Dummy grades retrieved successfully",
|
||||
"data": {
|
||||
"grades": [
|
||||
{
|
||||
"id": 1,
|
||||
"student_id": 101,
|
||||
"course_id": 1,
|
||||
"assignment_id": 1,
|
||||
"score": 85,
|
||||
"grade": "B",
|
||||
"submitted_at": "2024-03-15T10:00:00Z",
|
||||
"graded_at": "2024-03-16T14:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Add a New Dummy Grade
|
||||
```bash
|
||||
POST /api/v1/canvas-api/add
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"student_id": 123,
|
||||
"course_id": 456,
|
||||
"assignment_id": 789,
|
||||
"score": 85
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Dummy grade added successfully",
|
||||
"data": {
|
||||
"id": 3,
|
||||
"student_id": 123,
|
||||
"course_id": 456,
|
||||
"assignment_id": 789,
|
||||
"score": 85,
|
||||
"grade": "B",
|
||||
"submitted_at": "2024-03-15T10:00:00Z",
|
||||
"graded_at": "2024-03-16T14:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Update a Dummy Grade
|
||||
```bash
|
||||
PUT /api/v1/canvas-api/update
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"student_id": 123,
|
||||
"course_id": 456,
|
||||
"assignment_id": 789,
|
||||
"score": 90
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Dummy grade updated successfully",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"student_id": 123,
|
||||
"course_id": 456,
|
||||
"assignment_id": 789,
|
||||
"score": 90,
|
||||
"grade": "A",
|
||||
"submitted_at": "2024-03-15T10:00:00Z",
|
||||
"graded_at": "2024-03-16T14:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete a Dummy Grade
|
||||
```bash
|
||||
DELETE /api/v1/canvas-api/delete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Dummy grade deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
npm test # Run all tests
|
||||
npm run test:watch # Run tests in watch mode
|
||||
npm run test:coverage # Run tests with coverage
|
||||
```
|
||||
|
||||
The test database is automatically used for running tests.
|
||||
|
||||
---
|
||||
|
||||
## 11. Authentication & Authorization
|
||||
|
||||
- All endpoints except login require a valid JWT token
|
||||
- The JWT token should be included in the Authorization header as: `Bearer your.jwt.token`
|
||||
- Some endpoints require specific roles (admin/user)
|
||||
- JWT tokens expire after 1 hour
|
||||
|
||||
---
|
||||
|
||||
## 12. Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Database Connection Issues**
|
||||
- Ensure Docker is running
|
||||
- Check if the Postgres containers are up: `docker ps`
|
||||
- Verify database credentials in `.env`
|
||||
|
||||
2. **Authentication Issues**
|
||||
- Ensure JWT_SECRET is set in `.env`
|
||||
- Check if the user exists in the database
|
||||
- Verify the password is correct
|
||||
|
||||
3. **Role-based Access Issues**
|
||||
- Ensure the user has the required role
|
||||
- Check if the JWT token includes the correct roles
|
||||
- Verify the token hasn't expired
|
||||
|
||||
### Logs
|
||||
|
||||
- Application logs can be viewed in the terminal where `npm run dev` was executed
|
||||
- Database logs can be viewed using: `docker logs fusero-boilerplate-db`
|
||||
|
||||
---
|
||||
|
||||
## 13. Notes
|
||||
|
||||
- The app uses separate databases, ports, and Docker volumes from any other Fusero projects, so it can run in parallel.
|
||||
- The default admin user is created by the seed script and can be changed via `.env`.
|
||||
- For production, use `docker-compose.yml` and adjust ports/credentials as needed.
|
||||
- The app includes TypeScript, ESLint, and Prettier for code quality.
|
||||
### Developer Notes
|
||||
- The ChatGPT modal logic is in `frontend/src/components/CanvasEndpoints.tsx`.
|
||||
- The backend endpoint creation API is `/api/v1/canvas-api/endpoints`.
|
||||
- The system expects ChatGPT to return a JSON object with at least `name`, `method`, and `path`.
|
||||
- The endpoint list is auto-refreshed after creation.
|
||||
|
||||
---
|
@ -1,3 +1,5 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
fusero-boilerplate-db:
|
||||
image: postgres:15
|
||||
|
@ -1,91 +1,97 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
fusero-frontend:
|
||||
container_name: fusero-frontend
|
||||
env_file: ../fusero-frontend/.env
|
||||
build:
|
||||
context: ../fusero-frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- '3000:80'
|
||||
networks:
|
||||
- fusero-network
|
||||
fusero-app-frontend:
|
||||
container_name: fusero-app-frontend
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '3000:80'
|
||||
networks:
|
||||
- fusero-network
|
||||
depends_on:
|
||||
- fusero-app-backend
|
||||
|
||||
fusero-app-boilerplate:
|
||||
environment:
|
||||
- POSTGRES_HOST=fusero-app-db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file: .env
|
||||
restart: always
|
||||
ports:
|
||||
- '5000:14000'
|
||||
depends_on:
|
||||
- fusero-app-db
|
||||
container_name: fusero-app-boilerplate
|
||||
networks:
|
||||
- fusero-network
|
||||
fusero-app-frontend-dev:
|
||||
container_name: fusero-app-frontend-dev
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '8080:8080'
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
command: npm run dev
|
||||
networks:
|
||||
- fusero-network
|
||||
depends_on:
|
||||
- fusero-app-backend
|
||||
|
||||
fusero-app-db:
|
||||
image: postgres:15
|
||||
env_file: .env
|
||||
restart: always
|
||||
volumes:
|
||||
- fusero_app_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '19090:5432'
|
||||
container_name: fusero-app-db
|
||||
networks:
|
||||
- fusero-network
|
||||
fusero-app-backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file: .env
|
||||
restart: always
|
||||
ports:
|
||||
- '5000:14000'
|
||||
depends_on:
|
||||
- fusero-boilerplate-db
|
||||
container_name: fusero-app-backend
|
||||
networks:
|
||||
- fusero-network
|
||||
|
||||
fusero-app-test-db:
|
||||
image: postgres:15
|
||||
env_file: .env
|
||||
restart: always
|
||||
volumes:
|
||||
- fusero_app_test_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '19091:5432'
|
||||
container_name: fusero-app-test-db
|
||||
networks:
|
||||
- fusero-network
|
||||
environment:
|
||||
- POSTGRES_DB=test-db
|
||||
fusero-boilerplate-db:
|
||||
image: postgres:15
|
||||
env_file: .env
|
||||
restart: always
|
||||
volumes:
|
||||
- fusero_boilerplate_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '19095:5432'
|
||||
container_name: fusero-boilerplate-db
|
||||
networks:
|
||||
- fusero-network
|
||||
|
||||
# ngrok:
|
||||
# image: ngrok/ngrok:latest
|
||||
# restart: unless-stopped
|
||||
# command:
|
||||
# - 'start'
|
||||
# - '--all'
|
||||
# - '--config'
|
||||
# - '/etc/ngrok.yml'
|
||||
# volumes:
|
||||
# - ./ngrok.yml:/etc/ngrok.yml
|
||||
# ports:
|
||||
# - 19095:4040
|
||||
# networks:
|
||||
# - fusero-network
|
||||
fusero-boilerplate-test-db:
|
||||
image: postgres:15
|
||||
env_file: .env
|
||||
restart: always
|
||||
volumes:
|
||||
- fusero_boilerplate_test_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '19096:5432'
|
||||
container_name: fusero-boilerplate-test-db
|
||||
networks:
|
||||
- fusero-network
|
||||
environment:
|
||||
- POSTGRES_DB=test-db
|
||||
|
||||
# fusero-redis:
|
||||
# image: redis:7-alpine
|
||||
# restart: always
|
||||
# ports:
|
||||
# - '6379:6379'
|
||||
# volumes:
|
||||
# - redis_data:/data
|
||||
# container_name: fusero-redis
|
||||
# networks:
|
||||
# - fusero-network
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: fusero-nginx
|
||||
ports:
|
||||
- '14001:80'
|
||||
- '14443:443'
|
||||
volumes:
|
||||
- ./nginx/nginx.conf.prod:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./nginx/certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
- fusero-app-frontend
|
||||
- fusero-app-backend
|
||||
networks:
|
||||
- fusero-network
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
fusero_app_pgdata:
|
||||
external: true
|
||||
fusero_app_test_pgdata:
|
||||
external: false
|
||||
fusero_boilerplate_pgdata:
|
||||
external: true
|
||||
fusero_boilerplate_test_pgdata:
|
||||
external: false
|
||||
|
||||
networks:
|
||||
fusero-network:
|
||||
name: fusero-network
|
||||
fusero-network:
|
||||
name: fusero-network
|
||||
|
10
frontend/.prettierrc.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true
|
||||
}
|
31
frontend/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
18
frontend/Dockerfile.dev
Normal file
@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose port 3000
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev"]
|
19
frontend/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/favicon/site.webmanifest">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fusero</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
19
frontend/nginx.conf
Normal file
@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /favicon/ {
|
||||
alias /usr/share/nginx/html/dist/favicon/;
|
||||
access_log off;
|
||||
expires max;
|
||||
}
|
||||
|
||||
# DO NOT proxy /api here — let the global Nginx handle it
|
||||
}
|
27
frontend/nginx.conf.bkup
Normal file
@ -0,0 +1,27 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Serve favicon files
|
||||
location /favicon/ {
|
||||
alias /usr/share/nginx/html/dist/favicon/;
|
||||
access_log off;
|
||||
expires max;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend
|
||||
location /api {
|
||||
proxy_pass http://fusero-app-backend:14000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
10239
frontend/package-lock.json
generated
Normal file
45
frontend/package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "fusero-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/jwt-decode": "^3.1.0",
|
||||
"axios": "^1.6.8",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.6.3",
|
||||
"react": "^18.2.0",
|
||||
"react-cropper": "^2.3.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zod": "^3.23.4",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
frontend/public/favicon/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/favicon/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 80 KiB |
BIN
frontend/public/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 759 B |
BIN
frontend/public/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
1
frontend/public/favicon/site.webmanifest
Normal file
@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
BIN
frontend/public/fusero_logo_alphapng.png
Normal file
After Width: | Height: | Size: 72 KiB |
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/assets/female-avatar.png
Normal file
After Width: | Height: | Size: 266 KiB |
38
frontend/src/assets/fusero_logo.svg
Normal file
@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 160 160">
|
||||
<!-- Generator: Adobe Illustrator 29.2.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 116) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #099;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: none;
|
||||
stroke: #000;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-width: 10px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<polygon points="160 124.58 0 124.58 0 80.36 6.06 80.36 6.06 118.53 153.94 118.53 153.94 80.36 160 80.36 160 124.58"/>
|
||||
<polygon class="st0" points="6.06 71.02 0 71.02 0 32.72 79.08 32.72 79.08 38.78 6.06 38.78 6.06 71.02"/>
|
||||
<polygon points="159.77 71.42 153.71 71.42 153.71 38.78 78.85 38.78 78.85 32.72 159.77 32.72 159.77 71.42"/>
|
||||
<path class="st1" d="M14.28,105.16"/>
|
||||
<g>
|
||||
<rect x="13.34" y="107.13" width="66.8" height="5.05"/>
|
||||
<rect class="st0" x="80.15" y="107.13" width="66.51" height="5.05"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect class="st0" x="29.75" y="99.34" width="116.9" height="5.05"/>
|
||||
<rect x="13.34" y="99.34" width="16.41" height="5.05"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M13.32,48.95h17.63v6.06h-10.96v11.81h8.6v6.06h-8.6v18.47h-6.66v-42.4Z"/>
|
||||
<path d="M36.45,89.2c-1.7-1.84-2.54-4.47-2.54-7.9v-32.34h6.66v32.83c0,1.45.29,2.5.88,3.15.58.65,1.42.97,2.51.97s1.93-.32,2.51-.97c.58-.65.88-1.7.88-3.15v-32.83h6.42v32.34c0,3.43-.85,6.07-2.54,7.9-1.7,1.84-4.16,2.76-7.39,2.76s-5.69-.92-7.39-2.76Z"/>
|
||||
<path d="M59.83,89.2c-1.66-1.84-2.48-4.47-2.48-7.9v-2.42h6.3v2.91c0,2.75,1.15,4.12,3.45,4.12,1.13,0,1.99-.33,2.57-1,.58-.67.88-1.75.88-3.24,0-1.78-.4-3.34-1.21-4.69-.81-1.35-2.3-2.98-4.48-4.88-2.75-2.42-4.66-4.61-5.75-6.57-1.09-1.96-1.64-4.17-1.64-6.63,0-3.35.85-5.95,2.54-7.78,1.7-1.84,4.16-2.76,7.39-2.76s5.6.92,7.24,2.76c1.64,1.84,2.45,4.47,2.45,7.9v1.76h-6.3v-2.18c0-1.45-.28-2.51-.85-3.18-.57-.67-1.39-1-2.48-1-2.22,0-3.33,1.35-3.33,4.06,0,1.54.41,2.97,1.24,4.3.83,1.33,2.33,2.95,4.51,4.84,2.79,2.42,4.7,4.62,5.75,6.6,1.05,1.98,1.57,4.3,1.57,6.96,0,3.47-.86,6.14-2.57,8-1.72,1.86-4.21,2.79-7.48,2.79s-5.67-.92-7.33-2.76Z"/>
|
||||
<path d="M81.03,48.95h18.17v6.06h-11.51v11.21h9.15v6.06h-9.15v13.02h11.51v6.06h-18.17v-42.4Z"/>
|
||||
<path d="M103.02,48.95h9.87c3.43,0,5.94.8,7.51,2.39,1.58,1.6,2.36,4.05,2.36,7.36v2.6c0,4.4-1.45,7.19-4.36,8.36v.12c1.62.48,2.76,1.47,3.42,2.97.67,1.49,1,3.49,1,6v7.45c0,1.21.04,2.19.12,2.94.08.75.28,1.48.61,2.21h-6.78c-.24-.69-.4-1.33-.48-1.94-.08-.61-.12-1.7-.12-3.27v-7.75c0-1.94-.31-3.29-.94-4.06-.63-.77-1.71-1.15-3.24-1.15h-2.3v18.17h-6.66v-42.4ZM112.1,67.12c1.33,0,2.33-.34,3-1.03.67-.69,1-1.84,1-3.45v-3.27c0-1.53-.27-2.64-.82-3.33-.54-.69-1.4-1.03-2.57-1.03h-3.03v12.11h2.42Z"/>
|
||||
<path d="M129.54,89.17c-1.74-1.86-2.6-4.48-2.6-7.87v-22.29c0-3.39.87-6.02,2.6-7.87,1.74-1.86,4.24-2.79,7.51-2.79s5.77.93,7.51,2.79c1.74,1.86,2.6,4.48,2.6,7.87v22.29c0,3.39-.87,6.02-2.6,7.87-1.74,1.86-4.24,2.79-7.51,2.79s-5.77-.93-7.51-2.79ZM140.51,81.72v-23.14c0-2.79-1.15-4.18-3.45-4.18s-3.45,1.39-3.45,4.18v23.14c0,2.79,1.15,4.18,3.45,4.18s3.45-1.39,3.45-4.18Z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.0 KiB |
BIN
frontend/src/assets/fusero_logo.webp
Normal file
After Width: | Height: | Size: 74 KiB |
BIN
frontend/src/assets/male-avatar.png
Normal file
After Width: | Height: | Size: 482 KiB |
1
frontend/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/roc_logo.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
frontend/src/assets/teal_iso_bg.png
Normal file
After Width: | Height: | Size: 1.0 MiB |
23
frontend/src/components/ApiCalls.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { useApiLogStore } from '../services/apiLogStore';
|
||||
|
||||
const ApiCalls = () => {
|
||||
const apiLogs = useApiLogStore((state) => state.apiLogs);
|
||||
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<h2 className='text-lg font-semibold'>API Calls</h2>
|
||||
<DataTable value={apiLogs}>
|
||||
{/* <Column field='timestamp' header='Timestamp' sortable /> */}
|
||||
<Column field='method' header='Method' />
|
||||
<Column field='url' header='URL' />
|
||||
<Column field='status' header='Status' />
|
||||
<Column field='responseTime' header='Time (ms)' />
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiCalls;
|
40
frontend/src/components/AuthWrapper.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { getExpirationDateFromToken, useAuth } from '../hooks/useAuth';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const AuthWrapper = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const user = localStorage.getItem('user');
|
||||
const isLoginPage = location.pathname === '/login';
|
||||
|
||||
if (!user && !isLoginPage) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
checkUserToken();
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
const checkUserToken = () => {
|
||||
const user = localStorage.getItem('user');
|
||||
if (!user) return navigate('/');
|
||||
|
||||
const parsedUser = JSON.parse(user);
|
||||
if (parsedUser) {
|
||||
let expiredDate = getExpirationDateFromToken(parsedUser.token);
|
||||
const currentDate = new Date();
|
||||
|
||||
if (expiredDate < currentDate) {
|
||||
logout();
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AuthWrapper;
|
32
frontend/src/components/Avatar.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AvatarProps {
|
||||
avatarUrl: string;
|
||||
name: string;
|
||||
type: 'CFO' | 'CPO' | 'CTO'; // Specific types
|
||||
speech?: string;
|
||||
}
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({ avatarUrl, name, type, speech }) => (
|
||||
<div className='flex flex-col items-center space-y-2'>
|
||||
{/* Avatar Image */}
|
||||
<div className='w-32 h-32'>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${name}'s Avatar`}
|
||||
className='w-full h-full border-2 border-gray-300 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
{/* Name and Type */}
|
||||
<span className='text-sm font-semibold'>{name}</span>
|
||||
<span className='text-xs font-medium text-gray-600'>{type}</span>
|
||||
{/* Speech Bubble */}
|
||||
{speech && (
|
||||
<div className='px-3 py-2 text-xs text-gray-800 bg-gray-200 border rounded shadow'>
|
||||
{speech}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Avatar;
|
103
frontend/src/components/CouncilAI/CouncilAI.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import Avatar from '../Avatar';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import Button from '../../shared/components/_V2/Button';
|
||||
import maleAvatar from '../../assets/male-avatar.png';
|
||||
import femaleAvatar from '../../assets/female-avatar.png';
|
||||
import { useChatStore } from '../../state/stores/useChatStore';
|
||||
|
||||
const PromptBoxModal = ({ response }) => {
|
||||
return response ? (
|
||||
<div className='absolute w-full p-2 mb-2 text-center text-white bg-gray-700 rounded bottom-full'>
|
||||
<strong>Assistant:</strong> {response}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PromptBox = ({ onSubmit, activeRole }) => {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
onSubmit(input);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className='flex items-center w-full gap-4 p-4 bg-gray-500 rounded-lg shadow-md'
|
||||
>
|
||||
<div className='flex items-center flex-grow px-6 py-3 bg-white border border-gray-400 rounded-lg focus-within:ring-2 focus-within:ring-gray-500 h-14'>
|
||||
<InputText
|
||||
type='text'
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className='w-full h-full text-black bg-transparent border-none focus:outline-none'
|
||||
placeholder={`Enter your message for ${activeRole}...`}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex h-14'>
|
||||
<Button
|
||||
label='Send'
|
||||
type='submit'
|
||||
className='px-6 bg-blue-500 text-white rounded-lg hover:bg-blue-600 flex items-center justify-center h-full !m-0'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AvatarPromptScreen() {
|
||||
const [responses, setResponses] = useState({ cfo: '', cpo: '' });
|
||||
const [activeRole, setActiveRole] = useState('CFO');
|
||||
const { sendChatRequest } = useChatStore();
|
||||
|
||||
const handlePromptSubmit = async (message) => {
|
||||
console.log(`Prompt submitted for ${activeRole}:`, message);
|
||||
const requestData = { keywords: ['example', 'test'], data: message };
|
||||
try {
|
||||
const result = await sendChatRequest('/chat/completions', requestData);
|
||||
const responseText = result.responseText || 'No response from server';
|
||||
setResponses((prev) => ({
|
||||
...prev,
|
||||
[activeRole.toLowerCase()]: responseText,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
setResponses((prev) => ({
|
||||
...prev,
|
||||
[activeRole.toLowerCase()]: 'Error communicating with server',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col justify-between w-screen h-screen bg-primary-light'>
|
||||
<div className='grid items-end flex-grow w-full grid-cols-6 pb-4'>
|
||||
{/* CFO (Column 2) */}
|
||||
<div className='relative flex flex-col items-center col-span-1 col-start-2'>
|
||||
<PromptBoxModal response={responses.cfo} />
|
||||
<div className='cursor-pointer' onClick={() => setActiveRole('CFO')}>
|
||||
<Avatar avatarUrl={maleAvatar} name='CFO' type='CFO' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CPO (Column 5) */}
|
||||
<div className='relative flex flex-col items-center col-span-1 col-start-5'>
|
||||
<PromptBoxModal response={responses.cpo} />
|
||||
<div className='cursor-pointer' onClick={() => setActiveRole('CPO')}>
|
||||
<Avatar avatarUrl={femaleAvatar} name='CPO' type='CPO' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Width Input Box */}
|
||||
<div className='w-full p-4'>
|
||||
<PromptBox onSubmit={handlePromptSubmit} activeRole={activeRole} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
84
frontend/src/components/CouncilAI/PromptBoxModal.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import Button from '../../shared/components/_V2/Button';
|
||||
import { useChatStore } from '../../state/stores/useChatStore';
|
||||
|
||||
const PromptBoxModal = ({ onSubmit }) => {
|
||||
const [promptMessage, setPromptMessage] = useState('');
|
||||
const [latestMessage, setLatestMessage] = useState({ user: '', response: '' });
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { sendChatRequest } = useChatStore();
|
||||
|
||||
const handleFormSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (promptMessage.trim()) {
|
||||
const requestData = {
|
||||
keywords: ['example', 'test'],
|
||||
data: promptMessage,
|
||||
};
|
||||
|
||||
setLatestMessage({ user: promptMessage, response: '' });
|
||||
|
||||
try {
|
||||
const result = await sendChatRequest('/chat/completions', requestData);
|
||||
const responseText = result.responseText || 'No response from server';
|
||||
setLatestMessage({ user: promptMessage, response: responseText });
|
||||
setIsModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
setLatestMessage({ user: promptMessage, response: 'Error communicating with server' });
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
|
||||
onSubmit(requestData);
|
||||
setPromptMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex items-center w-full h-full p-2 bg-gray-800'>
|
||||
<form onSubmit={handleFormSubmit} className='flex items-center w-full space-x-2'>
|
||||
<InputText
|
||||
type='text'
|
||||
value={promptMessage}
|
||||
onChange={(e) => setPromptMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleFormSubmit(e);
|
||||
}
|
||||
}}
|
||||
className='flex-grow p-2 text-black rounded'
|
||||
placeholder='Enter your message...'
|
||||
/>
|
||||
<Button label='Send' type='submit' className='p-2' />
|
||||
</form>
|
||||
|
||||
{/* Modal for Latest Question & Response */}
|
||||
<Dialog
|
||||
visible={isModalOpen}
|
||||
onHide={() => setIsModalOpen(false)}
|
||||
header='Chat Response'
|
||||
className='w-[400px] p-4 bg-white rounded-lg shadow-lg'
|
||||
modal
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='p-2 text-white bg-blue-500 rounded-lg'>
|
||||
<strong>You:</strong> {latestMessage.user}
|
||||
</div>
|
||||
<div className='p-2 text-white bg-gray-700 rounded-lg'>
|
||||
<strong>Assistant:</strong> {latestMessage.response}
|
||||
</div>
|
||||
</div>
|
||||
<Button label='Close' onClick={() => setIsModalOpen(false)} className='mt-3' />
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PromptBoxModal.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default PromptBoxModal;
|
83
frontend/src/components/FuseMind/Avatar/MoleculeAvatar.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
|
||||
const MoleculeAvatar: React.FC = () => {
|
||||
return (
|
||||
<div className="molecule-container">
|
||||
<div className="molecule"></div>
|
||||
<div className="molecule"></div>
|
||||
<div className="molecule"></div>
|
||||
<style >{`
|
||||
.molecule-container {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
perspective: 500px;
|
||||
animation: rotateContainer 10s linear infinite;
|
||||
}
|
||||
|
||||
.molecule {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: teal;
|
||||
}
|
||||
|
||||
@keyframes float1 {
|
||||
0% { transform: translate(0px, 0px) translateZ(0px); }
|
||||
50% { transform: translate(30px, 20px) translateZ(20px); }
|
||||
100% { transform: translate(0px, 0px) translateZ(0px); }
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
0% { transform: translate(0px, 0px) translateZ(0px); }
|
||||
50% { transform: translate(-25px, 30px) translateZ(-15px); }
|
||||
100% { transform: translate(0px, 0px) translateZ(0px); }
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
0% { transform: translate(0px, 0px) translateZ(0px); }
|
||||
50% { transform: translate(20px, -25px) translateZ(10px); }
|
||||
100% { transform: translate(0px, 0px) translateZ(0px); }
|
||||
}
|
||||
|
||||
@keyframes rotateContainer {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.molecule:nth-child(1) { animation: float1 3s ease-in-out infinite; }
|
||||
.molecule:nth-child(2) { animation: float2 3.5s ease-in-out infinite; }
|
||||
.molecule:nth-child(3) { animation: float3 4s ease-in-out infinite; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.molecule-container {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
.molecule {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.molecule-container {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
.molecule {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoleculeAvatar;
|
54
frontend/src/components/FuseMind/Chat/ChatBubble.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Message } from '../../../types/fusemind/messages';
|
||||
|
||||
interface ChatBubbleProps {
|
||||
message: Message | null;
|
||||
position: 'left' | 'right';
|
||||
bgColor?: string;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const ChatBubble: React.FC<ChatBubbleProps> = ({
|
||||
message,
|
||||
position,
|
||||
bgColor = 'white',
|
||||
isVisible = true
|
||||
}) => {
|
||||
const getBubblePositionClasses = () => {
|
||||
const baseClasses = "relative rounded-lg shadow-sm p-3 max-w-[70%]";
|
||||
const positionClass = position === 'right' ? 'ml-[30%]' : 'ml-[30%]';
|
||||
const animationClasses = `transition-opacity duration-500 ${isVisible ? 'opacity-100' : 'opacity-0'}`;
|
||||
|
||||
return `${baseClasses} ${positionClass} ${animationClasses}`;
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={getBubblePositionClasses()}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative z-10">
|
||||
<p className="text-[13px] leading-[18px] text-[#f8fafa] whitespace-pre-wrap break-words mb-1">
|
||||
{message.content}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#e0f1f1] text-right">
|
||||
{formatTime(message.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBubble;
|
17
frontend/src/components/FuseMind/FuseMind.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import FuseMindHome from './FuseMindHome';
|
||||
|
||||
const FuseMind: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const path = location.pathname.split('/').pop() || 'home';
|
||||
|
||||
// If we're at the home route, render FuseMindHome directly
|
||||
if (path === 'home' || path === 'fusemind') {
|
||||
return <FuseMindHome />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default FuseMind;
|
203
frontend/src/components/FuseMind/FuseMindHome.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Message } from '../../types/fusemind/messages';
|
||||
import { useChatStore } from '../../state/stores/useChatStore';
|
||||
import ChatBubble from './Chat/ChatBubble';
|
||||
import MoleculeAvatar from './Avatar/MoleculeAvatar';
|
||||
import { useModalCommands } from './modals/hooks/useModalCommands';
|
||||
import ModalContainer from './modals/ModalContainer';
|
||||
import { useModalGrid } from './modals/hooks/useModalGrid';
|
||||
import { ModalConfig } from './modals/core/types';
|
||||
|
||||
interface VisibleMessage extends Message {
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
// Helper function to convert grid coordinates to percentages
|
||||
const gridToPercent = (x: number, y: number, width: number, height: number) => {
|
||||
// Grid is 20x8, so each cell is 5% of width and 12.5% of height
|
||||
// Since we're using transform: translate(-50%, -50%), we need to center the coordinates
|
||||
return {
|
||||
x: (x * 5) + (width * 5 / 2),
|
||||
y: (y * 12.5) + (height * 12.5 / 2),
|
||||
width: `${width * 5}%`,
|
||||
height: `${height * 12.5}%`
|
||||
};
|
||||
};
|
||||
|
||||
const FuseMindHome: React.FC = () => {
|
||||
const [messages, setMessages] = useState<VisibleMessage[]>([]);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const { sendChatRequest } = useChatStore();
|
||||
const { modals, createModal, removeModal } = useModalCommands();
|
||||
const { addModal, removeModal: removeGridModal } = useModalGrid();
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputMessage.trim()) return;
|
||||
|
||||
const userMessage: VisibleMessage = {
|
||||
id: Date.now().toString(),
|
||||
type: 'user',
|
||||
content: inputMessage,
|
||||
timestamp: new Date(),
|
||||
isVisible: true
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputMessage('');
|
||||
|
||||
try {
|
||||
const response = await sendChatRequest('/chat/completions', {
|
||||
data: `Create a comprehensive table about ${inputMessage}. Return ONLY a JSON object in this format:
|
||||
{
|
||||
"columns": [
|
||||
{"field": "column1", "header": "Column 1"},
|
||||
{"field": "column2", "header": "Column 2"}
|
||||
],
|
||||
"data": [
|
||||
{"column1": "value1", "column2": "value2"}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
1. Return ONLY the JSON object, no explanations
|
||||
2. Make sure column names are descriptive and relevant to the topic
|
||||
3. Return ONLY the JSON object, no explanations or additional text
|
||||
4. Ensure data is accurate, comprehensive, and relevant to the topic
|
||||
5. Include as many relevant entries as possible to provide a thorough overview
|
||||
6. Make sure all field names in data match column fields exactly
|
||||
7. Each entry should be unique and provide distinct information`,
|
||||
responseFormat: 'json'
|
||||
});
|
||||
|
||||
const tableData = JSON.parse(response.responseText);
|
||||
if (tableData.columns && tableData.data) {
|
||||
const modalId = Date.now().toString();
|
||||
// Try to find a position in the grid (14 columns wide, 5 rows tall)
|
||||
const gridPosition = addModal(modalId, 14, 5);
|
||||
|
||||
if (gridPosition) {
|
||||
// Make modal wider by using 16 columns instead of 14
|
||||
const pos = gridToPercent(gridPosition.x, gridPosition.y, 16, 7);
|
||||
await createModal({
|
||||
type: 'table',
|
||||
position: pos,
|
||||
content: tableData
|
||||
});
|
||||
}
|
||||
|
||||
const columnCount = tableData.columns.length;
|
||||
const rowCount = tableData.data.length;
|
||||
const botMessage: VisibleMessage = {
|
||||
id: Date.now().toString(),
|
||||
type: 'system',
|
||||
content: `I've created a table showing ${rowCount} entries with ${columnCount} columns of information about ${inputMessage}. You can view it in the window above.`,
|
||||
timestamp: new Date(),
|
||||
isVisible: true
|
||||
};
|
||||
setMessages(prev => [...prev, botMessage]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing response:', error);
|
||||
const errorMessage: VisibleMessage = {
|
||||
id: Date.now().toString(),
|
||||
type: 'system',
|
||||
content: "Sorry, I ran into an error displaying the table. Please try again.",
|
||||
timestamp: new Date(),
|
||||
isVisible: true
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = (id: string) => {
|
||||
removeModal(id);
|
||||
removeGridModal(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="flex-1 relative w-full">
|
||||
{/* Grid Container */}
|
||||
<div className="absolute inset-0 p-4">
|
||||
<div className="relative w-full h-full border border-gray-200 bg-white">
|
||||
{/* Grid Lines and Labels */}
|
||||
<div className="absolute inset-0 grid grid-cols-20 grid-rows-8">
|
||||
{/* Grid Cells with Coordinates */}
|
||||
{Array.from({ length: 160 }).map((_, i) => {
|
||||
const col = i % 20;
|
||||
const row = Math.floor(i / 20);
|
||||
const colLabel = String.fromCharCode(65 + col);
|
||||
const rowLabel = row + 1;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="border border-gray-100 relative hover:bg-gray-50"
|
||||
style={{ gridColumn: `${col + 1}`, gridRow: `${row + 1}` }}
|
||||
>
|
||||
<span className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-gray-300 text-xs select-none">
|
||||
{`${colLabel}${rowLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Modal Container */}
|
||||
<ModalContainer modals={modals} onClose={handleModalClose} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 right-8 w-[400px] z-40">
|
||||
<div className="w-full mb-1">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="mb-0.5">
|
||||
<ChatBubble
|
||||
message={message}
|
||||
position={message.type === 'user' ? 'right' : 'left'}
|
||||
bgColor={message.type === 'user' ? '#3b958a' : '#3d8b96'}
|
||||
isVisible={message.isVisible}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="scale-75 origin-bottom-right flex justify-end translate-y-2">
|
||||
<MoleculeAvatar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center absolute bottom-5">
|
||||
<div className="w-2/3 flex items-center gap-2">
|
||||
<form onSubmit={handleSendMessage} className="flex-1 flex gap-2">
|
||||
<div className="flex-grow relative">
|
||||
<input
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
placeholder="Example: Show me a table about beer"
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:border-teal-500 shadow-md bg-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-3 bg-teal-500 text-white rounded-lg hover:bg-teal-600 transition-colors shadow-md flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FuseMindHome;
|
189
frontend/src/components/FuseMind/README.md
Normal file
@ -0,0 +1,189 @@
|
||||
# FuseMind Memory System Architecture
|
||||
|
||||
## Overview
|
||||
FuseMind implements a sophisticated memory system that combines psychological memory models with modern LLM capabilities. The system is designed to be modular and extensible, allowing for gradual implementation and refinement.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Memory Types
|
||||
1. **Episodic Memory**
|
||||
- Stores specific events and experiences
|
||||
- Includes temporal and emotional context
|
||||
- Used for conversation history and user interactions
|
||||
|
||||
2. **Semantic Memory**
|
||||
- Stores general knowledge and facts
|
||||
- Includes relationships between concepts
|
||||
- Used for system knowledge and agent capabilities
|
||||
|
||||
3. **Procedural Memory**
|
||||
- Stores skills and procedures
|
||||
- Includes conditions and exceptions
|
||||
- Used for agent behaviors and capabilities
|
||||
|
||||
4. **Life Event Memory**
|
||||
- Stores significant life events
|
||||
- Tracks emotional impact and phases
|
||||
- Influences memory formation and retrieval
|
||||
|
||||
### Emotional Context
|
||||
- Tracks emotional state (mood, energy, stress, focus)
|
||||
- Manages emotional triggers and biases
|
||||
- Influences memory formation and retrieval
|
||||
- Handles emotional volatility and stability
|
||||
|
||||
### Temporal Context
|
||||
- Tracks life events and their duration
|
||||
- Manages memory decay and importance
|
||||
- Handles period-specific biases
|
||||
- Influences memory relevance
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Memory Service
|
||||
The `MemoryService` class handles all memory operations:
|
||||
- Memory storage and retrieval
|
||||
- Emotional state management
|
||||
- Life event tracking
|
||||
- Memory consolidation
|
||||
|
||||
### Database Schema
|
||||
Memories are stored with the following structure:
|
||||
```typescript
|
||||
interface DatabaseMemory {
|
||||
id: string;
|
||||
type: MemoryType;
|
||||
content: string; // JSON stringified
|
||||
temporal: string; // JSON stringified
|
||||
emotional: string; // JSON stringified
|
||||
connections: string; // JSON stringified
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### React Integration
|
||||
The system provides a `useFusemindMemory` hook for React components:
|
||||
```typescript
|
||||
const {
|
||||
activeMemories,
|
||||
emotionalState,
|
||||
storeMemory,
|
||||
retrieveMemories,
|
||||
updateEmotionalState
|
||||
} = useFusemindMemory();
|
||||
```
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Core Memory System
|
||||
- [x] Basic memory types and interfaces
|
||||
- [x] Memory service implementation
|
||||
- [x] React integration
|
||||
- [ ] Database integration
|
||||
|
||||
### Phase 2: Emotional Context
|
||||
- [ ] Emotional state tracking
|
||||
- [ ] Trigger management
|
||||
- [ ] Bias handling
|
||||
- [ ] Emotional influence on memory
|
||||
|
||||
### Phase 3: Life Events
|
||||
- [ ] Life event tracking
|
||||
- [ ] Event phase management
|
||||
- [ ] Recovery and healing
|
||||
- [ ] Temporal context influence
|
||||
|
||||
### Phase 4: LLM Integration
|
||||
- [ ] Context window management
|
||||
- [ ] Embedding storage and retrieval
|
||||
- [ ] Prompt template management
|
||||
- [ ] Memory-augmented generation
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Storing a Memory
|
||||
```typescript
|
||||
const memory: MemoryUnit = {
|
||||
id: generateId(),
|
||||
type: 'episodic',
|
||||
content: {
|
||||
data: { /* memory content */ },
|
||||
metadata: { /* additional info */ }
|
||||
},
|
||||
temporal: {
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
lastAccessed: new Date(),
|
||||
decayRate: 0.1,
|
||||
importance: 0.8
|
||||
},
|
||||
emotional: {
|
||||
valence: 0.5,
|
||||
arousal: 0.3,
|
||||
emotionalTags: ['positive', 'exciting']
|
||||
},
|
||||
connections: []
|
||||
};
|
||||
|
||||
await memoryService.storeMemory(memory);
|
||||
```
|
||||
|
||||
### Retrieving Memories
|
||||
```typescript
|
||||
const memories = await memoryService.retrieveMemory({
|
||||
query: 'search term',
|
||||
context: {
|
||||
currentTask: 'task description',
|
||||
emotionalState: 'current state'
|
||||
},
|
||||
filters: {
|
||||
type: 'episodic',
|
||||
timeRange: [startDate, endDate]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Memory Formation**
|
||||
- Always include temporal and emotional context
|
||||
- Consider current life events
|
||||
- Track memory importance and decay
|
||||
|
||||
2. **Memory Retrieval**
|
||||
- Use appropriate filters
|
||||
- Consider emotional context
|
||||
- Account for temporal relevance
|
||||
|
||||
3. **Life Event Management**
|
||||
- Track event phases
|
||||
- Monitor emotional impact
|
||||
- Handle event transitions
|
||||
|
||||
4. **Emotional State**
|
||||
- Update state gradually
|
||||
- Consider multiple factors
|
||||
- Handle state transitions
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Advanced Memory Processing**
|
||||
- Machine learning for memory importance
|
||||
- Automated memory consolidation
|
||||
- Dynamic decay rates
|
||||
|
||||
2. **Enhanced Emotional Context**
|
||||
- Multi-dimensional emotional states
|
||||
- Complex trigger patterns
|
||||
- Emotional memory networks
|
||||
|
||||
3. **Improved LLM Integration**
|
||||
- Context-aware prompting
|
||||
- Memory-augmented generation
|
||||
- Dynamic context management
|
||||
|
||||
4. **Visualization Tools**
|
||||
- Memory network visualization
|
||||
- Emotional state tracking
|
||||
- Life event timeline
|
117
frontend/src/components/FuseMind/modals/GridCellIndicator.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
|
||||
interface GridCellIndicatorProps {
|
||||
startCol: number;
|
||||
startRow: number;
|
||||
width: number;
|
||||
height: number;
|
||||
onResize?: (newWidth: number, newHeight: number) => void;
|
||||
}
|
||||
|
||||
const GridCellIndicator: React.FC<GridCellIndicatorProps> = ({
|
||||
startCol,
|
||||
startRow,
|
||||
width,
|
||||
height,
|
||||
onResize
|
||||
}) => {
|
||||
// Convert grid units to cell indices
|
||||
const endCol = startCol + width - 1;
|
||||
const endRow = startRow + height - 1;
|
||||
|
||||
// Generate array of cells that should be highlighted
|
||||
const cells = [];
|
||||
for (let row = startRow; row <= endRow; row++) {
|
||||
for (let col = startCol; col <= endCol; col++) {
|
||||
cells.push({ row, col });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resize interactions
|
||||
const handleResizeStart = (e: React.MouseEvent, edge: 'se' | 'e' | 's') => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startWidth = width;
|
||||
const startHeight = height;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = Math.round((moveEvent.clientX - startX) / (window.innerWidth * 0.05)); // 5% per cell
|
||||
const deltaY = Math.round((moveEvent.clientY - startY) / (window.innerHeight * 0.125)); // 12.5% per cell
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
|
||||
if (edge === 'se' || edge === 'e') {
|
||||
newWidth = Math.max(1, startWidth + deltaX);
|
||||
}
|
||||
if (edge === 'se' || edge === 's') {
|
||||
newHeight = Math.max(1, startHeight + deltaY);
|
||||
}
|
||||
|
||||
// Ensure we don't exceed grid boundaries
|
||||
newWidth = Math.min(newWidth, 20 - startCol);
|
||||
newHeight = Math.min(newHeight, 8 - startRow);
|
||||
|
||||
onResize?.(newWidth, newHeight);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Highlight occupied cells */}
|
||||
{cells.map(({ row, col }) => (
|
||||
<div
|
||||
key={`${row}-${col}`}
|
||||
className="absolute border-2 border-teal-200 bg-teal-50/20"
|
||||
style={{
|
||||
left: `${col * 5}%`,
|
||||
top: `${row * 12.5}%`,
|
||||
width: '5%',
|
||||
height: '12.5%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Resize handles */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-teal-500 rounded-full cursor-se-resize hover:scale-125 transition-transform"
|
||||
style={{
|
||||
right: `${(20 - endCol - 1) * 5}%`,
|
||||
bottom: `${(8 - endRow - 1) * 12.5}%`,
|
||||
transform: 'translate(50%, 50%)',
|
||||
}}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'se')}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-teal-500 rounded-full cursor-e-resize hover:scale-125 transition-transform"
|
||||
style={{
|
||||
right: `${(20 - endCol - 1) * 5}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(50%, -50%)',
|
||||
}}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'e')}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-teal-500 rounded-full cursor-s-resize hover:scale-125 transition-transform"
|
||||
style={{
|
||||
bottom: `${(8 - endRow - 1) * 12.5}%`,
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, 50%)',
|
||||
}}
|
||||
onMouseDown={(e) => handleResizeStart(e, 's')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridCellIndicator;
|
51
frontend/src/components/FuseMind/modals/ModalContainer.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { ModalConfig } from './core/types';
|
||||
import ModalFactory from './ModalFactory';
|
||||
|
||||
interface TableContent {
|
||||
columns: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
}>;
|
||||
data: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface ModalContainerProps {
|
||||
modals: ModalConfig[];
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const ModalContainer: React.FC<ModalContainerProps> = ({ modals, onClose }) => {
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<style>{`
|
||||
.modal-transition {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
`}</style>
|
||||
{modals.map((modal) => (
|
||||
<div
|
||||
key={modal.id}
|
||||
className="absolute modal-transition"
|
||||
style={{
|
||||
left: `${modal.position.x}%`,
|
||||
top: `${modal.position.y}%`,
|
||||
width: modal.position.width,
|
||||
height: modal.position.height,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<ModalFactory
|
||||
id={modal.id}
|
||||
type={modal.type}
|
||||
position={modal.position}
|
||||
content={modal.content as TableContent}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalContainer;
|
66
frontend/src/components/FuseMind/modals/ModalFactory.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { ModalType, ModalPosition } from './core/types';
|
||||
import TableModal from './TableModal';
|
||||
|
||||
interface TableContent {
|
||||
columns: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
}>;
|
||||
data: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface ModalFactoryProps {
|
||||
id: string;
|
||||
type: ModalType;
|
||||
position: ModalPosition;
|
||||
style?: {
|
||||
backgroundColor?: string;
|
||||
};
|
||||
content?: TableContent | React.ReactNode;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const ModalFactory: React.FC<ModalFactoryProps> = ({ id, type, position, style, content, onClose }) => {
|
||||
const renderContent = (): React.ReactNode => {
|
||||
if (!content) return <div>Empty modal</div>;
|
||||
|
||||
switch (type) {
|
||||
case 'table':
|
||||
if (typeof content === 'object' && 'columns' in content && 'data' in content) {
|
||||
return <TableModal content={content as TableContent} />;
|
||||
}
|
||||
return <div>Invalid table content</div>;
|
||||
default:
|
||||
return content as React.ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border shadow-lg p-4 absolute"
|
||||
style={{
|
||||
left: `${position.x}%`,
|
||||
top: `${position.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: position.width,
|
||||
height: position.height,
|
||||
backgroundColor: style?.backgroundColor || 'white'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="font-semibold">Data Table</div>
|
||||
<button className="hover:bg-gray-100 rounded-full p-1" onClick={() => onClose(id)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-4rem)] overflow-hidden">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalFactory;
|
17
frontend/src/components/FuseMind/modals/TableModal.d.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TableContent {
|
||||
columns: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
}>;
|
||||
data: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface TableModalProps {
|
||||
content: TableContent;
|
||||
}
|
||||
|
||||
declare const TableModal: React.FC<TableModalProps>;
|
||||
|
||||
export default TableModal;
|
144
frontend/src/components/FuseMind/modals/TableModal.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
|
||||
interface TableModalProps {
|
||||
content: {
|
||||
columns: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
}>;
|
||||
data: Array<Record<string, any>>;
|
||||
};
|
||||
}
|
||||
|
||||
const TableModal: React.FC<TableModalProps> = ({ content }) => {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col p-0">
|
||||
<style>{`
|
||||
.modal-table {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.modal-table .p-datatable-wrapper {
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.modal-table .p-datatable-table {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-spacing: 0 !important;
|
||||
}
|
||||
.modal-table .p-datatable-thead {
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
z-index: 1 !important;
|
||||
background: white !important;
|
||||
}
|
||||
.modal-table .p-datatable-thead > tr > th {
|
||||
padding: 0.4rem !important;
|
||||
background: #f8f9fa !important;
|
||||
white-space: normal !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.modal-table .p-datatable-tbody > tr > td {
|
||||
padding: 0.3rem 0.4rem !important;
|
||||
line-height: 1 !important;
|
||||
white-space: normal !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.modal-table .p-datatable-tbody > tr:hover {
|
||||
background: #f8f9fa !important;
|
||||
}
|
||||
.p-paginator {
|
||||
padding: 0.15rem !important;
|
||||
margin: 0 !important;
|
||||
border-width: 0 !important;
|
||||
background: white !important;
|
||||
border-top: 1px solid #dee2e6 !important;
|
||||
}
|
||||
.p-column-header-content {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
.p-column-title {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.p-sortable-column-icon {
|
||||
width: 0.75rem !important;
|
||||
height: 0.75rem !important;
|
||||
}
|
||||
.p-datatable-tbody > tr:last-child > td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.p-datatable.p-datatable-gridlines {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.p-paginator .p-paginator-pages .p-paginator-page {
|
||||
min-width: 1.8rem !important;
|
||||
height: 1.8rem !important;
|
||||
margin: 0 0.1rem !important;
|
||||
}
|
||||
.p-paginator .p-paginator-first,
|
||||
.p-paginator .p-paginator-prev,
|
||||
.p-paginator .p-paginator-next,
|
||||
.p-paginator .p-paginator-last {
|
||||
min-width: 1.8rem !important;
|
||||
height: 1.8rem !important;
|
||||
margin: 0 0.1rem !important;
|
||||
}
|
||||
.p-paginator .p-paginator-current {
|
||||
margin: 0 0.3rem !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
<DataTable
|
||||
value={content.data}
|
||||
className="modal-table"
|
||||
scrollable={false}
|
||||
tableStyle={{ width: '100%', margin: 0 }}
|
||||
showGridlines
|
||||
stripedRows
|
||||
sortMode="multiple"
|
||||
removableSort
|
||||
resizableColumns
|
||||
columnResizeMode="fit"
|
||||
size="small"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10]}
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
|
||||
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries"
|
||||
>
|
||||
{content.columns.map(col => (
|
||||
<Column
|
||||
key={col.field}
|
||||
field={col.field}
|
||||
header={col.header}
|
||||
sortable
|
||||
style={{
|
||||
minWidth: '100px',
|
||||
padding: '0.3rem 0.4rem',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
headerStyle={{
|
||||
fontWeight: 600,
|
||||
padding: '0.4rem',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableModal;
|
56
frontend/src/components/FuseMind/modals/commands/parser.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { ModalType } from '../core/types';
|
||||
|
||||
export interface ParsedCommand {
|
||||
type: 'MODAL_COMMAND';
|
||||
action: 'CREATE' | 'SPLIT' | 'UPDATE' | 'STYLE' | 'HELP' | 'RESIZE';
|
||||
params: {
|
||||
modalType?: ModalType;
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
position?: 'left' | 'right' | 'top' | 'bottom';
|
||||
style?: {
|
||||
backgroundColor?: string;
|
||||
};
|
||||
size?: {
|
||||
width?: string;
|
||||
height?: string;
|
||||
};
|
||||
count?: number;
|
||||
content?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const HELP_TEXT = `
|
||||
I can help you with the following commands:
|
||||
- Create modals: "show me a table/text/image"
|
||||
- Split screen: "show two tables side by side"
|
||||
- Style modals: "make right modal blue"
|
||||
- Resize modals: "make modal bigger" or "set modal width to 80%"
|
||||
- Layout: "arrange modals vertically"
|
||||
|
||||
Examples:
|
||||
- "show me options" - displays available commands
|
||||
- "show me a table" - creates a table modal
|
||||
- "make modal 80% wide" - resizes modal width
|
||||
- "split screen horizontally" - arranges modals horizontally
|
||||
`;
|
||||
|
||||
export const COMMAND_PROMPT = `You are a helpful assistant that creates data tables. If the user's message implies they want to see data in a table format (they might mention "table", "data", "information", "stats", etc., or just ask about a topic that would be best shown in a table), create a table about that topic.
|
||||
|
||||
Return ONLY a JSON object in this format:
|
||||
{
|
||||
"columns": [
|
||||
{"field": "column1", "header": "Column 1"},
|
||||
{"field": "column2", "header": "Column 2"}
|
||||
],
|
||||
"data": [
|
||||
{"column1": "value1", "column2": "value2"},
|
||||
{"column1": "value3", "column2": "value4"}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
1. Return ONLY the JSON object, no explanations
|
||||
2. Make sure field names in data match column fields exactly
|
||||
3. Choose appropriate column names based on the topic
|
||||
4. Include at least 5 rows of data
|
||||
5. If the user's message doesn't imply they want tabular data, return null`;
|
55
frontend/src/components/FuseMind/modals/components/Modal.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ModalConfig } from '../core/types';
|
||||
import { useModal } from '../context/ModalContext';
|
||||
|
||||
interface ModalProps extends Omit<ModalConfig, 'content'> {
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
style,
|
||||
content
|
||||
}) => {
|
||||
const { removeModal } = useModal();
|
||||
|
||||
const handleClose = () => {
|
||||
removeModal(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: `${position.y}%`,
|
||||
left: `${position.x}%`,
|
||||
width: position.width,
|
||||
height: position.height,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">Data Table</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { useModal } from '../context/ModalContext';
|
||||
import { ModalConfig } from '../core/types';
|
||||
|
||||
interface ModalWithContent extends ModalConfig {
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export const ModalRenderer: React.FC = () => {
|
||||
const { modals } = useModal();
|
||||
|
||||
return (
|
||||
<>
|
||||
{modals.map((modal) => (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
{...modal as ModalWithContent}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { ModalType, ModalPosition, ModalConfig } from '../core/types';
|
||||
|
||||
interface ModalContextType {
|
||||
modals: ModalConfig[];
|
||||
createModal: (params: { type: ModalType; position?: ModalPosition; content: unknown }) => Promise<ModalConfig>;
|
||||
removeModal: (id: string) => void;
|
||||
updateModalStyle: (id: string, style: { backgroundColor?: string }) => void;
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextType | undefined>(undefined);
|
||||
|
||||
export const ModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [modals, setModals] = useState<ModalConfig[]>([]);
|
||||
|
||||
const createModal = useCallback((params: { type: ModalType; position?: ModalPosition; content: unknown }) => {
|
||||
const newModal: ModalConfig = {
|
||||
id: Date.now().toString(),
|
||||
type: params.type,
|
||||
position: params.position || {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
content: params.content
|
||||
};
|
||||
|
||||
return new Promise<ModalConfig>((resolve) => {
|
||||
setModals(prev => {
|
||||
const newModals = [...prev, newModal];
|
||||
resolve(newModal);
|
||||
return newModals;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeModal = useCallback((id: string) => {
|
||||
setModals(prev => prev.filter(modal => modal.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateModalStyle = useCallback((id: string, style: { backgroundColor?: string }) => {
|
||||
setModals(prev =>
|
||||
prev.map(modal =>
|
||||
modal.id === id ? { ...modal, style: { ...modal.style, ...style } } : modal
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ modals, createModal, removeModal, updateModalStyle }}>
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useModal must be used within a ModalProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { ModalProvider as Provider } from './ModalContext';
|
||||
|
||||
export const ModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Provider>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
};
|
44
frontend/src/components/FuseMind/modals/core/types.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export type ModalPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
|
||||
export type ModalType = 'table' | 'default';
|
||||
|
||||
export type ModalId = string;
|
||||
|
||||
export interface Modal {
|
||||
id: ModalId;
|
||||
type: ModalType;
|
||||
position: ModalPosition;
|
||||
style?: {
|
||||
backgroundColor?: string;
|
||||
};
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
export interface ModalConfig {
|
||||
id: ModalId;
|
||||
type: ModalType;
|
||||
position: ModalPosition;
|
||||
content: unknown;
|
||||
style?: {
|
||||
backgroundColor?: string;
|
||||
borderRadius?: string;
|
||||
padding?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ModalAction =
|
||||
| { type: 'CREATE_MODAL'; payload: Omit<ModalConfig, 'id'> }
|
||||
| { type: 'UPDATE_MODAL'; payload: { id: ModalId } & Partial<ModalConfig> }
|
||||
| { type: 'DELETE_MODAL'; payload: { id: ModalId } }
|
||||
| { type: 'UPDATE_POSITION'; payload: { id: ModalId; position: Partial<ModalPosition> } }
|
||||
| { type: 'UPDATE_STYLE'; payload: { id: ModalId; style: Partial<ModalConfig['style']> } };
|
||||
|
||||
export interface ModalState {
|
||||
modals: Record<ModalId, ModalConfig>;
|
||||
activeModal: ModalId | null;
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ModalType, ModalPosition, ModalConfig } from '../core/types';
|
||||
|
||||
export const useModalCommands = () => {
|
||||
const [modals, setModals] = useState<ModalConfig[]>([]);
|
||||
const [layout, setLayout] = useState<'horizontal' | 'vertical'>('horizontal');
|
||||
|
||||
const calculatePositions = useCallback((count: number, layout: 'horizontal' | 'vertical') => {
|
||||
const positions: ModalPosition[] = [];
|
||||
const gap = 10;
|
||||
|
||||
if (layout === 'horizontal') {
|
||||
const width = (100 - gap * (count - 1)) / count;
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions.push({
|
||||
x: i * (width + gap),
|
||||
y: 0,
|
||||
width: `${width}%`,
|
||||
height: '100%'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const height = (100 - gap * (count - 1)) / count;
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions.push({
|
||||
x: 0,
|
||||
y: i * (height + gap),
|
||||
width: '100%',
|
||||
height: `${height}%`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}, []);
|
||||
|
||||
const createModal = useCallback((params: { type: ModalType; position?: ModalPosition; content: unknown }) => {
|
||||
const newModal: ModalConfig = {
|
||||
id: Date.now().toString(),
|
||||
type: params.type,
|
||||
position: params.position || {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
content: params.content
|
||||
};
|
||||
|
||||
return new Promise<ModalConfig>((resolve) => {
|
||||
setModals(prev => {
|
||||
const newModals = [...prev, newModal];
|
||||
resolve(newModal);
|
||||
return newModals;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeModal = useCallback((id: string) => {
|
||||
setModals(prev => prev.filter(modal => modal.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateModalStyle = useCallback((id: string, style: { backgroundColor?: string }) => {
|
||||
setModals(prev =>
|
||||
prev.map(modal =>
|
||||
modal.id === id ? { ...modal, style: { ...modal.style, ...style } } : modal
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const splitScreen = useCallback((modalIds: string[], newLayout: 'horizontal' | 'vertical') => {
|
||||
setLayout(newLayout);
|
||||
setModals(prev => {
|
||||
const targetModals = prev.filter(modal => modalIds.includes(modal.id));
|
||||
const otherModals = prev.filter(modal => !modalIds.includes(modal.id));
|
||||
|
||||
const positions = calculatePositions(targetModals.length, newLayout);
|
||||
const updatedTargetModals = targetModals.map((modal, index) => ({
|
||||
...modal,
|
||||
position: positions[index]
|
||||
}));
|
||||
|
||||
return [...otherModals, ...updatedTargetModals];
|
||||
});
|
||||
}, [calculatePositions]);
|
||||
|
||||
const getModalsByPosition = useCallback((position: 'left' | 'right' | 'top' | 'bottom') => {
|
||||
return modals.filter(modal => {
|
||||
switch (position) {
|
||||
case 'left':
|
||||
return modal.position.x === 0;
|
||||
case 'right':
|
||||
return modal.position.x > 0;
|
||||
case 'top':
|
||||
return modal.position.y === 0;
|
||||
case 'bottom':
|
||||
return modal.position.y > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [modals]);
|
||||
|
||||
return {
|
||||
modals,
|
||||
createModal,
|
||||
removeModal,
|
||||
updateModalStyle,
|
||||
splitScreen,
|
||||
getModalsByPosition,
|
||||
layout
|
||||
};
|
||||
};
|
21
frontend/src/components/FuseMind/modals/hooks/useModalGrid.d.ts
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
import { ModalPosition } from '../core/types';
|
||||
|
||||
export interface GridPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GridModal {
|
||||
id: string;
|
||||
position: GridPosition;
|
||||
}
|
||||
|
||||
export const useModalGrid: () => {
|
||||
addModal: (id: string, width: number, height: number) => GridPosition | null;
|
||||
removeModal: (id: string) => void;
|
||||
moveModal: (id: string, newPos: GridPosition) => boolean;
|
||||
modals: GridModal[];
|
||||
gridState: boolean[][];
|
||||
};
|
111
frontend/src/components/FuseMind/modals/hooks/useModalGrid.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface GridPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GridModal {
|
||||
id: string;
|
||||
position: GridPosition;
|
||||
}
|
||||
|
||||
const GRID_COLS = 20;
|
||||
const GRID_ROWS = 8;
|
||||
|
||||
export const useModalGrid = () => {
|
||||
const [gridState, setGridState] = useState<boolean[][]>(
|
||||
Array(GRID_ROWS).fill(null).map(() => Array(GRID_COLS).fill(false))
|
||||
);
|
||||
const [modals, setModals] = useState<GridModal[]>([]);
|
||||
|
||||
// Check if a position is available in the grid
|
||||
const isPositionAvailable = useCallback((pos: GridPosition) => {
|
||||
for (let y = pos.y; y < pos.y + pos.height; y++) {
|
||||
for (let x = pos.x; x < pos.x + pos.width; x++) {
|
||||
if (y >= GRID_ROWS || x >= GRID_COLS || gridState[y][x]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, [gridState]);
|
||||
|
||||
// Find next available position for given dimensions
|
||||
const findNextPosition = useCallback((width: number, height: number): GridPosition | null => {
|
||||
for (let y = 0; y <= GRID_ROWS - height; y++) {
|
||||
for (let x = 0; x <= GRID_COLS - width; x++) {
|
||||
const pos = { x, y, width, height };
|
||||
if (isPositionAvailable(pos)) {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [isPositionAvailable]);
|
||||
|
||||
// Update grid state for a position
|
||||
const updateGridState = useCallback((pos: GridPosition, value: boolean) => {
|
||||
setGridState(prev => {
|
||||
const newState = prev.map(row => [...row]);
|
||||
for (let y = pos.y; y < pos.y + pos.height; y++) {
|
||||
for (let x = pos.x; x < pos.x + pos.width; x++) {
|
||||
newState[y][x] = value;
|
||||
}
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Add a new modal to the grid
|
||||
const addModal = useCallback((id: string, width: number, height: number) => {
|
||||
const position = findNextPosition(width, height);
|
||||
if (!position) return null;
|
||||
|
||||
const newModal = { id, position };
|
||||
setModals(prev => [...prev, newModal]);
|
||||
updateGridState(position, true);
|
||||
return position;
|
||||
}, [findNextPosition, updateGridState]);
|
||||
|
||||
// Remove a modal from the grid
|
||||
const removeModal = useCallback((id: string) => {
|
||||
setModals(prev => {
|
||||
const modal = prev.find(m => m.id === id);
|
||||
if (modal) {
|
||||
updateGridState(modal.position, false);
|
||||
}
|
||||
return prev.filter(m => m.id !== id);
|
||||
});
|
||||
}, [updateGridState]);
|
||||
|
||||
// Move a modal to a new position
|
||||
const moveModal = useCallback((id: string, newPos: GridPosition) => {
|
||||
if (!isPositionAvailable(newPos)) return false;
|
||||
|
||||
setModals(prev => {
|
||||
const modal = prev.find(m => m.id === id);
|
||||
if (!modal) return prev;
|
||||
|
||||
updateGridState(modal.position, false);
|
||||
updateGridState(newPos, true);
|
||||
|
||||
return prev.map(m =>
|
||||
m.id === id ? { ...m, position: newPos } : m
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}, [isPositionAvailable, updateGridState]);
|
||||
|
||||
return {
|
||||
addModal,
|
||||
removeModal,
|
||||
moveModal,
|
||||
modals,
|
||||
gridState
|
||||
};
|
||||
};
|
||||
|
||||
export type { GridPosition, GridModal };
|
85
frontend/src/components/FuseMind/modals/store/modalStore.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import create from 'zustand';
|
||||
import { ModalState, ModalAction, ModalConfig, ModalId } from '../core/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const initialState: ModalState = {
|
||||
modals: {},
|
||||
activeModal: null
|
||||
};
|
||||
|
||||
export const useModalStore = create<ModalState & {
|
||||
dispatch: (action: ModalAction) => void;
|
||||
}>((set) => ({
|
||||
...initialState,
|
||||
dispatch: (action) => {
|
||||
set((state) => {
|
||||
switch (action.type) {
|
||||
case 'CREATE_MODAL':
|
||||
const id = nanoid();
|
||||
return {
|
||||
...state,
|
||||
modals: {
|
||||
...state.modals,
|
||||
[id]: {
|
||||
...action.payload,
|
||||
id
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'UPDATE_MODAL':
|
||||
return {
|
||||
...state,
|
||||
modals: {
|
||||
...state.modals,
|
||||
[action.payload.id]: {
|
||||
...state.modals[action.payload.id],
|
||||
...action.payload
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'DELETE_MODAL':
|
||||
const { [action.payload.id]: _, ...remainingModals } = state.modals;
|
||||
return {
|
||||
...state,
|
||||
modals: remainingModals,
|
||||
activeModal: state.activeModal === action.payload.id ? null : state.activeModal
|
||||
};
|
||||
|
||||
case 'UPDATE_POSITION':
|
||||
return {
|
||||
...state,
|
||||
modals: {
|
||||
...state.modals,
|
||||
[action.payload.id]: {
|
||||
...state.modals[action.payload.id],
|
||||
position: {
|
||||
...state.modals[action.payload.id].position,
|
||||
...action.payload.position
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'UPDATE_STYLE':
|
||||
return {
|
||||
...state,
|
||||
modals: {
|
||||
...state.modals,
|
||||
[action.payload.id]: {
|
||||
...state.modals[action.payload.id],
|
||||
style: {
|
||||
...state.modals[action.payload.id].style,
|
||||
...action.payload.style
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
91
frontend/src/components/LoginModal.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Button from '../shared/components/_V2/Button';
|
||||
import Input from '../shared/components/_V2/InputText';
|
||||
import { useAuthStore } from '../state/stores/useAuthStore';
|
||||
|
||||
interface LoginModalProps {
|
||||
redirectUrl?: string;
|
||||
onLoginSuccess?: () => void;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<LoginModalProps> = ({ redirectUrl = '/dashboard', onLoginSuccess }) => {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsTransitioning(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess();
|
||||
}
|
||||
navigate(redirectUrl, { replace: true });
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
} finally {
|
||||
setIsTransitioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin();
|
||||
}
|
||||
};
|
||||
|
||||
const header = () => <h2 className='text-2xl'>Fusero</h2>;
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 350);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isTransitioning]);
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center w-full h-full overflow-hidden'>
|
||||
<Card
|
||||
header={header}
|
||||
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
className='flex flex-col items-center justify-center gap-4 p-8 bg-white rounded-md shadowlg'
|
||||
|
||||
>
|
||||
<div className='w-full'>
|
||||
<label htmlFor='username' className='text-sm'>
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
id='username'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<label htmlFor='password' className='text-sm'>
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id='password'
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
<div className='pt-8'>
|
||||
<Button label='Login' className='w-full' onClick={handleLogin} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
47
frontend/src/components/PromptBox.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
type PromptBoxProps = {
|
||||
onSubmit: (message: string) => void;
|
||||
};
|
||||
|
||||
const PromptBox: React.FC<PromptBoxProps> = ({ onSubmit }) => {
|
||||
const [promptMessage, setPromptMessage] = useState<string>('');
|
||||
|
||||
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (promptMessage.trim()) {
|
||||
onSubmit(promptMessage);
|
||||
setPromptMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='p-4 text-white bg-gray-600' style={{ height: '6rem' }}>
|
||||
<form onSubmit={handleFormSubmit} className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={promptMessage}
|
||||
onChange={(e) => setPromptMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// set up typing
|
||||
handleFormSubmit(e as unknown as React.FormEvent<HTMLFormElement>); // Trigger submit on Enter
|
||||
}
|
||||
}}
|
||||
className='flex-grow p-2 text-black rounded'
|
||||
placeholder='Enter your message...'
|
||||
/>
|
||||
<button type='submit' className='p-4 text-white rounded bg-accent'>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PromptBox.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default PromptBox;
|
478
frontend/src/components/SystemSelectionModal.tsx
Normal file
@ -0,0 +1,478 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { FileUpload } from 'primereact/fileupload';
|
||||
import Button from '../shared/components/_V2/Button';
|
||||
import Dropdown from '../shared/components/_V2/Dropdown';
|
||||
import Divider from '../shared/components/_V1/Divider';
|
||||
|
||||
import fusero_logo from '../assets/fusero_logo.svg';
|
||||
import roc_logo from '../assets/roc_logo.png';
|
||||
import { useSystemStore } from '../state/stores/useSystemStore';
|
||||
|
||||
export type SystemDropdownOption = {
|
||||
id: number;
|
||||
label: string;
|
||||
name: string;
|
||||
businessLabel: string;
|
||||
urlSlug: string;
|
||||
logo: string;
|
||||
category: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
description?: string;
|
||||
publicPath?: string;
|
||||
enterprises?: Enterprise[];
|
||||
};
|
||||
|
||||
export type Enterprise = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export interface SystemSelectionModalProps {
|
||||
systemDetails: SystemDropdownOption | null;
|
||||
onSystemSelected: (systemData: any) => void;
|
||||
onLogoChange: (newLogoPath: string) => void;
|
||||
}
|
||||
|
||||
// Add system default routes configuration
|
||||
const systemDefaultRoutes = {
|
||||
council: 'chat',
|
||||
fusemind: 'home',
|
||||
};
|
||||
|
||||
const HARDCODED_SYSTEMS = [
|
||||
{
|
||||
id: 1,
|
||||
label: 'Canvas API',
|
||||
name: 'Canvas API',
|
||||
businessLabel: 'Canvas',
|
||||
urlSlug: 'canvas',
|
||||
logo: roc_logo,
|
||||
category: 'Apps',
|
||||
method: 'GET',
|
||||
path: '/api/v1/canvas-endpoints',
|
||||
description: 'Integration with Canvas LMS',
|
||||
publicPath: '/canvas-endpoints',
|
||||
enterprises: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'FuseMind',
|
||||
name: 'FuseMind',
|
||||
businessLabel: 'FuseMind',
|
||||
urlSlug: 'fusemind',
|
||||
logo: fusero_logo,
|
||||
category: 'Apps',
|
||||
method: 'GET',
|
||||
path: '/api/v1/fusemind',
|
||||
description: 'FuseMind AI Platform',
|
||||
publicPath: '/fusemind',
|
||||
enterprises: [],
|
||||
},
|
||||
];
|
||||
|
||||
const SystemSelectionModal = ({ systemDetails }: SystemSelectionModalProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [selectedSystem, setSelectedSystem] = useState<SystemDropdownOption | null>(null);
|
||||
const [selectedEnterprise, setSelectedEnterprise] = useState<number | null>(null);
|
||||
const [showAddSystemDialog, setShowAddSystemDialog] = useState(false);
|
||||
const [showEditLogoDialog, setShowEditLogoDialog] = useState(false);
|
||||
const [systems, setSystems] = useState<SystemDropdownOption[]>(HARDCODED_SYSTEMS);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [uploadedLogo, setUploadedLogo] = useState<string | null>(null);
|
||||
const [newSystem, setNewSystem] = useState({
|
||||
name: '',
|
||||
businessLabel: '',
|
||||
urlSlug: '',
|
||||
method: '',
|
||||
path: '',
|
||||
description: '',
|
||||
publicPath: '',
|
||||
});
|
||||
const { setSystem } = useSystemStore();
|
||||
|
||||
useEffect(() => {
|
||||
[fusero_logo].forEach((image) => {
|
||||
const img = new Image();
|
||||
img.src = image;
|
||||
});
|
||||
if (systemDetails) {
|
||||
setSelectedSystem(systemDetails);
|
||||
setSystem(systemDetails);
|
||||
}
|
||||
}, [systemDetails, setSystem]);
|
||||
|
||||
const handleSystemChange = useCallback(
|
||||
(e: { value: SystemDropdownOption }) => {
|
||||
setSelectedSystem(e.value);
|
||||
setSystem(e.value);
|
||||
},
|
||||
[setSystem]
|
||||
);
|
||||
|
||||
const handleSelectSystem = () => {
|
||||
if (selectedSystem) {
|
||||
navigate(`/dashboard/${selectedSystem.urlSlug}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterpriseChange = useCallback((e: { value: number }) => {
|
||||
setSelectedEnterprise(e.value);
|
||||
}, []);
|
||||
|
||||
const handleLogoUpload = async (event: any) => {
|
||||
const file = event.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const base64 = e.target?.result as string;
|
||||
setUploadedLogo(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateLogo = async () => {
|
||||
if (!selectedSystem || !uploadedLogo) return;
|
||||
|
||||
try {
|
||||
// Upload the new logo
|
||||
const logoResponse = await fetch('/api/v1/assets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `${selectedSystem.name}-logo`,
|
||||
type: 'image',
|
||||
data: uploadedLogo,
|
||||
mimeType: 'image/png',
|
||||
assetType: 'system_logo',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!logoResponse.ok) {
|
||||
throw new Error('Failed to upload logo');
|
||||
}
|
||||
|
||||
const logoData = await logoResponse.json();
|
||||
|
||||
// Update the system with the new logo ID
|
||||
const response = await fetch(`/api/v1/apps/${selectedSystem.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
logoId: logoData.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update system logo');
|
||||
}
|
||||
|
||||
const updatedSystem = await response.json();
|
||||
|
||||
// Update the local state
|
||||
setSystems(prev => prev.map(system =>
|
||||
system.id === selectedSystem.id
|
||||
? { ...system, logo: updatedSystem.logo || fusero_logo }
|
||||
: system
|
||||
));
|
||||
|
||||
setSelectedSystem(prev => prev ? { ...prev, logo: updatedSystem.logo || fusero_logo } : null);
|
||||
setShowEditLogoDialog(false);
|
||||
setUploadedLogo(null);
|
||||
} catch (error) {
|
||||
console.error('Error updating logo:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSystem = async () => {
|
||||
try {
|
||||
// First upload the logo if exists
|
||||
let logoId = null;
|
||||
if (uploadedLogo) {
|
||||
const logoResponse = await fetch('/api/v1/assets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `${newSystem.name}-logo`,
|
||||
type: 'image',
|
||||
data: uploadedLogo,
|
||||
mimeType: 'image/png',
|
||||
assetType: 'system_logo',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!logoResponse.ok) {
|
||||
throw new Error('Failed to upload logo');
|
||||
}
|
||||
|
||||
const logoData = await logoResponse.json();
|
||||
logoId = logoData.id;
|
||||
}
|
||||
|
||||
// Then create the system
|
||||
const response = await fetch('/api/v1/apps', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...newSystem,
|
||||
logoId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create system');
|
||||
}
|
||||
|
||||
const createdSystem = await response.json();
|
||||
const systemOption: SystemDropdownOption = {
|
||||
...createdSystem,
|
||||
label: createdSystem.name,
|
||||
businessLabel: createdSystem.businessLabel || createdSystem.name,
|
||||
logo: createdSystem.logo || fusero_logo,
|
||||
category: 'Apps',
|
||||
};
|
||||
|
||||
setSystems(prev => [...prev, systemOption]);
|
||||
setShowAddSystemDialog(false);
|
||||
setNewSystem({
|
||||
name: '',
|
||||
businessLabel: '',
|
||||
urlSlug: '',
|
||||
method: '',
|
||||
path: '',
|
||||
description: '',
|
||||
publicPath: '',
|
||||
});
|
||||
setUploadedLogo(null);
|
||||
} catch (error) {
|
||||
console.error('Error creating system:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
style={{ border: 'none', boxShadow: 'none', overflow: 'hidden' }}
|
||||
className='px-8 bg-white'
|
||||
>
|
||||
<h2 className='my-2 text-xl font-semibold text-gray-700'>Apps & Flows</h2>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Dropdown
|
||||
id='system'
|
||||
value={selectedSystem}
|
||||
options={systems}
|
||||
onChange={handleSystemChange}
|
||||
optionLabel='businessLabel'
|
||||
placeholder='Select an integration'
|
||||
className='flex-1'
|
||||
loading={isLoading}
|
||||
/>
|
||||
{/* <Button
|
||||
label='Add System'
|
||||
onClick={() => setShowAddSystemDialog(true)}
|
||||
className='px-4 py-2 text-white transition-colors bg-blue-500 border-2 border-blue-500 rounded-md hover:bg-white hover:text-blue-500'
|
||||
/> */}
|
||||
</div>
|
||||
{selectedSystem && selectedSystem.enterprises && (
|
||||
<Dropdown
|
||||
id='enterprise'
|
||||
value={selectedEnterprise}
|
||||
options={selectedSystem.enterprises.map((enterprise) => ({
|
||||
label: enterprise.name,
|
||||
value: enterprise.id,
|
||||
}))}
|
||||
onChange={handleEnterpriseChange}
|
||||
placeholder='Select an enterprise'
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
label='Select System'
|
||||
onClick={handleSelectSystem}
|
||||
className='w-full mt-4 text-white transition-colors bg-teal-500 border-2 border-teal-500 rounded-md hover:bg-white hover:text-teal-500'
|
||||
/>
|
||||
<Divider />
|
||||
</Card>
|
||||
{selectedSystem && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={selectedSystem.logo}
|
||||
alt={`${selectedSystem.label} Logo`}
|
||||
className='w-48'
|
||||
/>
|
||||
<Button
|
||||
label="Edit Logo"
|
||||
onClick={() => setShowEditLogoDialog(true)}
|
||||
className="absolute bottom-0 right-0 px-2 py-1 text-sm text-white bg-blue-500 rounded hover:bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
header="Add New System"
|
||||
visible={showAddSystemDialog}
|
||||
style={{ width: '50vw' }}
|
||||
onHide={() => setShowAddSystemDialog(false)}
|
||||
footer={
|
||||
<div>
|
||||
<Button
|
||||
label="Cancel"
|
||||
onClick={() => setShowAddSystemDialog(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Button
|
||||
label="Create"
|
||||
onClick={handleAddSystem}
|
||||
className="bg-blue-500 text-white"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block mb-2">Name</label>
|
||||
<InputText
|
||||
value={newSystem.name}
|
||||
onChange={(e) => setNewSystem({ ...newSystem, name: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">Business Label</label>
|
||||
<InputText
|
||||
value={newSystem.businessLabel}
|
||||
onChange={(e) => setNewSystem({ ...newSystem, businessLabel: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">URL Slug</label>
|
||||
<InputText
|
||||
value={newSystem.urlSlug}
|
||||
onChange={(e) => setNewSystem({ ...newSystem, urlSlug: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">Method</label>
|
||||
<InputText
|
||||
value={newSystem.method}
|
||||
onChange={(e) => setNewSystem({ ...newSystem, method: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">Path</label>
|
||||
<InputText
|
||||
value={newSystem.path}
|
||||
onChange={(e) => setNewSystem({ ...newSystem, path: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">Description</label>
|
||||
<InputTextarea
|
||||
value={newSystem.description}
|
||||
onChange={(e) => setNewSystem({ ...newSystem, description: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">Public Path</label>
|
||||
<InputText
|
||||
value={newSystem.publicPath}
|
||||
onChange={(e) => setNewSystem({ ...newSystem, publicPath: e.target.value })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">Logo</label>
|
||||
<FileUpload
|
||||
mode="basic"
|
||||
name="logo"
|
||||
accept="image/*"
|
||||
maxFileSize={1000000}
|
||||
chooseLabel="Upload Logo"
|
||||
onUpload={handleLogoUpload}
|
||||
auto
|
||||
customUpload
|
||||
/>
|
||||
{uploadedLogo && (
|
||||
<div className="mt-2">
|
||||
<img src={uploadedLogo} alt="Uploaded logo" className="w-32 h-32 object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
header="Update System Logo"
|
||||
visible={showEditLogoDialog}
|
||||
style={{ width: '30vw' }}
|
||||
onHide={() => setShowEditLogoDialog(false)}
|
||||
footer={
|
||||
<div>
|
||||
<Button
|
||||
label="Cancel"
|
||||
onClick={() => setShowEditLogoDialog(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Button
|
||||
label="Update"
|
||||
onClick={handleUpdateLogo}
|
||||
className="bg-blue-500 text-white"
|
||||
disabled={!uploadedLogo}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block mb-2">Current Logo</label>
|
||||
{selectedSystem && (
|
||||
<img
|
||||
src={selectedSystem.logo}
|
||||
alt="Current logo"
|
||||
className="w-32 h-32 object-contain mb-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2">New Logo</label>
|
||||
<FileUpload
|
||||
mode="basic"
|
||||
name="logo"
|
||||
accept="image/*"
|
||||
maxFileSize={1000000}
|
||||
chooseLabel="Upload New Logo"
|
||||
onUpload={handleLogoUpload}
|
||||
auto
|
||||
customUpload
|
||||
/>
|
||||
{uploadedLogo && (
|
||||
<div className="mt-2">
|
||||
<img src={uploadedLogo} alt="New logo preview" className="w-32 h-32 object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSelectionModal;
|
110
frontend/src/components/TenantProfile.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import logo_fusero from '../assets/fusero_logo.svg';
|
||||
import Divider from '../shared/components/_V1/Divider';
|
||||
import Button from '../shared/components/_V2/Button';
|
||||
import { useAuthStore } from '../state/stores/useAuthStore';
|
||||
import { TabPanel, TabView } from 'primereact/tabview';
|
||||
import 'primereact/resources/themes/saga-blue/theme.css'; // Import theme for TabView
|
||||
import 'primereact/resources/primereact.min.css'; // Core CSS for TabView
|
||||
import ApiCalls from './ApiCalls'; // Import the new component
|
||||
|
||||
const Notifications = () => (
|
||||
<div className='p-4'>
|
||||
<h2 className='text-lg font-semibold'>Notifications</h2>
|
||||
<p>You have no new notifications.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProfileDetails = ({ user }) => {
|
||||
if (!user) {
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<h2 className='text-lg font-semibold'>Profile Details</h2>
|
||||
<p>No user information available.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<h2 className='text-lg font-semibold'>Profile Details</h2>
|
||||
<p>Tenant ID: {user.id}</p>
|
||||
<p>Username: {user.username}</p>
|
||||
<p>Roles: {user.roles.join(', ')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TenantProfile = () => {
|
||||
const { user, logout } = useAuthStore();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const username = user ? user.username : 'Guest';
|
||||
const roles = user && user.roles ? user.roles.join(', ') : 'No roles assigned';
|
||||
const tenantId = user ? user.id : 'No tenant ID found';
|
||||
|
||||
return (
|
||||
<div className='container w-full h-full p-4 mx-auto'>
|
||||
<div
|
||||
className={`transition-opacity duration-500 ${isVisible ? 'opacity-100' : 'opacity-0'
|
||||
} flex flex-col items-center justify-center w-full h-full p-4 relative`}
|
||||
>
|
||||
<div className='flex flex-col w-full h-full overflow-hidden'>
|
||||
<div className='flex items-center justify-between w-full px-4 py-2 bg-white'>
|
||||
<div className='flex items-center'>
|
||||
<img src={logo_fusero} alt='Fusero Logo' className='w-8 h-8 mr-2' />
|
||||
<h1 className='text-lg font-semibold text-teal-600'>Tenant Dashboard</h1>
|
||||
</div>
|
||||
<Button label='Logout' onClick={handleLogout} />
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<TabView
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(e) => setActiveIndex(e.index)}
|
||||
className='custom-tabview'
|
||||
>
|
||||
<TabPanel header='Home' headerClassName='custom-tab-header'>
|
||||
<div className='flex flex-col items-center justify-center p-4'>
|
||||
<img src={logo_fusero} alt='Fusero Logo' className='w-1/3 h-auto object-contain' />
|
||||
<h2 className='text-lg font-semibold'>Welcome back, {username}!</h2>
|
||||
{/* <p className='text-xs'>Tenant ID: {tenantId}</p>
|
||||
<p className='text-xs'>{roles}</p> */}
|
||||
<Divider />
|
||||
<p className='text-sm text-center'>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel header='Details' headerClassName='custom-tab-header'>
|
||||
<ProfileDetails user={user} />
|
||||
</TabPanel>
|
||||
<TabPanel header='Notifications' headerClassName='custom-tab-header'>
|
||||
<Notifications />
|
||||
</TabPanel>
|
||||
<TabPanel header='Recent Calls' headerClassName='custom-tab-header'>
|
||||
<ApiCalls />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</div>
|
||||
<div className='flex flex-col items-center justify-center flex-grow overflow-y-auto'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantProfile;
|
904
frontend/src/components/canvas-api/CanvasEndpoints.tsx
Normal file
@ -0,0 +1,904 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Column } from 'primereact/column';
|
||||
import { FilterMatchMode } from 'primereact/api';
|
||||
import TableGroupHeader from '../../shared/components/_V1/TableGroupHeader';
|
||||
import TableGroup from '../../shared/components/_V1/TableGroup';
|
||||
import { TSizeOptionValue } from './DataTable.types';
|
||||
import { api } from '../../services/api';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import ChatGptModal from './ChatGPTModal';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { useAuthStore } from '../../state/stores/useAuthStore';
|
||||
import SettingsMenu, { SettingsMenuItem } from '../../shared/components/SettingsMenu';
|
||||
import ManageDocsModal from './ManageDocsModal';
|
||||
import PromptCreatorModal from './PromptCreatorModal';
|
||||
import { Tooltip } from 'primereact/tooltip';
|
||||
|
||||
const CANVAS_APP_ID = 1; // Hardcoded Canvas app ID
|
||||
const CANVAS_API_BASE_URL = import.meta.env.VITE_CANVAS_API_BASE_URL || 'https://canvas.instructure.com/api/v1';
|
||||
|
||||
export default function CanvasEndpoints() {
|
||||
const [endpoints, setEndpoints] = useState<any[]>([]);
|
||||
const [filters, setFilters] = useState<any>(null);
|
||||
const [globalFilterValue, setGlobalFilterValue] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Large', value: 'large' },
|
||||
];
|
||||
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
const [showCallModal, setShowCallModal] = useState(false);
|
||||
const [callModalData, setCallModalData] = useState<any[]>([]);
|
||||
const [callModalColumns, setCallModalColumns] = useState<string[]>([]);
|
||||
const [callModalLoading, setCallModalLoading] = useState(false);
|
||||
const [callEndpoint, setCallEndpoint] = useState<any>(null);
|
||||
const [callModalFilters, setCallModalFilters] = useState<any>(null);
|
||||
const [callModalGlobalFilterValue, setCallModalGlobalFilterValue] = useState('');
|
||||
const [callModalSize, setCallModalSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
|
||||
const [callModalFirst, setCallModalFirst] = useState(0);
|
||||
const [callModalRows, setCallModalRows] = useState(10);
|
||||
const [showChatGptModal, setShowChatGptModal] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState('');
|
||||
const [chatMessages, setChatMessages] = useState<{ role: 'user' | 'assistant'; content: string }[]>([]);
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const toast = useRef(null);
|
||||
const [editEndpointId, setEditEndpointId] = useState<number | null>(null);
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [apiKeyLoading, setApiKeyLoading] = useState(false);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const [appId, setAppId] = useState<number | null>(CANVAS_APP_ID);
|
||||
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
|
||||
const [editByVoiceEndpoint, setEditByVoiceEndpoint] = useState<any>(null);
|
||||
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
||||
const [liveTranscript, setLiveTranscript] = useState('');
|
||||
const [showVoiceInfoModal, setShowVoiceInfoModal] = useState(false);
|
||||
const [fullTranscript, setFullTranscript] = useState('');
|
||||
const [showCogMenu, setShowCogMenu] = useState(false);
|
||||
const cogMenuRef = useRef<any>(null);
|
||||
const [showManageDocs, setShowManageDocs] = useState(false);
|
||||
const [showPromptCreator, setShowPromptCreator] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Load API key from localStorage if available
|
||||
const storedKey = localStorage.getItem('canvas_api_key');
|
||||
if (storedKey) setApiKey(storedKey);
|
||||
initFilters();
|
||||
fetchEndpoints();
|
||||
}, []);
|
||||
|
||||
const fetchEndpoints = async () => {
|
||||
try {
|
||||
const response = await api('get', '/api/v1/canvas-api/endpoints');
|
||||
setEndpoints(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoints:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initFilters = () => {
|
||||
setFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
});
|
||||
setGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
initFilters();
|
||||
};
|
||||
|
||||
const onGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
let _filters = { ...filters };
|
||||
_filters['global'].value = value;
|
||||
setFilters(_filters);
|
||||
setGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
// Utility to ensure path never starts with /api/v1
|
||||
function sanitizeCanvasPath(path: string) {
|
||||
return path.replace(/^\/api\/v1/, '');
|
||||
}
|
||||
|
||||
const handleCreateEndpoint = async () => {
|
||||
try {
|
||||
const sanitizedEndpoint = { ...newEndpoint, path: sanitizeCanvasPath(newEndpoint.path) };
|
||||
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
|
||||
setShowCreateModal(false);
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
fetchEndpoints();
|
||||
} catch (error) {
|
||||
console.error('Failed to create endpoint:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallEndpoint = async (endpoint: any) => {
|
||||
setShowCallModal(true);
|
||||
setCallModalLoading(true);
|
||||
setCallEndpoint(endpoint);
|
||||
try {
|
||||
const res = await api('post', '/api/v1/canvas-api/proxy-external', {
|
||||
path: endpoint.path,
|
||||
method: endpoint.method || 'GET',
|
||||
});
|
||||
let data = res.data;
|
||||
if (!Array.isArray(data)) data = [data];
|
||||
setCallModalData(data);
|
||||
setCallModalColumns(data.length > 0 ? Object.keys(data[0]) : []);
|
||||
} catch (error) {
|
||||
setCallModalData([]);
|
||||
setCallModalColumns([]);
|
||||
} finally {
|
||||
setCallModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initCallModalFilters = () => {
|
||||
setCallModalFilters({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
});
|
||||
setCallModalGlobalFilterValue('');
|
||||
};
|
||||
|
||||
const clearCallModalFilter = () => {
|
||||
initCallModalFilters();
|
||||
};
|
||||
|
||||
const onCallModalGlobalFilterChange = (e: { target: { value: any } }) => {
|
||||
const value = e.target.value;
|
||||
let _filters = { ...callModalFilters };
|
||||
_filters['global'].value = value;
|
||||
setCallModalFilters(_filters);
|
||||
setCallModalGlobalFilterValue(value);
|
||||
};
|
||||
|
||||
const handleChatPromptSend = async (promptOverride?: string) => {
|
||||
const prompt = promptOverride !== undefined ? promptOverride : chatPrompt;
|
||||
if (!prompt.trim()) return;
|
||||
setChatMessages(prev => [...prev, { role: 'user', content: prompt }]);
|
||||
setChatLoading(true);
|
||||
setChatError(null);
|
||||
try {
|
||||
const formattedRequest = { data: prompt };
|
||||
const response = await api(
|
||||
'post',
|
||||
'/api/v1/canvas-api/chatgpt/completions',
|
||||
formattedRequest,
|
||||
undefined,
|
||||
'json',
|
||||
60,
|
||||
true
|
||||
);
|
||||
const data = response.data;
|
||||
let isEndpointJson = false;
|
||||
if (data && data.responseText) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = typeof data.responseText === 'string' ? JSON.parse(data.responseText) : data.responseText;
|
||||
} catch (e) {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && parsed.name && parsed.method && parsed.path) {
|
||||
// Auto-create endpoint
|
||||
const sanitizedParsed = { ...parsed, path: sanitizeCanvasPath(parsed.path) };
|
||||
await api('post', '/api/v1/canvas-api/endpoints', sanitizedParsed);
|
||||
fetchEndpoints();
|
||||
isEndpointJson = true;
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'success', summary: 'Endpoint Created', detail: `Endpoint "${parsed.name}" created successfully!` });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isEndpointJson) {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: typeof data.responseText === 'string' ? data.responseText : JSON.stringify(data.responseText, null, 2) }]);
|
||||
}
|
||||
} catch (err) {
|
||||
setChatError('Failed to get response from server.');
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: 'Failed to get response from server.' }]);
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
setChatPrompt('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatPromptKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleChatPromptSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = async (endpoint: any) => {
|
||||
try {
|
||||
await api('delete', `/api/v1/canvas-api/endpoints/${endpoint.id}`);
|
||||
fetchEndpoints();
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'success', summary: 'Endpoint Deleted', detail: `Endpoint "${endpoint.name}" deleted successfully!` });
|
||||
}
|
||||
} catch (error) {
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'error', summary: 'Delete Failed', detail: 'Failed to delete endpoint.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (endpoint: any) => {
|
||||
setEditEndpointId(endpoint.id);
|
||||
setNewEndpoint({
|
||||
name: endpoint.name,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
description: endpoint.description || '',
|
||||
publicPath: endpoint.publicPath || '',
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleSaveEndpoint = async () => {
|
||||
try {
|
||||
const sanitizedEndpoint = { ...newEndpoint, path: sanitizeCanvasPath(newEndpoint.path) };
|
||||
if (editEndpointId) {
|
||||
await api('put', `/api/v1/canvas-api/endpoints/${editEndpointId}`, sanitizedEndpoint);
|
||||
} else {
|
||||
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
|
||||
}
|
||||
setShowCreateModal(false);
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
setEditEndpointId(null);
|
||||
fetchEndpoints();
|
||||
} catch (error) {
|
||||
console.error('Failed to save endpoint:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalHide = () => {
|
||||
setShowCreateModal(false);
|
||||
setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
|
||||
setEditEndpointId(null);
|
||||
};
|
||||
|
||||
const handleApiKey = async () => {
|
||||
console.log('handleApiKey called');
|
||||
setApiKeyLoading(true);
|
||||
setApiKeyError(null);
|
||||
try {
|
||||
const user = useAuthStore.getState().user;
|
||||
console.log('User:', user, 'AppId:', appId);
|
||||
if (!user) throw new Error('User not logged in');
|
||||
if (!appId) throw new Error('App ID not loaded');
|
||||
const response = await api('post', '/api/v1/app/apikey/generate', { userId: user.id, appId });
|
||||
setApiKey(response.data.apiKey);
|
||||
localStorage.setItem('canvas_api_key', response.data.apiKey);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to fetch or generate API key';
|
||||
setApiKeyError(errorMessage);
|
||||
console.error('API Key Error:', error);
|
||||
} finally {
|
||||
setApiKeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchApiKey = async () => {
|
||||
try {
|
||||
const user = useAuthStore.getState().user;
|
||||
if (!user || !appId) return;
|
||||
const response = await api('post', '/api/v1/app/apikey/get', { userId: user.id, appId });
|
||||
setApiKey(response.data.apiKey || null);
|
||||
if (response.data.apiKey) {
|
||||
localStorage.setItem('canvas_api_key', response.data.apiKey);
|
||||
}
|
||||
setApiKeyError(null);
|
||||
} catch (error: any) {
|
||||
setApiKey(null);
|
||||
setApiKeyError(error.response?.data?.error || error.message || 'Failed to fetch API key');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showApiKeyModal) {
|
||||
fetchApiKey();
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [showApiKeyModal, appId]);
|
||||
|
||||
const tryDeleteFromSpeech = async (transcript) => {
|
||||
const lower = transcript.toLowerCase();
|
||||
if (lower.startsWith('create')) return false; // let normal create/chat logic handle it
|
||||
|
||||
if (lower.includes('delete')) {
|
||||
// Try to match "delete endpoint 5" or "remove endpoint 5"
|
||||
const idMatch = transcript.match(/delete endpoint (\d+)/i) || transcript.match(/remove endpoint (\d+)/i);
|
||||
if (idMatch) {
|
||||
const id = parseInt(idMatch[1], 10);
|
||||
await handleDeleteEndpoint({ id });
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint with ID ${id} deleted (if it existed).` }]);
|
||||
return true;
|
||||
}
|
||||
// Try to match "delete endpoint called X" or "remove endpoint named X"
|
||||
const nameMatch = transcript.match(/delete endpoint (called|named) (.+)/i) || transcript.match(/remove endpoint (called|named) (.+)/i);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[2].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
await handleDeleteEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint \"${name}\" deleted.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Try to match "delete endpoint [name]"
|
||||
const nameAfterEndpointMatch = transcript.match(/delete endpoint ([a-zA-Z0-9 _-]+)/i);
|
||||
if (nameAfterEndpointMatch) {
|
||||
const name = nameAfterEndpointMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
await handleDeleteEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint \"${name}\" deleted.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// If just "delete endpoint" with no id or name, show a message
|
||||
if (lower.includes('delete endpoint')) {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Please specify the endpoint ID or name to delete.` }]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryShowFromSpeech = async (transcript) => {
|
||||
const lower = transcript.toLowerCase();
|
||||
const viewKeywords = ['show', 'view', 'open'];
|
||||
// Find which keyword (if any) is present
|
||||
const action = viewKeywords.find(kw => lower.includes(kw));
|
||||
if (!action) return false;
|
||||
|
||||
// Try "<action> endpoint 5"
|
||||
const idMatch = lower.match(/(?:show|view|open) endpoint (\d+)/i);
|
||||
if (idMatch) {
|
||||
const id = parseInt(idMatch[1], 10);
|
||||
const endpoint = endpoints.find(e => e.id === id);
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint with ID ${id}.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with ID ${id}.` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try "<action> endpoint [name]"
|
||||
const nameAfterEndpointMatch = lower.match(/(?:show|view|open) endpoint ([a-zA-Z0-9 _-]+)/i);
|
||||
if (nameAfterEndpointMatch) {
|
||||
const name = nameAfterEndpointMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try "<action> [name] endpoint"
|
||||
const nameEndpointMatch = lower.match(/(?:show|view|open) (.+) endpoint/i);
|
||||
if (nameEndpointMatch) {
|
||||
const name = nameEndpointMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try "<action> [name]"
|
||||
const nameMatch = lower.match(/(?:show|view|open) (.+)/i);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1].trim();
|
||||
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
|
||||
if (endpoint) {
|
||||
handleCallEndpoint(endpoint);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const trySearchFromSpeech = (transcript) => {
|
||||
const lower = transcript.toLowerCase().trim();
|
||||
// Match "search [term]", "find [term]", or "filter by [term]"
|
||||
const searchMatch = lower.match(/^(search|find|filter by)\s+(.+)$/i);
|
||||
if (searchMatch) {
|
||||
const term = searchMatch[2].trim();
|
||||
setGlobalFilterValue(term);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Filtered endpoints by: "${term}"` }]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryModifyFromSpeech = (transcript) => {
|
||||
const lower = transcript.toLowerCase();
|
||||
const match = lower.match(/modify endpoint (\d+)/i);
|
||||
if (match) {
|
||||
const id = parseInt(match[1], 10);
|
||||
const endpoint = endpoints.find(e => e.id === id);
|
||||
if (endpoint) {
|
||||
setEditByVoiceEndpoint({ ...endpoint });
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Ready to modify endpoint with ID ${id}. Say 'method is GET' or 'path is slash course slash id'.` }]);
|
||||
} else {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with ID ${id}.` }]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryUpdateEditByVoice = async (transcript) => {
|
||||
if (!editByVoiceEndpoint) return false;
|
||||
let updated = { ...editByVoiceEndpoint };
|
||||
let didUpdate = false;
|
||||
|
||||
// Method update
|
||||
const methodMatch = transcript.match(/method is (get|post|put|delete|patch)/i);
|
||||
if (methodMatch) {
|
||||
updated.method = methodMatch[1].toUpperCase();
|
||||
didUpdate = true;
|
||||
}
|
||||
|
||||
// Path update
|
||||
const pathMatch = transcript.match(/path is (.+)/i);
|
||||
if (pathMatch) {
|
||||
let path = pathMatch[1]
|
||||
.replace(/slash/gi, '/')
|
||||
.replace(/colon id|id/gi, ':id')
|
||||
.replace(/\s+/g, '');
|
||||
updated.path = path;
|
||||
didUpdate = true;
|
||||
}
|
||||
|
||||
if (didUpdate) {
|
||||
setEditByVoiceEndpoint(updated);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Updated endpoint: method=${updated.method}, path=${updated.path}` }]);
|
||||
// Auto-save
|
||||
await api('put', `/api/v1/canvas-api/endpoints/${updated.id}`, updated);
|
||||
fetchEndpoints();
|
||||
setEditByVoiceEndpoint(null);
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint ${updated.id} saved.` }]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleVoiceInput = () => {
|
||||
// @ts-ignore
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
alert('Speech recognition not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
setShowVoiceModal(true);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'en-US';
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
|
||||
recognition.onresult = async (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setLiveTranscript(transcript);
|
||||
setFullTranscript(prev => prev + (prev && !prev.endsWith(' ') ? ' ' : '') + transcript);
|
||||
setChatPrompt(transcript);
|
||||
if (event.results[0].isFinal) {
|
||||
setShowVoiceModal(false);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
if (transcript.toLowerCase().includes('open command list')) {
|
||||
setShowVoiceInfoModal(true);
|
||||
return;
|
||||
}
|
||||
if (await tryDeleteFromSpeech(transcript)) return;
|
||||
if (await tryShowFromSpeech(transcript)) return;
|
||||
if (trySearchFromSpeech(transcript)) return;
|
||||
if (tryModifyFromSpeech(transcript)) return;
|
||||
if (await tryUpdateEditByVoice(transcript)) return;
|
||||
handleChatPromptSend(transcript);
|
||||
}
|
||||
};
|
||||
recognition.onerror = (event: any) => {
|
||||
setShowVoiceModal(false);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
alert('Speech recognition error: ' + event.error);
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setShowVoiceModal(false);
|
||||
setLiveTranscript('');
|
||||
setFullTranscript('');
|
||||
};
|
||||
recognition.start();
|
||||
};
|
||||
|
||||
const settingsItems = [
|
||||
{
|
||||
label: 'Manage API Key',
|
||||
icon: 'pi pi-key',
|
||||
command: () => setShowApiKeyModal(true),
|
||||
},
|
||||
{
|
||||
label: 'Voice Command List',
|
||||
icon: 'pi pi-info-circle',
|
||||
command: () => setShowVoiceInfoModal(true),
|
||||
},
|
||||
{
|
||||
label: 'Manage API Docs',
|
||||
icon: 'pi pi-link',
|
||||
command: () => setShowManageDocs(true),
|
||||
},
|
||||
{
|
||||
label: 'Create Prompt',
|
||||
icon: 'pi pi-plus-circle',
|
||||
command: () => setShowPromptCreator(true),
|
||||
},
|
||||
];
|
||||
|
||||
const renderHeader = () => (
|
||||
<TableGroupHeader
|
||||
size={size}
|
||||
setSize={setSize}
|
||||
sizeOptions={sizeOptions}
|
||||
globalFilterValue={globalFilterValue}
|
||||
onGlobalFilterChange={onGlobalFilterChange}
|
||||
onRefresh={fetchEndpoints}
|
||||
clearFilter={clearFilter}
|
||||
extraButtonsTemplate={() => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="p-button-secondary"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-comments"
|
||||
onClick={() => setShowChatGptModal(true)}
|
||||
className="p-button-primary"
|
||||
/>
|
||||
<SettingsMenu
|
||||
items={settingsItems}
|
||||
buttonClassName="p-button-secondary"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-volume-up"
|
||||
onClick={handleVoiceInput}
|
||||
className="p-button-secondary"
|
||||
disabled={chatLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const serverPath = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
|
||||
const generateCurlCommand = (endpoint: any, apiKey: string) => {
|
||||
const backendUrl = `${serverPath}/api/v1/canvas-api/proxy-external`;
|
||||
const method = endpoint.method || 'GET';
|
||||
const path = endpoint.path;
|
||||
const header = `-H 'Authorization: Bearer ${apiKey}'`;
|
||||
const data = `-d '{"path": "${path}", "method": "${method}"}'`;
|
||||
return `curl -X POST ${header} -H 'Content-Type: application/json' ${data} '${backendUrl}'`;
|
||||
};
|
||||
|
||||
const renderCallButton = (rowData: any) => (
|
||||
<div className="flex gap-2">
|
||||
<Button icon="pi pi-play" onClick={() => handleCallEndpoint(rowData)} />
|
||||
<Button icon="pi pi-pencil" className="p-button-warning" onClick={() => openEditModal(rowData)} />
|
||||
<Button icon="pi pi-trash" className="p-button-danger" onClick={() => handleDeleteEndpoint(rowData)} />
|
||||
<span>
|
||||
<Button icon="pi pi-copy" className="p-button-secondary" onClick={() => {
|
||||
if (!apiKey) {
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'warn', summary: 'API Key Required', detail: 'Please generate or enter your API key first.' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const curl = generateCurlCommand(rowData, apiKey);
|
||||
navigator.clipboard.writeText(curl);
|
||||
if (toast.current) {
|
||||
toast.current.show({ severity: 'info', summary: 'Copied', detail: 'cURL command copied to clipboard!' });
|
||||
}
|
||||
}} data-pr-tooltip="Copy cURL command" />
|
||||
<Tooltip target=".p-button-secondary" content="Copy cURL command" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCallModalHeader = () => (
|
||||
<TableGroupHeader
|
||||
size={callModalSize}
|
||||
setSize={setCallModalSize}
|
||||
sizeOptions={sizeOptions}
|
||||
globalFilterValue={callModalGlobalFilterValue}
|
||||
onGlobalFilterChange={onCallModalGlobalFilterChange}
|
||||
onRefresh={() => handleCallEndpoint(callEndpoint)}
|
||||
clearFilter={clearCallModalFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
// Compute filtered endpoints for search-everything
|
||||
const filteredEndpoints = globalFilterValue
|
||||
? endpoints.filter(endpoint =>
|
||||
Object.values(endpoint)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(globalFilterValue.toLowerCase())
|
||||
)
|
||||
: endpoints;
|
||||
|
||||
return (
|
||||
<div className='w-full h-full p-6'>
|
||||
<h2 className='text-xl font-semibold mb-4'>Canvas Endpoints</h2>
|
||||
<Toast ref={toast} position="bottom-right" />
|
||||
<TableGroup
|
||||
body={filteredEndpoints}
|
||||
size={size}
|
||||
header={renderHeader}
|
||||
filters={filters}
|
||||
rows={10}
|
||||
paginator
|
||||
showGridlines={true}
|
||||
removableSort={true}
|
||||
dragSelection={false}
|
||||
emptyMessage='No endpoints found.'
|
||||
>
|
||||
<Column field='id' header='ID' sortable filter style={{ width: '10%' }} />
|
||||
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
|
||||
<Column field='method' header='Method' sortable filter style={{ width: '10%' }} />
|
||||
<Column field='path' header='Path' sortable filter style={{ width: '25%' }} />
|
||||
<Column field='publicPath' header='Public Path' sortable filter style={{ width: '15%' }} />
|
||||
<Column header='Action' body={renderCallButton} style={{ width: '20%' }} />
|
||||
</TableGroup>
|
||||
|
||||
<Dialog
|
||||
visible={showCreateModal}
|
||||
onHide={handleModalHide}
|
||||
header={editEndpointId ? 'Edit Endpoint' : 'Create Endpoint'}
|
||||
style={{ width: '40vw' }}
|
||||
modal
|
||||
footer={
|
||||
<div>
|
||||
<Button label="Cancel" onClick={handleModalHide} className="mr-2" />
|
||||
<Button label={editEndpointId ? 'Save' : 'Create'} onClick={handleSaveEndpoint} className="bg-blue-500 text-white" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-fluid">
|
||||
<div className="p-field">
|
||||
<label htmlFor="name">Name</label>
|
||||
<InputText id="name" value={newEndpoint.name} onChange={(e) => setNewEndpoint({ ...newEndpoint, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="method">Method</label>
|
||||
<InputText id="method" value={newEndpoint.method} onChange={(e) => setNewEndpoint({ ...newEndpoint, method: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="path">Path</label>
|
||||
<InputText id="path" value={newEndpoint.path} onChange={(e) => setNewEndpoint({ ...newEndpoint, path: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="publicPath">Public Path</label>
|
||||
<InputText id="publicPath" value={newEndpoint.publicPath || ''} onChange={(e) => setNewEndpoint({ ...newEndpoint, publicPath: e.target.value })} />
|
||||
</div>
|
||||
<div className="p-field">
|
||||
<label htmlFor="description">Description</label>
|
||||
<InputText id="description" value={newEndpoint.description} onChange={(e) => setNewEndpoint({ ...newEndpoint, description: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showCallModal}
|
||||
onHide={() => setShowCallModal(false)}
|
||||
header={callEndpoint ? `Response for ${callEndpoint.name}` : 'Endpoint Response'}
|
||||
style={{ width: '96vw', maxWidth: 1800, minHeight: 600 }}
|
||||
modal
|
||||
>
|
||||
{callModalLoading ? (
|
||||
<div className="flex justify-center items-center" style={{ minHeight: 200 }}>
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
) : callModalData.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '70vh' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto', maxWidth: '100%' }}>
|
||||
<TableGroup
|
||||
body={callModalData.slice(callModalFirst, callModalFirst + callModalRows)}
|
||||
size={callModalSize}
|
||||
header={renderCallModalHeader}
|
||||
filters={callModalFilters}
|
||||
rows={callModalRows}
|
||||
paginator={false}
|
||||
showGridlines={true}
|
||||
removableSort={true}
|
||||
dragSelection={false}
|
||||
emptyMessage='No data found.'
|
||||
>
|
||||
{callModalColumns.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
field={col}
|
||||
header={col}
|
||||
sortable
|
||||
filter
|
||||
body={rowData => {
|
||||
const value = rowData[col];
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{JSON.stringify(value)}</span>;
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableGroup>
|
||||
</div>
|
||||
<div style={{ position: 'sticky', bottom: 0, background: '#fff', padding: '8px 0 0 0', zIndex: 2, borderTop: '1px solid #eee' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<span style={{ float: 'right' }}>
|
||||
<span style={{ marginRight: 16 }}>
|
||||
Showing {callModalFirst + 1} to {Math.min(callModalFirst + callModalRows, callModalData.length)} of {callModalData.length} entries
|
||||
</span>
|
||||
<select
|
||||
value={callModalRows}
|
||||
onChange={e => setCallModalRows(Number(e.target.value))}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{[10, 25, 50].map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => setCallModalFirst(0)} disabled={callModalFirst === 0}>{'<<'}</button>
|
||||
<button onClick={() => setCallModalFirst(Math.max(0, callModalFirst - callModalRows))} disabled={callModalFirst === 0}>{'<'}</button>
|
||||
<span style={{ margin: '0 8px' }}>{Math.floor(callModalFirst / callModalRows) + 1}</span>
|
||||
<button onClick={() => setCallModalFirst(Math.min(callModalFirst + callModalRows, callModalData.length - callModalRows))} disabled={callModalFirst + callModalRows >= callModalData.length}>{'>'}</button>
|
||||
<button onClick={() => setCallModalFirst(Math.max(0, callModalData.length - callModalRows))} disabled={callModalFirst + callModalRows >= callModalData.length}>{'>>'}</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>No data found or error.</div>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showChatGptModal}
|
||||
onHide={() => setShowChatGptModal(false)}
|
||||
header="ChatGPT Assistant"
|
||||
style={{ width: '50vw' }}
|
||||
>
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<div className="flex-1 overflow-auto mb-4 p-2 bg-gray-50 rounded">
|
||||
{chatMessages.length === 0 && <div className="text-gray-400 text-center mt-8">Start a conversation with ChatGPT...</div>}
|
||||
{chatMessages.map((msg, idx) => (
|
||||
<div key={idx} className={`my-2 p-2 rounded-lg max-w-[80%] ${msg.role === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-200'}`}>
|
||||
<span>{msg.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 p-2 border rounded"
|
||||
placeholder="Type your message..."
|
||||
value={chatPrompt}
|
||||
onChange={e => setChatPrompt(e.target.value)}
|
||||
onKeyDown={handleChatPromptKeyDown}
|
||||
disabled={chatLoading}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-send"
|
||||
onClick={() => handleChatPromptSend()}
|
||||
loading={chatLoading}
|
||||
disabled={!chatPrompt.trim() || chatLoading}
|
||||
/>
|
||||
</div>
|
||||
{chatError && <div className="text-red-500 mt-2">{chatError}</div>}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showApiKeyModal}
|
||||
onHide={() => setShowApiKeyModal(false)}
|
||||
header="API Key Management"
|
||||
footer={
|
||||
<div>
|
||||
<Button
|
||||
label={apiKey ? "Regenerate API Key" : "Create API Key"}
|
||||
icon="pi pi-refresh"
|
||||
onClick={handleApiKey}
|
||||
loading={apiKeyLoading}
|
||||
className="p-button-danger"
|
||||
/>
|
||||
<Button
|
||||
label="Close"
|
||||
icon="pi pi-times"
|
||||
onClick={() => setShowApiKeyModal(false)}
|
||||
className="p-button-text"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{apiKey && (
|
||||
<div>
|
||||
<div className="mb-2 font-bold">Current API Key:</div>
|
||||
<div className="font-mono bg-gray-100 p-2 rounded">{apiKey}</div>
|
||||
<div className="mt-2 text-sm text-red-500">
|
||||
Regenerating will invalidate the old key.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!apiKey && (
|
||||
<div>
|
||||
<div>No API key exists for this app. Click "Create API Key" to generate one.</div>
|
||||
</div>
|
||||
)}
|
||||
{apiKeyError && <div className="text-red-500 mt-2">{apiKeyError}</div>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showVoiceModal}
|
||||
onHide={() => setShowVoiceModal(false)}
|
||||
header="Listening..."
|
||||
modal
|
||||
style={{ minWidth: 400, textAlign: 'center' }}
|
||||
closable={false}
|
||||
>
|
||||
<div style={{ fontSize: 24, minHeight: 60 }}>{fullTranscript || <span className="text-gray-400">Say something…</span>}</div>
|
||||
<div className="mt-4 text-sm text-gray-500">Speak your command or query.</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
visible={showVoiceInfoModal}
|
||||
onHide={() => setShowVoiceInfoModal(false)}
|
||||
header="Voice Command List"
|
||||
modal
|
||||
style={{ minWidth: 400 }}
|
||||
>
|
||||
<ul style={{ textAlign: 'left', lineHeight: 2 }}>
|
||||
<li><b>show/view/open endpoint [name or id]</b> — Open an endpoint's table view</li>
|
||||
<li><b>delete endpoint [name or id]</b> — Delete an endpoint</li>
|
||||
<li><b>search [term]</b> — Filter endpoints</li>
|
||||
<li><b>modify endpoint [id]</b> — Start editing an endpoint by voice</li>
|
||||
<li><b>method is [GET/POST/PUT/DELETE/PATCH]</b> — Set method (in edit mode)</li>
|
||||
<li><b>path is [your path]</b> — Set path (in edit mode, say 'slash' for /, 'colon id' for :id)</li>
|
||||
<li><b>open command list</b> — Show this help</li>
|
||||
</ul>
|
||||
</Dialog>
|
||||
|
||||
<ManageDocsModal visible={showManageDocs} onHide={() => setShowManageDocs(false)} />
|
||||
|
||||
<PromptCreatorModal
|
||||
visible={showPromptCreator}
|
||||
onHide={() => setShowPromptCreator(false)}
|
||||
appId={appId || 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
314
frontend/src/components/canvas-api/ChatGPTModal.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useChatStore } from '../../state/stores/useChatStore';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { generateTablePrompt } from '../../prompts/table-generation';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { useRef } from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
|
||||
interface ChatGPTModalProps {
|
||||
content?: {
|
||||
prompt: string;
|
||||
id?: string;
|
||||
keywords?: string[];
|
||||
context?: string;
|
||||
tableSchema?: {
|
||||
name: string;
|
||||
fields: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface TableData {
|
||||
columns: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
}>;
|
||||
data: any[];
|
||||
metadata?: {
|
||||
id?: string;
|
||||
keywords?: string[];
|
||||
context?: string;
|
||||
timestamp: string;
|
||||
actions?: Array<{
|
||||
action: string;
|
||||
target: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const ChatGPTModal = ({ content }: ChatGPTModalProps) => {
|
||||
const [tableData, setTableData] = useState<TableData>({
|
||||
columns: [],
|
||||
data: [],
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRow, setSelectedRow] = useState<any>(null);
|
||||
const [showRowDialog, setShowRowDialog] = useState(false);
|
||||
const [finalPrompt, setFinalPrompt] = useState<string>('');
|
||||
const { sendChatRequest } = useChatStore();
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
const constructPath = (id: string | number, pathname: string): string => {
|
||||
// Remove any leading/trailing slashes from pathname
|
||||
const cleanPathname = pathname.replace(/^\/+|\/+$/g, '');
|
||||
// Construct the path with proper slashes
|
||||
return `/${id}/${cleanPathname}`.replace(/\/+/g, '/');
|
||||
};
|
||||
|
||||
const findRowById = (id: string | number) => {
|
||||
const row = tableData.data.find(row => row.id?.toString() === id.toString());
|
||||
if (row) {
|
||||
setSelectedRow(row);
|
||||
setShowRowDialog(true);
|
||||
showToast('info', `Viewing details for ID: ${id}`);
|
||||
} else {
|
||||
showToast('warn', `No row found with ID: ${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublicPath = (prompt: string) => {
|
||||
// Check for path construction pattern
|
||||
const pathMatch = prompt.match(/add\s+public\s+path\s+to\s+(\d+)\s+slash\s+([^\s]+)/i);
|
||||
if (pathMatch) {
|
||||
const [, id, pathname] = pathMatch;
|
||||
const constructedPath = constructPath(id, pathname);
|
||||
// Find the row with the given ID
|
||||
const rowIndex = tableData.data.findIndex(row => row.id?.toString() === id.toString());
|
||||
if (rowIndex !== -1) {
|
||||
// Update the path of the existing row
|
||||
setTableData(prev => {
|
||||
const newData = [...prev.data];
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
path: constructedPath
|
||||
};
|
||||
// Add an 'add_path' action to metadata
|
||||
return {
|
||||
...prev,
|
||||
data: newData,
|
||||
metadata: {
|
||||
...prev.metadata,
|
||||
actions: [
|
||||
...(prev.metadata?.actions || []),
|
||||
{
|
||||
action: 'add_path',
|
||||
target: constructedPath,
|
||||
description: `added public path ${constructedPath} to endpoint ID ${id}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
});
|
||||
showToast('success', `Updated path for ID ${id}: ${constructedPath}`);
|
||||
findRowById(id);
|
||||
} else {
|
||||
showToast('warn', `No endpoint found with ID: ${id}`);
|
||||
}
|
||||
return { id, constructedPath };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (content?.prompt) {
|
||||
setFinalPrompt(content.prompt);
|
||||
// Handle public path update only, not endpoint creation
|
||||
handlePublicPath(content.prompt);
|
||||
generateTable();
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const showToast = (severity: 'success' | 'info' | 'warn' | 'error', message: string) => {
|
||||
toast.current?.show({
|
||||
severity,
|
||||
summary: severity.charAt(0).toUpperCase() + severity.slice(1),
|
||||
detail: message,
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const generateTable = async () => {
|
||||
if (!content?.prompt) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const promptText = generateTablePrompt({
|
||||
prompt: content.prompt,
|
||||
context: content.context,
|
||||
keywords: content.keywords,
|
||||
id: content.id,
|
||||
tableSchema: content.tableSchema
|
||||
});
|
||||
|
||||
const response = await sendChatRequest('/chat/completions', {
|
||||
data: promptText,
|
||||
responseFormat: 'json'
|
||||
});
|
||||
|
||||
if (response?.responseText) {
|
||||
try {
|
||||
const parsedData = JSON.parse(response.responseText);
|
||||
setTableData({
|
||||
...parsedData,
|
||||
metadata: {
|
||||
...parsedData.metadata,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
// Show success message based on the primary action
|
||||
const primaryAction = parsedData.metadata?.actions?.[0];
|
||||
if (primaryAction) {
|
||||
const actionMessage = {
|
||||
create: 'Successfully created',
|
||||
delete: 'Successfully deleted',
|
||||
update: 'Successfully updated',
|
||||
show: 'Successfully retrieved',
|
||||
search: 'Search completed',
|
||||
view: 'Viewing details'
|
||||
}[primaryAction.action] || 'Operation completed';
|
||||
|
||||
showToast('success', `${actionMessage} ${primaryAction.target}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing response:', error);
|
||||
showToast('error', 'Error processing the response');
|
||||
}
|
||||
} else {
|
||||
showToast('error', 'Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating table:', error);
|
||||
showToast('error', 'Error generating table');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (event: any) => {
|
||||
const rowData = event.data;
|
||||
setSelectedRow(rowData);
|
||||
setShowRowDialog(true);
|
||||
|
||||
// Add view action to metadata if not present
|
||||
if (tableData.metadata?.actions) {
|
||||
const hasViewAction = tableData.metadata.actions.some(
|
||||
action => action.action === 'view' && action.target === rowData.id
|
||||
);
|
||||
|
||||
if (!hasViewAction) {
|
||||
setTableData(prev => ({
|
||||
...prev,
|
||||
metadata: {
|
||||
...prev.metadata,
|
||||
actions: [
|
||||
...(prev.metadata?.actions || []),
|
||||
{
|
||||
action: 'view',
|
||||
target: rowData.id || 'selected row',
|
||||
description: `view details for ${rowData.id || 'selected row'}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
showToast('info', `Viewing details for ${rowData.id || 'selected row'}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderRowDialog = () => {
|
||||
if (!selectedRow) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={showRowDialog}
|
||||
onHide={() => setShowRowDialog(false)}
|
||||
header={`View Details - ${selectedRow.id || 'Selected Row'}`}
|
||||
style={{ width: '50vw' }}
|
||||
>
|
||||
<div className="grid">
|
||||
{tableData.columns.map(col => (
|
||||
<div key={col.field} className="col-12 md:col-6 p-2">
|
||||
<div className="font-bold">{col.header}</div>
|
||||
<div>{selectedRow[col.field] || 'N/A'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} position="top-right" />
|
||||
<div className="flex flex-column gap-3 p-4 relative">
|
||||
{finalPrompt && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Your Request
|
||||
</label>
|
||||
<InputText
|
||||
value={finalPrompt}
|
||||
readOnly
|
||||
className="w-full p-2 border rounded"
|
||||
style={{ backgroundColor: '#f8f9fa' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
value={tableData.data}
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
onRowClick={handleRowClick}
|
||||
selectionMode="single"
|
||||
rowHover
|
||||
>
|
||||
{tableData.columns.map(col => (
|
||||
<Column
|
||||
key={col.field}
|
||||
field={col.field}
|
||||
header={col.header}
|
||||
body={(rowData) => (
|
||||
<div className="cursor-pointer hover:bg-gray-100 p-2">
|
||||
{rowData[col.field] || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</DataTable>
|
||||
{tableData.metadata && (
|
||||
<div className="text-sm text-gray-500 mt-2">
|
||||
<p>ID: {tableData.metadata.id}</p>
|
||||
<p>Keywords: {tableData.metadata.keywords?.join(', ')}</p>
|
||||
<p>Context: {tableData.metadata.context}</p>
|
||||
<p>Generated: {new Date(tableData.metadata.timestamp).toLocaleString()}</p>
|
||||
{tableData.metadata.actions && tableData.metadata.actions.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="font-bold">Detected Actions:</p>
|
||||
{tableData.metadata.actions.map((action, index) => (
|
||||
<div key={index} className="ml-2">
|
||||
<span className="font-semibold">{action.action}:</span> {action.target}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderRowDialog()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatGPTModal;
|
1
frontend/src/components/canvas-api/DataTable.types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type TSizeOptionValue = 'small' | 'normal' | 'large';
|
143
frontend/src/components/canvas-api/ManageDocsModal.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ListBox } from 'primereact/listbox';
|
||||
|
||||
interface ManageDocsModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
rawHtml: string;
|
||||
aiOutput: any;
|
||||
restructured: any;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'apiDocUrls';
|
||||
|
||||
export function ManageDocsModal({ visible, onHide }: ManageDocsModalProps) {
|
||||
const [urls, setUrls] = useState<string[]>([]);
|
||||
const [selectedUrl, setSelectedUrl] = useState<string | null>(null);
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load URLs from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (saved) setUrls(JSON.parse(saved));
|
||||
}, []);
|
||||
|
||||
// Save URLs to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(urls));
|
||||
}, [urls]);
|
||||
|
||||
const addUrl = () => {
|
||||
if (newUrl && !urls.includes(newUrl)) {
|
||||
setUrls([...urls, newUrl]);
|
||||
setNewUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeUrl = (url: string) => {
|
||||
setUrls(urls.filter(u => u !== url));
|
||||
if (selectedUrl === url) setSelectedUrl(null);
|
||||
};
|
||||
|
||||
const startEdit = (idx: number) => {
|
||||
setEditIndex(idx);
|
||||
setEditValue(urls[idx]);
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editIndex !== null && editValue) {
|
||||
const newUrls = [...urls];
|
||||
newUrls[editIndex] = editValue;
|
||||
setUrls(newUrls);
|
||||
setEditIndex(null);
|
||||
setEditValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleParse = async (url: string) => {
|
||||
setParsing(true);
|
||||
setError(null);
|
||||
setParseResult(null);
|
||||
try {
|
||||
const response = await fetch('/api/parse-docs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch or parse docs');
|
||||
const data = await response.json();
|
||||
setParseResult(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Unknown error');
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onHide={onHide} header="Manage API Docs" style={{ width: '60vw' }}>
|
||||
<div className="flex gap-4">
|
||||
{/* URL List and Controls */}
|
||||
<div style={{ minWidth: 300 }}>
|
||||
<h4>Documentation URLs</h4>
|
||||
<ListBox
|
||||
value={selectedUrl}
|
||||
options={urls.map(u => ({ label: u, value: u }))}
|
||||
onChange={e => setSelectedUrl(e.value)}
|
||||
style={{ width: '100%', minHeight: 200 }}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<InputText
|
||||
value={newUrl}
|
||||
onChange={e => setNewUrl(e.target.value)}
|
||||
placeholder="Add new URL"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button label="Add" onClick={addUrl} disabled={!newUrl} />
|
||||
</div>
|
||||
{selectedUrl && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button label="Parse" onClick={() => handleParse(selectedUrl)} loading={parsing} />
|
||||
<Button label="Remove" className="p-button-danger" onClick={() => removeUrl(selectedUrl)} />
|
||||
<Button label="Edit" onClick={() => startEdit(urls.indexOf(selectedUrl))} />
|
||||
</div>
|
||||
)}
|
||||
{editIndex !== null && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<InputText value={editValue} onChange={e => setEditValue(e.target.value)} className="w-full" />
|
||||
<Button label="Save" onClick={saveEdit} />
|
||||
<Button label="Cancel" className="p-button-secondary" onClick={() => setEditIndex(null)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Parse Results */}
|
||||
<div style={{ flex: 1 }}>
|
||||
{error && <div className="text-red-500 mb-2">{error}</div>}
|
||||
{parseResult && (
|
||||
<div>
|
||||
<h4>Raw HTML (first 500 chars):</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{parseResult.rawHtml?.slice(0, 500)}...</pre>
|
||||
<h4>AI Output:</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(parseResult.aiOutput, null, 2)}</pre>
|
||||
<h4>Restructured Output:</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(parseResult.restructured, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageDocsModal;
|
65
frontend/src/components/canvas-api/ParseDocsModal.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
interface ParseDocsModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
onParsed?: (endpoints: any[]) => void;
|
||||
}
|
||||
|
||||
export function ParseDocsModal({ visible, onHide, onParsed }: ParseDocsModalProps) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleParse = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
try {
|
||||
const response = await fetch('/api/parse-docs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch or parse docs');
|
||||
const data = await response.json();
|
||||
setResults(data);
|
||||
if (onParsed && data.restructured) onParsed(data.restructured);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog visible={visible} onHide={onHide} header="Parse API Docs" style={{ width: '50vw' }}>
|
||||
<div>
|
||||
<InputText
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="Enter documentation URL"
|
||||
className="w-full mb-3"
|
||||
/>
|
||||
<Button label="Parse" onClick={handleParse} loading={loading} disabled={!url} />
|
||||
{error && <div className="text-red-500 mt-2">{error}</div>}
|
||||
</div>
|
||||
{results && (
|
||||
<div className="mt-4">
|
||||
<h4>Raw HTML (first 500 chars):</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{results.rawHtml?.slice(0, 500)}...</pre>
|
||||
<h4>AI Output:</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(results.aiOutput, null, 2)}</pre>
|
||||
<h4>Restructured Output:</h4>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(results.restructured, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParseDocsModal;
|
153
frontend/src/components/canvas-api/PromptCreatorModal.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { useRef } from 'react';
|
||||
import { api } from '../../services/api';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
|
||||
interface PromptCreatorModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
appId: number;
|
||||
}
|
||||
|
||||
const promptTypes = [
|
||||
{ label: 'API Documentation', value: 'api_documentation' },
|
||||
{ label: 'Endpoint Analysis', value: 'endpoint_analysis' },
|
||||
{ label: 'Schema Generation', value: 'schema_generation' },
|
||||
{ label: 'Custom', value: 'custom' }
|
||||
];
|
||||
|
||||
export default function PromptCreatorModal({ visible, onHide, appId }: PromptCreatorModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [type, setType] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name || !content || !type) {
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Please fill in all required fields',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api('post', '/api/v1/prompts', {
|
||||
name,
|
||||
content,
|
||||
type,
|
||||
appId,
|
||||
metadata: {
|
||||
source: 'canvas-api',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Prompt created successfully',
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Process the prompt immediately
|
||||
await api('post', `/api/v1/prompts/${response.data.id}/process`);
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
setContent('');
|
||||
setType(null);
|
||||
onHide();
|
||||
} catch (error) {
|
||||
console.error('Error creating prompt:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to create prompt',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} />
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
header="Create New Prompt"
|
||||
style={{ width: '50vw' }}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
label="Cancel"
|
||||
icon="pi pi-times"
|
||||
onClick={onHide}
|
||||
className="p-button-text"
|
||||
/>
|
||||
<Button
|
||||
label="Create"
|
||||
icon="pi pi-check"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!name || !content || !type}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="field">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Prompt Name
|
||||
</label>
|
||||
<InputText
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder="Enter a name for your prompt..."
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Prompt Type
|
||||
</label>
|
||||
<Dropdown
|
||||
id="type"
|
||||
value={type}
|
||||
options={promptTypes}
|
||||
onChange={(e) => setType(e.value)}
|
||||
placeholder="Select a type"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Prompt Content
|
||||
</label>
|
||||
<InputTextarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full"
|
||||
rows={8}
|
||||
autoResize
|
||||
placeholder="Enter your prompt..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
61
frontend/src/components/canvas-api/api.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
interface ApiConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Clean base URL once at top-level
|
||||
const rawBaseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const cleanedBaseUrl = rawBaseUrl.replace(/\/$/, '');
|
||||
|
||||
const DEFAULT_CONFIG: ApiConfig = {
|
||||
baseURL: cleanedBaseUrl,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
export const api = async <T = any>(
|
||||
method: 'get' | 'post' | 'put' | 'delete' | 'patch',
|
||||
url: string,
|
||||
data?: any,
|
||||
params?: any,
|
||||
responseType: 'json' | 'blob' | 'text' = 'json',
|
||||
timeout?: number,
|
||||
useAuth: boolean = true
|
||||
): Promise<AxiosResponse<T>> => {
|
||||
const config: AxiosRequestConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
params,
|
||||
responseType,
|
||||
timeout: timeout || DEFAULT_CONFIG.timeout,
|
||||
};
|
||||
|
||||
if (useAuth) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await axios(config);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
78
frontend/src/components/canvas-api/canvasApi.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { api } from './api';
|
||||
|
||||
interface CanvasApiConfig {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
courseId?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_CONFIG: CanvasApiConfig = {
|
||||
baseUrl: import.meta.env.VITE_CANVAS_API_BASE_URL || 'https://canvas.instructure.com',
|
||||
accessToken: import.meta.env.VITE_CANVAS_ACCESS_TOKEN || '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs a Canvas API endpoint URL with optional parameters
|
||||
*/
|
||||
export const constructCanvasEndpoint = (path: string, params?: Record<string, any>) => {
|
||||
const queryString = params ? `?${new URLSearchParams(params).toString()}` : '';
|
||||
return `${path}${queryString}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a request to the Canvas API
|
||||
*/
|
||||
export const getCanvasData = async <T = any>(
|
||||
endpoint: string,
|
||||
params?: Record<string, any>,
|
||||
config: Partial<CanvasApiConfig> = {}
|
||||
): Promise<T> => {
|
||||
const finalConfig = { ...DEFAULT_CANVAS_CONFIG, ...config };
|
||||
const url = constructCanvasEndpoint(endpoint, params);
|
||||
|
||||
try {
|
||||
const response = await api<T>('get', url, undefined, undefined, 'json', 60, true);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Canvas API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Example usage:
|
||||
// const students = await getCanvasData('/courses/123/users', { enrollment_type: ['student'] });
|
||||
// const courses = await getCanvasData('/courses', { per_page: 100 });
|
||||
|
||||
class CanvasApi {
|
||||
private accessToken: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(config: CanvasApiConfig) {
|
||||
this.accessToken = config.accessToken;
|
||||
this.baseUrl = config.baseUrl || DEFAULT_CANVAS_CONFIG.baseUrl;
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async getStudents(courseId: number) {
|
||||
try {
|
||||
const response = await getCanvasData('/courses/' + courseId + '/users', {
|
||||
enrollment_type: ['student'],
|
||||
per_page: 100,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error fetching students:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Add more Canvas API methods here as needed
|
||||
}
|
||||
|
||||
export default CanvasApi;
|
35
frontend/src/components/canvas-api/useCanvasStore.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
interface CanvasRequestData {
|
||||
endpoint: string;
|
||||
params?: Record<string, any>;
|
||||
courseId?: number;
|
||||
}
|
||||
|
||||
type CanvasStore = {
|
||||
sendCanvasRequest: (data: CanvasRequestData) => Promise<any>;
|
||||
};
|
||||
|
||||
export const useCanvasStore = create<CanvasStore>(() => ({
|
||||
sendCanvasRequest: async (requestData) => {
|
||||
try {
|
||||
if (!requestData.endpoint) {
|
||||
throw new Error("Invalid input: 'endpoint' is required.");
|
||||
}
|
||||
|
||||
// Construct the full URL with course ID if provided
|
||||
const url = requestData.courseId
|
||||
? `/courses/${requestData.courseId}${requestData.endpoint}`
|
||||
: requestData.endpoint;
|
||||
|
||||
console.log('Sending Canvas request:', { url, params: requestData.params });
|
||||
const response = await api('get', url, undefined, requestData.params, 'json', 60, true);
|
||||
console.log('Got Canvas response:', response);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to send Canvas request:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
13
frontend/src/config/canvas.ts
Normal file
@ -0,0 +1,13 @@
|
||||
interface CanvasConfig {
|
||||
accessToken: string;
|
||||
baseUrl: string;
|
||||
defaultCourseId: number;
|
||||
}
|
||||
|
||||
const canvasConfig: CanvasConfig = {
|
||||
accessToken: process.env.VITE_CANVAS_ACCESS_TOKEN || '',
|
||||
baseUrl: process.env.VITE_CANVAS_API_BASE_URL || 'https://canvas.instructure.com/api/v1',
|
||||
defaultCourseId: parseInt(process.env.VITE_CANVAS_DEFAULT_COURSE_ID || '1', 10),
|
||||
};
|
||||
|
||||
export default canvasConfig;
|
75
frontend/src/constants/actions.ts
Normal file
@ -0,0 +1,75 @@
|
||||
export interface ActionKeyword {
|
||||
action: string;
|
||||
target: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const ACTION_KEYWORDS: Record<string, string[]> = {
|
||||
create: ['create', 'new', 'make'],
|
||||
delete: ['delete', 'remove', 'drop'],
|
||||
update: ['update', 'modify', 'change', 'edit'],
|
||||
show: ['show', 'display', 'list', 'get', 'fetch', 'view'],
|
||||
search: ['search', 'find', 'query', 'filter'],
|
||||
view: ['view', 'details', 'info', 'expand']
|
||||
};
|
||||
|
||||
export const detectActions = (prompt: string, keywords: string[]): ActionKeyword[] => {
|
||||
const actions: ActionKeyword[] = [];
|
||||
const promptLower = prompt.toLowerCase().trim();
|
||||
|
||||
// If prompt starts with 'add', treat as 'add' action
|
||||
if (promptLower.startsWith('add')) {
|
||||
// Try to match 'add ... to [id] slash [path]'
|
||||
const match = promptLower.match(/add.*to\s+(\d+)\s+slash\s+([^\s]+)/i);
|
||||
if (match) {
|
||||
const [, id, pathname] = match;
|
||||
actions.push({
|
||||
action: 'add',
|
||||
target: `id:${id} path:/${pathname.replace(/^\/+|\/+$/g, '')}`,
|
||||
description: `add /${pathname.replace(/^\/+|\/+$/g, '')} to endpoint ID ${id}`
|
||||
});
|
||||
} else {
|
||||
// Fallback: just mark as a generic add action
|
||||
actions.push({
|
||||
action: 'add',
|
||||
target: '',
|
||||
description: 'add action'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// First check for explicit action-target pairs
|
||||
const words = promptLower.split(' ');
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
const word = words[i];
|
||||
const nextWord = words[i + 1];
|
||||
for (const [action, triggers] of Object.entries(ACTION_KEYWORDS)) {
|
||||
if (triggers.includes(word)) {
|
||||
actions.push({
|
||||
action,
|
||||
target: nextWord,
|
||||
description: `${action} ${nextWord}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then check keywords for additional context
|
||||
keywords.forEach(keyword => {
|
||||
const keywordWords = keyword.toLowerCase().split(' ');
|
||||
for (const [action, triggers] of Object.entries(ACTION_KEYWORDS)) {
|
||||
if (triggers.some(trigger => keywordWords.includes(trigger))) {
|
||||
const target = keywordWords.find(word => !triggers.includes(word));
|
||||
if (target) {
|
||||
actions.push({
|
||||
action,
|
||||
target,
|
||||
description: `${action} ${target}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
};
|
7
frontend/src/global.types.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
type User = {
|
||||
id: string;
|
||||
username?: string;
|
||||
roles?: string[];
|
||||
token?: string;
|
||||
tenantId: string;
|
||||
};
|
67
frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { api } from '../services/api';
|
||||
|
||||
interface AuthHook {
|
||||
user: User | null;
|
||||
login: (username: string, password: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
type DecodedToken = {
|
||||
tenantId: string;
|
||||
};
|
||||
|
||||
type Token = {
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
roles: string[];
|
||||
exp: number;
|
||||
iat: number;
|
||||
};
|
||||
|
||||
const useAuth = (): AuthHook => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<User> => {
|
||||
const response = await api('post', '/login', { username, password });
|
||||
const user = response;
|
||||
|
||||
const decodedToken: DecodedToken = jwtDecode(user.data.token);
|
||||
user.data.tenantId = decodedToken.tenantId;
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(user.data));
|
||||
setUser(user.data.data);
|
||||
return user.data;
|
||||
};
|
||||
|
||||
const logout = (): void => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('connectionId');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return { user, login, logout };
|
||||
};
|
||||
|
||||
export const getExpirationDateFromToken = (token: string) => {
|
||||
const decoded: Token = jwtDecode(token);
|
||||
|
||||
if (decoded && decoded.exp) {
|
||||
const expirationTimestamp = decoded.exp;
|
||||
// Convert to miliseconds, Date constructs using miliseconds
|
||||
return new Date(expirationTimestamp * 1000);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export { useAuth };
|
46
frontend/src/hooks/useFusemindMemory.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { memoryService } from '../services/fusemind/memoryService';
|
||||
import { MemoryUnit, MemoryRetrieval, EmotionalState } from '../types/fusemind/memory';
|
||||
|
||||
export const useFusemindMemory = () => {
|
||||
const [activeMemories, setActiveMemories] = useState<MemoryUnit[]>([]);
|
||||
const [emotionalState, setEmotionalState] = useState<EmotionalState>(
|
||||
memoryService.getCurrentEmotionalState()
|
||||
);
|
||||
|
||||
// Store a new memory
|
||||
const storeMemory = async (memory: MemoryUnit) => {
|
||||
const id = await memoryService.storeMemory(memory);
|
||||
return id;
|
||||
};
|
||||
|
||||
// Retrieve memories based on query
|
||||
const retrieveMemories = async (query: MemoryRetrieval) => {
|
||||
const memories = await memoryService.retrieveMemory(query);
|
||||
setActiveMemories(memories);
|
||||
return memories;
|
||||
};
|
||||
|
||||
// Update emotional state
|
||||
const updateEmotionalState = (update: Partial<EmotionalState>) => {
|
||||
memoryService.updateEmotionalState(update);
|
||||
setEmotionalState(memoryService.getCurrentEmotionalState());
|
||||
};
|
||||
|
||||
// Effect to sync with memory service
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setEmotionalState(memoryService.getCurrentEmotionalState());
|
||||
}, 1000); // Update every second
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeMemories,
|
||||
emotionalState,
|
||||
storeMemory,
|
||||
retrieveMemories,
|
||||
updateEmotionalState
|
||||
};
|
||||
};
|
195
frontend/src/index.css
Normal file
@ -0,0 +1,195 @@
|
||||
@import 'primereact/resources/themes/saga-blue/theme.css';
|
||||
@import 'primereact/resources/primereact.min.css';
|
||||
@import 'primeicons/primeicons.css';
|
||||
@import './styles/canvas-theme.css';
|
||||
|
||||
@layer tailwind-base, primereact, tailwind-utilities;
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* li > a {
|
||||
margin: 6px 0;
|
||||
} */
|
||||
|
||||
/* src/index.css */
|
||||
.custom-tabview .p-tabview-nav li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
/* color: #0d9488; text-teal-700 */
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.custom-tabview .p-tabview-nav li:hover a {
|
||||
color: #14b8a6; /* text-teal-500 */
|
||||
}
|
||||
|
||||
.custom-tabview .p-tabview-nav li.p-highlight a {
|
||||
color: #0f766e; /* text-teal-600 */
|
||||
/* font-weight: 600; font-semibold */
|
||||
}
|
||||
|
||||
.custom-tabview .p-tabview-nav li.p-highlight {
|
||||
border-bottom: 2px solid #14b8a6; /* border-teal-500 */
|
||||
}
|
||||
|
||||
/*
|
||||
* {
|
||||
overflow: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
*/
|
||||
|
||||
/* .p-highlight {
|
||||
background-color: #212f4d !important;
|
||||
} */
|
||||
|
||||
/* ICONS */
|
||||
.p-icon.p-checkbox-icon {
|
||||
border: 1px solid teal;
|
||||
color: #fff;
|
||||
background-color: teal;
|
||||
}
|
||||
|
||||
/* DATA TABLE */
|
||||
.p-dialog .p-dialog-header {
|
||||
padding-top: 32px !important;
|
||||
padding-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.p-datatable.p-datatable-gridlines .p-datatable-header {
|
||||
/* padding: 10px 8px 10px 8px !important; */
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.p-datatable-header {
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* .p-datatable-header > div {
|
||||
display: flex;
|
||||
}*/
|
||||
|
||||
.p-datatable-header > div > div {
|
||||
padding: 0 0 18px 0;
|
||||
}
|
||||
.p-datatable-header > div > div > div > button {
|
||||
/* width: 100px; */
|
||||
}
|
||||
|
||||
.p-datatable > .p-datatable-wrapper {
|
||||
overflow: initial !important;
|
||||
margin: 0 0 80px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-header {
|
||||
position: sticky;
|
||||
padding: 0.3rem 1rem !important;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.p-datatable-thead {
|
||||
position: sticky;
|
||||
/* top: 114px; */
|
||||
top: 87.5px;
|
||||
z-index: 10;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.p-sortable-column,
|
||||
thead tr th {
|
||||
/* padding: 12px; */
|
||||
box-shadow: inset 0 -1px 0 #dee2e6;
|
||||
/* width: 100%; */
|
||||
background-color: #fff;
|
||||
}
|
||||
.p-dropdownpanel {
|
||||
width: 365px !important;
|
||||
}
|
||||
|
||||
.p-datatable {
|
||||
width: 100%;
|
||||
/* margin-top: 32px; */
|
||||
}
|
||||
|
||||
.p-datatable-tbody {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* search bar data table */
|
||||
.p-datatable-header .p-inputtext.p-component {
|
||||
padding: 6px 64px;
|
||||
margin-left: -8px;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
.p-paginator.p-component.p-paginator-bottom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
box-shadow: inset 0 1px 0 #dee2e6;
|
||||
width: 86%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.pi.pi-search {
|
||||
margin: -8px 14px;
|
||||
}
|
||||
|
||||
.p-inputtext.p-component {
|
||||
/* padding: 6px 64px; */
|
||||
/* margin-left: -8px; */
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
.p-component.p-highlight {
|
||||
outline: none !important;
|
||||
background-color: teal;
|
||||
border: 0px solid;
|
||||
}
|
||||
.p-button {
|
||||
outline: none !important;
|
||||
/* border: 0px solid; */
|
||||
padding: 10px !important;
|
||||
margin: 8px 0px;
|
||||
}
|
||||
|
||||
/* in place element removal to make own clickable area*/
|
||||
.p-inplace .p-inplace-display {
|
||||
display: block !important;
|
||||
padding: 0 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* .p-tabview-ink-bar {
|
||||
width: 0 !important;
|
||||
} */
|
||||
|
||||
.link-style {
|
||||
color: blue;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-style:hover {
|
||||
text-decoration: underline;
|
||||
color: blue;
|
||||
}
|
||||
|
||||
/* Ensure custom header in .p-datatable-header fills the width */
|
||||
.p-datatable-header > div {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
background: #fff !important;
|
||||
display: flex !important;
|
||||
}
|
114
frontend/src/layouts/DualModal.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Outlet, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../state/stores/useAuthStore';
|
||||
import SystemSelectionModal from '../components/SystemSelectionModal';
|
||||
import TenantProfile from '../components/TenantProfile';
|
||||
import Sidebar from './Sidebar';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
|
||||
const DualModalComponent = () => {
|
||||
const { systemId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { isLoggedIn } = useAuthStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showOutlet, setShowOutlet] = useState(false);
|
||||
const [logoPath, setLogoPath] = useState('');
|
||||
const [systemDetails, setSystemDetails] = useState(null);
|
||||
const [animationFinished, setAnimationFinished] = useState(false);
|
||||
const animationSpeed = 500;
|
||||
|
||||
const [redirectTo, setRedirectTo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`useEffect triggered: systemId=${systemId}, isLoggedIn=${isLoggedIn}`);
|
||||
if (!isLoggedIn) {
|
||||
setIsExpanded(false);
|
||||
setShowOutlet(false);
|
||||
setAnimationFinished(false); // Reset animation
|
||||
} else if (systemId && systemId !== 'default') {
|
||||
setIsExpanded(true);
|
||||
setShowOutlet(true);
|
||||
setTimeout(() => setAnimationFinished(true), animationSpeed);
|
||||
} else {
|
||||
setIsExpanded(false);
|
||||
setShowOutlet(false);
|
||||
setAnimationFinished(false); // Reset animation
|
||||
}
|
||||
}, [systemId, isLoggedIn]);
|
||||
|
||||
const handleSystemSelected = (systemData) => {
|
||||
console.log(`System selected: ${systemData.urlSlug}`);
|
||||
setRedirectTo(`/dashboard/${systemData.urlSlug}`);
|
||||
setIsExpanded(true);
|
||||
setShowOutlet(true);
|
||||
setLogoPath(systemData.logo);
|
||||
setSystemDetails(systemData);
|
||||
setAnimationFinished(false);
|
||||
};
|
||||
|
||||
const handleLogoChange = (newLogoPath) => {
|
||||
setLogoPath(newLogoPath);
|
||||
};
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
if (redirectTo) {
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center h-screen bg-gradient-to-br from-gray-700 to-teal-900' data-system={systemId}>
|
||||
<div
|
||||
className={`transition-all ease-in-out duration-500 rounded ${isExpanded
|
||||
? 'w-1/5 h-screen px-4'
|
||||
: `${isLoggedIn ? 'w-1/2 h-3/4' : 'w-1/3 h-2/4'} my-12 mx-6 px-4`
|
||||
} m-2 bg-white shadow-xl flex justify-center items-center px-4`}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
showOutlet && systemId && animationFinished ? (
|
||||
<Sidebar
|
||||
systemId={systemId}
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className={`h-full w-full transition-opacity duration-50 ${isExpanded ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<TenantProfile />
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<LoginModal />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative flex-1 w-full overflow-auto transition-all ease-in-out duration-500 flex rounded ${isExpanded ? 'w-1/3 max-w-none h-full' : 'w-1/3 h-1/2 mx-6'
|
||||
} bg-white shadow-xl justify-center items-center ${isLoggedIn ? '' : 'hidden'}`}
|
||||
>
|
||||
{isLoggedIn && showOutlet && systemId ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<>
|
||||
<SystemSelectionModal
|
||||
onSystemSelected={handleSystemSelected}
|
||||
onLogoChange={handleLogoChange}
|
||||
systemDetails={systemDetails}
|
||||
/>
|
||||
{logoPath && (
|
||||
<img
|
||||
src={logoPath}
|
||||
alt='Selected System Logo'
|
||||
style={{ maxHeight: '150px', padding: '0 40px 0 0', overflow: 'hidden' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DualModalComponent;
|
109
frontend/src/layouts/Sidebar.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import fusero_logo from '../assets/fusero_logo.svg';
|
||||
import roc_logo from '../assets/roc_logo.png';
|
||||
|
||||
import 'primeicons/primeicons.css';
|
||||
|
||||
const systemConfig = {
|
||||
council: {
|
||||
routes: [{ path: 'chat', label: 'chat', icon: 'pi pi-users' }],
|
||||
logo: fusero_logo,
|
||||
},
|
||||
fusemind: {
|
||||
routes: [
|
||||
{ path: 'home', label: 'Home', icon: 'pi pi-home' },
|
||||
{ path: 'chat', label: 'Chat', icon: 'pi pi-comments' },
|
||||
{ path: 'agents', label: 'Agents', icon: 'pi pi-users' },
|
||||
{ path: 'settings', label: 'Settings', icon: 'pi pi-cog' }
|
||||
],
|
||||
logo: fusero_logo,
|
||||
},
|
||||
canvas: {
|
||||
routes: [
|
||||
{ path: 'canvas-endpoints', label: 'Canvas Endpoints', icon: 'pi pi-table' }
|
||||
],
|
||||
logo: roc_logo,
|
||||
},
|
||||
};
|
||||
|
||||
const Sidebar = ({ systemId, isCollapsed, setIsCollapsed }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const system = systemConfig[systemId];
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [systemId, isCollapsed]);
|
||||
|
||||
const handleBackClick = () => {
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const toggleCollapse = () => setIsCollapsed((prevState) => !prevState);
|
||||
|
||||
if (!systemId || !system) {
|
||||
console.error('Sidebar: systemId is undefined or not supported.');
|
||||
return <p>No system selected</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transition-opacity duration-500 ${isVisible ? 'opacity-100' : 'opacity-0'} ${isCollapsed ? 'w-20' : 'w-64'
|
||||
} h-full bg-white flex flex-col items-center pt-2`}
|
||||
onTransitionEnd={() => {
|
||||
if (!isVisible) navigate('/dashboard');
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={isCollapsed ? 'pi pi-arrow-circle-right' : 'pi pi-arrow-circle-left'}
|
||||
onClick={toggleCollapse}
|
||||
className='mt-0 p-button-rounded p-button-text'
|
||||
aria-label='Toggle'
|
||||
/>
|
||||
|
||||
<div className='flex justify-center w-full p-0 m-0'>
|
||||
<Link to='/'>
|
||||
<Button
|
||||
label={isCollapsed ? '' : 'Back'}
|
||||
icon='pi pi-arrow-left'
|
||||
onClick={handleBackClick}
|
||||
className='p-0 mt-1 p-button-rounded p-button-outlined'
|
||||
aria-label='Back'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={`transition-all duration-400 ${isCollapsed ? 'w-32' : 'w-3/4 p-0 m-0'}`}>
|
||||
<img
|
||||
src={system.logo}
|
||||
alt='Logo'
|
||||
className={`transition-all duration-300 ${isCollapsed ? 'w-32' : 'w-3/4 p-0 m-0'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className='w-full mt-2 border-gray-700' />
|
||||
|
||||
<ul className='w-full mt-2 space-y-1'>
|
||||
{system.routes.map(({ path, label, icon }) => (
|
||||
<li key={path}>
|
||||
<Link
|
||||
to={`/dashboard/${systemId}/${path}`}
|
||||
className={`px-6 py-2 ${location.pathname.includes(`/${path}`) ? 'active' : ''
|
||||
} flex items-center justify-${isCollapsed ? 'center' : 'start'}`}
|
||||
>
|
||||
<i className={`mr-2 pi ${icon} ${isCollapsed ? 'text-xl' : ''}`}></i>
|
||||
{isCollapsed ? '' : label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
14
frontend/src/main.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import Router from './router'; // Importing the router configuration
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
|
||||
const rootElement = document.getElementById('root') as HTMLElement;
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={Router} />
|
||||
</React.StrictMode>
|
||||
);
|
75
frontend/src/prompts/table-generation.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { ActionKeyword, detectActions } from '../constants/actions';
|
||||
|
||||
interface TableGenerationPromptParams {
|
||||
prompt: string;
|
||||
context?: string;
|
||||
keywords?: string[];
|
||||
id?: string;
|
||||
tableSchema?: {
|
||||
name: string;
|
||||
fields: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const generateTablePrompt = (params: TableGenerationPromptParams): string => {
|
||||
const {
|
||||
prompt,
|
||||
context = 'General information',
|
||||
keywords = [],
|
||||
id = 'auto-generated',
|
||||
tableSchema
|
||||
} = params;
|
||||
|
||||
const detectedActions = detectActions(prompt, keywords);
|
||||
|
||||
return `Create a table with data about: ${prompt}
|
||||
Context: ${context}
|
||||
Keywords: ${keywords.join(', ') || 'None specified'}
|
||||
${tableSchema ? `
|
||||
Table Schema:
|
||||
Name: ${tableSchema.name}
|
||||
Fields:
|
||||
${tableSchema.fields.map(f => `- ${f.name} (${f.type})${f.description ? `: ${f.description}` : ''}`).join('\n')}
|
||||
` : ''}
|
||||
|
||||
Detected Actions:
|
||||
${detectedActions.map(action => `- ${action.description}`).join('\n')}
|
||||
|
||||
Return ONLY a JSON object in this format:
|
||||
{
|
||||
"columns": [
|
||||
{"field": "column1", "header": "Column 1"},
|
||||
{"field": "column2", "header": "Column 2"}
|
||||
],
|
||||
"data": [
|
||||
{"column1": "value1", "column2": "value2"},
|
||||
{"column1": "value3", "column2": "value4"}
|
||||
],
|
||||
"metadata": {
|
||||
"id": "${id}",
|
||||
"keywords": ${JSON.stringify(keywords)},
|
||||
"context": "${context}",
|
||||
"timestamp": "${new Date().toISOString()}",
|
||||
"actions": ${JSON.stringify(detectedActions)}
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
1. Return ONLY the JSON object, no explanations
|
||||
2. Make sure field names in data match column fields exactly
|
||||
3. Choose appropriate column names based on the topic
|
||||
4. Include at least 5 rows of data
|
||||
5. Ensure all data is relevant to the context and keywords
|
||||
6. Format dates in ISO format if present
|
||||
7. Use consistent data types within columns
|
||||
8. If table schema is provided, strictly follow the field names and types
|
||||
9. Ensure all required fields from the schema are present in the data
|
||||
10. Format the data according to the specified types in the schema
|
||||
11. For multiple actions, prioritize the first action mentioned
|
||||
12. When creating endpoints, include all related actions in the endpoint description
|
||||
13. For row click actions, use the 'view' action type`;
|
||||
};
|
53
frontend/src/router.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import DualModalComponent from './layouts/DualModal';
|
||||
|
||||
import CouncilAI from './components/CouncilAI/CouncilAI';
|
||||
import FuseMindHome from './components/FuseMind/FuseMindHome';
|
||||
import CanvasEndpoints from './components/canvas-api/CanvasEndpoints';
|
||||
|
||||
const FuseMindHomeWrapper = () => {
|
||||
const { systemId } = useParams();
|
||||
return systemId === 'fusemind' ? <FuseMindHome /> : null;
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to='dashboard' replace />,
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <DualModalComponent />,
|
||||
children: [
|
||||
{
|
||||
path: ':systemId',
|
||||
children: [
|
||||
{
|
||||
path: 'chat',
|
||||
element: <CouncilAI />,
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
element: <FuseMindHomeWrapper />,
|
||||
},
|
||||
{
|
||||
path: 'endpoints',
|
||||
element: <CanvasEndpoints />,
|
||||
},
|
||||
{
|
||||
path: 'canvas-endpoints',
|
||||
element: <CanvasEndpoints />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <p>Error: Page not found here?</p>,
|
||||
},
|
||||
]);
|
||||
|
||||
export default router;
|
92
frontend/src/services/api.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import axios, { AxiosResponse, ResponseType } from 'axios';
|
||||
import { useApiLogStore } from './apiLogStore';
|
||||
|
||||
// Clean server path, removing trailing slash if present
|
||||
const rawBaseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const serverPath = rawBaseUrl.replace(/\/$/, '');
|
||||
|
||||
type THttpMethod = 'get' | 'put' | 'post' | 'delete';
|
||||
|
||||
const DEFAULT_TIMEOUT = 60;
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
[x: string]: any;
|
||||
data: T;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export async function api<T = any>(
|
||||
method: THttpMethod,
|
||||
url: string,
|
||||
data?: any,
|
||||
params?: any,
|
||||
responseType: ResponseType = 'json',
|
||||
timeoutInSeconds: number = DEFAULT_TIMEOUT,
|
||||
skipAuth: boolean = false
|
||||
): Promise<ApiResponse<T>> {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const token = user?.token || '';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (!skipAuth && token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: serverPath,
|
||||
timeout: timeoutInSeconds * 1000,
|
||||
headers,
|
||||
withCredentials: !skipAuth,
|
||||
responseType,
|
||||
});
|
||||
|
||||
const logApiCall = useApiLogStore.getState().logApiCall;
|
||||
|
||||
try {
|
||||
const requestOptions: any = {
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
params,
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
const response: AxiosResponse<T> = await axiosInstance.request(requestOptions);
|
||||
const end = performance.now();
|
||||
|
||||
const apiResponse: ApiResponse<T> = {
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
};
|
||||
|
||||
logApiCall({
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
responseTime: end - start,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return apiResponse;
|
||||
} catch (error: any) {
|
||||
console.error('API Error:', {
|
||||
url,
|
||||
method,
|
||||
error: error.response?.data || error.message,
|
||||
status: error.response?.status,
|
||||
});
|
||||
|
||||
logApiCall({
|
||||
method,
|
||||
url,
|
||||
status: error.response?.status || 500,
|
||||
responseTime: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
19
frontend/src/services/apiLogStore.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type ApiLog = {
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
responseTime: number;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
type ApiLogStore = {
|
||||
apiLogs: ApiLog[];
|
||||
logApiCall: (log: ApiLog) => void;
|
||||
};
|
||||
|
||||
export const useApiLogStore = create<ApiLogStore>((set) => ({
|
||||
apiLogs: [],
|
||||
logApiCall: (log) => set((state) => ({ apiLogs: [...state.apiLogs, log] })),
|
||||
}));
|
76
frontend/src/services/fusemind/chatService.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { Message, ChatConfig, ChatResponse } from '../../types/fusemind/messages';
|
||||
|
||||
class ChatService {
|
||||
private config: ChatConfig = {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000
|
||||
};
|
||||
|
||||
private context: Message[] = [];
|
||||
|
||||
async sendMessage(message: Message): Promise<ChatResponse> {
|
||||
try {
|
||||
// Add message to context
|
||||
this.context.push(message);
|
||||
|
||||
// Prepare the messages for ChatGPT
|
||||
const messages = this.context.map(msg => ({
|
||||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
// Make API call to ChatGPT
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
...this.config
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get response from ChatGPT');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Create response message
|
||||
const responseMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: data.message,
|
||||
type: 'system',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
// Add response to context
|
||||
this.context.push(responseMessage);
|
||||
|
||||
// Return formatted response
|
||||
return {
|
||||
message: responseMessage,
|
||||
usage: data.usage
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in ChatService:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(config: Partial<ChatConfig>) {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
clearContext() {
|
||||
this.context = [];
|
||||
}
|
||||
|
||||
getContext(): Message[] {
|
||||
return [...this.context];
|
||||
}
|
||||
}
|
||||
|
||||
export const chatService = new ChatService();
|
456
frontend/src/services/fusemind/memoryService.ts
Normal file
@ -0,0 +1,456 @@
|
||||
import {
|
||||
MemoryUnit,
|
||||
MemoryType,
|
||||
WorkingMemory,
|
||||
MemoryRetrieval,
|
||||
MemoryConsolidation,
|
||||
DatabaseMemory,
|
||||
EmotionalState,
|
||||
LLMWorkingMemory,
|
||||
LLMContextMemory,
|
||||
EmbeddingMemory,
|
||||
PromptMemory,
|
||||
TemporalProperties,
|
||||
MemoryFormationContext,
|
||||
EmotionalProperties
|
||||
} from '../../types/fusemind/memory';
|
||||
|
||||
class MemoryService {
|
||||
private workingMemory: LLMWorkingMemory;
|
||||
private emotionalState: EmotionalState;
|
||||
private activeLifeEvents: Map<string, TemporalProperties['lifeEvents'][0]>;
|
||||
|
||||
constructor() {
|
||||
this.workingMemory = {
|
||||
activeUnits: [],
|
||||
focus: {
|
||||
primary: '',
|
||||
secondary: [],
|
||||
attentionWeights: {}
|
||||
},
|
||||
context: {
|
||||
currentTask: '',
|
||||
relevantMemories: [],
|
||||
emotionalState: {
|
||||
mood: 0,
|
||||
energy: 1,
|
||||
stress: 0,
|
||||
focus: 1
|
||||
}
|
||||
},
|
||||
llmContext: {
|
||||
currentModel: 'gpt-4',
|
||||
contextWindow: {
|
||||
used: 0,
|
||||
available: 8192
|
||||
},
|
||||
recentPrompts: [],
|
||||
temperature: 0.7,
|
||||
systemPrompt: 'You are a helpful AI assistant.'
|
||||
},
|
||||
embeddings: {
|
||||
model: 'text-embedding-ada-002',
|
||||
cache: new Map(),
|
||||
similarityThreshold: 0.8
|
||||
}
|
||||
};
|
||||
this.emotionalState = this.workingMemory.context.emotionalState;
|
||||
this.activeLifeEvents = new Map();
|
||||
}
|
||||
|
||||
// Memory Operations
|
||||
async storeMemory(memory: MemoryUnit, formationContext?: MemoryFormationContext): Promise<string> {
|
||||
// Add life event context if available
|
||||
if (formationContext?.temporalContext.lifeEvent) {
|
||||
const event = this.activeLifeEvents.get(formationContext.temporalContext.lifeEvent);
|
||||
if (event) {
|
||||
memory.temporal.lifeEvents.push(event);
|
||||
memory.temporal.temporalContext = {
|
||||
period: formationContext.temporalContext.period,
|
||||
moodState: this.getMoodState(),
|
||||
cognitiveBias: this.getActiveBiases()
|
||||
};
|
||||
|
||||
// Apply emotional biases
|
||||
memory.emotional.triggers.push(...formationContext.emotionalInfluence.biasEffects.map(bias => ({
|
||||
type: 'contextual' as const,
|
||||
source: 'life_event',
|
||||
intensity: bias.strength,
|
||||
associatedEvent: formationContext.temporalContext.lifeEvent,
|
||||
biasEffect: {
|
||||
type: bias.type as 'amplification' | 'distortion' | 'suppression',
|
||||
strength: bias.strength,
|
||||
duration: event.emotionalImpact.persistence
|
||||
}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to database format
|
||||
const dbMemory: DatabaseMemory = {
|
||||
id: memory.id,
|
||||
type: memory.type,
|
||||
content: JSON.stringify(memory.content),
|
||||
temporal: JSON.stringify(memory.temporal),
|
||||
emotional: JSON.stringify(memory.emotional),
|
||||
connections: JSON.stringify(memory.connections),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
// TODO: Implement database storage
|
||||
// await db.memories.insert(dbMemory);
|
||||
|
||||
// Update working memory if relevant
|
||||
this.updateWorkingMemory(memory);
|
||||
|
||||
return memory.id;
|
||||
}
|
||||
|
||||
async retrieveMemory(query: MemoryRetrieval): Promise<MemoryUnit[]> {
|
||||
const memories = await this.retrieveMemoryFromStorage(query);
|
||||
|
||||
// Apply emotional and temporal context filters
|
||||
return memories.filter(memory => {
|
||||
// Check if memory is relevant to current life events
|
||||
const isRelevantToCurrentEvents = memory.temporal.lifeEvents.some(event =>
|
||||
this.activeLifeEvents.has(event.eventId)
|
||||
);
|
||||
|
||||
// Check emotional triggers
|
||||
const hasActiveTriggers = memory.emotional.triggers.some(trigger =>
|
||||
trigger.associatedEvent && this.activeLifeEvents.has(trigger.associatedEvent)
|
||||
);
|
||||
|
||||
return isRelevantToCurrentEvents || hasActiveTriggers;
|
||||
});
|
||||
}
|
||||
|
||||
private async retrieveMemoryFromStorage(query: MemoryRetrieval): Promise<MemoryUnit[]> {
|
||||
// TODO: Implement actual storage retrieval
|
||||
return [];
|
||||
}
|
||||
|
||||
async consolidateMemories(consolidation: MemoryConsolidation): Promise<MemoryUnit> {
|
||||
const memories = await Promise.all(
|
||||
consolidation.sourceMemories.map(id => this.retrieveMemory({
|
||||
query: id,
|
||||
context: {},
|
||||
filters: {}
|
||||
}))
|
||||
).then(results => results.flat());
|
||||
|
||||
// Calculate emotional influence
|
||||
const emotionalInfluence = this.calculateEmotionalInfluence(memories);
|
||||
|
||||
// Create new memory with emotional context
|
||||
const newMemory: MemoryUnit = {
|
||||
id: this.generateId(),
|
||||
type: 'semantic',
|
||||
content: this.mergeMemoryContents(memories),
|
||||
temporal: {
|
||||
...this.calculateTemporalProperties(memories),
|
||||
lifeEvents: memories.flatMap(m => m.temporal.lifeEvents),
|
||||
temporalContext: this.getCurrentTemporalContext()
|
||||
},
|
||||
emotional: {
|
||||
...this.calculateEmotionalProperties(memories),
|
||||
triggers: emotionalInfluence.triggers,
|
||||
emotionalState: {
|
||||
baseline: emotionalInfluence.baseline,
|
||||
current: this.emotionalState.mood,
|
||||
volatility: emotionalInfluence.volatility
|
||||
}
|
||||
},
|
||||
connections: this.buildMemoryConnections(memories)
|
||||
};
|
||||
|
||||
await this.storeMemory(newMemory);
|
||||
return newMemory;
|
||||
}
|
||||
|
||||
// Working Memory Management
|
||||
private updateWorkingMemory(memory: MemoryUnit) {
|
||||
// Add to active units if not already present
|
||||
if (!this.workingMemory.activeUnits.find(u => u.id === memory.id)) {
|
||||
this.workingMemory.activeUnits.push(memory);
|
||||
}
|
||||
|
||||
// Update focus based on memory importance
|
||||
this.updateFocus(memory);
|
||||
}
|
||||
|
||||
private updateFocus(memory: MemoryUnit) {
|
||||
const importance = memory.temporal.importance;
|
||||
|
||||
if (importance > 0.8) {
|
||||
// High importance memories become primary focus
|
||||
this.workingMemory.focus.primary = memory.id;
|
||||
} else if (importance > 0.5) {
|
||||
// Medium importance memories become secondary focus
|
||||
if (!this.workingMemory.focus.secondary.includes(memory.id)) {
|
||||
this.workingMemory.focus.secondary.push(memory.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update attention weights
|
||||
this.workingMemory.focus.attentionWeights[memory.id] = importance;
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
private matchesQuery(memory: MemoryUnit, query: MemoryRetrieval): boolean {
|
||||
// Implement query matching logic
|
||||
return true; // Placeholder
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
private mergeMemoryContents(memories: MemoryUnit[]): any {
|
||||
// Implement memory content merging logic
|
||||
return {}; // Placeholder
|
||||
}
|
||||
|
||||
private calculateTemporalProperties(memories: MemoryUnit[]): any {
|
||||
// Implement temporal properties calculation
|
||||
return {}; // Placeholder
|
||||
}
|
||||
|
||||
private calculateEmotionalProperties(memories: MemoryUnit[]): any {
|
||||
// Implement emotional properties calculation
|
||||
return {}; // Placeholder
|
||||
}
|
||||
|
||||
private buildMemoryConnections(memories: MemoryUnit[]): any[] {
|
||||
// Implement memory connections building
|
||||
return []; // Placeholder
|
||||
}
|
||||
|
||||
// Emotional State Management
|
||||
updateEmotionalState(update: Partial<EmotionalState>) {
|
||||
this.emotionalState = {
|
||||
...this.emotionalState,
|
||||
...update
|
||||
};
|
||||
this.workingMemory.context.emotionalState = this.emotionalState;
|
||||
}
|
||||
|
||||
getCurrentEmotionalState(): EmotionalState {
|
||||
return this.emotionalState;
|
||||
}
|
||||
|
||||
// LLM-specific operations
|
||||
async storeLLMContext(context: LLMContextMemory): Promise<string> {
|
||||
// Update working memory context
|
||||
this.workingMemory.llmContext = {
|
||||
...this.workingMemory.llmContext,
|
||||
currentModel: context.content.data.model,
|
||||
contextWindow: {
|
||||
used: context.content.data.tokens.total,
|
||||
available: context.content.data.contextWindow
|
||||
},
|
||||
temperature: context.content.data.parameters.temperature
|
||||
};
|
||||
|
||||
return this.storeMemory(context);
|
||||
}
|
||||
|
||||
async storeEmbedding(embedding: EmbeddingMemory): Promise<string> {
|
||||
// Cache the embedding
|
||||
this.workingMemory.embeddings.cache.set(
|
||||
embedding.content.data.text,
|
||||
embedding.content.data.embedding
|
||||
);
|
||||
|
||||
return this.storeMemory(embedding);
|
||||
}
|
||||
|
||||
async storePrompt(prompt: PromptMemory): Promise<string> {
|
||||
// Update recent prompts
|
||||
this.workingMemory.llmContext.recentPrompts.push(
|
||||
prompt.content.data.template
|
||||
);
|
||||
|
||||
// Keep only last 10 prompts
|
||||
if (this.workingMemory.llmContext.recentPrompts.length > 10) {
|
||||
this.workingMemory.llmContext.recentPrompts.shift();
|
||||
}
|
||||
|
||||
return this.storeMemory(prompt);
|
||||
}
|
||||
|
||||
async retrieveSimilarMemories(query: string, threshold: number = 0.8): Promise<MemoryUnit[]> {
|
||||
// Get embedding for query
|
||||
const queryEmbedding = await this.getEmbedding(query);
|
||||
|
||||
// Find similar memories
|
||||
const similarMemories: MemoryUnit[] = [];
|
||||
|
||||
for (const [text, embedding] of this.workingMemory.embeddings.cache) {
|
||||
const similarity = this.calculateSimilarity(queryEmbedding, embedding);
|
||||
if (similarity >= threshold) {
|
||||
const memories = await this.retrieveMemory({
|
||||
query: text,
|
||||
context: {},
|
||||
filters: {}
|
||||
});
|
||||
similarMemories.push(...memories);
|
||||
}
|
||||
}
|
||||
|
||||
return similarMemories;
|
||||
}
|
||||
|
||||
// Helper methods for LLM operations
|
||||
private async getEmbedding(text: string): Promise<number[]> {
|
||||
// Check cache first
|
||||
if (this.workingMemory.embeddings.cache.has(text)) {
|
||||
return this.workingMemory.embeddings.cache.get(text)!;
|
||||
}
|
||||
|
||||
// TODO: Implement actual embedding generation
|
||||
// const embedding = await openai.embeddings.create({
|
||||
// model: this.workingMemory.embeddings.model,
|
||||
// input: text
|
||||
// });
|
||||
|
||||
// For now, return placeholder
|
||||
return new Array(1536).fill(0).map(() => Math.random());
|
||||
}
|
||||
|
||||
private calculateSimilarity(embedding1: number[], embedding2: number[]): number {
|
||||
// Implement cosine similarity
|
||||
const dotProduct = embedding1.reduce((sum, val, i) => sum + val * embedding2[i], 0);
|
||||
const norm1 = Math.sqrt(embedding1.reduce((sum, val) => sum + val * val, 0));
|
||||
const norm2 = Math.sqrt(embedding2.reduce((sum, val) => sum + val * val, 0));
|
||||
|
||||
return dotProduct / (norm1 * norm2);
|
||||
}
|
||||
|
||||
// Enhanced memory consolidation for LLM context
|
||||
async consolidateLLMMemories(
|
||||
sourceMemories: string[],
|
||||
llmContext: Partial<LLMContextMemory['content']>
|
||||
): Promise<MemoryUnit> {
|
||||
const memories = await Promise.all(
|
||||
sourceMemories.map(id => this.retrieveMemory({
|
||||
query: id,
|
||||
context: {},
|
||||
filters: {}
|
||||
}))
|
||||
).then(results => results.flat());
|
||||
|
||||
// Create new consolidated memory with LLM context
|
||||
const newMemory: MemoryUnit = {
|
||||
id: this.generateId(),
|
||||
type: 'llm_context',
|
||||
content: {
|
||||
...this.mergeMemoryContents(memories),
|
||||
...llmContext
|
||||
},
|
||||
temporal: this.calculateTemporalProperties(memories),
|
||||
emotional: this.calculateEmotionalProperties(memories),
|
||||
connections: this.buildMemoryConnections(memories)
|
||||
};
|
||||
|
||||
await this.storeMemory(newMemory);
|
||||
return newMemory;
|
||||
}
|
||||
|
||||
// Life event management
|
||||
async registerLifeEvent(event: TemporalProperties['lifeEvents'][0]) {
|
||||
this.activeLifeEvents.set(event.eventId, event);
|
||||
|
||||
// Update emotional state based on event
|
||||
this.updateEmotionalState({
|
||||
mood: event.emotionalImpact.valence,
|
||||
energy: event.emotionalImpact.arousal,
|
||||
stress: 1 - event.emotionalImpact.persistence,
|
||||
focus: 0.5 // Reduced focus during significant events
|
||||
});
|
||||
}
|
||||
|
||||
async endLifeEvent(eventId: string) {
|
||||
const event = this.activeLifeEvents.get(eventId);
|
||||
if (event) {
|
||||
event.endDate = new Date();
|
||||
this.activeLifeEvents.delete(eventId);
|
||||
|
||||
// Gradually return to baseline emotional state
|
||||
this.updateEmotionalState({
|
||||
mood: 0, // Neutral
|
||||
energy: 1,
|
||||
stress: 0,
|
||||
focus: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private calculateEmotionalInfluence(memories: MemoryUnit[]): {
|
||||
triggers: EmotionalProperties['triggers'];
|
||||
baseline: number;
|
||||
volatility: number;
|
||||
} {
|
||||
const triggers: EmotionalProperties['triggers'] = [];
|
||||
let totalValence = 0;
|
||||
let totalArousal = 0;
|
||||
let count = 0;
|
||||
|
||||
memories.forEach(memory => {
|
||||
// Collect triggers
|
||||
triggers.push(...memory.emotional.triggers);
|
||||
|
||||
// Calculate average emotional state
|
||||
totalValence += memory.emotional.valence;
|
||||
totalArousal += memory.emotional.arousal;
|
||||
count++;
|
||||
});
|
||||
|
||||
return {
|
||||
triggers,
|
||||
baseline: count > 0 ? totalValence / count : 0,
|
||||
volatility: count > 0 ? totalArousal / count : 0
|
||||
};
|
||||
}
|
||||
|
||||
private getCurrentTemporalContext(): TemporalProperties['temporalContext'] {
|
||||
const activeEvents = Array.from(this.activeLifeEvents.values());
|
||||
return {
|
||||
period: activeEvents.length > 0 ?
|
||||
activeEvents.map(e => e.type).join('-') :
|
||||
'normal',
|
||||
moodState: this.getMoodState(),
|
||||
cognitiveBias: this.getActiveBiases()
|
||||
};
|
||||
}
|
||||
|
||||
private getMoodState(): string {
|
||||
const mood = this.emotionalState.mood;
|
||||
if (mood > 0.7) return 'euphoric';
|
||||
if (mood > 0.3) return 'positive';
|
||||
if (mood < -0.7) return 'depressed';
|
||||
if (mood < -0.3) return 'negative';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
private getActiveBiases(): string[] {
|
||||
const biases: string[] = [];
|
||||
const mood = this.emotionalState.mood;
|
||||
|
||||
if (mood > 0.5) {
|
||||
biases.push('optimism', 'overestimation');
|
||||
} else if (mood < -0.5) {
|
||||
biases.push('pessimism', 'catastrophizing');
|
||||
}
|
||||
|
||||
if (this.emotionalState.stress > 0.7) {
|
||||
biases.push('anxiety', 'hypervigilance');
|
||||
}
|
||||
|
||||
return biases;
|
||||
}
|
||||
}
|
||||
|
||||
export const memoryService = new MemoryService();
|
37
frontend/src/shared/components/SettingsMenu.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useRef } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Menu } from 'primereact/menu';
|
||||
|
||||
export interface SettingsMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
command?: () => void;
|
||||
}
|
||||
|
||||
interface SettingsMenuProps {
|
||||
items: SettingsMenuItem[];
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({ items, buttonClassName }) => {
|
||||
const menuRef = useRef<any>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon="pi pi-cog"
|
||||
aria-label="Settings"
|
||||
onClick={e => menuRef.current.toggle(e)}
|
||||
className={buttonClassName || ''}
|
||||
tooltip="Settings"
|
||||
/>
|
||||
<Menu
|
||||
model={items}
|
||||
popup
|
||||
ref={menuRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMenu;
|
13
frontend/src/shared/components/_V1/BorderSpinner.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
const BorderSpinner = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BorderSpinner;
|
10
frontend/src/shared/components/_V1/Divider.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { DividerProps, Divider as PrimeDivider } from "primereact/divider";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const Divider: FunctionComponent<DividerProps> = ({ children }) => {
|
||||
return (
|
||||
<PrimeDivider>{children}</PrimeDivider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Divider;
|
16
frontend/src/shared/components/_V1/LoadingPage.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import BorderSpinner from './BorderSpinner';
|
||||
|
||||
const LoadingPage = ({ height = 200, fullScreen = true }) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center ${fullScreen ? 'h-screen' : ''}`}
|
||||
style={!fullScreen ? { height: height } : undefined}
|
||||
>
|
||||
<div className=''>
|
||||
<BorderSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingPage;
|
28
frontend/src/shared/components/_V1/TableGroup.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { DataTable, DataTableProps } from 'primereact/datatable';
|
||||
import LoadingPage from './LoadingPage';
|
||||
|
||||
type CustomTableGroupProps = DataTableProps<any> & {
|
||||
body: any[];
|
||||
};
|
||||
|
||||
const TableGroup: FunctionComponent<CustomTableGroupProps> = ({ body, ...dataTableProps }) => {
|
||||
return dataTableProps.loading ? (
|
||||
<LoadingPage />
|
||||
) : (
|
||||
<DataTable
|
||||
value={body}
|
||||
tableStyle={{ minWidth: '50rem', width: '100%' }}
|
||||
{...(dataTableProps.paginator && {
|
||||
paginator: true,
|
||||
paginatorTemplate:
|
||||
'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown',
|
||||
rowsPerPageOptions: [10, 25, 50],
|
||||
currentPageReportTemplate: 'Showing {first} to {last} of {totalRecords} entries',
|
||||
})}
|
||||
{...dataTableProps}
|
||||
></DataTable>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableGroup;
|
85
frontend/src/shared/components/_V1/TableGroupHeader.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { SelectButton } from 'primereact/selectbutton';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import Button from '../_V2/Button';
|
||||
import { TSizeOptionValue } from '../../../components/canvas-api/DataTable.types';
|
||||
|
||||
interface HeaderProps {
|
||||
size: any;
|
||||
setSize: Dispatch<SetStateAction<TSizeOptionValue>>;
|
||||
sizeOptions: { label: string; value: TSizeOptionValue }[];
|
||||
globalFilterValue: string;
|
||||
onGlobalFilterChange: (e: { target: { value: any } }) => void;
|
||||
onRefresh: () => void;
|
||||
clearFilter: () => void;
|
||||
hasExpandButtons?: boolean;
|
||||
setExpandedRows?: Dispatch<SetStateAction<any>>;
|
||||
expandedData?: any[];
|
||||
extraButtonsTemplate?: () => JSX.Element;
|
||||
}
|
||||
|
||||
export const TableGroupHeader: React.FC<HeaderProps> = ({
|
||||
size,
|
||||
setSize,
|
||||
sizeOptions,
|
||||
globalFilterValue,
|
||||
onGlobalFilterChange,
|
||||
onRefresh,
|
||||
clearFilter,
|
||||
hasExpandButtons,
|
||||
setExpandedRows,
|
||||
expandedData,
|
||||
extraButtonsTemplate,
|
||||
}) => {
|
||||
if (hasExpandButtons && (!setExpandedRows || !expandedData)) {
|
||||
throw new Error(
|
||||
'When hasExpandButtons is true, setExpandedRows and expandedData are required.'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between ${hasExpandButtons ? 'gap-4' : ''}`}>
|
||||
<SelectButton
|
||||
value={size}
|
||||
onChange={(e) => setSize(e.value)}
|
||||
options={sizeOptions}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
{hasExpandButtons ? (
|
||||
<div className='flex flex-wrap gap-2 justify-content-end'>
|
||||
<Button
|
||||
icon='pi pi-plus'
|
||||
label='Expand All'
|
||||
onClick={() => {
|
||||
// Set the expandedRows state with the new object
|
||||
const elements = [];
|
||||
expandedData.forEach((element) => {
|
||||
elements.push(element);
|
||||
});
|
||||
setExpandedRows(elements);
|
||||
}}
|
||||
/>
|
||||
<Button icon='pi pi-minus' label='Collapse All' onClick={() => setExpandedRows(null)} />
|
||||
</div>
|
||||
) : null}
|
||||
<span className='w-full mx-auto p-input-icon-left md:w-auto'>
|
||||
<i className='pi pi-search' />
|
||||
<InputText
|
||||
value={globalFilterValue}
|
||||
onChange={onGlobalFilterChange}
|
||||
placeholder='Search everything'
|
||||
className='w-full'
|
||||
/>
|
||||
</span>
|
||||
<div className='flex gap-4'>
|
||||
<div className='flex items-center gap-2 '>
|
||||
<Button type='button' label='Refresh' outlined onClick={onRefresh} />
|
||||
<Button type='button' label='Clear Filters' outlined onClick={clearFilter} />
|
||||
{extraButtonsTemplate ? extraButtonsTemplate() : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default TableGroupHeader;
|
21
frontend/src/shared/components/_V2/Button.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Button as PrimeButton, ButtonProps } from 'primereact/button';
|
||||
import React from 'react';
|
||||
|
||||
interface CustomButtonProps extends ButtonProps {}
|
||||
|
||||
const Button: React.FC<CustomButtonProps> = ({ style, className, disabled, ...buttonProps }) => {
|
||||
const disabledClass = disabled
|
||||
? 'bg-gray-500 hover:bg-gray-500 text-white'
|
||||
: 'bg-teal-500 hover:bg-white hover:text-teal-500';
|
||||
return (
|
||||
<PrimeButton
|
||||
{...buttonProps}
|
||||
unstyled
|
||||
disabled={disabled}
|
||||
className={` py-2 px-4 min-w-1 mt-4 text-white border-2 border-teal-500 rounded-md transition-colors ${disabledClass} ${className} `}
|
||||
style={{ ...style, fontSize: '0.75rem', fontWeight: '700' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
60
frontend/src/shared/components/_V2/CustomAvatar.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Avatar } from 'primereact/avatar';
|
||||
|
||||
const CustomAvatar = () => {
|
||||
const [image, setImage] = useState<string | ArrayBuffer | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const localStorageKey = 'userAvatarImage';
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result;
|
||||
setImage(result);
|
||||
if (typeof result === 'string') {
|
||||
localStorage.setItem(localStorageKey, result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedImage = localStorage.getItem(localStorageKey);
|
||||
if (savedImage) {
|
||||
setImage(savedImage);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Avatar
|
||||
className='p-overlay-badge'
|
||||
icon={!image ? 'pi pi-user' : undefined}
|
||||
image={typeof image === 'string' ? image : undefined}
|
||||
size='xlarge'
|
||||
onClick={handleAvatarClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAvatar;
|
16
frontend/src/shared/components/_V2/Dropdown.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Dropdown as PrimeDropdown, DropdownProps } from 'primereact/dropdown';
|
||||
import React from 'react';
|
||||
|
||||
interface CustomDropdownProps extends DropdownProps {}
|
||||
|
||||
const Dropdown: React.FC<CustomDropdownProps> = ({ style, className, ...dropdownProps }) => {
|
||||
return (
|
||||
<PrimeDropdown
|
||||
{...dropdownProps}
|
||||
className={`w-full px-6 py-1 mb-4 border-2 border-gray-300 hover:border-teal-600 focus:ring-teal-500' rounded-md transition-colors hover:bg-white hover:text-teal-500 ${className}`}
|
||||
style={{ ...style, fontWeight: '400' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
16
frontend/src/shared/components/_V2/InputText.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { InputText as PrimeInputText, InputTextProps } from 'primereact/inputtext';
|
||||
import React from 'react';
|
||||
|
||||
interface CustomInputTextProps extends InputTextProps {}
|
||||
|
||||
const InputText: React.FC<CustomInputTextProps> = ({ style, className, ...inputProps }) => {
|
||||
return (
|
||||
<PrimeInputText
|
||||
{...inputProps}
|
||||
className={`w-full p-inputtext p-2 bg-white border-2 border-gray-400 rounded-md transition-colors focus:border-teal-700 ${className}`}
|
||||
style={{ ...style, fontWeight: '400' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputText;
|
60
frontend/src/shared/components/modals/ConfirmModal.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
const ConfirmModal = ({
|
||||
visible,
|
||||
header,
|
||||
message,
|
||||
onHide,
|
||||
onConfirm,
|
||||
disableConfirm,
|
||||
disableCancel,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onCancel = onHide,
|
||||
style,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header: string;
|
||||
message: string | JSX.Element;
|
||||
onHide: () => void;
|
||||
onConfirm: () => void;
|
||||
disableConfirm?: boolean;
|
||||
disableCancel?: boolean;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onCancel: () => void;
|
||||
style?: CSSProperties;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
closable
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
header={header}
|
||||
style={{ width: '25vw', ...style }}
|
||||
footer={
|
||||
<div>
|
||||
<Button
|
||||
label={confirmLabel}
|
||||
style={{ minWidth: '4rem' }}
|
||||
onClick={onConfirm}
|
||||
disabled={disableConfirm}
|
||||
/>
|
||||
<Button
|
||||
label={cancelLabel}
|
||||
onClick={onCancel}
|
||||
style={{ minWidth: '4rem' }}
|
||||
severity="danger"
|
||||
disabled={disableCancel}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mt-2">{message}</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmModal;
|
78
frontend/src/shared/components/modals/DeleteModal.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
|
||||
interface DeleteModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
onConfirm: () => void;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DeleteModal: React.FC<DeleteModalProps> = ({
|
||||
visible,
|
||||
onHide,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const deleteKeyword = 'DELETE';
|
||||
|
||||
const isDeleteMatch = inputValue === deleteKeyword;
|
||||
|
||||
const onCloseModal = () => {
|
||||
setInputValue('');
|
||||
onHide();
|
||||
}
|
||||
|
||||
const onCancelModal = () => {
|
||||
setInputValue('');
|
||||
onCancel();
|
||||
}
|
||||
|
||||
const onConfirmModal = () => {
|
||||
setInputValue('');
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
closable
|
||||
visible={visible}
|
||||
onHide={onCloseModal}
|
||||
header={`Type 'DELETE' to confirm`}
|
||||
footer={
|
||||
<div>
|
||||
<Button
|
||||
label={confirmLabel}
|
||||
style={{ width: '5rem' }}
|
||||
onClick={onConfirmModal}
|
||||
disabled={!isDeleteMatch}
|
||||
/>
|
||||
<Button
|
||||
label={cancelLabel}
|
||||
onClick={onCancelModal}
|
||||
style={{ width: '5rem' }}
|
||||
severity="danger"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<InputTextarea
|
||||
style={{ marginTop: '30px' }}
|
||||
placeholder='DELETE'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
rows={5}
|
||||
cols={50}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteModal;
|
63
frontend/src/state/stores/useAuthStore.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
type AuthStore = {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoggedIn: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
token: localStorage.getItem('token'),
|
||||
isLoggedIn: !!localStorage.getItem('token'),
|
||||
|
||||
login: async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await api('post', '/api/v1/app/users/login', { username, password });
|
||||
const { success, data } = response.data;
|
||||
|
||||
if (success) {
|
||||
const { token, user } = data;
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
localStorage.setItem('token', token);
|
||||
|
||||
set({ user, token, isLoggedIn: true });
|
||||
} else {
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to login:', error);
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
set({ user: null, token: null, isLoggedIn: false });
|
||||
},
|
||||
}));
|
||||
|
||||
function parseJwt(token) {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(function (c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
45
frontend/src/state/stores/useChatStore.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
interface ChatRequestData {
|
||||
data: string;
|
||||
responseFormat?: 'text' | 'json';
|
||||
model?: string;
|
||||
}
|
||||
|
||||
type ChatStore = {
|
||||
sendChatRequest: (endpoint: string, data: ChatRequestData) => Promise<any>;
|
||||
};
|
||||
|
||||
export const useChatStore = create<ChatStore>(() => ({
|
||||
sendChatRequest: async (endpoint, requestData) => {
|
||||
try {
|
||||
if (
|
||||
!requestData.data ||
|
||||
typeof requestData.data !== 'string' ||
|
||||
requestData.data.trim() === ''
|
||||
) {
|
||||
throw new Error("Invalid input: 'data' must be a non-empty string.");
|
||||
}
|
||||
|
||||
const formattedRequest = {
|
||||
data: requestData.data,
|
||||
model: requestData.model || 'gpt-4-mini',
|
||||
response_format: requestData.responseFormat === 'json' ? { type: 'json_object' } : undefined
|
||||
};
|
||||
|
||||
console.log('Sending formatted request:', formattedRequest);
|
||||
const response = await api('post', endpoint, formattedRequest, undefined, 'json', 60, true);
|
||||
console.log('Got API response:', response);
|
||||
|
||||
if (!response || !response.data) {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to send chat request to ${endpoint}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
30
frontend/src/state/stores/useSystemStore.ts
Normal file
@ -0,0 +1,30 @@
|
||||
// Adjusting the store to handle SystemDropdownOption properly
|
||||
import { create } from 'zustand';
|
||||
import { Enterprise } from '../../components/SystemSelectionModal';
|
||||
|
||||
export type System = {
|
||||
id: number;
|
||||
name: string;
|
||||
logo: string;
|
||||
};
|
||||
|
||||
export type SystemDropdownOption = {
|
||||
id: number;
|
||||
label: string;
|
||||
businessLabel: string;
|
||||
urlSlug: string;
|
||||
logo: string;
|
||||
enterprises?: Enterprise[];
|
||||
};
|
||||
|
||||
type SystemStore = {
|
||||
currentSystem: SystemDropdownOption | null;
|
||||
setSystem: (system: SystemDropdownOption) => void;
|
||||
clearSystem: () => void;
|
||||
};
|
||||
|
||||
export const useSystemStore = create<SystemStore>((set) => ({
|
||||
currentSystem: null,
|
||||
setSystem: (system: SystemDropdownOption) => set({ currentSystem: system }),
|
||||
clearSystem: () => set({ currentSystem: null }),
|
||||
}));
|
100
frontend/src/styles/canvas-theme.css
Normal file
@ -0,0 +1,100 @@
|
||||
/* Canvas API specific theme overrides */
|
||||
[data-system="canvas"] {
|
||||
/* Override all teal colors with #e70022 (red) */
|
||||
--canvas-primary: #e70022;
|
||||
--canvas-primary-light: #ff1a3d;
|
||||
--canvas-primary-dark: #cc001f;
|
||||
--canvas-button-text: #f3f4f6; /* light gray for button text */
|
||||
}
|
||||
|
||||
/* Override teal colors in Canvas API system */
|
||||
[data-system="canvas"] .p-button {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
color: var(--canvas-button-text) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button:hover {
|
||||
background-color: var(--canvas-primary-dark) !important;
|
||||
border-color: var(--canvas-primary-dark) !important;
|
||||
color: var(--canvas-button-text) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button:hover:not(.p-disabled) {
|
||||
background-color: var(--canvas-primary-dark) !important;
|
||||
border-color: var(--canvas-primary-dark) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button.p-button-outlined {
|
||||
color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-button.p-button-outlined:hover {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
color: var(--canvas-button-text) !important;
|
||||
}
|
||||
|
||||
/* Override teal in tabview */
|
||||
[data-system="canvas"] .p-tabview .p-tabview-nav li.p-highlight .p-tabview-nav-link {
|
||||
color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-tabview .p-tabview-nav li .p-tabview-nav-link:hover {
|
||||
color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in dropdowns */
|
||||
[data-system="canvas"] .p-dropdown:not(.p-disabled).p-focus {
|
||||
border-color: var(--canvas-primary) !important;
|
||||
box-shadow: 0 0 0 1px var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .p-dropdown-panel .p-dropdown-items .p-dropdown-item.p-highlight {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in checkboxes */
|
||||
[data-system="canvas"] .p-checkbox .p-checkbox-box.p-highlight {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in links */
|
||||
[data-system="canvas"] .link-style {
|
||||
color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .link-style:hover {
|
||||
color: var(--canvas-primary-dark) !important;
|
||||
}
|
||||
|
||||
/* Override teal in borders */
|
||||
[data-system="canvas"] .border-teal-500 {
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in text */
|
||||
[data-system="canvas"] .text-teal-500,
|
||||
[data-system="canvas"] .text-teal-600,
|
||||
[data-system="canvas"] .text-teal-700 {
|
||||
color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in backgrounds */
|
||||
[data-system="canvas"] .bg-teal-500,
|
||||
[data-system="canvas"] .bg-teal-600,
|
||||
[data-system="canvas"] .bg-teal-700 {
|
||||
background-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
/* Override teal in focus states */
|
||||
[data-system="canvas"] .focus\:border-teal-500:focus {
|
||||
border-color: var(--canvas-primary) !important;
|
||||
}
|
||||
|
||||
[data-system="canvas"] .focus\:ring-teal-500:focus {
|
||||
--tw-ring-color: var(--canvas-primary) !important;
|
||||
}
|
13
frontend/src/types/fusemind/avatar.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type AvatarPosition = 'left' | 'center' | 'right';
|
||||
|
||||
export interface AvatarState {
|
||||
position: AvatarPosition;
|
||||
isVisible: boolean;
|
||||
isAnimating: boolean;
|
||||
}
|
||||
|
||||
export interface AvatarConfig {
|
||||
size: number;
|
||||
color: string;
|
||||
animationDuration: number;
|
||||
}
|