Commit 1732c42d authored by Hoanganhvu123's avatar Hoanganhvu123

feat: Add OpenAI API key management, Docker setup, and complete documentation

parent af456699
...@@ -795,7 +795,7 @@ check_prerequisites() { ...@@ -795,7 +795,7 @@ check_prerequisites() {
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || -n "$WINDIR" ]]; then if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || -n "$WINDIR" ]]; then
echo " PowerShell: irm 'https://cursor.com/install?win32=true' | iex" echo " PowerShell: irm 'https://cursor.com/install?win32=true' | iex"
else else
echo " curl https://cursor.com/install -fsS | bash" echo " curl https://cursor.com/install -fsS | bash"
fi fi
return 1 return 1
fi fi
......
# Git
.git
.gitignore
.gitattributes
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
.env.local
.env.*.local
# Build
dist/
build/
*.egg-info/
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
*.md
!README.md
# Docker
docker-compose*.yml
Dockerfile*
...@@ -37,6 +37,13 @@ backend/__pycache__/ ...@@ -37,6 +37,13 @@ backend/__pycache__/
backend/*.pyc backend/*.pyc
backend/.pyscn/ backend/.pyscn/
# Frontend specifically
frontend/.env
frontend/.env.local
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Preference folder (development/temporary) # Preference folder (development/temporary)
preference/ preference/
......
# Deployment Guide
Hướng dẫn deploy CuCu Note lên server production.
## 🚀 Quick Deploy với Docker Compose
### 1. Chuẩn bị Server
```bash
# Cài đặt Docker và Docker Compose
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
# Hoặc trên Ubuntu/Debian
sudo apt-get update
sudo apt-get install docker.io docker-compose-plugin
```
### 2. Clone Repository
```bash
git clone https://github.com/Hoanganhvu123/cuccu_note.git
cd cuccu_note
```
### 3. Cấu hình Environment Variables
**Backend `.env`** (`backend/.env`):
```bash
# MongoDB
MONGODB_URI=mongodb://mongodb:27017
MONGODB_DB_NAME=cucu_note
# Authentication
CLERK_SECRET_KEY=sk_live_your_production_key
CLERK_JWKS_URL=https://your-clerk-instance.clerk.accounts.dev/.well-known/jwks.json
CLERK_ISSUER=https://your-clerk-instance.clerk.accounts.dev
# OpenAI
OPENAI_API_KEY=sk-your-openai-key
# Encryption (QUAN TRỌNG!)
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=your-generated-32-byte-base64-key
# Server
PORT=5000
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_CACHE_URL=redis
RATE_STORAGE_URI=redis://redis:6379/0
# CORS (QUAN TRỌNG: Không dùng * trong production!)
CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
# Production settings
DISABLE_AUTH=false
```
**Frontend `.env`** (`frontend/.env`):
```bash
VITE_API_BASE_URL=https://api.yourdomain.com
```
### 4. Deploy
```bash
# Build và start tất cả services
docker-compose up -d
# Xem logs
docker-compose logs -f
# Kiểm tra status
docker-compose ps
```
### 5. Cấu hình Nginx Reverse Proxy (Optional)
Tạo file `/etc/nginx/sites-available/cuccu-note`:
```nginx
# Backend API
server {
listen 80;
server_name api.yourdomain.com;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Frontend
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
location / {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable site:
```bash
sudo ln -s /etc/nginx/sites-available/cuccu-note /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 6. SSL với Let's Encrypt
```bash
sudo apt-get install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com -d api.yourdomain.com
```
## 🔧 Maintenance Commands
```bash
# Restart services
docker-compose restart
# Stop services
docker-compose stop
# Start services
docker-compose start
# Rebuild after code changes
docker-compose up -d --build
# View logs
docker-compose logs -f backend
docker-compose logs -f frontend
# Backup MongoDB
docker exec cuccu_mongodb mongodump --out /data/backup
# Restore MongoDB
docker exec -i cuccu_mongodb mongorestore /data/backup
```
## 📊 Monitoring
### Health Checks
- Backend: `http://localhost:5000/docs`
- Frontend: `http://localhost:3001`
- MongoDB: `docker exec cuccu_mongodb mongosh --eval "db.adminCommand('ping')"`
- Redis: `docker exec cuccu_redis redis-cli ping`
### Logs
```bash
# All logs
docker-compose logs
# Specific service
docker-compose logs backend
docker-compose logs frontend
# Follow logs
docker-compose logs -f
```
## 🔒 Security Checklist
- [ ] `ENCRYPTION_KEY` được set và không commit vào git
- [ ] `CORS_ORIGINS` chỉ cho phép domain của bạn (không dùng `*`)
- [ ] `DISABLE_AUTH=false` trong production
- [ ] `CLERK_SECRET_KEY` là production key (không phải test key)
- [ ] MongoDB có authentication enabled
- [ ] Redis có password (nếu cần)
- [ ] SSL/HTTPS được cấu hình
- [ ] Firewall chỉ mở ports cần thiết
- [ ] Regular backups được setup
- [ ] Monitoring và alerting được cấu hình
## 🚨 Troubleshooting
### Backend không start
```bash
# Check logs
docker-compose logs backend
# Check MongoDB connection
docker exec cuccu_backend python -c "from common.mongo_client import mongodb_client; import asyncio; asyncio.run(mongodb_client.connect())"
```
### Frontend không build
```bash
# Check Node version
docker exec cuccu_frontend node --version
# Rebuild
docker-compose build --no-cache frontend
```
### MongoDB connection issues
```bash
# Check MongoDB status
docker exec cuccu_mongodb mongosh --eval "db.adminCommand('ping')"
# Check network
docker network inspect cuccu_network
```
## 📈 Scaling
### Horizontal Scaling
Để scale backend:
```bash
docker-compose up -d --scale backend=3
```
### Load Balancer
Sử dụng Nginx hoặc Traefik làm load balancer cho multiple backend instances.
## 🔄 Updates
```bash
# Pull latest code
git pull origin main
# Rebuild và restart
docker-compose up -d --build
# Migrate database (nếu có)
docker exec cuccu_backend python -m alembic upgrade head
```
# 🔄 Logic Flow: Agent nhận ngày hôm nay → Sinh query → Gửi cho tool
## 📋 Tổng quan luồng xử lý
```
1. Agent nhận ngày hôm nay
2. Format ngày thành YYYY-MM-DD
3. Thêm vào Filter Context
4. Filter Context sync với URL
5. Hook useMemoFilters convert filter → query string
6. Query string được truyền vào API call
7. API nhận query và trả về kết quả
```
---
## 🔍 Chi tiết từng bước
### **Bước 1: Agent nhận ngày hôm nay**
📍 File: `frontend/src/pages/Home.tsx` (dòng 18-36)
```typescript
useEffect(() => {
if (!user) return;
const hasFilterInUrl = searchParams.has("filter");
if (!hasFilterInUrl) {
// ✅ LẤY NGÀY HÔM NAY
const today = new Date();
const year = today.getUTCFullYear();
const month = String(today.getUTCMonth() + 1).padStart(2, "0");
const day = String(today.getUTCDate()).padStart(2, "0");
const todayStr = `${year}-${month}-${day}`; // VD: "2026-01-24"
// ✅ THÊM VÀO FILTER
addFilter({ factor: "displayTime", value: todayStr });
}
}, [user, searchParams, addFilter]);
```
**Giải thích:**
- Agent (component Home) tự động lấy ngày hôm nay khi component mount
- Format thành chuỗi `YYYY-MM-DD` (UTC)
- Chỉ thêm filter nếu URL chưa có filter nào
---
### **Bước 2: Filter được lưu vào Context**
📍 File: `frontend/src/contexts/MemoFilterContext.tsx` (dòng 101-103)
```typescript
const addFilter = useCallback((filter: MemoFilter) => {
setFiltersState((prev) => uniqBy([...prev, filter], getMemoFilterKey));
}, []);
```
**Giải thích:**
- `addFilter` thêm filter vào state `filters`
- State được lưu trong `MemoFilterContext`
- Context tự động sync với URL query params
---
### **Bước 3: Filter sync với URL**
📍 File: `frontend/src/contexts/MemoFilterContext.tsx` (dòng 79-93)
```typescript
useEffect(() => {
const storeString = stringifyFilters(filters);
// VD: "displayTime:2026-01-24"
if (storeString !== lastSyncedStoreRef.current) {
const newParams = new URLSearchParams(searchParams);
if (filters.length > 0) {
newParams.set("filter", storeString); // URL: ?filter=displayTime:2026-01-24
}
setSearchParams(newParams, { replace: true });
}
}, [filters, searchParams, setSearchParams]);
```
**Giải thích:**
- Filter được convert thành string: `"displayTime:2026-01-24"`
- String này được thêm vào URL: `?filter=displayTime:2026-01-24`
- Giúp user có thể bookmark/share URL với filter
---
### **Bước 4: Hook convert Filter → Query String**
📍 File: `frontend/src/hooks/useMemoFilters.ts` (dòng 78-96)
```typescript
// Trong useMemoFilters hook
for (const filter of filters) {
if (filter.factor === "displayTime") {
const displayWithUpdateTime = memoRelatedSetting?.displayWithUpdateTime ?? false;
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
// ✅ PARSE NGÀY TỪ STRING
const dateParts = filter.value.split("-"); // ["2026", "01", "24"]
if (dateParts.length === 3) {
const year = parseInt(dateParts[0], 10);
const month = parseInt(dateParts[1], 10) - 1; // JS months 0-indexed
const day = parseInt(dateParts[2], 10);
// ✅ TẠO UTC TIMESTAMP (00:00:00 UTC của ngày đó)
const utcDate = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
const timestampAfter = Math.floor(utcDate.getTime() / 1000); // VD: 1706054400
const timestampEnd = timestampAfter + 60 * 60 * 24; // +24 giờ
// ✅ SINH QUERY STRING
conditions.push(`${factor} >= ${timestampAfter} && ${factor} < ${timestampEnd}`);
// VD: "created_ts >= 1706054400 && created_ts < 1706140800"
}
}
}
return conditions.length > 0 ? conditions.join(" && ") : undefined;
```
**Giải thích:**
- Hook đọc filter từ Context
- Parse ngày `"2026-01-24"` → timestamp UTC
- Tạo range: từ 00:00:00 đến 23:59:59 của ngày đó
- Sinh query string: `"created_ts >= 1706054400 && created_ts < 1706140800"`
---
### **Bước 5: Query được truyền vào Component**
📍 File: `frontend/src/pages/Home.tsx` (dòng 39-43, 65-72)
```typescript
// ✅ BUILD FILTER QUERY
const memoFilter = useMemoFilters({
creatorName: user?.name,
includeShortcuts: true,
includePinned: true,
});
// ✅ TRUYỀN VÀO PagedMemoList
<PagedMemoList
filter={memoFilter} // "created_ts >= 1706054400 && created_ts < 1706140800"
// ... other props
/>
```
**Giải thích:**
- `useMemoFilters` trả về query string
- Query string được truyền vào `PagedMemoList` component qua prop `filter`
---
### **Bước 6: Query được gửi đến API**
📍 File: `frontend/src/components/PagedMemoList/PagedMemoList.tsx` (dòng 92-97)
```typescript
const { data, fetchNextPage, hasNextPage } = useInfiniteMemos({
state: props.state || State.NORMAL,
orderBy: props.orderBy || "display_time desc",
filter: props.filter, // ✅ Query string từ bước 5
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
```
📍 File: `frontend/src/hooks/useMemoQueries.ts` (dòng 29-46)
```typescript
export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}) {
return useInfiniteQuery({
queryKey: memoKeys.list(request),
queryFn: async ({ pageParam }) => {
// ✅ GỌI API VỚI FILTER
const response = await memoServiceClient.listMemos(
create(ListMemosRequestSchema, {
...request, // { filter: "created_ts >= 1706054400 && created_ts < 1706140800" }
pageToken: pageParam || "",
})
);
return response;
},
// ...
});
}
```
**Giải thích:**
- `useInfiniteMemos` nhận `filter` trong request object
- Gọi `memoServiceClient.listMemos()` với filter
- API backend nhận filter và query database
---
## ✅ Kết luận
**Logic ĐÚNG và HOẠT ĐỘNG TỐT:**
1. ✅ Agent (Home component) tự động nhận ngày hôm nay
2. ✅ Format ngày thành `YYYY-MM-DD`
3. ✅ Lưu vào Filter Context
4. ✅ Context sync với URL
5. ✅ Hook `useMemoFilters` convert filter → query string với timestamp
6. ✅ Query string được gửi đến API qua `memoServiceClient.listMemos()`
7. ✅ API trả về memos của ngày hôm nay
**Điểm mạnh:**
- ✅ Tự động filter theo ngày hôm nay khi vào trang Home
- ✅ URL có thể bookmark/share
- ✅ Xử lý timezone đúng (UTC)
- ✅ Hỗ trợ cả `created_ts``updated_ts` tùy setting
**Lưu ý:**
- Filter chỉ được set tự động nếu URL chưa có filter nào
- Nếu user đã có filter trong URL, sẽ không override
task: "Refactor & tối ưu backend CuCu: kiến trúc FastAPI + agent clean, tách rõ layer, xoá dead code, tránh bottleneck (N+1 query, DB, cache, rate limit), đảm bảo 'cd backend && pytest' luôn xanh." task: "Refactor backend: tách rõ API routes, dọn dead code trong agent module"
test_command: "cd backend && pytest" test_command: "cd backend && pytest"
--- ---
......
# Hướng dẫn cài đặt Ralph Wiggum cho Windows
## Cách 1: Dùng Git Bash (Khuyến nghị)
1. Mở **Git Bash** (cài cùng với Git for Windows)
2. Navigate đến repo:
```bash
cd /e/opennotion
```
3. Chạy install script:
```bash
curl -fsSL https://raw.githubusercontent.com/agrimsingh/ralph-wiggum-cursor/main/install.sh | bash
```
## Cách 2: Dùng WSL (Windows Subsystem for Linux)
1. Mở **WSL terminal** (Ubuntu/Debian)
2. Navigate đến repo:
```bash
cd /mnt/e/opennotion
```
3. Chạy install script:
```bash
curl -fsSL https://raw.githubusercontent.com/agrimsingh/ralph-wiggum-cursor/main/install.sh | bash
```
## Cách 3: Cài thủ công (nếu script không chạy được)
1. Tạo thư mục `.cursor/ralph-scripts/`
2. Download các script từ repo: https://github.com/agrimsingh/ralph-wiggum-cursor/tree/main/scripts
3. Copy vào `.cursor/ralph-scripts/`
4. Tạo thư mục `.ralph/` ở root repo
5. Đảm bảo có `cursor-agent` CLI:
```bash
curl https://cursor.com/install -fsS | bash
```
## Sau khi cài xong
Chạy loop backend với:
```bash
# Interactive mode
./.cursor/ralph-scripts/ralph-setup.sh
# Hoặc CLI mode với nhiều iterations
./.cursor/ralph-scripts/ralph-loop.sh -n 100 -m gpt-5.2-high --branch feature/ralph-backend-cleanup
```
## Kiểm tra cài đặt
```bash
# Check script có chưa
ls .cursor/ralph-scripts/ralph-loop.sh
# Check cursor-agent
which cursor-agent
```
# Complete Testing Guide
## Overview
This guide covers testing for both backend APIs and frontend E2E tests.
## Backend API Testing
### Setup
1. Install test dependencies:
```bash
cd backend
pip install -r requirements-test.txt
```
2. Ensure backend server is running:
```bash
python server.py
# Server should be at http://localhost:5000
```
### Running Backend Tests
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_auth.py
pytest tests/test_memos.py
pytest tests/test_comments.py
# Run with verbose output
pytest -v
# Run with coverage
pytest --cov=memos_core --cov=api tests/
```
### Test Files
- `tests/test_auth.py` - Authentication (sign in, sign up, get current user)
- `tests/test_memos.py` - Memo CRUD operations
- `tests/test_comments.py` - Comment creation and listing
## Frontend E2E Testing (Playwright)
### Setup
1. Install dependencies:
```bash
cd frontend
npm install
# or
pnpm install
```
2. Install Playwright browsers:
```bash
npx playwright install
```
3. Ensure frontend dev server is running:
```bash
npm run dev
# Server should be at http://localhost:3001
```
### Running Frontend Tests
```bash
# Run all tests
npm run test:e2e
# Run in UI mode (interactive)
npm run test:e2e:ui
# Run in headed mode (see browser)
npm run test:e2e:headed
# Run in debug mode
npm run test:e2e:debug
# Run specific test file
npx playwright test auth.spec.ts
npx playwright test comments.spec.ts
```
### Test Files
- `tests/e2e/auth.spec.ts` - Authentication flows
- `tests/e2e/comments.spec.ts` - Comment functionality
- `tests/e2e/memos.spec.ts` - Memo CRUD operations
- `tests/e2e/filters.spec.ts` - Filter functionality
## Test Data
Both backend and frontend tests use:
- Email: `test@example.com`
- Password: `testpassword123`
Make sure this user exists or tests will create it.
## Viewing Results
### Backend
- Test output in terminal
- Coverage report: `pytest --cov --cov-report=html`
### Frontend
- HTML report: `npx playwright show-report`
- Screenshots: `test-results/` (on failure)
- Videos: `test-results/` (on failure)
## CI/CD Integration
### Backend
```yaml
- name: Run backend tests
run: |
cd backend
pip install -r requirements-test.txt
pytest --cov --cov-report=xml
```
### Frontend
```yaml
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
```
## Troubleshooting
### Backend Tests
- **Import errors**: Make sure you're in the backend directory
- **Connection errors**: Ensure server is running
- **Auth errors**: Check test user credentials
### Frontend Tests
- **Timeout errors**: Increase timeout in `playwright.config.ts`
- **Selector errors**: Update selectors if UI changed
- **Server not found**: Ensure dev server is running on port 3001
# Testing Implementation Summary
## ✅ Đã Hoàn Thành
### Backend Tests (Pytest)
1. **Test Infrastructure**
-`backend/tests/conftest.py` - Pytest fixtures (client, auth tokens, test data)
-`backend/pytest.ini` - Pytest configuration
-`backend/requirements-test.txt` - Test dependencies
2. **Test Files Created**
-`backend/tests/test_auth.py` - Authentication API tests
- Sign up with valid data
- Sign up duplicate email rejection
- Sign in with valid credentials
- Sign in with invalid credentials
- Get current user (authenticated/unauthenticated)
-`backend/tests/test_memos.py` - Memo CRUD tests
- List memos (authenticated/anonymous)
- Filter by tag
- Filter by date
- Create memo
- Get memo (own/public/protected/private)
- Update memo
- Delete memo
-`backend/tests/test_comments.py` - Comment tests
- Create comment for PUBLIC memo
- Create comment for PROTECTED memo
- Create comment for PRIVATE memo (access denied)
- List comments (empty, with comments)
- Comment visibility filtering
- Comment sorting (oldest first)
### Frontend Tests (Playwright)
1. **Test Infrastructure**
-`frontend/tests/e2e/playwright.config.ts` - Playwright configuration
-`frontend/package.json` - Added Playwright scripts and dependencies
2. **Test Files Created**
-`frontend/tests/e2e/auth.spec.ts` - Authentication flows
- Navigate to sign in page
- Sign in with valid credentials
- Sign in with invalid credentials
- Redirect to sign in for protected routes
- Sign out successfully
-`frontend/tests/e2e/comments.spec.ts` - Comment functionality
- Create comment on memo
- Display comments list
- Show sign in prompt for anonymous users
- Redirect back to memo after sign in
- Update comment count after creating comment
-`frontend/tests/e2e/memos.spec.ts` - Memo CRUD operations
- Create a new memo
- Display memo list
- Navigate to memo detail page
- Edit memo
- Filter memos by date
- Filter memos by tag
-`frontend/tests/e2e/filters.spec.ts` - Filter functionality
- Filter by date using calendar
- Filter by tag
- Clear filter
- Persist filter on page refresh
### Documentation
-`backend/tests/README.md` - Backend testing guide
-`frontend/tests/README.md` - Frontend testing guide
-`TESTING_GUIDE.md` - Complete testing guide (both backend and frontend)
## 📋 API Endpoints Inventory
### Authentication (`/api/v1/auth`)
- POST `/api/v1/auth/signin`
- POST `/api/v1/auth/signup`
- GET `/api/v1/auth/me`
- POST `/api/v1/auth/me`
### Memos (`/api/v1/memos`)
- GET `/api/v1/memos` (with filters: tag, filter, start_date, end_date)
- POST `/api/v1/memos`
- GET `/api/v1/memos/{memo_id}`
- PATCH `/api/v1/memos/{memo_id}`
- DELETE `/api/v1/memos/{memo_id}`
- POST `/api/v1/memos/{memo_id}/comments`
- GET `/api/v1/memos/{memo_id}/comments`
### Users (`/api/v1/users`)
- GET `/api/v1/users`
- POST `/api/v1/users`
- POST `/api/v1/users/settings`
- GET `/api/v1/users/{user_id}`
- PATCH `/api/v1/users/{user_id}`
### Attachments (`/api/v1/attachments`)
- GET `/api/v1/attachments`
- POST `/api/v1/attachments`
- GET `/api/v1/attachments/{attachment_id}`
### Shortcuts (`/api/v1/shortcuts`)
- GET `/api/v1/shortcuts`
- POST `/api/v1/shortcuts`
- PATCH `/api/v1/shortcuts/{shortcut_id}`
- DELETE `/api/v1/shortcuts/{shortcut_id}`
### Instance (`/api/v1/instance`)
- GET `/api/v1/instance`
- POST `/api/v1/instance`
- PATCH `/api/v1/instance`
- POST `/api/v1/instance/setting`
### Identity Providers (`/api/v1/idp`)
- GET `/api/v1/idp`
- POST `/api/v1/idp`
- PATCH `/api/v1/idp/{idp_id}`
### Activities (`/api/v1/activities`)
- GET `/api/v1/activities`
### Embeddings (`/api/v1/memo-embeddings`)
- POST `/api/v1/memo-embeddings`
- POST `/api/v1/memo-embeddings/search`
### Chatbot (`/api/agent/chat`)
- POST `/api/agent/chat`
### Chat History (`/api/history`)
- GET `/api/history/{identity_key}`
- DELETE `/api/history/{identity_key}`
### Cache Analytics (`/cache`)
- GET `/cache/stats`
- DELETE `/cache/user/{user_id}`
- POST `/cache/stats/reset`
### Prompts (`/api/agent/system-prompt`)
- GET `/api/agent/system-prompt`
- POST `/api/agent/system-prompt`
## 🚀 Next Steps
1. **Install Dependencies**
```bash
# Backend
cd backend
pip install -r requirements-test.txt
# Frontend
cd frontend
npm install
npx playwright install
```
2. **Run Tests**
```bash
# Backend
cd backend
pytest
# Frontend
cd frontend
npm run test:e2e
```
3. **Adjust Test Selectors**
- Update Playwright selectors based on actual UI elements
- Add `data-testid` attributes to key UI components for easier testing
4. **Add More Tests**
- User management tests
- Attachment upload tests
- Shortcut management tests
- Integration tests
## 📝 Notes
- Test user credentials: `test@example.com` / `testpassword123`
- Backend server should run on `http://localhost:5000`
- Frontend server should run on `http://localhost:3001`
- Tests are designed to be resilient to UI changes
- Screenshots and videos are saved on test failures
__pycache__ # Git
*.pyc
.env
.venv
venv
.git .git
.gitignore .gitignore
.dockerignore
logs # Python
data __pycache__
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
.env.local
# Testing
.pytest_cache/
.coverage
htmlcov/
tests/
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Documentation
*.md
!readme.md
!ENV_VARIABLES.md
# Docker
Dockerfile*
docker-compose*.yml
# Lấy Python 3.11 slim (ít file, nhẹ) # Production Dockerfile for Backend
FROM python:3.11-slim FROM python:3.11-slim
# Thư mục làm việc
WORKDIR /app WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 # Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Copy requirements rồi cài package # Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy code # Copy application code
COPY . . COPY . .
# Copy entrypoint script (nếu có) # Copy and set up entrypoint script
COPY entrypoint.sh /app/entrypoint.sh COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
# Mở port 5000 # Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 5000 EXPOSE 5000
# Chạy server # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5000/docs || exit 1
# Run entrypoint script
CMD ["/app/entrypoint.sh"] CMD ["/app/entrypoint.sh"]
\ No newline at end of file
# Backend Environment Variables
This document describes all environment variables used by the backend.
## Quick Start
1. Copy the template below to create your `.env` file:
```bash
cp ENV_VARIABLES.md .env
# Then edit .env and fill in your values
```
2. **DO NOT commit `.env` file to version control**
## Required Variables
### MongoDB Configuration
```bash
# Required: MongoDB connection string
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB_NAME=cucu_note
```
### Authentication
```bash
# Required for production: Clerk authentication
CLERK_SECRET_KEY=sk_test_...
CLERK_JWKS_URL=https://your-clerk-instance.clerk.accounts.dev/.well-known/jwks.json
CLERK_ISSUER=https://your-clerk-instance.clerk.accounts.dev
```
### AI Services
```bash
# Required: OpenAI API key for embeddings
OPENAI_API_KEY=sk-...
```
## Optional Variables
### Server Configuration
- `PORT` - Server port (default: `5000`)
### MongoDB Connection Pooling
- `MONGODB_MAX_POOL_SIZE` - Maximum pool size (default: `50`)
- `MONGODB_MIN_POOL_SIZE` - Minimum pool size (default: `10`)
- `MONGODB_MAX_IDLE_TIME_MS` - Max idle time in milliseconds (default: `45000`)
### Redis Configuration
- `REDIS_HOST` - Redis host (default: `localhost`)
- `REDIS_PORT` - Redis port (default: `6379`)
- `REDIS_USERNAME` - Redis username (optional)
- `REDIS_PASSWORD` - Redis password (optional)
- `REDIS_CACHE_URL` - Redis cache host (default: from `REDIS_HOST`)
- `REDIS_CACHE_PORT` - Redis cache port (default: `6379`)
- `REDIS_CACHE_DB` - Redis cache database number (default: `2`)
- `REDIS_CACHE_TURN_ON` - Enable Redis cache (default: `true`)
### CORS Configuration
- `CORS_ORIGINS` - Comma-separated list of allowed origins, or `*` for all (default: `*`)
- Example: `http://localhost:3001,https://yourdomain.com`
### Rate Limiting
- `RATE_LIMIT_GUEST` - Daily message limit for guests (default: `10`)
- `RATE_LIMIT_USER` - Daily message limit for authenticated users (default: `100`)
- `RATE_LIMIT_BLOCK_MINUTES` - Block duration after exceeding limit (default: `5`)
- `RATE_STORAGE_URI` - Rate limit storage URI (default: `memory://`)
- Use `redis://host:port/db` for production
- Or `memory://` for development (not suitable for multiple instances)
- `RATE_LIMIT_REDIS_DB` - Redis DB number for rate limiting (default: `0`)
### Development/Testing
- `DISABLE_AUTH` - Disable authentication (default: `false`, **NOT for production!**)
- `USE_SQLITE_HISTORY` - Use SQLite for chat history (default: `true`)
- `SQLITE_DB_PATH` - SQLite database path (default: `./data/chat_history.db`)
- `MEMO_DB_PATH` - Memo database path (default: `./db/memos.db`)
### Encryption Configuration
- `ENCRYPTION_KEY` - Fernet encryption key for sensitive data (API keys, etc.) (required for production)
- Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
- Must be a 32-byte base64-encoded string
- **CRITICAL**: Do not commit to version control
- If not set, will use password-based derivation (NOT secure for production)
- `ENCRYPTION_PASSWORD` - Fallback password for key derivation (dev only, NOT for production)
### Other Services
- `GOOGLE_API_KEY` - Google API key (optional)
- `GROQ_API_KEY` - Groq API key (optional)
- `DEFAULT_MODEL` - Default AI model (default: `gpt-4o-mini`)
- `FIRECRAWL_API_KEY` - Firecrawl API key (optional)
- `LANGFUSE_PUBLIC_KEY` - Langfuse public key (optional)
- `LANGFUSE_SECRET_KEY` - Langfuse secret key (optional)
- `LANGFUSE_BASE_URL` - Langfuse base URL (default: `https://cloud.langfuse.com`)
## Production Checklist
Before deploying to production, ensure:
1.`MONGODB_URI` is set (no hardcoded credentials)
2.`CLERK_SECRET_KEY`, `CLERK_JWKS_URL`, `CLERK_ISSUER` are configured
3.`CORS_ORIGINS` is set to specific origins (not `*`)
4.`REDIS_HOST` is configured for caching and rate limiting
5.`RATE_STORAGE_URI` uses Redis (not `memory://`)
6.`DISABLE_AUTH=false` (authentication enabled)
7.`ENCRYPTION_KEY` is set (required for user API key encryption)
8. ✅ All API keys are set and valid
...@@ -4,8 +4,8 @@ Memo service routes for Memos-style backend. ...@@ -4,8 +4,8 @@ Memo service routes for Memos-style backend.
from typing import List, Any from typing import List, Any
import logging import logging
import secrets
import re import re
import secrets
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
...@@ -17,6 +17,7 @@ from common.memos_core.schemas import ( ...@@ -17,6 +17,7 @@ from common.memos_core.schemas import (
MemoResponse, MemoResponse,
) )
from common.memos_core.services import get_memo_service, get_memo_relation_service from common.memos_core.services import get_memo_service, get_memo_relation_service
from common.memos_core.query_parser import parse_date_range
from common.mongo_client import mongodb_client from common.mongo_client import mongodb_client
...@@ -42,54 +43,15 @@ async def list_memos( ...@@ -42,54 +43,15 @@ async def list_memos(
try: try:
user_id = get_current_user_id(request) user_id = get_current_user_id(request)
# Parse Dates from start_date/end_date params # Parse date range from query params or filter string
from datetime import datetime, timedelta, timezone dt_start, dt_end = parse_date_range(
import re start_date=start_date,
end_date=end_date,
dt_start = None filter_str=filter,
dt_end = None )
if start_date:
try:
dt_start = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
except ValueError:
pass # Ignore invalid format
if end_date:
try:
dt_end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
except ValueError:
pass
# Parse 'filter' query param (overrides start/end if present and matches)
if filter: if filter:
logger.debug("List memos GET with filter=%r", filter) logger.debug("List memos GET with filter=%r", filter)
# Case 1: Complex Timestamp Filter (created_ts >= ...)
pattern_ts = r"(?:created_ts|updated_ts)\s*>=\s*([\d\.]+)\s*&&\s*(?:created_ts|updated_ts)\s*<\s*([\d\.]+)"
match_ts = re.search(pattern_ts, filter)
# Case 2: Frontend DisplayTime Filter (displayTime:2026-01-24)
pattern_dt = r"displayTime:(\d{4}-\d{2}-\d{2})"
match_dt = re.search(pattern_dt, filter)
if match_ts:
try:
ts_start = float(match_ts.group(1))
ts_end = float(match_ts.group(2))
dt_start = datetime.fromtimestamp(ts_start, tz=timezone.utc)
dt_end = datetime.fromtimestamp(ts_end, tz=timezone.utc)
except (ValueError, TypeError):
pass
elif match_dt:
try:
date_str = match_dt.group(1)
# Parse YYYY-MM-DD
dt = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
# Set range for that full day
dt_start = dt
dt_end = dt + timedelta(days=1)
except ValueError:
pass
return await memo_service.list_memos( return await memo_service.list_memos(
user_id=user_id, user_id=user_id,
...@@ -137,48 +99,18 @@ async def create_memo_or_list_memos( ...@@ -137,48 +99,18 @@ async def create_memo_or_list_memos(
end_date = None end_date = None
raw_filter = raw.get("filter", "") raw_filter = raw.get("filter", "")
creator_id = None
if raw_filter and isinstance(raw_filter, str): if raw_filter and isinstance(raw_filter, str):
import re
from datetime import datetime, timedelta, timezone
logger.debug("List memos POST with raw_filter=%r", raw_filter) logger.debug("List memos POST with raw_filter=%r", raw_filter)
# Case 1: Complex Timestamp Filter (created_ts >= ...)
pattern_ts = r"(?:created_ts|updated_ts)\s*>=\s*([\d\.]+)\s*&&\s*(?:created_ts|updated_ts)\s*<\s*([\d\.]+)"
match_ts = re.search(pattern_ts, raw_filter)
# Case 2: Frontend DisplayTime Filter (displayTime:2026-01-24) # Parse date range from filter string
pattern_dt = r"displayTime:(\d{4}-\d{2}-\d{2})" start_date, end_date = parse_date_range(filter_str=raw_filter)
match_dt = re.search(pattern_dt, raw_filter)
# Case 3: Creator ID Filter (creator_id == ...) # Parse creator_id filter (creator_id == ...)
# Supports alphanumeric, hyphen, underscore, dot
pattern_creator = r"creator_id\s*==\s*([a-zA-Z0-9_\-\.]+)" pattern_creator = r"creator_id\s*==\s*([a-zA-Z0-9_\-\.]+)"
match_creator = re.search(pattern_creator, raw_filter) match_creator = re.search(pattern_creator, raw_filter)
if match_ts:
try:
ts_start = float(match_ts.group(1))
ts_end = float(match_ts.group(2))
start_date = datetime.fromtimestamp(ts_start, tz=timezone.utc)
end_date = datetime.fromtimestamp(ts_end, tz=timezone.utc)
except (ValueError, TypeError):
pass
elif match_dt:
try:
date_str = match_dt.group(1)
# Parse YYYY-MM-DD
dt = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
# Set range for that full day
start_date = dt
end_date = dt + timedelta(days=1)
except ValueError:
pass
if match_creator: if match_creator:
creator_id = match_creator.group(1) creator_id = match_creator.group(1)
else:
creator_id = None
else:
creator_id = None
return await memo_service.list_memos( return await memo_service.list_memos(
user_id=user_id, user_id=user_id,
......
...@@ -5,13 +5,15 @@ User service routes for Memos-style backend. ...@@ -5,13 +5,15 @@ User service routes for Memos-style backend.
from typing import List from typing import List
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from starlette.requests import Request
from common.encryption import mask_api_key, validate_openai_key_format
from common.memos_core.schemas import ( from common.memos_core.schemas import (
UserCreate, UserCreate,
UserUpdate, UserUpdate,
UserResponse, UserResponse,
) )
from common.memos_core.services import get_user_service from common.memos_core.services import get_user_service, get_user_settings_service
router = APIRouter(prefix="/users") router = APIRouter(prefix="/users")
...@@ -79,3 +81,102 @@ async def update_user( ...@@ -79,3 +81,102 @@ async def update_user(
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
# =============================================================================
# OpenAI API Key Management Endpoints
# =============================================================================
@router.get("/{user_id}/openai-key", summary="Get user's OpenAI API key (masked)")
async def get_user_openai_key(
user_id: str,
request: Request,
settings_service=Depends(get_user_settings_service),
):
"""
Get user's OpenAI API key (masked for security).
User can only access their own key.
"""
# Verify user can only access their own key
authenticated_user_id = getattr(request.state, "user_id", None)
if not authenticated_user_id or authenticated_user_id != user_id:
raise HTTPException(status_code=403, detail="You can only access your own API key")
try:
has_key = await settings_service.has_openai_api_key(user_id)
if not has_key:
return {"has_key": False, "masked_key": None}
# Get the key to mask it
key = await settings_service.get_openai_api_key(user_id)
if key:
return {
"has_key": True,
"masked_key": mask_api_key(key),
}
return {"has_key": False, "masked_key": None}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.put("/{user_id}/openai-key", summary="Update user's OpenAI API key")
async def update_user_openai_key(
user_id: str,
payload: dict = Body(...),
request: Request,
settings_service=Depends(get_user_settings_service),
):
"""
Update user's OpenAI API key.
Key will be encrypted before storage.
User can only update their own key.
"""
# Verify user can only update their own key
authenticated_user_id = getattr(request.state, "user_id", None)
if not authenticated_user_id or authenticated_user_id != user_id:
raise HTTPException(status_code=403, detail="You can only update your own API key")
api_key = payload.get("api_key") or payload.get("key")
if not api_key:
raise HTTPException(status_code=400, detail="api_key is required")
# Validate key format
if not validate_openai_key_format(api_key):
raise HTTPException(
status_code=400,
detail="Invalid OpenAI API key format. Key must start with 'sk-' and be 20-100 characters."
)
try:
await settings_service.set_openai_api_key(user_id, api_key)
return {
"success": True,
"message": "OpenAI API key updated successfully",
"masked_key": mask_api_key(api_key),
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.delete("/{user_id}/openai-key", summary="Delete user's OpenAI API key")
async def delete_user_openai_key(
user_id: str,
request: Request,
settings_service=Depends(get_user_settings_service),
):
"""
Delete user's OpenAI API key.
User can only delete their own key.
"""
# Verify user can only delete their own key
authenticated_user_id = getattr(request.state, "user_id", None)
if not authenticated_user_id or authenticated_user_id != user_id:
raise HTTPException(status_code=403, detail="You can only delete your own API key")
try:
await settings_service.delete_openai_api_key(user_id)
return {"success": True, "message": "OpenAI API key deleted successfully"}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
...@@ -50,6 +50,11 @@ class RedisClient: ...@@ -50,6 +50,11 @@ class RedisClient:
return self._client return self._client
try: try:
if not REDIS_CACHE_URL:
logger.warning("⚠️ REDIS_CACHE_URL not set, disabling Redis cache")
self._enabled = False
return None
connection_kwargs = { connection_kwargs = {
"host": REDIS_CACHE_URL, "host": REDIS_CACHE_URL,
"port": REDIS_CACHE_PORT, "port": REDIS_CACHE_PORT,
...@@ -63,14 +68,17 @@ class RedisClient: ...@@ -63,14 +68,17 @@ class RedisClient:
connection_kwargs["username"] = REDIS_USERNAME connection_kwargs["username"] = REDIS_USERNAME
self._client = aioredis.Redis(**connection_kwargs) self._client = aioredis.Redis(**connection_kwargs)
# Test connection
await self._client.ping() await self._client.ping()
logger.info(f"✅ Redis Hybrid Cache connected: {REDIS_CACHE_URL}:{REDIS_CACHE_PORT} (db={REDIS_CACHE_DB})") logger.info(f"✅ Redis Hybrid Cache connected: {REDIS_CACHE_URL}:{REDIS_CACHE_PORT} (db={REDIS_CACHE_DB})")
return self._client return self._client
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to connect to Redis: {e}") # Redis is optional - log as warning, not error
logger.warning(f"⚠️ Redis cache unavailable: {e}. Continuing without cache.")
self._enabled = False self._enabled = False
self._client = None
return None return None
def get_client(self) -> aioredis.Redis | None: def get_client(self) -> aioredis.Redis | None:
......
...@@ -18,27 +18,65 @@ __all__ = [ ...@@ -18,27 +18,65 @@ __all__ = [
class EmbeddingClientManager: class EmbeddingClientManager:
""" """
Singleton Class quản lý OpenAI Embedding Client (Sync & Async). Singleton Class quản lý OpenAI Embedding Client (Sync & Async).
Supports both env key and user-specific keys.
""" """
def __init__(self): def __init__(self):
self._client: OpenAI | None = None self._client: OpenAI | None = None
self._async_client: AsyncOpenAI | None = None self._async_client: AsyncOpenAI | None = None
self._user_clients: dict[str, AsyncOpenAI] = {} # Cache per user_id
def get_client(self) -> OpenAI:
"""Sync Client lazy loading""" def get_client(self, api_key: str | None = None) -> OpenAI:
"""
Sync Client lazy loading.
Args:
api_key: Optional API key. If provided, creates client with this key.
Otherwise uses env OPENAI_API_KEY.
"""
key = api_key or OPENAI_API_KEY
if not key:
raise RuntimeError("CRITICAL: OPENAI_API_KEY chưa được thiết lập")
# If using default key, cache the client
if not api_key:
if self._client is None: if self._client is None:
if not OPENAI_API_KEY: self._client = OpenAI(api_key=key)
raise RuntimeError("CRITICAL: OPENAI_API_KEY chưa được thiết lập")
self._client = OpenAI(api_key=OPENAI_API_KEY)
return self._client return self._client
def get_async_client(self) -> AsyncOpenAI: # For custom keys, create new client (not cached)
"""Async Client lazy loading""" return OpenAI(api_key=key)
def get_async_client(self, api_key: str | None = None, user_id: str | None = None) -> AsyncOpenAI:
"""
Async Client lazy loading.
Args:
api_key: Optional API key. If provided, uses this key.
Otherwise uses env OPENAI_API_KEY.
user_id: Optional user ID for caching user-specific clients.
Returns:
AsyncOpenAI client
"""
key = api_key or OPENAI_API_KEY
if not key:
raise RuntimeError("CRITICAL: OPENAI_API_KEY chưa được thiết lập")
# If using default key, cache the default client
if not api_key:
if self._async_client is None: if self._async_client is None:
if not OPENAI_API_KEY: self._async_client = AsyncOpenAI(api_key=key)
raise RuntimeError("CRITICAL: OPENAI_API_KEY chưa được thiết lập")
self._async_client = AsyncOpenAI(api_key=OPENAI_API_KEY)
return self._async_client return self._async_client
# For user-specific keys, cache per user_id
if user_id and user_id in self._user_clients:
return self._user_clients[user_id]
client = AsyncOpenAI(api_key=key)
if user_id:
self._user_clients[user_id] = client
return client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -65,13 +103,27 @@ def create_embedding(text: str) -> list[float]: ...@@ -65,13 +103,27 @@ def create_embedding(text: str) -> list[float]:
return [] return []
async def create_embedding_async(text: str) -> list[float]: async def create_embedding_async(text: str, user_id: str | None = None) -> list[float]:
""" """
Async embedding generation (KHÔNG dùng cache). Async embedding generation (KHÔNG dùng cache).
Nếu sau này cần cache lại, có thể thêm redis_cache.get_embedding / set_embedding. Nếu sau này cần cache lại, có thể thêm redis_cache.get_embedding / set_embedding.
Args:
text: Text to embed
user_id: Optional user ID. If provided, will use user's API key if available.
Returns:
Embedding vector
""" """
try: try:
client = get_async_embedding_client() api_key = None
if user_id:
# Try to get user's API key
from common.memos_core.services import get_user_settings_service
settings_service = get_user_settings_service()
api_key = await settings_service.get_openai_api_key(user_id)
client = _manager.get_async_client(api_key=api_key, user_id=user_id)
response = await client.embeddings.create(model="text-embedding-3-small", input=text) response = await client.embeddings.create(model="text-embedding-3-small", input=text)
embedding = response.data[0].embedding embedding = response.data[0].embedding
return embedding return embedding
...@@ -80,15 +132,29 @@ async def create_embedding_async(text: str) -> list[float]: ...@@ -80,15 +132,29 @@ async def create_embedding_async(text: str) -> list[float]:
return [] return []
async def create_embeddings_async(texts: list[str]) -> list[list[float]]: async def create_embeddings_async(texts: list[str], user_id: str | None = None) -> list[list[float]]:
""" """
Batch async embedding generation with per-item Layer 2 Cache. Batch async embedding generation with per-item Layer 2 Cache.
Args:
texts: List of texts to embed
user_id: Optional user ID. If provided, will use user's API key if available.
Returns:
List of embedding vectors
""" """
try: try:
if not texts: if not texts:
return [] return []
client = get_async_embedding_client() api_key = None
if user_id:
# Try to get user's API key
from common.memos_core.services import get_user_settings_service
settings_service = get_user_settings_service()
api_key = await settings_service.get_openai_api_key(user_id)
client = _manager.get_async_client(api_key=api_key, user_id=user_id)
response = await client.embeddings.create(model="text-embedding-3-small", input=texts) response = await client.embeddings.create(model="text-embedding-3-small", input=texts)
# Giữ nguyên thứ tự embedding theo order input # Giữ nguyên thứ tự embedding theo order input
......
"""
Encryption utilities for sensitive data (e.g., API keys).
Uses Fernet symmetric encryption from cryptography library.
"""
import base64
import logging
import os
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
logger = logging.getLogger(__name__)
# Get encryption key from environment
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
# Fallback: Generate key from a password (NOT recommended for production)
# This is only for development/testing
FALLBACK_PASSWORD = os.getenv("ENCRYPTION_PASSWORD", "default-dev-password-change-in-production")
def _get_fernet_key() -> bytes:
"""
Get or generate Fernet encryption key.
Priority: ENCRYPTION_KEY env var > generated from password (dev only)
"""
if ENCRYPTION_KEY:
try:
# Try to use as-is (should be base64-encoded 32-byte key)
return ENCRYPTION_KEY.encode()
except Exception:
# If not valid, try to decode as base64
try:
return base64.urlsafe_b64decode(ENCRYPTION_KEY)
except Exception:
logger.warning("Invalid ENCRYPTION_KEY format, using fallback")
# Fallback: Generate from password (dev only - NOT secure for production)
logger.warning(
"⚠️ ENCRYPTION_KEY not set. Using password-based key derivation (NOT secure for production!)"
)
salt = b"cucu_note_salt" # Fixed salt for dev (should be random in production)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(FALLBACK_PASSWORD.encode()))
return key
def encrypt_api_key(api_key: str) -> str:
"""
Encrypt an API key using Fernet symmetric encryption.
Args:
api_key: Plain text API key to encrypt
Returns:
Encrypted API key as base64 string
Raises:
ValueError: If api_key is empty
RuntimeError: If encryption fails
"""
if not api_key or not api_key.strip():
raise ValueError("API key cannot be empty")
try:
fernet = Fernet(_get_fernet_key())
encrypted = fernet.encrypt(api_key.encode())
return encrypted.decode()
except Exception as e:
logger.error(f"Failed to encrypt API key: {e}")
raise RuntimeError("Failed to encrypt API key") from e
def decrypt_api_key(encrypted_key: str) -> str:
"""
Decrypt an encrypted API key.
Args:
encrypted_key: Encrypted API key (base64 string)
Returns:
Decrypted plain text API key
Raises:
ValueError: If encrypted_key is empty
RuntimeError: If decryption fails (wrong key, corrupted data, etc.)
"""
if not encrypted_key or not encrypted_key.strip():
raise ValueError("Encrypted key cannot be empty")
try:
fernet = Fernet(_get_fernet_key())
decrypted = fernet.decrypt(encrypted_key.encode())
return decrypted.decode()
except Exception as e:
logger.error(f"Failed to decrypt API key: {e}")
raise RuntimeError("Failed to decrypt API key. Key may be corrupted or encryption key changed.") from e
def mask_api_key(api_key: str) -> str:
"""
Mask an API key for display (show only first 7 chars and last 4 chars).
Args:
api_key: API key to mask
Returns:
Masked API key (e.g., "sk-...xxxx")
"""
if not api_key or len(api_key) < 11:
return "sk-...xxxx"
return f"{api_key[:7]}...{api_key[-4:]}"
def validate_openai_key_format(api_key: str) -> bool:
"""
Validate OpenAI API key format.
Args:
api_key: API key to validate
Returns:
True if format is valid, False otherwise
"""
if not api_key or not api_key.strip():
return False
# OpenAI keys typically start with "sk-" and are ~51 characters
key = api_key.strip()
if not key.startswith("sk-"):
return False
if len(key) < 20 or len(key) > 100: # Reasonable range
return False
return True
"""
Query parameter parsing utilities for memo routes.
Extracted from route handlers to keep routes thin.
"""
import re
from datetime import datetime, timedelta, timezone
from typing import Tuple
def parse_date_range(
start_date: str | None = None,
end_date: str | None = None,
filter_str: str | None = None,
) -> Tuple[datetime | None, datetime | None]:
"""
Parse date range from query parameters or filter string.
Supports:
- ISO format dates (start_date/end_date params)
- Timestamp filter: "created_ts >= 1234567890 && created_ts < 1234567890"
- DisplayTime filter: "displayTime:2026-01-24"
Returns:
Tuple of (start_datetime, end_datetime) or (None, None)
"""
dt_start = None
dt_end = None
# Parse ISO format dates
if start_date:
try:
dt_start = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
except ValueError:
pass
if end_date:
try:
dt_end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
except ValueError:
pass
# Parse filter string (overrides start/end if present)
if filter_str:
# Case 1: Timestamp filter (created_ts >= ... && created_ts < ...)
pattern_ts = r"(?:created_ts|updated_ts)\s*>=\s*([\d\.]+)\s*&&\s*(?:created_ts|updated_ts)\s*<\s*([\d\.]+)"
match_ts = re.search(pattern_ts, filter_str)
if match_ts:
try:
ts_start = float(match_ts.group(1))
ts_end = float(match_ts.group(2))
dt_start = datetime.fromtimestamp(ts_start, tz=timezone.utc)
dt_end = datetime.fromtimestamp(ts_end, tz=timezone.utc)
except (ValueError, TypeError):
pass
else:
# Case 2: DisplayTime filter (displayTime:YYYY-MM-DD)
pattern_dt = r"displayTime:(\d{4}-\d{2}-\d{2})"
match_dt = re.search(pattern_dt, filter_str)
if match_dt:
try:
date_str = match_dt.group(1)
dt = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
dt_start = dt
dt_end = dt + timedelta(days=1)
except ValueError:
pass
return dt_start, dt_end
...@@ -183,7 +183,7 @@ class MemoService: ...@@ -183,7 +183,7 @@ class MemoService:
result = await mongodb_client.memos.insert_one(doc) result = await mongodb_client.memos.insert_one(doc)
doc["_id"] = result.inserted_id doc["_id"] = result.inserted_id
await self._create_embedding(str(result.inserted_id), payload.content, payload.tags or []) await self._create_embedding(str(result.inserted_id), payload.content, payload.tags or [], user_id=user_id)
return self._doc_to_response(doc) return self._doc_to_response(doc)
...@@ -295,6 +295,7 @@ class MemoService: ...@@ -295,6 +295,7 @@ class MemoService:
str(doc["_id"]), str(doc["_id"]),
payload.content, payload.content,
payload.tags or doc.get("payload", {}).get("tags", []), payload.tags or doc.get("payload", {}).get("tags", []),
user_id=user_id,
) )
return await self.get_memo(str(doc["_id"]), user_id=user_id) return await self.get_memo(str(doc["_id"]), user_id=user_id)
...@@ -383,15 +384,35 @@ class MemoService: ...@@ -383,15 +384,35 @@ class MemoService:
parent=doc.get("parent"), parent=doc.get("parent"),
) )
async def _create_embedding(self, memo_id: str, content: str, tags: list[str]) -> None: async def _create_embedding(self, memo_id: str, content: str, tags: list[str], user_id: str | None = None) -> None:
"""Create or update embedding for a memo.""" """
Create or update embedding for a memo.
Uses user's API key if available, otherwise falls back to env key.
Args:
memo_id: Memo ID
content: Memo content to embed
tags: Memo tags
user_id: Optional user ID to use user's API key
"""
try: try:
api_key = os.getenv("OPENAI_API_KEY") # Try to get user's API key first
api_key = None
if user_id:
from common.memos_core.services import get_user_settings_service
settings_service = get_user_settings_service()
api_key = await settings_service.get_openai_api_key(user_id)
# Fallback to env key
if not api_key:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key: if not api_key:
return return
embedder = OpenAIEmbeddings(model="text-embedding-3-small", api_key=api_key) # Use async embedding service which supports user keys
vec = embedder.embed_query(content) from common.embedding_service import create_embedding_async
vec = await create_embedding_async(content, user_id=user_id)
if not vec: if not vec:
return return
...@@ -798,6 +819,113 @@ class UserSettingsService: ...@@ -798,6 +819,113 @@ class UserSettingsService:
) )
return serialize_doc(result) return serialize_doc(result)
async def get_openai_api_key(self, user_id: str) -> str | None:
"""
Get user's OpenAI API key (decrypted).
Args:
user_id: User ID
Returns:
Decrypted API key or None if not set
"""
from common.encryption import decrypt_api_key
doc = await mongodb_client.user_settings.find_one({"user_id": user_id})
if not doc:
return None
encrypted_key = doc.get("openai_api_key_encrypted")
if not encrypted_key:
return None
try:
return decrypt_api_key(encrypted_key)
except Exception as e:
logging.warning(f"Failed to decrypt OpenAI key for user {user_id}: {e}")
return None
async def set_openai_api_key(self, user_id: str, api_key: str) -> dict:
"""
Set user's OpenAI API key (encrypted).
Args:
user_id: User ID
api_key: Plain text API key to encrypt and store
Returns:
Updated settings document
"""
from common.encryption import encrypt_api_key, validate_openai_key_format
# Validate key format
if not validate_openai_key_format(api_key):
raise ValueError("Invalid OpenAI API key format. Key must start with 'sk-' and be 20-100 characters.")
# Encrypt the key
encrypted_key = encrypt_api_key(api_key)
# Update or create settings
result = await mongodb_client.user_settings.find_one_and_update(
{"user_id": user_id},
{
"$set": {
"openai_api_key_encrypted": encrypted_key,
"updated_at": utc_now(),
},
"$setOnInsert": {
"user_id": user_id,
"locale": "en",
"theme": "system",
"memo_visibility": "PRIVATE",
"enable_notifications": True,
"created_at": utc_now(),
},
},
upsert=True,
return_document=True,
)
return serialize_doc(result)
async def delete_openai_api_key(self, user_id: str) -> dict:
"""
Delete user's OpenAI API key.
Args:
user_id: User ID
Returns:
Updated settings document
"""
result = await mongodb_client.user_settings.find_one_and_update(
{"user_id": user_id},
{
"$unset": {"openai_api_key_encrypted": ""},
"$set": {"updated_at": utc_now()},
},
return_document=True,
)
if result:
return serialize_doc(result)
# If user settings don't exist, return empty dict
return {}
async def has_openai_api_key(self, user_id: str) -> bool:
"""
Check if user has an OpenAI API key set.
Args:
user_id: User ID
Returns:
True if key exists, False otherwise
"""
doc = await mongodb_client.user_settings.find_one(
{"user_id": user_id},
{"openai_api_key_encrypted": 1}
)
return bool(doc and doc.get("openai_api_key_encrypted"))
class S3StorageService: class S3StorageService:
"""S3 storage service for attachments.""" """S3 storage service for attachments."""
......
...@@ -18,12 +18,8 @@ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase ...@@ -18,12 +18,8 @@ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# MongoDB Configuration # MongoDB Configuration - Load from config.py to ensure consistency
MONGODB_URI = os.getenv( from config import MONGODB_URI, MONGODB_DB_NAME
"MONGODB_URI",
"mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0",
)
MONGODB_DB_NAME = os.getenv("MONGODB_DB_NAME", "cucu_note")
# Collection names với prefix cuccu_ # Collection names với prefix cuccu_
COLLECTION_MEMOS = "cuccu_memos" COLLECTION_MEMOS = "cuccu_memos"
...@@ -48,13 +44,31 @@ class MongoDBClient: ...@@ -48,13 +44,31 @@ class MongoDBClient:
return cls._instance return cls._instance
async def connect(self) -> None: async def connect(self) -> None:
"""Initialize MongoDB connection.""" """Initialize MongoDB connection with connection pooling."""
if self._client is None: if self._client is None:
try: try:
self._client = AsyncIOMotorClient(MONGODB_URI) if not MONGODB_URI:
raise ValueError("MONGODB_URI environment variable is required")
# Connection pooling configuration from environment
max_pool_size = int(os.getenv("MONGODB_MAX_POOL_SIZE", "50"))
min_pool_size = int(os.getenv("MONGODB_MIN_POOL_SIZE", "10"))
max_idle_time_ms = int(os.getenv("MONGODB_MAX_IDLE_TIME_MS", "45000"))
self._client = AsyncIOMotorClient(
MONGODB_URI,
maxPoolSize=max_pool_size,
minPoolSize=min_pool_size,
maxIdleTimeMS=max_idle_time_ms,
)
self._db = self._client[MONGODB_DB_NAME] self._db = self._client[MONGODB_DB_NAME]
await self._client.admin.command("ping") await self._client.admin.command("ping")
logger.info("✅ Connected to MongoDB: %s", MONGODB_DB_NAME) logger.info(
"✅ Connected to MongoDB: %s (pool: min=%d, max=%d)",
MONGODB_DB_NAME,
min_pool_size,
max_pool_size,
)
except Exception as e: # pragma: no cover - startup failure except Exception as e: # pragma: no cover - startup failure
logger.error("❌ MongoDB connection failed: %s", e) logger.error("❌ MongoDB connection failed: %s", e)
raise raise
...@@ -140,9 +154,44 @@ def parse_object_id(id_str: str) -> ObjectId | None: ...@@ -140,9 +154,44 @@ def parse_object_id(id_str: str) -> ObjectId | None:
return None return None
async def create_indexes():
"""Create database indexes for performance optimization."""
try:
db = mongodb_client.db
# Indexes for memos collection
await db[COLLECTION_MEMOS].create_index([("user_id", 1), ("created_at", -1)])
await db[COLLECTION_MEMOS].create_index([("user_id", 1), ("visibility", 1)])
await db[COLLECTION_MEMOS].create_index([("tags", 1)])
await db[COLLECTION_MEMOS].create_index([("created_at", -1)])
# Indexes for memo relations
await db[COLLECTION_MEMO_RELATIONS].create_index([("memo_id", 1)])
await db[COLLECTION_MEMO_RELATIONS].create_index([("related_memo_id", 1)])
# Indexes for reactions
await db[COLLECTION_REACTIONS].create_index([("memo_id", 1)])
await db[COLLECTION_REACTIONS].create_index([("user_id", 1)])
# Indexes for memo embeddings
await db[COLLECTION_MEMO_EMBEDDINGS].create_index([("memo_id", 1)])
await db[COLLECTION_MEMO_EMBEDDINGS].create_index([("user_id", 1), ("date", -1)])
# Indexes for inbox
await db[COLLECTION_INBOX].create_index([("user_id", 1), ("created_at", -1)])
# Indexes for user settings
await db[COLLECTION_USER_SETTINGS].create_index([("user_id", 1)], unique=True)
logger.info("✅ Database indexes created successfully")
except Exception as e:
logger.warning(f"⚠️ Error creating indexes (may already exist): {e}")
async def init_mongodb(): async def init_mongodb():
"""Call on app startup.""" """Call on app startup."""
await mongodb_client.connect() await mongodb_client.connect()
await create_indexes()
async def close_mongodb(): async def close_mongodb():
......
...@@ -60,7 +60,25 @@ class RateLimitService: ...@@ -60,7 +60,25 @@ class RateLimitService:
return return
# Configuration # Configuration
# Use Redis for rate limiting in production (format: redis://host:port/db)
# Default to memory:// for development
redis_host = os.getenv("REDIS_HOST")
redis_port = os.getenv("REDIS_PORT", "6379")
redis_password = os.getenv("REDIS_PASSWORD")
redis_db = os.getenv("RATE_LIMIT_REDIS_DB", "0")
if redis_host:
# Build Redis URI for rate limiting
if redis_password:
self.storage_uri = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}"
else:
self.storage_uri = f"redis://{redis_host}:{redis_port}/{redis_db}"
logger.info(f"Using Redis for rate limiting: {redis_host}:{redis_port}/{redis_db}")
else:
# Fallback to memory (not suitable for production with multiple instances)
self.storage_uri = os.getenv("RATE_STORAGE_URI", "memory://") self.storage_uri = os.getenv("RATE_STORAGE_URI", "memory://")
logger.warning("⚠️ Using in-memory rate limiting (not suitable for production with multiple instances)")
self.default_limits = ["100/hour", "30/minute"] self.default_limits = ["100/hour", "30/minute"]
self.block_duration_minutes = int(os.getenv("RATE_LIMIT_BLOCK_MINUTES", "5")) self.block_duration_minutes = int(os.getenv("RATE_LIMIT_BLOCK_MINUTES", "5"))
......
...@@ -61,6 +61,15 @@ __all__ = [ ...@@ -61,6 +61,15 @@ __all__ = [
"SQLITE_DB_PATH", "SQLITE_DB_PATH",
"DISABLE_AUTH", "DISABLE_AUTH",
"MEMO_DB_PATH", "MEMO_DB_PATH",
"MONGODB_MAX_POOL_SIZE",
"MONGODB_MIN_POOL_SIZE",
"MONGODB_MAX_IDLE_TIME_MS",
"CORS_ORIGINS",
"RATE_STORAGE_URI",
"RATE_LIMIT_REDIS_DB",
"RATE_LIMIT_BLOCK_MINUTES",
"ENCRYPTION_KEY",
"ENCRYPTION_PASSWORD",
] ]
# ====================== SUPABASE CONFIGURATION ====================== # ====================== SUPABASE CONFIGURATION ======================
...@@ -113,7 +122,7 @@ CLERK_ISSUER: str | None = os.getenv("CLERK_ISSUER") ...@@ -113,7 +122,7 @@ CLERK_ISSUER: str | None = os.getenv("CLERK_ISSUER")
# ====================== DATABASE CONNECTION ====================== # ====================== DATABASE CONNECTION ======================
# Redis Cache Configuration # Redis Cache Configuration
REDIS_CACHE_URL: str = os.getenv("REDIS_CACHE_URL", "172.16.2.192") REDIS_CACHE_URL: str | None = os.getenv("REDIS_CACHE_URL")
REDIS_CACHE_PORT: int = int(os.getenv("REDIS_CACHE_PORT", "6379")) REDIS_CACHE_PORT: int = int(os.getenv("REDIS_CACHE_PORT", "6379"))
REDIS_CACHE_DB: int = int(os.getenv("REDIS_CACHE_DB", "2")) REDIS_CACHE_DB: int = int(os.getenv("REDIS_CACHE_DB", "2"))
REDIS_CACHE_TURN_ON: bool = os.getenv("REDIS_CACHE_TURN_ON", "true").lower() == "true" REDIS_CACHE_TURN_ON: bool = os.getenv("REDIS_CACHE_TURN_ON", "true").lower() == "true"
...@@ -121,10 +130,15 @@ REDIS_CACHE_TURN_ON: bool = os.getenv("REDIS_CACHE_TURN_ON", "true").lower() == ...@@ -121,10 +130,15 @@ REDIS_CACHE_TURN_ON: bool = os.getenv("REDIS_CACHE_TURN_ON", "true").lower() ==
CONV_DATABASE_URL: str | None = os.getenv("CONV_DATABASE_URL") CONV_DATABASE_URL: str | None = os.getenv("CONV_DATABASE_URL")
# ====================== MONGO CONFIGURATION ====================== # ====================== MONGO CONFIGURATION ======================
MONGODB_URI: str | None = os.getenv("MONGODB_URI", "mongodb://localhost:27017") MONGODB_URI: str | None = os.getenv("MONGODB_URI")
MONGODB_DB_NAME: str | None = os.getenv("MONGODB_DB_NAME", "ai_law") MONGODB_DB_NAME: str | None = os.getenv("MONGODB_DB_NAME", "cucu_note")
USE_MONGO_CONVERSATION: bool = os.getenv("USE_MONGO_CONVERSATION", "true").lower() == "true" USE_MONGO_CONVERSATION: bool = os.getenv("USE_MONGO_CONVERSATION", "true").lower() == "true"
# MongoDB Connection Pooling
MONGODB_MAX_POOL_SIZE: int = int(os.getenv("MONGODB_MAX_POOL_SIZE", "50"))
MONGODB_MIN_POOL_SIZE: int = int(os.getenv("MONGODB_MIN_POOL_SIZE", "10"))
MONGODB_MAX_IDLE_TIME_MS: int = int(os.getenv("MONGODB_MAX_IDLE_TIME_MS", "45000"))
# ====================== CANIFA INTERNAL POSTGRES ====================== # ====================== CANIFA INTERNAL POSTGRES ======================
CHECKPOINT_POSTGRES_URL: str | None = os.getenv("CHECKPOINT_POSTGRES_URL") CHECKPOINT_POSTGRES_URL: str | None = os.getenv("CHECKPOINT_POSTGRES_URL")
CHECKPOINT_POSTGRES_SCHEMA: str = os.getenv("CHECKPOINT_POSTGRES_SCHEMA", "canifa_chat") CHECKPOINT_POSTGRES_SCHEMA: str = os.getenv("CHECKPOINT_POSTGRES_SCHEMA", "canifa_chat")
...@@ -163,4 +177,20 @@ MEMO_DB_PATH: str = os.getenv( ...@@ -163,4 +177,20 @@ MEMO_DB_PATH: str = os.getenv(
os.path.join(os.path.dirname(__file__), "db", "memos.db"), os.path.join(os.path.dirname(__file__), "db", "memos.db"),
) )
# ====================== CORS CONFIGURATION ======================
# CORS origins - comma-separated list, or "*" for all origins
# Default: "*" for development, should be restricted in production
CORS_ORIGINS_STR: str = os.getenv("CORS_ORIGINS", "*")
CORS_ORIGINS: list[str] = (
[origin.strip() for origin in CORS_ORIGINS_STR.split(",") if origin.strip()]
if CORS_ORIGINS_STR != "*"
else ["*"]
)
# ====================== ENCRYPTION CONFIGURATION ======================
# Encryption key for sensitive data (API keys, etc.)
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY: str | None = os.getenv("ENCRYPTION_KEY")
ENCRYPTION_PASSWORD: str | None = os.getenv("ENCRYPTION_PASSWORD") # Fallback for dev only
services:
# --- Backend Service ---
backend:
build: .
container_name: canifa_backend
env_file: .env
ports:
- "5000:5000"
volumes:
- .:/app
environment:
- PORT=5000
restart: unless-stopped
deploy:
resources:
limits:
memory: 8g
logging:
driver: "json-file"
options:
tag: "{{.Name}}"
#!/bin/bash #!/bin/bash
NUM_CORES=$(nproc) set -e
WORKERS=$((2 * NUM_CORES + 1))
echo "🔧 [STARTUP] CPU cores: $NUM_CORES" echo "🚀 Starting CuCu Note Backend..."
echo "🔧 [STARTUP] Gunicorn workers: $WORKERS"
# Wait for MongoDB to be ready
if [ -n "$MONGODB_URI" ]; then
echo "⏳ Waiting for MongoDB..."
until python -c "import motor.motor_asyncio; import asyncio; asyncio.run(motor.motor_asyncio.AsyncIOMotorClient('$MONGODB_URI').admin.command('ping'))" 2>/dev/null; do
echo "MongoDB is unavailable - sleeping"
sleep 2
done
echo "✅ MongoDB is ready!"
fi
# Wait for Redis to be ready (if configured)
if [ -n "$REDIS_HOST" ]; then
echo "⏳ Waiting for Redis..."
until python -c "import redis; r = redis.Redis(host='$REDIS_HOST', port=${REDIS_PORT:-6379}, decode_responses=True); r.ping()" 2>/dev/null; do
echo "Redis is unavailable - sleeping"
sleep 2
done
echo "✅ Redis is ready!"
fi
# Run database migrations/indexes (if needed)
echo "📊 Setting up database indexes..."
python -c "
import asyncio
from common.mongo_client import mongodb_client
async def setup():
await mongodb_client.connect()
print('✅ Database indexes ready')
asyncio.run(setup())
" || echo "⚠️ Could not set up indexes (may already exist)"
# Start the server
echo "🌟 Starting Gunicorn server..."
exec gunicorn \ exec gunicorn \
server:app \ --workers 4 \
--workers "$WORKERS" \
--worker-class uvicorn.workers.UvicornWorker \ --worker-class uvicorn.workers.UvicornWorker \
--worker-connections 1000 \ --bind 0.0.0.0:5000 \
--max-requests 1000 \ --timeout 120 \
--max-requests-jitter 100 \
--timeout 30 \
--access-logfile - \ --access-logfile - \
--error-logfile - \ --error-logfile - \
--bind 0.0.0.0:5000 \ --log-level info \
--log-level info server:app
\ No newline at end of file
...@@ -338,29 +338,54 @@ curl "http://localhost:8000/history/user_12345?limit=20&before_id=150" ...@@ -338,29 +338,54 @@ curl "http://localhost:8000/history/user_12345?limit=20&before_id=150"
## Environment Variables ## Environment Variables
For detailed documentation on all environment variables, see [ENV_VARIABLES.md](./ENV_VARIABLES.md).
### Quick Setup
1. Copy environment variables template:
```bash ```bash
# PostgreSQL # See ENV_VARIABLES.md for complete list
POSTGRES_HOST=localhost ```
POSTGRES_PORT=5432
POSTGRES_DB=chatbot_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_password
# OpenAI
OPENAI_API_KEY=sk-...
# StarRocks (Vector Database) 2. Required variables:
STARROCKS_HOST=localhost ```bash
STARROCKS_PORT=9030 # MongoDB (required)
STARROCKS_USER=root MONGODB_URI=mongodb://localhost:27017
STARROCKS_PASSWORD=your_password MONGODB_DB_NAME=cucu_note
STARROCKS_DB=chatbot_products
# Clerk Authentication (required for production)
CLERK_SECRET_KEY=sk_test_...
CLERK_JWKS_URL=https://your-clerk-instance.clerk.accounts.dev/.well-known/jwks.json
CLERK_ISSUER=https://your-clerk-instance.clerk.accounts.dev
# OpenAI (required for AI features)
OPENAI_API_KEY=sk-...
# Server # Server
PORT=8000 PORT=5000
HOST=0.0.0.0
``` ```
3. Optional but recommended:
```bash
# Redis for caching and rate limiting
REDIS_HOST=localhost
REDIS_PORT=6379
# CORS (restrict in production)
CORS_ORIGINS=http://localhost:3001,https://yourdomain.com
```
### Performance Optimization
The backend includes several performance optimizations:
- **MongoDB Connection Pooling**: Configurable via `MONGODB_MAX_POOL_SIZE`, `MONGODB_MIN_POOL_SIZE`
- **Database Indexes**: Automatically created on startup for common queries
- **Redis Caching**: For response and embedding caching
- **Rate Limiting**: Uses Redis for distributed rate limiting in production
See [ENV_VARIABLES.md](./ENV_VARIABLES.md) for all configuration options.
--- ---
## Testing ## Testing
......
...@@ -13,7 +13,7 @@ from api.memos import router as memos_router ...@@ -13,7 +13,7 @@ from api.memos import router as memos_router
from common.cache import redis_cache from common.cache import redis_cache
from common.langfuse_client import get_langfuse_client from common.langfuse_client import get_langfuse_client
from common.middleware import middleware_manager from common.middleware import middleware_manager
from config import PORT from config import PORT, CORS_ORIGINS
# Windows event loop handling # Windows event loop handling
...@@ -48,12 +48,38 @@ app = FastAPI( ...@@ -48,12 +48,38 @@ app = FastAPI(
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Initialize dependencies on startup.""" """Initialize dependencies on startup."""
await redis_cache.initialize() # Initialize Redis (optional - will continue without cache if unavailable)
logger.info("Redis cache initialized for message limit") redis_client = await redis_cache.initialize()
# MongoDB initialization if redis_client:
logger.info("✅ Redis cache initialized for message limit")
else:
logger.info("⚠️ Redis cache unavailable - continuing without cache")
# MongoDB initialization (required)
from common.mongo_client import init_mongodb from common.mongo_client import init_mongodb
await init_mongodb() await init_mongodb()
logger.info("MongoDB connection initialized") logger.info("✅ MongoDB connection initialized")
@app.on_event("shutdown")
async def shutdown_event():
"""Cleanup on shutdown."""
try:
# Close Redis connection if exists
redis_client = redis_cache.get_client()
if redis_client:
await redis_client.aclose()
logger.info("Redis connection closed")
except Exception as e:
logger.debug(f"Error closing Redis: {e}")
# Close MongoDB connection
try:
from common.mongo_client import close_mongodb
await close_mongodb()
logger.info("MongoDB connection closed")
except Exception as e:
logger.debug(f"Error closing MongoDB: {e}")
# ============================================================================= # =============================================================================
...@@ -64,7 +90,7 @@ middleware_manager.setup( ...@@ -64,7 +90,7 @@ middleware_manager.setup(
enable_auth=True, # bật/tắt Auth enable_auth=True, # bật/tắt Auth
enable_rate_limit=False, # tắt slowapi business rate limit enable_rate_limit=False, # tắt slowapi business rate limit
enable_cors=True, # bật CORS enable_cors=True, # bật CORS
cors_origins=["*"], # trong prod nên giới hạn origins cors_origins=CORS_ORIGINS, # từ environment variable
) )
app.include_router(chatbot_router) app.include_router(chatbot_router)
......
version: '3.8'
services:
# MongoDB Database
mongodb:
image: mongo:7.0
container_name: cuccu_mongodb
restart: unless-stopped
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
- mongodb_config:/data/configdb
environment:
MONGO_INITDB_DATABASE: cucu_note
networks:
- cuccu_network
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
# Redis Cache
redis:
image: redis:7-alpine
container_name: cuccu_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- cuccu_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# Backend API
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
container_name: cuccu_backend
restart: unless-stopped
ports:
- "5000:5000"
env_file:
- ./backend/.env
volumes:
- ./backend:/app
- backend_data:/app/data
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
networks:
- cuccu_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/docs"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 2G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
# Frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
container_name: cuccu_frontend
restart: unless-stopped
ports:
- "3001:80"
env_file:
- ./frontend/.env
depends_on:
- backend
networks:
- cuccu_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
volumes:
mongodb_data:
driver: local
mongodb_config:
driver: local
redis_data:
driver: local
backend_data:
driver: local
networks:
cuccu_network:
driver: bridge
node_modules/
dist/
*.log
.DS_Store
# Fix Extension cho Facebook
## Vấn đề
Facebook có **CSP (Content Security Policy) nghiêm ngặt** và có thể block một số DOM APIs, khiến sidebar không hiện khi bôi đen text.
## Đã fix
1.**Error handling**: Thêm try-catch cho tất cả DOM operations
2.**Fallback append**: Nếu `document.body.appendChild()` fail → thử `document.documentElement.appendChild()`
3.**Console logging**: Log errors để debug
4.**Safe remove**: Check cả `document.body``document.documentElement` khi remove
## Test lại
1. **Reload extension** trong `chrome://extensions`
2. **Mở Facebook**: https://www.facebook.com
3. **Bôi đen text** trên một post
4. **Check Console** (F12) xem có errors không
5. **Sidebar sẽ hiện** nếu không có CSP block
## Nếu vẫn không hiện
Facebook có thể:
- Block `innerHTML` với user content (XSS protection)
- Block `appendChild` vào một số elements
- Có CSP policy block inline styles
**Giải pháp tiếp theo**:
- Dùng `textContent` thay vì `innerHTML`
- Dùng `createElement` + `appendChild` thay vì `innerHTML`
- Inject CSS vào `<head>` thay vì inline styles
## Debug
Mở Console (F12) và check:
- `[CuCu Note] Error in handleTextSelection:` - Lỗi khi detect selection
- `[CuCu Note] Error showing sidebar:` - Lỗi khi tạo sidebar
- `[CuCu Note] Error appending sidebar:` - Lỗi khi append vào DOM
# Icons cho Extension
Extension cần 3 file icons:
- `icons/icon16.png` (16x16 pixels)
- `icons/icon48.png` (48x48 pixels)
- `icons/icon128.png` (128x128 pixels)
## Cách tạo icons đơn giản:
1. **Dùng online tool**: https://www.favicon-generator.org/
2. **Hoặc tạo bằng code**: Tôi có thể tạo script generate icons đơn giản
3. **Hoặc dùng icon có sẵn**: Copy từ frontend/public nếu có
## Tạm thời:
Bạn có thể tạo icons đơn giản bằng cách:
- Vẽ icon đơn giản (ví dụ: chữ "CN" hoặc icon note)
- Export thành 3 sizes: 16x16, 48x48, 128x128
- Save vào folder `icons/`
Hoặc để tôi tạo script generate icons tự động?
# Quick Start - Tạo Icons & Fix Lỗi
## 🔧 Fix lỗi npm install
Lỗi: `@types/chrome@^0.0.0` không tồn tại
**Đã fix**: Đổi version thành `@types/chrome@^0.0.268` trong `package.json`
Bây giờ chạy lại:
```bash
cd extension
npm install
```
---
## 🎨 Tạo Icons (3 cách)
### Cách 1: Dùng HTML Generator (Dễ nhất) ⭐
1. Mở file `scripts/create-icons-simple.html` trong browser
2. Click button "Generate Icons"
3. Icons sẽ tự động download
4. Copy 3 file PNG vào folder `extension/icons/`
### Cách 2: Dùng Python Script
```bash
# Install Pillow
pip install Pillow
# Run script
cd extension
python scripts/create-icons-simple.py
```
### Cách 3: Tạo thủ công
1. Tạo 3 file PNG:
- `icon16.png` (16x16 pixels)
- `icon48.png` (48x48 pixels)
- `icon128.png` (128x128 pixels)
2. Design đơn giản: Background màu #4F46E5, chữ "CN" màu trắng
3. Save vào `extension/icons/`
---
## ✅ Sau khi có icons
1. **Install dependencies**:
```bash
cd extension
npm install
```
2. **Build extension**:
```bash
npm run build
```
3. **Load vào Chrome**:
- Mở `chrome://extensions`
- Bật "Developer mode"
- Click "Load unpacked"
- Chọn folder `extension/dist`
4. **Test**:
- Mở một trang web
- Bôi đen text
- Popup "💾 Save to CuCu Note" sẽ hiện!
---
## 🐛 Nếu vẫn lỗi
### Lỗi build:
- Check `tsconfig.json` có đúng không
- Check `vite.config.ts` có đúng không
- Xem console khi build: `npm run build`
### Extension không load:
- Check `manifest.json` có đúng format không
- Check icons có đủ 3 file không
- Xem errors trong `chrome://extensions` → "Errors"
### Popup không hiện:
- Check Console trong DevTools (F12)
- Check content script có inject không
- Check permissions trong manifest.json
# CuCu Note Browser Extension
Browser extension để lưu note nhanh từ web - chỉ cần bôi đen text và save!
## 🚀 Setup
```bash
# Install dependencies
npm install
# Build extension
npm run build
# Development mode (watch)
npm run dev
```
## 📦 Load vào Chrome
1. Build extension: `npm run build`
2. Mở Chrome: `chrome://extensions`
3. Bật "Developer mode"
4. Click "Load unpacked"
5. Chọn folder `extension/dist`
## 🎯 Cách dùng
1. **Bôi đen text** trên web
2. Extension hiện popup "💾 Save to CuCu Note"
3. Click popup → Form hiện
4. Điền tag (ví dụ: "important, article")
5. Click "Save" → Done!
## 🏗️ Structure
```
extension/
├── src/
│ ├── content/ # Content script (detect text selection)
│ ├── popup/ # Popup UI (React)
│ ├── components/ # React components
│ ├── background/ # Service worker
│ └── shared/ # Shared code (API client)
├── manifest.json # Extension config
└── vite.config.ts # Build config
```
## ⚙️ Config
### Backend URL
Sửa trong `src/shared/api-client.ts`:
```typescript
const API_BASE = 'http://localhost:5000/api/v1'; // Thay đổi URL backend
```
### Auth Token
Extension cần auth token để gọi API. Có thể:
- Dùng Clerk (như frontend)
- Hoặc API key (đơn giản hơn)
## 📝 TODO
- [ ] Setup auth (Clerk hoặc API key)
- [ ] Config backend URL từ settings page
- [ ] Add icons (16x16, 48x48, 128x128)
- [ ] Error handling tốt hơn
- [ ] Success toast notification
# 🔄 Cách Reload Extension sau khi Build
## ⚠️ Lưu ý quan trọng
**Chrome không tự động reload extension khi bạn build lại!**
Sau khi chạy `npm run build`, bạn cần **reload extension thủ công** trong Chrome.
---
## 📋 Các bước reload
### Bước 1: Build extension
```bash
cd extension
npm run build
```
### Bước 2: Reload extension trong Chrome
1. Mở `chrome://extensions`
2. Tìm extension **"CuCu Note"**
3. Click nút **🔄 Reload** trên card extension
- Hoặc toggle OFF rồi ON lại
### Bước 3: Test lại
- Mở một trang web
- Bôi đen text → nhấn Space
- Form sẽ hiện với code mới
---
## 🚀 Tips để dev nhanh hơn
### Option 1: Dùng Extension Reloader
Cài extension này để tự động reload:
- **Extension Reloader**: https://chrome.google.com/webstore/detail/extensions-reloader/fimgfedafeadlieiabdeeaodndnlbhid
Sau khi cài:
1. Build: `npm run build`
2. Extension tự động reload!
### Option 2: Build watch mode
```bash
npm run build:watch
```
Sau đó mỗi lần save file → tự động build → bạn chỉ cần reload extension trong Chrome.
---
## 💡 Workflow đề xuất
1. **Lần đầu**: Build + Load unpacked
2. **Sau đó mỗi lần code thay đổi**:
- Save file
- Build (hoặc dùng watch mode)
- Reload extension trong Chrome (hoặc dùng Extension Reloader)
- Test ngay
---
## ❓ Tại sao không tự động reload?
Chrome extension được load từ folder `dist/` như một **snapshot** tại thời điểm load. Khi bạn build lại, Chrome không biết files đã thay đổi, nên cần reload thủ công.
# Setup Extension - Hướng dẫn từng bước
## 📋 Bước 1: Install Dependencies
```bash
cd extension
npm install
```
## 📋 Bước 2: Tạo Icons (Tạm thời)
Extension cần icons. Bạn có thể:
1. Tạo icons đơn giản (16x16, 48x48, 128x128)
2. Hoặc dùng placeholder icons từ internet
3. Hoặc để tôi tạo script generate icons đơn giản
**Tạm thời**: Tạo file placeholder trong `icons/`:
- `icon16.png`
- `icon48.png`
- `icon128.png`
## 📋 Bước 3: Build Extension
```bash
npm run build
```
Sau khi build, folder `dist/` sẽ được tạo với các file đã compile.
## 📋 Bước 4: Load vào Chrome
1. Mở Chrome: `chrome://extensions`
2. Bật **"Developer mode"** (góc trên bên phải)
3. Click **"Load unpacked"**
4. Chọn folder: `E:\opennotion\extension\dist`
## 📋 Bước 5: Test Extension
1. Mở một trang web bất kỳ (ví dụ: https://example.com)
2. **Bôi đen** một đoạn text
3. Popup "💾 Save to CuCu Note" sẽ hiện
4. Click popup → Form hiện
5. Điền tag → Save
## ⚠️ Lưu ý
### Backend URL
Hiện tại extension gọi API tại: `http://localhost:5000/api/v1`
Nếu backend chạy ở port khác, sửa trong:
- `src/shared/api-client.ts` → dòng `const API_BASE = ...`
### Auth Token
Extension cần auth token để gọi API. Hiện tại chưa implement auth.
**Tạm thời**: Backend có thể cho phép anonymous hoặc bạn cần:
1. Setup Clerk auth trong extension
2. Hoặc dùng API key
## 🐛 Troubleshooting
### Extension không load được
- Check `manifest.json` có đúng format không
- Check console trong `chrome://extensions` → "Errors"
### Popup không hiện khi bôi đen text
- Check Console trong DevTools (F12)
- Check content script có inject vào page không
### API call fail
- Check backend có chạy không: `http://localhost:5000`
- Check CORS settings trong backend
- Check auth token trong `chrome.storage.local`
## 📝 Next Steps
1. ✅ Setup icons
2. ✅ Test với backend thật
3. ✅ Setup auth (Clerk hoặc API key)
4. ✅ Config backend URL từ settings
5. ✅ Polish UI/UX
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="25.6" fill="#4F46E5"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="64" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central">CN</text>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3.2" fill="#4F46E5"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="8" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central">CN</text>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="48" height="48" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="9.6" fill="#4F46E5"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central">CN</text>
</svg>
{
"manifest_version": 3,
"name": "CuCu Note",
"version": "1.0.0",
"description": "Quick note-taking extension - Highlight text and save to CuCu Note",
"permissions": [
"activeTab",
"storage",
"scripting",
"tabs"
],
"host_permissions": [
"http://localhost:5000/*",
"http://*/*",
"https://*/*"
],
"background": {
"service_worker": "src/background/service-worker.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content/content-script.ts"],
"run_at": "document_idle",
"all_frames": false
}
],
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
{
"name": "cucu-note-extension",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cucu-note-extension",
"version": "1.0.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0",
"@types/chrome": "^0.0.268",
"@types/react": "^18.3.27",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^7.2.4"
}
},
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
"integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/generator": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
"integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.6"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
"integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.6",
"@babel/template": "^7.28.6",
"@babel/types": "^7.28.6",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@crxjs/vite-plugin": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.3.0.tgz",
"integrity": "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^4.1.2",
"@webcomponents/custom-elements": "^1.5.0",
"acorn-walk": "^8.2.0",
"cheerio": "^1.0.0-rc.10",
"convert-source-map": "^1.7.0",
"debug": "^4.3.3",
"es-module-lexer": "^0.10.0",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1",
"jsesc": "^3.0.2",
"magic-string": "^0.30.12",
"pathe": "^2.0.1",
"picocolors": "^1.1.1",
"react-refresh": "^0.13.0",
"rollup": "2.79.2",
"rxjs": "7.5.7"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/pluginutils": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
"integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"estree-walker": "^2.0.1",
"picomatch": "^2.2.2"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
"integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
"integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
"integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
"integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
"integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
"integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
"integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
"integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
"integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
"integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
"integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
"integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
"integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
"integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
"integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
"integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
"integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
"integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
"integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
"integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
"integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
"integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
"integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
"integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
"integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chrome": {
"version": "0.0.268",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.268.tgz",
"integrity": "sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitejs/plugin-react/node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@webcomponents/custom-elements": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz",
"integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true,
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.282",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz",
"integrity": "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz",
"integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"dev": true,
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-refresh": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz",
"integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "2.79.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=10.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz",
"integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.2.tgz",
"integrity": "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vite/node_modules/rollup": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
"integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.0",
"@rollup/rollup-android-arm64": "4.57.0",
"@rollup/rollup-darwin-arm64": "4.57.0",
"@rollup/rollup-darwin-x64": "4.57.0",
"@rollup/rollup-freebsd-arm64": "4.57.0",
"@rollup/rollup-freebsd-x64": "4.57.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
"@rollup/rollup-linux-arm-musleabihf": "4.57.0",
"@rollup/rollup-linux-arm64-gnu": "4.57.0",
"@rollup/rollup-linux-arm64-musl": "4.57.0",
"@rollup/rollup-linux-loong64-gnu": "4.57.0",
"@rollup/rollup-linux-loong64-musl": "4.57.0",
"@rollup/rollup-linux-ppc64-gnu": "4.57.0",
"@rollup/rollup-linux-ppc64-musl": "4.57.0",
"@rollup/rollup-linux-riscv64-gnu": "4.57.0",
"@rollup/rollup-linux-riscv64-musl": "4.57.0",
"@rollup/rollup-linux-s390x-gnu": "4.57.0",
"@rollup/rollup-linux-x64-gnu": "4.57.0",
"@rollup/rollup-linux-x64-musl": "4.57.0",
"@rollup/rollup-openbsd-x64": "4.57.0",
"@rollup/rollup-openharmony-arm64": "4.57.0",
"@rollup/rollup-win32-arm64-msvc": "4.57.0",
"@rollup/rollup-win32-ia32-msvc": "4.57.0",
"@rollup/rollup-win32-x64-gnu": "4.57.0",
"@rollup/rollup-win32-x64-msvc": "4.57.0",
"fsevents": "~2.3.2"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
}
}
}
{
"name": "cucu-note-extension",
"version": "1.0.0",
"description": "Browser extension for CuCu Note - Quick note-taking from web",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0",
"@types/chrome": "^0.0.268",
"@types/react": "^18.3.27",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^7.2.4"
}
}
/**
* Convert SVG icons to PNG using sharp
* Run: npm install -D sharp (nếu chưa có)
*/
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const iconsDir = path.join(__dirname, '..', 'icons');
const sizes = [16, 48, 128];
async function convertIcons() {
console.log('🔄 Converting SVG to PNG...');
for (const size of sizes) {
const svgPath = path.join(iconsDir, `icon${size}.svg`);
const pngPath = path.join(iconsDir, `icon${size}.png`);
if (!fs.existsSync(svgPath)) {
console.log(`⚠️ SVG not found: icon${size}.svg`);
continue;
}
try {
await sharp(svgPath)
.resize(size, size)
.png()
.toFile(pngPath);
console.log(`✅ Converted: icon${size}.png`);
} catch (error) {
console.error(`❌ Error converting icon${size}:`, error.message);
}
}
console.log('\n✅ Done! Icons ready in icons/ folder');
}
convertIcons().catch(console.error);
/**
* Tạo icons PNG đơn giản ngay lập tức
* Sử dụng canvas API của Node.js (cần cài canvas package)
* Hoặc tạo SVG và convert
*/
const fs = require('fs');
const path = require('path');
const iconsDir = path.join(__dirname, '..', 'icons');
const sizes = [16, 48, 128];
// Tạo folder nếu chưa có
if (!fs.existsSync(iconsDir)) {
fs.mkdirSync(iconsDir, { recursive: true });
}
// Tạo SVG icons (sẽ convert sau)
console.log('🎨 Creating SVG icons...');
sizes.forEach(size => {
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" rx="${size * 0.2}" fill="#4F46E5"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="${size * 0.5}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central">CN</text>
</svg>`;
const svgPath = path.join(iconsDir, `icon${size}.svg`);
fs.writeFileSync(svgPath, svg);
console.log(`✅ Created: icon${size}.svg`);
});
console.log('\n⚠️ Note: Created SVG files. For PNG:');
console.log(' 1. Open create-simple-icons.html in browser');
console.log(' 2. Or install sharp: npm install -D sharp');
console.log(' 3. Then run: node scripts/convert-icons.js');
# PowerShell script to create PNG icons
# Run: powershell -ExecutionPolicy Bypass -File scripts/create-icons-powershell.ps1
$iconsDir = Join-Path $PSScriptRoot "..\icons"
if (-not (Test-Path $iconsDir)) {
New-Item -ItemType Directory -Path $iconsDir -Force | Out-Null
}
Add-Type -AssemblyName System.Drawing
$sizes = @(16, 48, 128)
foreach ($size in $sizes) {
$bitmap = New-Object System.Drawing.Bitmap($size, $size)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
# Background color #4F46E5
$graphics.Clear([System.Drawing.Color]::FromArgb(79, 70, 229))
# Draw text "CN"
$brush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::White)
$fontSize = [Math]::Max(8, [int]($size * 0.5))
$font = New-Object System.Drawing.Font("Arial", $fontSize, [System.Drawing.FontStyle]::Bold)
$text = "CN"
$format = New-Object System.Drawing.StringFormat
$format.Alignment = [System.Drawing.StringAlignment]::Center
$format.LineAlignment = [System.Drawing.StringAlignment]::Center
$rect = New-Object System.Drawing.RectangleF(0, 0, $size, $size)
$graphics.DrawString($text, $font, $brush, $rect, $format)
$outputPath = Join-Path $iconsDir "icon$size.png"
$bitmap.Save($outputPath, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
Write-Host "✅ Created: $outputPath"
}
Write-Host "`n✅ All icons created successfully!"
"""
Simple script to create PNG icons using PIL/Pillow
Run: pip install Pillow
Then: python scripts/create-icons-simple.py
"""
try:
from PIL import Image, ImageDraw, ImageFont
import os
except ImportError:
print("❌ Pillow not installed. Install with: pip install Pillow")
exit(1)
# Create icons directory
icons_dir = os.path.join(os.path.dirname(__file__), '..', 'icons')
os.makedirs(icons_dir, exist_ok=True)
sizes = [16, 48, 128]
print("🎨 Generating icons...")
for size in sizes:
# Create image with rounded rectangle background
img = Image.new('RGB', (size, size), color='#4F46E5')
draw = ImageDraw.Draw(img)
# Draw rounded rectangle
radius = int(size * 0.2)
draw.rounded_rectangle([(0, 0), (size, size)], radius=radius, fill='#4F46E5')
# Draw text "CN"
try:
# Try to use a nice font
font_size = int(size * 0.5)
font = ImageFont.truetype("arial.ttf", font_size)
except:
# Fallback to default font
font = ImageFont.load_default()
text = "CN"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Center text
x = (size - text_width) // 2
y = (size - text_height) // 2
draw.text((x, y), text, fill='white', font=font)
# Save
output_path = os.path.join(icons_dir, f'icon{size}.png')
img.save(output_path, 'PNG')
print(f"✅ Created: icon{size}.png")
print(f"\n✅ Done! Icons saved to: {icons_dir}")
<!DOCTYPE html>
<html>
<head>
<title>Create CuCu Note Icons</title>
<style>
body {
font-family: system-ui;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.icon-preview {
display: inline-block;
margin: 10px;
text-align: center;
}
canvas {
border: 1px solid #ddd;
margin: 5px;
}
button {
padding: 10px 20px;
background: #4F46E5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
</style>
</head>
<body>
<h1>CuCu Note Extension Icons Generator</h1>
<p>Click button để generate và download icons:</p>
<div id="previews"></div>
<button onclick="generateIcons()">Generate Icons</button>
<script>
function generateIcons() {
const sizes = [16, 48, 128];
const previews = document.getElementById('previews');
previews.innerHTML = '';
sizes.forEach(size => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = '#4F46E5';
const radius = size * 0.2;
ctx.beginPath();
ctx.moveTo(radius, 0);
ctx.lineTo(size - radius, 0);
ctx.quadraticCurveTo(size, 0, size, radius);
ctx.lineTo(size, size - radius);
ctx.quadraticCurveTo(size, size, size - radius, size);
ctx.lineTo(radius, size);
ctx.quadraticCurveTo(0, size, 0, size - radius);
ctx.lineTo(0, radius);
ctx.quadraticCurveTo(0, 0, radius, 0);
ctx.closePath();
ctx.fill();
// Text "CN"
ctx.fillStyle = 'white';
ctx.font = `bold ${size * 0.5}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('CN', size / 2, size / 2);
// Preview
const div = document.createElement('div');
div.className = 'icon-preview';
div.innerHTML = `
<canvas width="${size}" height="${size}"></canvas>
<div>icon${size}.png</div>
`;
const previewCanvas = div.querySelector('canvas');
previewCanvas.getContext('2d').drawImage(canvas, 0, 0);
previews.appendChild(div);
// Download
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `icon${size}.png`;
a.click();
URL.revokeObjectURL(url);
});
});
alert('✅ Icons đã được download! Copy vào folder extension/icons/');
}
</script>
</body>
</html>
/**
* Script để generate icons tự động cho extension
* Tạo icons đơn giản với chữ "CN" (CuCu Note)
*/
const fs = require('fs');
const path = require('path');
// Tạo folder icons nếu chưa có
const iconsDir = path.join(__dirname, '..', 'icons');
if (!fs.existsSync(iconsDir)) {
fs.mkdirSync(iconsDir, { recursive: true });
}
// SVG template cho icon (chữ CN với background)
const svgTemplate = (size) => `
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" rx="${size * 0.2}" fill="#4F46E5"/>
<text
x="50%"
y="50%"
font-family="Arial, sans-serif"
font-size="${size * 0.5}"
font-weight="bold"
fill="white"
text-anchor="middle"
dominant-baseline="central"
>CN</text>
</svg>
`;
// Sizes cần tạo
const sizes = [16, 48, 128];
console.log('🎨 Generating icons...');
sizes.forEach(size => {
const svg = svgTemplate(size);
const filePath = path.join(iconsDir, `icon${size}.png`);
// Note: Node.js không có built-in SVG to PNG converter
// Tạm thời tạo SVG file, user có thể convert sau
// Hoặc dùng package như 'sharp' hoặc 'jimp'
const svgPath = path.join(iconsDir, `icon${size}.svg`);
fs.writeFileSync(svgPath, svg);
console.log(`✅ Created: icon${size}.svg`);
});
console.log('\n⚠️ Note: Created SVG files. To convert to PNG:');
console.log(' 1. Open SVG files in browser/image editor');
console.log(' 2. Export as PNG');
console.log(' 3. Or install sharp: npm install -D sharp');
console.log(' 4. Then run: node scripts/convert-icons.js');
<!DOCTYPE html>
<html>
<head>
<title>CuCu Note Icons Generator</title>
<style>
body {
font-family: system-ui;
padding: 40px;
text-align: center;
background: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
canvas {
border: 1px solid #ddd;
margin: 10px;
display: block;
margin-left: auto;
margin-right: auto;
}
button {
padding: 12px 24px;
background: #4F46E5;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
margin: 10px;
}
button:hover {
background: #4338CA;
}
.preview {
margin: 20px 0;
}
.instructions {
background: #F0F9FF;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
text-align: left;
}
</style>
</head>
<body>
<div class="container">
<h1>🎨 CuCu Note Icons Generator</h1>
<p>Click button để generate và download 3 icons PNG:</p>
<div class="instructions">
<strong>Hướng dẫn:</strong>
<ol>
<li>Click button "Generate & Download Icons"</li>
<li>3 file PNG sẽ tự động download</li>
<li>Copy 3 file vào folder: <code>extension/icons/</code></li>
<li>Chạy lại: <code>npm run build</code></li>
</ol>
</div>
<div id="previews" class="preview"></div>
<button onclick="generateIcons()">🚀 Generate & Download Icons</button>
<div id="status"></div>
</div>
<script>
function generateIcons() {
const sizes = [16, 48, 128];
const previews = document.getElementById('previews');
const status = document.getElementById('status');
previews.innerHTML = '';
status.innerHTML = '<p style="color: #059669;">⏳ Đang generate...</p>';
let downloaded = 0;
sizes.forEach(size => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Background với rounded corners
ctx.fillStyle = '#4F46E5';
const radius = size * 0.2;
// Draw rounded rectangle
ctx.beginPath();
ctx.moveTo(radius, 0);
ctx.lineTo(size - radius, 0);
ctx.quadraticCurveTo(size, 0, size, radius);
ctx.lineTo(size, size - radius);
ctx.quadraticCurveTo(size, size, size - radius, size);
ctx.lineTo(radius, size);
ctx.quadraticCurveTo(0, size, 0, size - radius);
ctx.lineTo(0, radius);
ctx.quadraticCurveTo(0, 0, radius, 0);
ctx.closePath();
ctx.fill();
// Text "CN"
ctx.fillStyle = 'white';
ctx.font = `bold ${Math.floor(size * 0.5)}px Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('CN', size / 2, size / 2);
// Preview
const div = document.createElement('div');
div.style.margin = '10px 0';
div.innerHTML = `
<canvas width="${size * 3}" height="${size * 3}" style="image-rendering: pixelated;"></canvas>
<div style="margin-top: 5px; font-weight: 500;">icon${size}.png</div>
`;
const previewCanvas = div.querySelector('canvas');
const previewCtx = previewCanvas.getContext('2d');
previewCtx.imageSmoothingEnabled = false;
previewCtx.drawImage(canvas, 0, 0, size * 3, size * 3);
previews.appendChild(div);
// Download
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `icon${size}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloaded++;
if (downloaded === sizes.length) {
status.innerHTML = `
<p style="color: #059669; font-weight: 500;">
✅ Đã download ${downloaded} icons!<br>
Copy vào folder: <code>extension/icons/</code>
</p>
`;
}
}, 'image/png');
});
}
// Auto-generate khi load page
window.addEventListener('load', () => {
setTimeout(generateIcons, 500);
});
</script>
</body>
</html>
# Cách Reload Extension sau khi Build
## Cách 1: Reload thủ công (Hiện tại)
1. Build extension: `npm run build`
2. Mở `chrome://extensions`
3. Tìm extension "CuCu Note"
4. Click nút **Reload** (🔄) trên card extension
## Cách 2: Auto-reload với Extension Reloader
Cài extension này để tự động reload khi files thay đổi:
- Extension Reloader: https://chrome.google.com/webstore/detail/extensions-reloader/fimgfedafeadlieiabdeeaodndnlbhid
Sau đó:
1. Build extension: `npm run build`
2. Extension sẽ tự động reload
## Cách 3: Script tự động reload (Advanced)
Có thể tạo script để tự động reload extension qua Chrome DevTools Protocol, nhưng phức tạp hơn.
---
**Lưu ý**: Chrome không tự động detect khi files trong `dist/` thay đổi, nên cần reload thủ công hoặc dùng extension reloader.
/**
* Background Service Worker
* Nhiệm vụ: Handle messages từ content script và gọi API
*/
import { createMemo } from '../shared/api-client';
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'SHOW_NOTE_FORM') {
// Mở popup với note form
chrome.action.openPopup();
// Lưu data vào storage để popup có thể lấy
chrome.storage.local.set({
pendingNote: message.data,
});
sendResponse({ success: true });
} else if (message.type === 'SAVE_NOTE') {
// Auto save note ngay lập tức
const { text, url, title } = message.data;
console.log('[CuCu Note] 📝 Saving note:', { text: text.substring(0, 50) + '...', url, title });
// Parse tags từ URL
const domain = new URL(url).hostname.replace('www.', '');
const tagList = [domain, 'web-highlight'];
// Add source info vào content
const contentWithSource = `${text}\n\n---\nSource: [${title}](${url})`;
// Gọi API để save (có gửi kèm Clerk token trong Authorization header)
createMemo({
content: contentWithSource,
tags: tagList,
visibility: 'PRIVATE',
})
.then((memo) => {
console.log('[CuCu Note] ✅ Note saved with ID:', memo.id);
sendResponse({ success: true, memo });
})
.catch((error) => {
console.error('[CuCu Note] ❌ Error saving note:', error);
sendResponse({ success: false, error: error.message });
});
return true; // Keep channel open for async
}
return true; // Keep channel open for async response
});
/**
* Note Form Component
* Form để user điền tag và edit note
*/
import { useState } from 'react';
import { createMemo } from '../shared/api-client';
interface NoteFormProps {
initialText: string;
initialUrl: string;
initialTitle: string;
onSave?: () => void;
onCancel?: () => void;
}
export function NoteForm({
initialText,
initialUrl,
initialTitle,
onSave,
onCancel,
}: NoteFormProps) {
const [text, setText] = useState(initialText);
const [tags, setTags] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
if (!text.trim()) {
setError('Note không được để trống');
return;
}
setLoading(true);
setError(null);
try {
// Parse tags (comma-separated)
const tagList = tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
// Add source info vào content
const contentWithSource = `${text}\n\n---\nSource: [${initialTitle}](${initialUrl})`;
await createMemo({
content: contentWithSource,
tags: tagList,
});
// Success
if (onSave) {
onSave();
} else {
// Default: show success message
alert('✅ Đã lưu vào CuCu Note!');
window.close();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Lỗi khi lưu note');
} finally {
setLoading(false);
}
};
return (
<div className="card">
<h2 style={{ marginTop: 0, marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
💾 Save to CuCu Note
</h2>
<div style={{ marginBottom: '20px' }}>
<label className="label">Note:</label>
<textarea
className="textarea"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Nhập note của bạn..."
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label className="label">Tags (phân cách bằng dấu phẩy):</label>
<input
type="text"
className="input"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="important, article, todo"
/>
<div className="label-hint">Ví dụ: important, article, todo</div>
</div>
<div className="source-info">
<div style={{ fontWeight: '500', marginBottom: '4px' }}>Source:</div>
<div style={{ wordBreak: 'break-all', marginBottom: '4px' }}>
<a href={initialUrl} target="_blank" rel="noopener noreferrer">
{initialTitle}
</a>
</div>
<div style={{ fontSize: '11px', opacity: 0.7, wordBreak: 'break-all' }}>
{initialUrl}
</div>
</div>
{error && <div className="error">{error}</div>}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
<button
className="btn btn-secondary"
onClick={onCancel || (() => window.close())}
disabled={loading}
>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={loading}
>
{loading ? 'Đang lưu...' : 'Save'}
</button>
</div>
</div>
);
}
/**
* Content Script - Chạy trên mọi web page
* Nhiệm vụ: Detect text selection → Space key → Auto save → Toast notification
*/
let selectedText = '';
let selectedUrl = '';
let selectedTitle = '';
// Listen khi user bôi đen text
document.addEventListener('mouseup', handleTextSelection);
document.addEventListener('keydown', async (e) => {
// Nếu nhấn Space hoặc Enter sau khi đã bôi đen text → tự động lưu ngay
if ((e.key === ' ' || e.key === 'Enter') && selectedText.length > 0) {
// Chỉ prevent default nếu không phải trong input/textarea
const target = e.target as HTMLElement;
if (!target || (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable)) {
e.preventDefault();
e.stopPropagation();
await handleAutoSave();
return;
}
}
});
document.addEventListener('keyup', handleTextSelection);
function handleTextSelection() {
try {
// Delay một chút để đảm bảo selection đã hoàn tất
setTimeout(() => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
selectedText = '';
removeQuickHint();
return;
}
const text = selection.toString().trim();
if (text.length === 0) {
selectedText = '';
removeQuickHint();
return;
}
// Lưu text đã chọn + metadata
selectedText = text;
selectedUrl = window.location.href;
selectedTitle = document.title;
// Hiện hint nhỏ để user biết có thể nhấn Space/Enter
showQuickHint();
}, 50);
} catch (error) {
console.error('[CuCu Note] Error in handleTextSelection:', error);
}
}
async function handleAutoSave() {
if (!selectedText) return;
try {
// Show loading toast
showToast('Đang lưu...', 'loading');
// Gửi message đến background để save
chrome.runtime.sendMessage({
type: 'SAVE_NOTE',
data: {
text: selectedText,
url: selectedUrl,
title: selectedTitle,
},
}, (response) => {
if (chrome.runtime.lastError) {
showToast('❌ Lỗi khi lưu note', 'error');
return;
}
if (response?.success) {
showToast('✅ Đã lưu vào CuCu Note!', 'success');
// Clear selection
window.getSelection()?.removeAllRanges();
selectedText = '';
removeQuickHint();
} else {
showToast('❌ Lỗi khi lưu note', 'error');
}
});
} catch (error) {
console.error('[CuCu Note] Error saving:', error);
showToast('❌ Lỗi khi lưu note', 'error');
}
}
function showQuickHint() {
// Hiện hint nhỏ "Nhấn Space để lưu" với gradient đẹp
removeQuickHint();
const hint = document.createElement('div');
hint.id = 'cucu-quick-hint';
hint.textContent = '💡 Nhấn Space hoặc Enter để lưu nhanh';
// Gradient màu đẹp
hint.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: cucuSlideUp 0.3s ease-out;
pointer-events: none;
`;
// Inject animation CSS
if (!document.getElementById('cucu-hint-style')) {
const style = document.createElement('style');
style.id = 'cucu-hint-style';
style.textContent = `
@keyframes cucuSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
document.head.appendChild(style);
}
try {
document.body.appendChild(hint);
} catch (e) {
document.documentElement.appendChild(hint);
}
// Auto ẩn sau 3 giây
setTimeout(() => {
removeQuickHint();
}, 3000);
}
function removeQuickHint() {
const hint = document.getElementById('cucu-quick-hint');
if (hint) {
try {
hint.remove();
} catch (e) {
// Ignore
}
}
}
function showToast(message: string, type: 'success' | 'error' | 'loading' = 'success') {
// Remove toast cũ nếu có
const oldToast = document.getElementById('cucu-toast');
if (oldToast) {
try {
oldToast.remove();
} catch (e) {
// Ignore
}
}
const toast = document.createElement('div');
toast.id = 'cucu-toast';
toast.textContent = message;
// Màu sắc đẹp theo type (gradient)
const colors = {
success: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
error: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
loading: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
};
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors[type]};
color: white;
padding: 16px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: cucuToastSlide 0.4s ease-out;
max-width: 350px;
word-wrap: break-word;
`;
// Inject animation CSS
if (!document.getElementById('cucu-toast-style')) {
const style = document.createElement('style');
style.id = 'cucu-toast-style';
style.textContent = `
@keyframes cucuToastSlide {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`;
try {
document.head.appendChild(style);
} catch (e) {
// Ignore
}
}
try {
document.body.appendChild(toast);
} catch (e) {
try {
document.documentElement.appendChild(toast);
} catch (e2) {
console.error('[CuCu Note] Cannot append toast:', e2);
return;
}
}
// Auto ẩn sau 3 giây (trừ loading)
if (type !== 'loading') {
setTimeout(() => {
if (toast.parentNode) {
toast.style.animation = 'cucuToastSlide 0.3s ease-out reverse';
setTimeout(() => {
try {
toast.remove();
} catch (e) {
// Ignore
}
}, 300);
}
}, 3000);
}
}
/* Styles cho floating popup */
#cucu-note-popup {
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cucu-popup-btn {
background: #4F46E5;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.cucu-popup-btn:hover {
background: #4338CA;
}
.cucu-popup-btn:active {
background: #3730A3;
}
/* CuCu Note Extension Popup Styles - Reuse từ frontend */
:root {
--background: oklch(0.9818 0.0054 95.0986);
--foreground: oklch(0.2438 0.0269 95.7226);
--primary: oklch(0.45 0.08 250);
--primary-foreground: oklch(0.9818 0.0054 95.0986);
--muted: oklch(0.9341 0.0153 90.239);
--muted-foreground: oklch(0.5559 0.0075 97.4233);
--border: oklch(0.8847 0.0069 97.3627);
--input: oklch(0.7621 0.0156 98.3528);
--ring: oklch(0.45 0.08 250);
--radius: 0.5rem;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background: var(--background);
color: var(--foreground);
font-size: 14px;
line-height: 1.5;
}
/* Button Styles (từ frontend button.tsx) */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
border-radius: calc(var(--radius) - 2px);
font-size: 14px;
font-weight: 500;
transition: all 150ms;
cursor: pointer;
border: none;
padding: 8px 16px;
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:active {
opacity: 0.8;
}
.btn-secondary {
background: transparent;
color: var(--foreground);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--muted);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Input Styles */
.input {
display: flex;
width: 100%;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--input);
background: var(--background);
padding: 8px 12px;
font-size: 14px;
transition: border-color 150ms;
font-family: inherit;
}
.input:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px var(--ring);
}
.input::placeholder {
color: var(--muted-foreground);
}
/* Textarea Styles */
.textarea {
display: flex;
width: 100%;
min-height: 100px;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--input);
background: var(--background);
padding: 8px 12px;
font-size: 14px;
transition: border-color 150ms;
font-family: inherit;
resize: vertical;
}
.textarea:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px var(--ring);
}
.textarea::placeholder {
color: var(--muted-foreground);
}
/* Label Styles */
.label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: var(--foreground);
}
.label-hint {
font-size: 12px;
color: var(--muted-foreground);
margin-top: 4px;
}
/* Card/Container Styles */
.card {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
/* Error Message */
.error {
padding: 12px;
margin-bottom: 16px;
background: oklch(0.35 0.02 250 / 0.1);
color: oklch(0.35 0.02 250);
border-radius: calc(var(--radius) - 2px);
font-size: 14px;
}
/* Source Info */
.source-info {
font-size: 12px;
color: var(--muted-foreground);
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.source-info a {
color: var(--primary);
text-decoration: none;
}
.source-info a:hover {
text-decoration: underline;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CuCu Note</title>
<link rel="stylesheet" href="./popup.css">
<style>
body {
margin: 0;
padding: 0;
width: 500px;
min-height: 400px;
}
</style>
</head>
<body>
<div id="popup-root"></div>
<script type="module" src="./popup.tsx"></script>
</body>
</html>
/**
* Popup Component - Hiện khi click icon extension
*/
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { NoteForm } from '../components/NoteForm';
interface PendingNote {
text: string;
url: string;
title: string;
}
function Popup() {
const [pendingNote, setPendingNote] = useState<PendingNote | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Lấy pending note từ storage (nếu có)
chrome.storage.local.get(['pendingNote'], async (result) => {
if (result.pendingNote) {
const note = result.pendingNote;
setPendingNote(note);
// Auto save ngay khi popup mở (nếu là từ Space key)
try {
const { createMemo } = await import('../shared/api-client');
// Parse tags từ URL hoặc domain
const domain = new URL(note.url).hostname.replace('www.', '');
const tagList = [domain, 'web-highlight'];
// Add source info vào content
const contentWithSource = `${note.text}\n\n---\nSource: [${note.title}](${note.url})`;
await createMemo({
content: contentWithSource,
tags: tagList,
});
// Success - close popup
chrome.storage.local.remove(['pendingNote']);
window.close();
} catch (error) {
console.error('Error auto-saving:', error);
// Nếu lỗi, hiện form để user save thủ công
setLoading(false);
}
} else {
setLoading(false);
}
});
}, []);
if (loading) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<div>Loading...</div>
</div>
);
}
if (pendingNote) {
// Hiện form với data từ content script
return (
<NoteForm
initialText={pendingNote.text}
initialUrl={pendingNote.url}
initialTitle={pendingNote.title}
onSave={() => {
// Success - có thể show message hoặc close
window.close();
}}
onCancel={() => {
window.close();
}}
/>
);
}
// Default: Quick note form (không có text từ selection)
return (
<div className="card">
<h2 style={{ marginTop: 0, marginBottom: '12px', fontSize: '20px', fontWeight: '600' }}>
📝 CuCu Note
</h2>
<p style={{ color: 'var(--muted-foreground)', fontSize: '14px', marginBottom: '20px' }}>
Bôi đen text trên web → nhấn Space → tự động lưu note!
</p>
<NoteForm
initialText=""
initialUrl={typeof window !== 'undefined' ? window.location.href : ''}
initialTitle={typeof document !== 'undefined' ? document.title : 'CuCu Note'}
onSave={() => window.close()}
onCancel={() => window.close()}
/>
</div>
);
}
// Render
const container = document.getElementById('popup-root');
if (container) {
const root = createRoot(container);
root.render(<Popup />);
}
/**
* API Client - Gọi backend CuCu Note
* Reuse logic từ frontend nếu có thể
*/
// Default API base URL - can be overridden via chrome.storage
// In production, this should be set via extension settings or build-time config
const DEFAULT_API_BASE_URL = 'http://localhost:5000';
// Lấy API base từ storage hoặc dùng default
async function getApiBase(): Promise<string> {
const result = await chrome.storage.local.get(['apiBaseUrl']);
const baseUrl = result.apiBaseUrl || DEFAULT_API_BASE_URL;
return `${baseUrl}/api/v1`;
}
// Export để có thể set từ popup/settings
export async function setApiBaseUrl(url: string): Promise<void> {
await chrome.storage.local.set({ apiBaseUrl: url });
}
export interface CreateMemoRequest {
content: string;
tags?: string[];
visibility?: string;
}
export interface MemoResponse {
id: string;
content: string;
tags: string[];
created_at: string;
}
export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse> {
const token = await getAuthToken();
const apiBase = await getApiBase();
// Debug: Log token status (không log full token vì security)
if (token) {
console.log('[CuCu Note] ✅ Auth token found, length:', token.length);
} else {
console.warn('[CuCu Note] ⚠️ No auth token found! Request will be unauthenticated.');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Gửi Clerk token trong Authorization header (format: Bearer <token>)
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${apiBase}/memos`, {
method: 'POST',
headers,
body: JSON.stringify({
content: data.content,
tags: data.tags || [],
visibility: data.visibility || 'PRIVATE',
}),
});
if (!response.ok) {
const error = await response.text();
console.error('[CuCu Note] ❌ API Error:', response.status, error);
throw new Error(`Failed to create memo: ${error}`);
}
const result = await response.json();
console.log('[CuCu Note] ✅ Memo saved successfully:', result.id);
return result;
}
async function getAuthToken(): Promise<string | null> {
// Lấy Clerk session token từ storage
// Frontend có thể lưu token vào chrome.storage.local khi user login
const result = await chrome.storage.local.get(['clerkSessionToken', 'authToken']);
// Ưu tiên Clerk token
if (result.clerkSessionToken) {
return result.clerkSessionToken;
}
// Fallback về authToken cũ
return result.authToken || null;
}
/**
* Lấy Clerk token từ frontend page (nếu đang ở trang frontend)
* Inject script vào page để access window.Clerk
*/
export async function syncClerkTokenFromPage(): Promise<string | null> {
try {
// Inject script vào page để lấy Clerk token
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id) return null;
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: async () => {
// Access window.Clerk từ page context
if (typeof window !== 'undefined' && (window as any).Clerk?.session?.getToken) {
try {
const token = await (window as any).Clerk.session.getToken();
return token || null;
} catch {
return null;
}
}
return null;
},
});
const token = results[0]?.result || null;
if (token) {
// Lưu token vào storage
await chrome.storage.local.set({ clerkSessionToken: token });
}
return token;
} catch (error) {
console.error('[CuCu Note] Error syncing Clerk token:', error);
return null;
}
}
export async function setAuthToken(token: string): Promise<void> {
await chrome.storage.local.set({ authToken: token });
}
# Test Extension với Playwright
## Cách test Extension
Extension chạy trong **isolated context**, nên Playwright không thể test trực tiếp content script. Nhưng có thể test:
1. **Test popup** (khi click icon extension)
2. **Test xem content script có inject không** (check DOM)
## Test thủ công (nhanh nhất)
1. **Reload extension** trong `chrome://extensions`
2. **Mở một trang web** (ví dụ: https://example.com)
3. **Bôi đen text** → Sidebar sẽ hiện bên phải
4. **Nhấn Space** → Form sẽ mở với text đã điền
## Test với Playwright (nếu cần)
Có thể test popup bằng cách:
- Load extension vào Chrome profile
- Navigate đến trang web
- Check xem sidebar có xuất hiện không khi select text
---
## ✅ Đã cải thiện
### Sidebar thay vì popup nhỏ:
- ✅ Sidebar 400px width, full height
- ✅ Hiện bên phải màn hình
- ✅ Preview text đã chọn
- ✅ Nút Save + Close
- ✅ Animation slide in mượt mà
- ✅ Không auto-ẩn (ở đó cho đến khi user đóng)
### Tính năng:
- ✅ Bôi đen text → Sidebar hiện
- ✅ Nhấn Space → Tự động mở form
- ✅ Click Save → Mở form với text đã điền
---
## 🚀 Test ngay
1. **Reload extension**: `chrome://extensions` → Reload CuCu Note
2. **Mở trang web**: https://example.com
3. **Bôi đen text** → Sidebar sẽ hiện bên phải!
4. **Nhấn Space** hoặc click "Save Note" → Form mở
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["chrome"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';
export default defineConfig({
plugins: [
react(),
crx({ manifest }),
],
build: {
outDir: 'dist',
rollupOptions: {
input: {
popup: 'src/popup/popup.html',
},
},
},
});
# Git
.git
.gitignore
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
# Build
dist/
build/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
.env.local
# Testing
tests/
coverage/
# Logs
*.log
# OS
.DS_Store
Thumbs.db
# Documentation
*.md
!README.md
!ENV_VARIABLES.md
# Docker
Dockerfile*
docker-compose*.yml
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY pnpm-lock.yaml* ./
# Install dependencies
RUN if [ -f pnpm-lock.yaml ]; then \
npm install -g pnpm && pnpm install; \
else \
npm install; \
fi
# Copy source code
COPY . .
# Expose port
EXPOSE 3001
# Start development server
CMD if [ -f pnpm-lock.yaml ]; then \
pnpm dev --host 0.0.0.0 --port 3001; \
else \
npm run dev -- --host 0.0.0.0 --port 3001; \
fi
# Multi-stage build for production
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY pnpm-lock.yaml* ./
# Install pnpm if pnpm-lock.yaml exists, otherwise use npm
RUN if [ -f pnpm-lock.yaml ]; then \
npm install -g pnpm && pnpm install --frozen-lockfile; \
else \
npm ci; \
fi
# Copy source code
COPY . .
# Build the application
ARG VITE_API_BASE_URL=http://localhost:5000
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN if [ -f pnpm-lock.yaml ]; then \
pnpm build; \
else \
npm run build; \
fi
# Production stage with Nginx
FROM nginx:alpine
# Copy built files from builder
COPY --from=builder /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;"]
# Frontend Environment Variables
This document describes all environment variables used by the frontend.
## Quick Start
1. Create a `.env` file in the `frontend` directory
2. Add the variables below with your values
3. **DO NOT commit `.env` file to version control**
## Required Variables
### API Configuration
```bash
# Backend API base URL
VITE_API_BASE_URL=http://localhost:5000
```
For production:
```bash
VITE_API_BASE_URL=https://api.yourdomain.com
```
## Optional Variables
### Development Server
- `VITE_PORT` - Development server port (default: `3001`)
- `DEV_PROXY_SERVER` - Proxy server for Vite dev server (default: same as `VITE_API_BASE_URL`)
## Usage
All environment variables in Vite must be prefixed with `VITE_` to be accessible in the browser.
Access in code:
```typescript
const apiUrl = import.meta.env.VITE_API_BASE_URL;
```
## Production Build
When building for production, ensure `VITE_API_BASE_URL` points to your production API server.
Build command:
```bash
npm run build
# or
pnpm build
```
The built files will be in the `dist` directory.
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Don't cache index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
import { KeyIcon, Trash2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { extractUserIdFromName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import { handleError } from "@/lib/error";
import { userServiceClient } from "@/service";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
const OpenAIKeySection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [apiKey, setApiKey] = useState("");
const [maskedKey, setMaskedKey] = useState<string | null>(null);
const [hasKey, setHasKey] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Get user ID from user name
const userId = currentUser?.name ? extractUserIdFromName(currentUser.name) : currentUser?.id || "";
// Load current key status on mount
useEffect(() => {
if (userId) {
loadKeyStatus();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
const loadKeyStatus = async () => {
if (!userId) return;
setIsLoading(true);
try {
const result = await userServiceClient.getOpenAIKey(userId);
setHasKey(result.has_key);
setMaskedKey(result.masked_key);
} catch (error) {
handleError(error, toast.error, {
context: "Failed to load OpenAI key status",
});
} finally {
setIsLoading(false);
}
};
const validateKey = (key: string): boolean => {
if (!key || !key.trim()) {
return false;
}
const trimmed = key.trim();
if (!trimmed.startsWith("sk-")) {
return false;
}
if (trimmed.length < 20 || trimmed.length > 100) {
return false;
}
return true;
};
const handleSave = async () => {
if (!userId) {
toast.error("User not found");
return;
}
if (!validateKey(apiKey)) {
toast.error("Invalid OpenAI API key format. Key must start with 'sk-' and be 20-100 characters.");
return;
}
setIsSaving(true);
try {
const result = await userServiceClient.updateOpenAIKey(userId, apiKey.trim());
setApiKey("");
setHasKey(true);
setMaskedKey(result.masked_key);
toast.success(result.message || "OpenAI API key saved successfully");
} catch (error) {
handleError(error, toast.error, {
context: "Failed to save OpenAI key",
});
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!userId) {
toast.error("User not found");
return;
}
if (!confirm("Are you sure you want to delete your OpenAI API key? This will disable personalized embeddings.")) {
return;
}
setIsDeleting(true);
try {
await userServiceClient.deleteOpenAIKey(userId);
setHasKey(false);
setMaskedKey(null);
setApiKey("");
toast.success("OpenAI API key deleted successfully");
} catch (error) {
handleError(error, toast.error, {
context: "Failed to delete OpenAI key",
});
} finally {
setIsDeleting(false);
}
};
if (isLoading) {
return (
<SettingSection>
<SettingGroup title="OpenAI API Key">
<p className="text-sm text-muted-foreground">Loading...</p>
</SettingGroup>
</SettingSection>
);
}
return (
<SettingSection>
<SettingGroup title="OpenAI API Key">
<div className="flex flex-col gap-4">
<div className="text-sm text-muted-foreground">
Enter your OpenAI API key to enable personalized embeddings and AI features.
Your key will be encrypted and stored securely.
</div>
{hasKey && maskedKey && (
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
<KeyIcon className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-mono">{maskedKey}</span>
<span className="text-xs text-muted-foreground ml-auto">Key saved</span>
</div>
)}
<SettingRow label="API Key">
<div className="flex flex-col gap-2 w-full">
<Input
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="font-mono"
disabled={isSaving}
/>
<div className="flex gap-2">
<Button
onClick={handleSave}
disabled={!apiKey.trim() || !validateKey(apiKey) || isSaving}
size="sm"
>
{isSaving ? "Saving..." : hasKey ? "Update Key" : "Save Key"}
</Button>
{hasKey && (
<Button
onClick={handleDelete}
variant="destructive"
size="sm"
disabled={isDeleting}
>
<Trash2Icon className="w-4 h-4 mr-1" />
{isDeleting ? "Deleting..." : "Delete"}
</Button>
)}
</div>
</div>
</SettingRow>
<div className="text-xs text-muted-foreground">
<p>• Your key is encrypted before storage</p>
<p>• Your key takes priority over system default key</p>
<p>• You can delete your key anytime to use system default</p>
</div>
</div>
</SettingGroup>
</SettingSection>
);
};
export default OpenAIKeySection;
import { useEffect, useState } from "react";
/**
* Custom hook for debouncing a value
* @param value - The value to debounce
* @param delay - Delay in milliseconds (default: 300ms)
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
...@@ -40,8 +40,9 @@ export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}) { ...@@ -40,8 +40,9 @@ export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}) {
}, },
initialPageParam: "", initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined, getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined,
staleTime: 5000, // 5 seconds - quick enough for updates, prevents excessive refetching staleTime: 1000 * 30, // 30 seconds - optimized to reduce excessive refetching
gcTime: 1000 * 60 * 5, // Keep unused data in cache for 5 minutes gcTime: 1000 * 60 * 5, // Keep unused data in cache for 5 minutes
refetchOnWindowFocus: false, // Prevent refetch on window focus
}); });
} }
...@@ -53,7 +54,8 @@ export function useMemo(name: string, options?: { enabled?: boolean }) { ...@@ -53,7 +54,8 @@ export function useMemo(name: string, options?: { enabled?: boolean }) {
return memo; return memo;
}, },
enabled: options?.enabled ?? true, enabled: options?.enabled ?? true,
staleTime: 1000 * 10, // 10 seconds - reduced to prevent stale data in collaborative editing staleTime: 1000 * 60, // 60 seconds - optimized for better performance
refetchOnWindowFocus: false, // Prevent unnecessary refetches
}); });
} }
......
...@@ -320,6 +320,7 @@ ...@@ -320,6 +320,7 @@
}, },
"my-account": "My Account", "my-account": "My Account",
"preference": "Preferences", "preference": "Preferences",
"openai-key": "OpenAI API Key",
"preference-section": { "preference-section": {
"default-memo-sort-option": "Memo display time", "default-memo-sort-option": "Memo display time",
"default-memo-visibility": "Default memo visibility", "default-memo-visibility": "Default memo visibility",
......
...@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"; ...@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { attachmentServiceClient } from "@/service"; import { attachmentServiceClient } from "@/service";
import { useDeleteAttachment } from "@/hooks/useAttachmentQueries"; import { useDeleteAttachment } from "@/hooks/useAttachmentQueries";
import { useDebounce } from "@/hooks/useDebounce";
import useDialog from "@/hooks/useDialog"; import useDialog from "@/hooks/useDialog";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useMediaQuery from "@/hooks/useMediaQuery"; import useMediaQuery from "@/hooks/useMediaQuery";
...@@ -80,8 +81,11 @@ const Attachments = () => { ...@@ -80,8 +81,11 @@ const Attachments = () => {
const [nextPageToken, setNextPageToken] = useState(""); const [nextPageToken, setNextPageToken] = useState("");
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
// Debounce search query to reduce filtering operations
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// Memoized computed values // Memoized computed values
const filteredAttachments = useMemo(() => filterAttachments(attachments, searchQuery), [attachments, searchQuery]); const filteredAttachments = useMemo(() => filterAttachments(attachments, debouncedSearchQuery), [attachments, debouncedSearchQuery]);
const usedAttachments = useMemo(() => filteredAttachments.filter((attachment) => attachment.memo), [filteredAttachments]); const usedAttachments = useMemo(() => filteredAttachments.filter((attachment) => attachment.memo), [filteredAttachments]);
......
import { CogIcon, DatabaseIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react"; import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import MobileHeader from "@/components/MobileHeader"; import MobileHeader from "@/components/MobileHeader";
...@@ -6,6 +6,7 @@ import InstanceSection from "@/components/Settings/InstanceSection"; ...@@ -6,6 +6,7 @@ import InstanceSection from "@/components/Settings/InstanceSection";
import MemberSection from "@/components/Settings/MemberSection"; import MemberSection from "@/components/Settings/MemberSection";
import MemoRelatedSettings from "@/components/Settings/MemoRelatedSettings"; import MemoRelatedSettings from "@/components/Settings/MemoRelatedSettings";
import MyAccountSection from "@/components/Settings/MyAccountSection"; import MyAccountSection from "@/components/Settings/MyAccountSection";
import OpenAIKeySection from "@/components/Settings/OpenAIKeySection";
import PreferencesSection from "@/components/Settings/PreferencesSection"; import PreferencesSection from "@/components/Settings/PreferencesSection";
import SectionMenuItem from "@/components/Settings/SectionMenuItem"; import SectionMenuItem from "@/components/Settings/SectionMenuItem";
import StorageSection from "@/components/Settings/StorageSection"; import StorageSection from "@/components/Settings/StorageSection";
...@@ -17,17 +18,18 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb"; ...@@ -17,17 +18,18 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { User_Role } from "@/types/proto/api/v1/user_service_pb"; import { User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
type SettingSection = "my-account" | "preference" | "member" | "system" | "memo-related" | "storage"; type SettingSection = "my-account" | "preference" | "openai-key" | "member" | "system" | "memo-related" | "storage";
interface State { interface State {
selectedSection: SettingSection; selectedSection: SettingSection;
} }
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference"]; const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "openai-key"];
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo-related", "storage"]; const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo-related", "storage"];
const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = { const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
"my-account": UserIcon, "my-account": UserIcon,
preference: CogIcon, preference: CogIcon,
"openai-key": KeyIcon,
member: UsersIcon, member: UsersIcon,
system: Settings2Icon, system: Settings2Icon,
"memo-related": LibraryIcon, "memo-related": LibraryIcon,
...@@ -142,6 +144,8 @@ const Setting = () => { ...@@ -142,6 +144,8 @@ const Setting = () => {
<MyAccountSection /> <MyAccountSection />
) : state.selectedSection === "preference" ? ( ) : state.selectedSection === "preference" ? (
<PreferencesSection /> <PreferencesSection />
) : state.selectedSection === "openai-key" ? (
<OpenAIKeySection />
) : state.selectedSection === "member" ? ( ) : state.selectedSection === "member" ? (
<MemberSection /> <MemberSection />
) : state.selectedSection === "system" ? ( ) : state.selectedSection === "system" ? (
......
...@@ -184,5 +184,22 @@ export const userServiceClient = { ...@@ -184,5 +184,22 @@ export const userServiceClient = {
void _request; void _request;
return; return;
}, },
// OpenAI API Key Management
async getOpenAIKey(userId: string): Promise<{ has_key: boolean; masked_key: string | null }> {
return fetchJson<{ has_key: boolean; masked_key: string | null }>(`/users/${userId}/openai-key`, {
method: "GET",
});
},
async updateOpenAIKey(userId: string, apiKey: string): Promise<{ success: boolean; message: string; masked_key: string }> {
return fetchJson<{ success: boolean; message: string; masked_key: string }>(`/users/${userId}/openai-key`, {
method: "PUT",
body: { api_key: apiKey },
});
},
async deleteOpenAIKey(userId: string): Promise<{ success: boolean; message: string }> {
return fetchJson<{ success: boolean; message: string }>(`/users/${userId}/openai-key`, {
method: "DELETE",
});
},
}; };
...@@ -3,18 +3,21 @@ import { resolve } from "path"; ...@@ -3,18 +3,21 @@ import { resolve } from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
let devProxyServer = "http://localhost:5000"; // Get dev proxy server from environment variable
if (process.env.DEV_PROXY_SERVER && process.env.DEV_PROXY_SERVER.length > 0) { const devProxyServer = process.env.DEV_PROXY_SERVER || process.env.VITE_API_BASE_URL || "http://localhost:5000";
console.log("Use devProxyServer from environment: ", process.env.DEV_PROXY_SERVER); if (process.env.DEV_PROXY_SERVER || process.env.VITE_API_BASE_URL) {
devProxyServer = process.env.DEV_PROXY_SERVER; console.log("Using devProxyServer from environment: ", devProxyServer);
} }
// Get port from environment variable
const port = parseInt(process.env.VITE_PORT || process.env.PORT || "3001", 10);
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 3001, port: port,
proxy: { proxy: {
"^/api": { "^/api": {
target: devProxyServer, target: devProxyServer,
......
# CuCu Note - Privacy-First Note-Taking Application
A modern, privacy-first note-taking application with AI-powered features, built with FastAPI (backend) and React (frontend).
## 🚀 Features
### Core Features
- **📝 Memo Management**: Create, edit, delete, and organize memos with rich markdown support
- **🔍 Semantic Search**: AI-powered semantic search using embeddings for finding related memos
- **🏷️ Tag System**: Organize memos with tags and tag-based filtering
- **🔗 Memo Relations**: Link related memos and visualize relationships with force graph
- **📎 Attachments**: Upload and manage file attachments
- **🌐 Multi-language Support**: Support for 30+ languages
- **🌓 Theme Support**: Light, dark, and system theme modes
- **📱 Responsive Design**: Works seamlessly on desktop and mobile devices
### AI Features
- **🤖 AI Chatbot**: Intelligent chatbot for note assistance
- **🧠 Embeddings**: Automatic memo embeddings for semantic search
- **🔑 User API Keys**: Users can input their own OpenAI API keys for personalized embeddings
- **💬 Conversation History**: Persistent chat history with pagination
### Security & Privacy
- **🔐 Clerk Authentication**: Secure authentication with Clerk
- **🔒 Encrypted API Keys**: User API keys are encrypted before storage
- **🛡️ Access Control**: User-specific memo visibility (Private, Protected, Public)
- **🔑 Encryption**: Fernet encryption for sensitive data
### Performance Optimizations
- **⚡ MongoDB Connection Pooling**: Optimized database connections
- **💾 Redis Caching**: Response and embedding caching
- **🚦 Rate Limiting**: Configurable rate limiting with Redis backend
- **📊 Database Indexes**: Automatic index creation for common queries
- **🔄 Query Optimization**: Optimized React Query staleTime and refetch behavior
### Extension
- **🌐 Chrome Extension**: Browser extension for quick note-taking
## 📋 Tech Stack
### Backend
- **FastAPI**: Modern Python web framework
- **MongoDB**: NoSQL database with Motor async driver
- **Redis**: Caching and rate limiting
- **OpenAI**: Embeddings and AI features
- **Clerk**: Authentication service
- **LangChain**: LLM orchestration
- **LangGraph**: Agent workflows
### Frontend
- **React 18**: UI framework
- **TypeScript**: Type safety
- **Vite**: Build tool
- **TanStack Query**: Data fetching and caching
- **Tailwind CSS**: Styling
- **Radix UI**: Accessible component primitives
- **React Router**: Routing
- **i18next**: Internationalization
## 🛠️ Installation
### Prerequisites
- Python 3.11+
- Node.js 18+
- MongoDB 6+
- Redis 6+ (optional but recommended)
- Docker & Docker Compose (for containerized deployment)
### Quick Start with Docker
1. **Clone the repository**
```bash
git clone https://github.com/Hoanganhvu123/cuccu_note.git
cd cuccu_note
```
2. **Set up environment variables**
Create `.env` files:
**Backend `.env`** (in `backend/` directory):
```bash
# MongoDB
MONGODB_URI=mongodb://mongodb:27017
MONGODB_DB_NAME=cucu_note
# Authentication
CLERK_SECRET_KEY=sk_test_your_clerk_secret_key
CLERK_JWKS_URL=https://your-clerk-instance.clerk.accounts.dev/.well-known/jwks.json
CLERK_ISSUER=https://your-clerk-instance.clerk.accounts.dev
# OpenAI
OPENAI_API_KEY=sk-your-openai-api-key
# Encryption (generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
ENCRYPTION_KEY=your-32-byte-base64-encoded-key
# Server
PORT=5000
# Redis (optional)
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_CACHE_URL=redis
RATE_STORAGE_URI=redis://redis:6379/0
# CORS
CORS_ORIGINS=http://localhost:3001,https://yourdomain.com
```
**Frontend `.env`** (in `frontend/` directory):
```bash
VITE_API_BASE_URL=http://localhost:5000
VITE_PORT=3001
```
3. **Run with Docker Compose**
```bash
docker-compose up -d
```
This will start:
- Backend API on `http://localhost:5000`
- Frontend on `http://localhost:3001`
- MongoDB on `mongodb://localhost:27017`
- Redis on `redis://localhost:6379`
### Manual Installation
#### Backend Setup
1. **Navigate to backend directory**
```bash
cd backend
```
2. **Create virtual environment**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install dependencies**
```bash
pip install -r requirements.txt
```
4. **Set up environment variables**
```bash
cp ENV_VARIABLES.md .env
# Edit .env with your values
```
5. **Run the server**
```bash
# Development
python run.py
# Production with Gunicorn
gunicorn --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:5000 --timeout 120 server:app
```
#### Frontend Setup
1. **Navigate to frontend directory**
```bash
cd frontend
```
2. **Install dependencies**
```bash
npm install
# or
pnpm install
```
3. **Set up environment variables**
```bash
# Create .env file
echo "VITE_API_BASE_URL=http://localhost:5000" > .env
```
4. **Run development server**
```bash
npm run dev
# or
pnpm dev
```
5. **Build for production**
```bash
npm run build
# or
pnpm build
```
## 🔑 Environment Variables
### Backend Environment Variables
#### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `MONGODB_URI` | MongoDB connection string | `mongodb://localhost:27017` |
| `MONGODB_DB_NAME` | Database name | `cucu_note` |
| `CLERK_SECRET_KEY` | Clerk secret key | `sk_test_...` |
| `CLERK_JWKS_URL` | Clerk JWKS URL | `https://...clerk.accounts.dev/.well-known/jwks.json` |
| `CLERK_ISSUER` | Clerk issuer URL | `https://...clerk.accounts.dev` |
| `OPENAI_API_KEY` | OpenAI API key | `sk-...` |
| `ENCRYPTION_KEY` | Fernet encryption key (32-byte base64) | Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
#### Optional Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `5000` |
| `MONGODB_MAX_POOL_SIZE` | MongoDB max pool size | `50` |
| `MONGODB_MIN_POOL_SIZE` | MongoDB min pool size | `10` |
| `MONGODB_MAX_IDLE_TIME_MS` | MongoDB max idle time (ms) | `45000` |
| `REDIS_HOST` | Redis host | `localhost` |
| `REDIS_PORT` | Redis port | `6379` |
| `REDIS_CACHE_URL` | Redis cache host | `localhost` |
| `REDIS_CACHE_DB` | Redis cache database | `2` |
| `CORS_ORIGINS` | CORS allowed origins (comma-separated) | `*` |
| `RATE_LIMIT_GUEST` | Daily message limit for guests | `10` |
| `RATE_LIMIT_USER` | Daily message limit for users | `100` |
| `RATE_STORAGE_URI` | Rate limit storage URI | `memory://` |
| `DISABLE_AUTH` | Disable authentication (dev only) | `false` |
| `GOOGLE_API_KEY` | Google API key (optional) | - |
| `GROQ_API_KEY` | Groq API key (optional) | - |
| `DEFAULT_MODEL` | Default AI model | `gpt-4o-mini` |
| `LANGFUSE_PUBLIC_KEY` | Langfuse public key (optional) | - |
| `LANGFUSE_SECRET_KEY` | Langfuse secret key (optional) | - |
For complete documentation, see [backend/ENV_VARIABLES.md](./backend/ENV_VARIABLES.md)
### Frontend Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `VITE_API_BASE_URL` | Backend API base URL | `http://localhost:5000` |
| `VITE_PORT` | Development server port | `3001` |
| `VITE_DEV_PROXY_SERVER` | Proxy server for Vite dev | Same as `VITE_API_BASE_URL` |
For complete documentation, see [frontend/ENV_VARIABLES.md](./frontend/ENV_VARIABLES.md)
## 🐳 Docker Deployment
### Docker Compose
A complete `docker-compose.yml` is provided for easy deployment:
```bash
docker-compose up -d
```
### Individual Dockerfiles
#### Backend Dockerfile
**Development** (`backend/Dockerfile.dev`):
```bash
docker build -f backend/Dockerfile.dev -t cuccu-backend-dev ./backend
docker run -p 5000:5000 --env-file backend/.env cuccu-backend-dev
```
**Production** (`backend/Dockerfile.prod`):
```bash
docker build -f backend/Dockerfile.prod -t cuccu-backend-prod ./backend
docker run -p 5000:5000 --env-file backend/.env cuccu-backend-prod
```
#### Frontend Dockerfile
**Development**:
```bash
docker build -f frontend/Dockerfile.dev -t cuccu-frontend-dev ./frontend
docker run -p 3001:3001 --env-file frontend/.env cuccu-frontend-dev
```
**Production**:
```bash
docker build -f frontend/Dockerfile.prod -t cuccu-frontend-prod ./frontend
docker run -p 80:80 cuccu-frontend-prod
```
## 📚 API Documentation
Once the backend is running, API documentation is available at:
- Swagger UI: `http://localhost:5000/docs`
- ReDoc: `http://localhost:5000/redoc`
### Main API Endpoints
#### Memos
- `GET /api/v1/memos` - List memos
- `POST /api/v1/memos` - Create memo
- `GET /api/v1/memos/{memo_id}` - Get memo
- `PATCH /api/v1/memos/{memo_id}` - Update memo
- `DELETE /api/v1/memos/{memo_id}` - Delete memo
#### Users
- `GET /api/v1/users` - List users
- `GET /api/v1/users/{user_id}` - Get user
- `GET /api/v1/users/{user_id}/openai-key` - Get user's OpenAI key (masked)
- `PUT /api/v1/users/{user_id}/openai-key` - Update user's OpenAI key
- `DELETE /api/v1/users/{user_id}/openai-key` - Delete user's OpenAI key
#### Chatbot
- `POST /chat` - Chat with AI assistant
- `GET /history/{user_id}` - Get chat history
For complete API documentation, see [backend/readme.md](./backend/readme.md)
## 🏗️ Project Structure
```
cuccu_note/
├── backend/ # FastAPI backend
│ ├── api/ # API routes
│ │ ├── memos/ # Memo endpoints
│ │ └── chatbot/ # Chatbot endpoints
│ ├── common/ # Shared utilities
│ │ ├── memos_core/ # Memo core logic
│ │ ├── embedding_service.py
│ │ ├── encryption.py # Encryption utilities
│ │ └── ...
│ ├── agent/ # AI agent logic
│ ├── config.py # Configuration
│ ├── server.py # FastAPI app
│ ├── requirements.txt # Python dependencies
│ ├── Dockerfile.prod # Production Dockerfile
│ └── docker-compose.yml # Docker Compose config
├── frontend/ # React frontend
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── pages/ # Page components
│ │ ├── hooks/ # React hooks
│ │ ├── service/ # API client
│ │ └── ...
│ ├── package.json # Node dependencies
│ └── vite.config.mts # Vite config
├── extension/ # Chrome extension
│ └── src/ # Extension source
└── docker-compose.yml # Root Docker Compose
```
## 🔒 Security Best Practices
1. **Never commit `.env` files** - Add to `.gitignore`
2. **Use strong encryption keys** - Generate `ENCRYPTION_KEY` properly
3. **Restrict CORS origins** - Don't use `*` in production
4. **Enable authentication** - Set `DISABLE_AUTH=false` in production
5. **Use Redis for rate limiting** - Don't use `memory://` in production
6. **Rotate API keys regularly** - Especially for production
7. **Use HTTPS** - Always use HTTPS in production
8. **Monitor logs** - Set up logging and monitoring
## 🚀 Production Deployment
### Checklist
Before deploying to production:
- [ ] Set `MONGODB_URI` with production MongoDB
- [ ] Configure Clerk authentication
- [ ] Set `ENCRYPTION_KEY` (generate new key)
- [ ] Set `CORS_ORIGINS` to specific domains (not `*`)
- [ ] Configure Redis for caching and rate limiting
- [ ] Set `RATE_STORAGE_URI` to Redis (not `memory://`)
- [ ] Set `DISABLE_AUTH=false`
- [ ] Set all required API keys
- [ ] Use HTTPS/SSL certificates
- [ ] Set up monitoring and logging
- [ ] Configure backup for MongoDB
- [ ] Set up CI/CD pipeline
- [ ] Test all features thoroughly
### Recommended Production Setup
1. **Use reverse proxy** (Nginx/Traefik) for SSL termination
2. **Use managed MongoDB** (MongoDB Atlas) for database
3. **Use managed Redis** (Redis Cloud/ElastiCache) for caching
4. **Use container orchestration** (Kubernetes/Docker Swarm)
5. **Set up monitoring** (Prometheus/Grafana)
6. **Configure logging** (ELK stack or similar)
## 🧪 Testing
### Backend Tests
```bash
cd backend
pytest
```
### Frontend Tests
```bash
cd frontend
npm run test:e2e
```
## 📖 Documentation
- [Backend README](./backend/readme.md) - Backend API documentation
- [Backend ENV Variables](./backend/ENV_VARIABLES.md) - Environment variables
- [Frontend ENV Variables](./frontend/ENV_VARIABLES.md) - Frontend environment variables
- [Extension README](./extension/README.md) - Chrome extension setup
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## 📝 License
This project is licensed under the MIT License.
## 🙏 Acknowledgments
- Built with [FastAPI](https://fastapi.tiangolo.com/)
- Frontend powered by [React](https://react.dev/)
- UI components from [Radix UI](https://www.radix-ui.com/)
- Icons from [Lucide](https://lucide.dev/)
## 📞 Support
For issues and questions:
- GitHub Repository: [https://github.com/Hoanganhvu123/cuccu_note](https://github.com/Hoanganhvu123/cuccu_note)
- GitHub Issues: [https://github.com/Hoanganhvu123/cuccu_note/issues](https://github.com/Hoanganhvu123/cuccu_note/issues)
## 📦 Deployment
For detailed deployment instructions, see [DEPLOYMENT.md](./DEPLOYMENT.md)
### Quick Deploy
```bash
# Clone repository
git clone https://github.com/Hoanganhvu123/cuccu_note.git
cd cuccu_note
# Set up environment variables (see Environment Variables section above)
# Then deploy with Docker Compose
docker-compose up -d
```
---
**Made with ❤️ by the CuCu Note team**
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment