Compare commits
145 commits
cloud-prep
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d57edd84 | |||
|
d6378b8eb1 |
|||
|
|
8ed519b89a | ||
|
|
3781e4507a | ||
|
|
3c8d08dc03 | ||
|
|
18081a28ba | ||
|
|
995cfbd9b0 | ||
|
|
95ce7b343c | ||
|
|
44b6faaeb9 | ||
|
|
4a2a9a7489 | ||
|
|
9035e00da3 | ||
|
|
b0d4eb86c1 | ||
|
|
94d3a90438 | ||
|
|
7a5cbc3549 | ||
|
|
feb87873fb | ||
|
|
1412f8ac1f | ||
|
|
ee41c3fbd3 | ||
|
|
18b96382f2 | ||
|
|
e479439bb4 | ||
|
|
606c1dff53 | ||
| 5c16d12f77 | |||
| eb48d61dcf | |||
|
|
586e77de2f | ||
| 94f87b9fee | |||
| c46fdc5f5e | |||
|
|
933f6297a8 | ||
|
|
60f7e644ef | ||
|
|
c67cbb148b |
||
|
|
a6d9d8b5e8 | ||
|
|
9888e2dd0a | ||
|
|
3fc2beed58 | ||
|
|
d1cddf2690 | ||
|
|
ded3e57e29 | ||
|
|
3f893e71b0 | ||
| 337703c4a0 | |||
|
|
893e857fd5 | ||
| 542ba32cda | |||
|
|
ba4444cc11 | ||
|
|
724fb69c87 | ||
| 309e0f6b73 | |||
| 8917a4d1a5 | |||
|
|
1bd7a716fd | ||
|
|
4ddd3e2dce | ||
|
|
d4c4fb2fe7 | ||
|
|
9ff012dd1d | ||
|
|
a9dd6c032e | ||
|
|
db99d625c4 | ||
|
|
38532056e5 | ||
|
|
7b0e86ea1b | ||
|
|
e44bc95a4e | ||
|
|
de632411a8 | ||
|
|
a03969e497 | ||
|
|
54169c2b93 | ||
|
|
381b7b8858 | ||
| 7785047976 | |||
|
|
b61b6fa884 | ||
|
|
8cda296143 | ||
|
|
af2367d8f3 | ||
|
|
1912ab2e53 | ||
|
|
da6ab3a782 | ||
|
|
11e7d9b140 | ||
|
|
ed079e9b2c | ||
|
|
af82b71657 | ||
|
|
855fea6b66 | ||
|
|
d037f85d83 | ||
|
|
533e223a39 | ||
|
|
16eb5d3775 | ||
|
|
5eebd85357 | ||
|
|
dde800c31a | ||
| b8a087945e | |||
| 7ef0c87ded | |||
| 607b25aa62 | |||
|
|
529c1eef2b | ||
|
|
ea38d51e25 | ||
|
|
840767adff | ||
| 6fd667edb2 | |||
|
|
493d6644f2 | ||
|
|
40a3f96df4 | ||
|
|
50ca52e781 | ||
|
|
721296c7f1 | ||
|
|
c95ccda282 | ||
|
76cb0e0536 |
|||
|
|
01c1594df1 | ||
| af7a246e77 | |||
|
|
d2c6fe7aec | ||
| 41d0451cce | |||
|
|
86b878cd60 | ||
|
|
ed31642cd3 | ||
|
|
833b304002 | ||
|
|
b214242d38 |
||
|
|
0f12533873 | ||
| 3661874789 | |||
|
|
47fc78057d | ||
|
|
88f79356f2 | ||
|
|
17679c609e | ||
|
|
2d7637ebcf | ||
|
|
6df73ca465 | ||
|
|
a32e0dd474 | ||
|
|
61f2cef7a8 | ||
|
|
5ca801bdd1 | ||
|
|
c9186ea923 | ||
|
|
9cd450e849 | ||
|
|
4989d30ec3 | ||
|
|
e8a971e879 | ||
| 848f27b29b | |||
| c9327b3ec3 | |||
|
|
2075c252f6 | ||
|
|
47bcd0aa5f | ||
|
d044eb5b06 |
|||
| 5e0de6e894 | |||
|
|
f730609f62 | ||
|
|
60ac72d41f | ||
|
|
cfd2bac2ae | ||
|
|
fc3e2b92e9 | ||
|
|
0eca3ae87c | ||
| 71c87e8468 | |||
|
|
734ecf8293 | ||
|
|
151a354518 | ||
|
|
066bab50ba | ||
|
|
eb17df8788 | ||
|
|
e49a90cf9f | ||
|
|
0b4125f3ca |
||
|
|
59c9d1d271 | ||
|
|
f3ecc01385 | ||
|
|
72b08a7bce | ||
|
|
807d1f27ac | ||
|
|
413c9766b9 | ||
|
|
3bcd4087de | ||
|
|
691d2f09fd | ||
| c4fc66e520 | |||
|
|
e3e2b2b501 | ||
|
|
f424a319ba | ||
| e9e5804ea1 | |||
|
|
32b7cc5ccb | ||
| 42a012ca9c | |||
|
|
97d373ed87 | ||
|
|
657a46f601 | ||
|
|
55898b7bdd | ||
|
|
5778b63e8e | ||
|
|
074ad0c3c3 | ||
|
|
fa78ffc1b7 | ||
|
|
dc8203967a | ||
|
|
5a9da85f07 | ||
|
|
64a70357eb | ||
|
|
3df311634d |
46 changed files with 10243 additions and 10692 deletions
80
.github/workflows/build-and-deploy.yml
vendored
80
.github/workflows/build-and-deploy.yml
vendored
|
|
@ -2,14 +2,12 @@
|
||||||
|
|
||||||
name: Build and Deploy
|
name: Build and Deploy
|
||||||
|
|
||||||
# This workflow runs only on pushes to the 'main' branch
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main", "rust-dev"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
# Set permissions for the job to read contents and write to GitHub Packages
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
@ -21,6 +19,10 @@ jobs:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set repo name to lowercase
|
||||||
|
id: repo_name
|
||||||
|
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|
@ -28,53 +30,81 @@ jobs:
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# --- NEW STEP TO FIX THE CACHING ERROR ---
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Create web-app .env file
|
||||||
id: meta
|
run: echo 'GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}' > web-app/.env
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
ghcr.io/${{ github.repository }}/web-app
|
|
||||||
ghcr.io/${{ github.repository }}/rust-engine
|
|
||||||
|
|
||||||
# --- Build and push one image for each service ---
|
|
||||||
- name: Build and push web-app image 🚀
|
- name: Build and push web-app image 🚀
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./web-app
|
context: ./web-app
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags_web-app }}
|
tags: ghcr.io/${{ steps.repo_name.outputs.name }}/web-app:${{ github.sha }}
|
||||||
labels: ${{ steps.meta.outputs.labels_web-app }}
|
cache-from: type=gha,scope=web-app
|
||||||
cache-from: type=gha
|
cache-to: type=gha,mode=max,scope=web-app
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Build and push Rust engine image ⚙️
|
- name: Build and push Rust engine image ⚙️
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./rust-engine
|
context: ./rust-engine
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags_rust-engine }}
|
tags: ghcr.io/${{ steps.repo_name.outputs.name }}/rust-engine:${{ github.sha }}
|
||||||
labels: ${{ steps.meta.outputs.labels_rust-engine }}
|
cache-from: type=gha,scope=rust-engine
|
||||||
cache-from: type=gha
|
cache-to: type=gha,mode=max,scope=rust-engine
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
# --- Deploy the new images to your server ---
|
- name: Ensure remote deploy directory exists
|
||||||
- name: Deploy to server via SSH ☁️
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SERVER_HOST }}
|
host: ${{ secrets.SERVER_HOST }}
|
||||||
username: ${{ secrets.SERVER_USERNAME }}
|
username: ${{ secrets.SERVER_USERNAME }}
|
||||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
script: |
|
script: |
|
||||||
cd /var/www/codered-astra
|
mkdir -p /home/github-actions/codered-astra
|
||||||
|
|
||||||
|
- name: Upload compose files to server
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SERVER_HOST }}
|
||||||
|
username: ${{ secrets.SERVER_USERNAME }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
source: "docker-compose.yml,docker-compose.prod.yml,rust-engine/demo-data"
|
||||||
|
target: "/home/github-actions/codered-astra/"
|
||||||
|
|
||||||
|
- name: Deploy to server via SSH ☁️
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
env:
|
||||||
|
RUNNER_GH_ACTOR: ${{ github.actor }}
|
||||||
|
RUNNER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SERVER_HOST }}
|
||||||
|
username: ${{ secrets.SERVER_USERNAME }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
# pass selected env vars to the remote shell so docker login works
|
||||||
|
envs: RUNNER_GITHUB_TOKEN,RUNNER_GH_ACTOR
|
||||||
|
debug: true
|
||||||
|
script: |
|
||||||
|
cd /home/github-actions/codered-astra
|
||||||
|
chmod -R o+rX rust-engine/demo-data
|
||||||
|
# wrapper to support both Docker Compose v2 and legacy v1
|
||||||
|
compose() { docker compose "$@" || docker-compose "$@"; }
|
||||||
|
# Log in to GHCR using the run's GITHUB_TOKEN so compose can pull images.
|
||||||
|
if [ -n "$RUNNER_GITHUB_TOKEN" ] && [ -n "$RUNNER_GH_ACTOR" ]; then
|
||||||
|
echo "$RUNNER_GITHUB_TOKEN" | docker login ghcr.io -u "$RUNNER_GH_ACTOR" --password-stdin || true
|
||||||
|
fi
|
||||||
|
export REPO_NAME_LOWER='${{ steps.repo_name.outputs.name }}'
|
||||||
export GEMINI_API_KEY='${{ secrets.GEMINI_API_KEY }}'
|
export GEMINI_API_KEY='${{ secrets.GEMINI_API_KEY }}'
|
||||||
export MYSQL_DATABASE='${{ secrets.MYSQL_DATABASE }}'
|
export MYSQL_DATABASE='${{ secrets.MYSQL_DATABASE }}'
|
||||||
export MYSQL_USER='${{ secrets.MYSQL_USER }}'
|
export MYSQL_USER='${{ secrets.MYSQL_USER }}'
|
||||||
export MYSQL_PASSWORD='${{ secrets.MYSQL_PASSWORD }}'
|
export MYSQL_PASSWORD='${{ secrets.MYSQL_PASSWORD }}'
|
||||||
export MYSQL_ROOT_PASSWORD='${{ secrets.MYSQL_ROOT_PASSWORD }}'
|
export MYSQL_ROOT_PASSWORD='${{ secrets.MYSQL_ROOT_PASSWORD }}'
|
||||||
export IMAGE_TAG=${{ github.sha }}
|
export IMAGE_TAG=${{ github.sha }}
|
||||||
docker-compose pull
|
# Stop and remove old containers before pulling new images
|
||||||
docker-compose up -d --force-recreate
|
compose -f docker-compose.prod.yml down
|
||||||
|
# Clear previous logs for a clean deployment log
|
||||||
|
: > ~/astra-logs/astra-errors.log || true
|
||||||
|
compose -f docker-compose.prod.yml pull
|
||||||
|
compose -f docker-compose.prod.yml up -d
|
||||||
|
# Security hygiene: remove GHCR credentials after pulling
|
||||||
|
docker logout ghcr.io || true
|
||||||
75
README.md
75
README.md
|
|
@ -1,75 +0,0 @@
|
||||||
# CodeRED-Astra 🚀
|
|
||||||
|
|
||||||
A hackathon-ready project with React frontend and Rust backend engine.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Setup environment
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your credentials
|
|
||||||
|
|
||||||
# 2. Start everything with Docker
|
|
||||||
docker-compose up --build
|
|
||||||
|
|
||||||
# 3. Access your app
|
|
||||||
# Frontend: http://localhost
|
|
||||||
# API: http://localhost:8000
|
|
||||||
# Database Admin: http://127.0.0.1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
**Frontend (React + Vite)**:
|
|
||||||
```bash
|
|
||||||
cd web-app
|
|
||||||
npm install
|
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
**Backend (Rust)**:
|
|
||||||
```bash
|
|
||||||
cd rust-engine
|
|
||||||
cargo run # http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
|
||||||
- **Backend**: Rust + Warp + SQLx
|
|
||||||
- **Database**: MySQL 8.0 + phpMyAdmin
|
|
||||||
- **API**: RESTful endpoints with CORS enabled
|
|
||||||
- **Docker**: Full containerization for easy deployment
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
├── web-app/ # React frontend
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── App.jsx # Main component
|
|
||||||
│ │ └── main.jsx # Entry point
|
|
||||||
│ └── Dockerfile
|
|
||||||
├── rust-engine/ # Rust backend
|
|
||||||
│ ├── src/
|
|
||||||
│ │ └── main.rs # API server
|
|
||||||
│ └── Dockerfile
|
|
||||||
├── docker-compose.yml # Full stack orchestration
|
|
||||||
└── .env.example # Environment template
|
|
||||||
```
|
|
||||||
|
|
||||||
## Team Workflow
|
|
||||||
|
|
||||||
- **Frontend devs**: Work in `web-app/src/`, use `/api/*` for backend calls
|
|
||||||
- **Backend devs**: Work in `rust-engine/src/`, add endpoints to main.rs
|
|
||||||
- **Database**: Access phpMyAdmin at http://127.0.0.1:8080
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
✅ Hot reload for both frontend and backend
|
|
||||||
✅ Automatic API proxying from React to Rust
|
|
||||||
✅ Database connection with graceful fallback
|
|
||||||
✅ CORS configured for cross-origin requests
|
|
||||||
✅ Production-ready Docker containers
|
|
||||||
✅ Health monitoring and status dashboard
|
|
||||||
|
|
||||||
Ready for your hackathon! See `DEVELOPMENT.md` for detailed setup instructions.
|
|
||||||
|
|
@ -13,6 +13,8 @@ services:
|
||||||
- DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
|
- DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
|
||||||
- RUST_ENGINE_URL=http://rust-engine:8000
|
- RUST_ENGINE_URL=http://rust-engine:8000
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- rust-storage:/app/storage:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql # <-- Updated dependency
|
- mysql # <-- Updated dependency
|
||||||
- rust-engine
|
- rust-engine
|
||||||
|
|
@ -23,8 +25,16 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
|
- DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
|
||||||
|
- ASTRA_STORAGE=/app/storage
|
||||||
|
- DEMO_DATA_DIR=/app/demo-data
|
||||||
|
- QDRANT_URL=http://qdrant:6333
|
||||||
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- rust-storage:/app/storage
|
||||||
|
- ./rust-engine/demo-data:/app/demo-data:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql # <-- Updated dependency
|
- mysql
|
||||||
|
- qdrant
|
||||||
|
|
||||||
# --- Key Changes are in this section ---
|
# --- Key Changes are in this section ---
|
||||||
mysql: # <-- Renamed service for clarity
|
mysql: # <-- Renamed service for clarity
|
||||||
|
|
@ -49,5 +59,18 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
- mysql
|
||||||
|
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6333:6333"
|
||||||
|
volumes:
|
||||||
|
- qdrant-data:/qdrant/storage
|
||||||
|
environment:
|
||||||
|
- QDRANT__SERVICE__GRPC_PORT=6334
|
||||||
|
# expose to rust-engine via service name 'qdrant'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data: # Renamed volume for clarity (optional but good practice)
|
mysql-data: # Renamed volume for clarity (optional but good practice)
|
||||||
|
qdrant-data:
|
||||||
|
rust-storage:
|
||||||
9
frontend/Dockerfile
Normal file
9
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM node:23-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm i
|
||||||
|
COPY . .
|
||||||
|
RUN npm run format && npm run build
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
1
frontend/README.md
Normal file
1
frontend/README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
35
frontend/eslint.config.js
Normal file
35
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,jsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link href="/src/index.css" rel="stylesheet" />
|
|
||||||
<title>codered-astra</title>
|
<title>codered-astra</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
9245
frontend/package-lock.json
generated
Normal file
9245
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
55
frontend/package.json
Normal file
55
frontend/package.json
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"name": "codered-astra",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||||
|
"host": "vite --host 0.0.0.0 --port 3000",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 3000",
|
||||||
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
|
"clean-dist": "find apps/ -type d -name 'dist' -print0 | xargs -r0 -- rm -r",
|
||||||
|
"clean-all": "find apps/ -type d -name 'dist' -print0 | xargs -r0 -- rm -r && find . -path ./node_modules -prune -o -name 'node_modules' | xargs rm -rf "
|
||||||
|
},
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.25.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"bootstrap-icons": "^1.13.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-bootstrap": "^2.10.10",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router": "^7.9.4",
|
||||||
|
"react-router-dom": "^7.9.4",
|
||||||
|
"shadcn-ui": "^0.9.5",
|
||||||
|
"vite-jsconfig-paths": "^2.0.1"
|
||||||
|
},
|
||||||
|
"packageManager": ">=npm@10.9.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.38.0",
|
||||||
|
"daisyui": "^5.4.7",
|
||||||
|
"eslint": "^9.38.0",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"vite": "^7.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,9 @@ import ChatLayout from "src/components/layouts/chat-layout";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900 text-white flex items-center justify-center p-6">
|
<div className="dark min-h-screen bg-gray-950 text-white flex justify-center pt-12">
|
||||||
<ChatLayout />
|
<ChatLayout />
|
||||||
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
244
frontend/src/components/layouts/chat-layout.jsx
Normal file
244
frontend/src/components/layouts/chat-layout.jsx
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import ChatHeader from "src/components/ui/chat/chat-header";
|
||||||
|
import ChatWindow from "src/components/ui/chat/chat-window";
|
||||||
|
import MessageInput from "src/components/ui/chat/message-input";
|
||||||
|
import {
|
||||||
|
createQuery,
|
||||||
|
getQueryResult,
|
||||||
|
getQueryStatus,
|
||||||
|
listFiles,
|
||||||
|
} from "src/lib/api";
|
||||||
|
|
||||||
|
const createId = () =>
|
||||||
|
globalThis.crypto?.randomUUID?.() ?? `id-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
|
const INTRO_MESSAGE = {
|
||||||
|
id: "intro",
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"Ask me about the demo PDFs and I'll respond with the best matches pulled from the processed files.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatLayout() {
|
||||||
|
const [messages, setMessages] = useState([INTRO_MESSAGE]);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [errorToast, setErrorToast] = useState("");
|
||||||
|
const pollAbortRef = useRef(null);
|
||||||
|
|
||||||
|
const showError = useCallback((message) => {
|
||||||
|
setErrorToast(message);
|
||||||
|
window.setTimeout(() => setErrorToast(""), 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshFiles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const latest = await listFiles();
|
||||||
|
setFiles(latest);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message ?? "Failed to load files");
|
||||||
|
}
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshFiles();
|
||||||
|
}, [refreshFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollAbortRef.current) {
|
||||||
|
pollAbortRef.current.aborted = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildAssistantMarkdown = useCallback((result) => {
|
||||||
|
if (!result || typeof result !== "object") {
|
||||||
|
return "I could not find a response for that request.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalAnswer = result.final_answer?.trim();
|
||||||
|
const relationships = result.relationships?.trim();
|
||||||
|
const relatedFiles = Array.isArray(result.related_files)
|
||||||
|
? result.related_files
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const fileLines = relatedFiles
|
||||||
|
.filter((f) => f && typeof f === "object")
|
||||||
|
.map((file) => {
|
||||||
|
const filename = file.filename || file.id || "download";
|
||||||
|
const storageUrl = file.storage_url || `/storage/${filename}`;
|
||||||
|
const linkTarget = storageUrl.startsWith("/storage/")
|
||||||
|
? `/storage/${encodeURIComponent(storageUrl.replace("/storage/", ""))}`
|
||||||
|
: storageUrl;
|
||||||
|
const description = file.description?.trim();
|
||||||
|
const score =
|
||||||
|
typeof file.score === "number"
|
||||||
|
? ` _(score: ${file.score.toFixed(3)})_`
|
||||||
|
: "";
|
||||||
|
const detail = description ? ` — ${description}` : "";
|
||||||
|
return `- [${filename}](${linkTarget})${detail}${score}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let content =
|
||||||
|
finalAnswer ||
|
||||||
|
"I could not determine an answer from the indexed documents yet.";
|
||||||
|
|
||||||
|
if (fileLines.length) {
|
||||||
|
content += `\n\n**Related Files**\n${fileLines.join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationships && relationships !== finalAnswer) {
|
||||||
|
content += `\n\n---\n${relationships}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileLines.length && (!finalAnswer || finalAnswer.length < 10)) {
|
||||||
|
content +=
|
||||||
|
"\n\n_No analyzed documents matched yet. Try seeding demo data or wait for processing to finish._";
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const waitForResult = useCallback(async (id) => {
|
||||||
|
const abortState = { aborted: false };
|
||||||
|
pollAbortRef.current = abortState;
|
||||||
|
const timeoutMs = 120_000;
|
||||||
|
const intervalMs = 1_500;
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
while (!abortState.aborted) {
|
||||||
|
if (Date.now() - started > timeoutMs) {
|
||||||
|
throw new Error("Timed out waiting for the query to finish");
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusPayload = await getQueryStatus(id);
|
||||||
|
const status = statusPayload?.status;
|
||||||
|
|
||||||
|
if (status === "Completed") {
|
||||||
|
const resultPayload = await getQueryResult(id);
|
||||||
|
return resultPayload?.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "Failed") {
|
||||||
|
const resultPayload = await getQueryResult(id);
|
||||||
|
const reason = resultPayload?.result?.error || "Query failed";
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "Cancelled") {
|
||||||
|
throw new Error("Query was cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "not_found") {
|
||||||
|
throw new Error("Query was not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Query polling was aborted");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (text) => {
|
||||||
|
if (isProcessing) {
|
||||||
|
showError("Please wait for the current response to finish.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEntry = {
|
||||||
|
id: createId(),
|
||||||
|
role: "user",
|
||||||
|
content: text,
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userEntry]);
|
||||||
|
|
||||||
|
const placeholderId = createId();
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: placeholderId,
|
||||||
|
role: "assistant",
|
||||||
|
content: "_Analyzing indexed documents..._",
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { q: text, top_k: 5 };
|
||||||
|
const created = await createQuery(payload);
|
||||||
|
const result = await waitForResult(created.id);
|
||||||
|
const content = buildAssistantMarkdown(result);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === placeholderId
|
||||||
|
? { ...message, content, pending: false }
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || "Something went wrong.";
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.id === placeholderId
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
content: `⚠️ ${message}`,
|
||||||
|
pending: false,
|
||||||
|
error: true,
|
||||||
|
}
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
pollAbortRef.current = null;
|
||||||
|
setIsProcessing(false);
|
||||||
|
refreshFiles();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isProcessing,
|
||||||
|
showError,
|
||||||
|
refreshFiles,
|
||||||
|
waitForResult,
|
||||||
|
buildAssistantMarkdown,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAll = useCallback(() => {
|
||||||
|
if (!window.confirm("Delete all messages?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessages([INTRO_MESSAGE]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const latestFileSummary = useMemo(() => {
|
||||||
|
if (!files.length) return "No files indexed yet.";
|
||||||
|
const pending = files.filter((f) => f.pending_analysis).length;
|
||||||
|
const ready = files.length - pending;
|
||||||
|
return `${ready} ready • ${pending} processing`;
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-start w-full max-w-3xl gap-4 p-4">
|
||||||
|
<ChatHeader
|
||||||
|
onClear={handleDeleteAll}
|
||||||
|
busy={isProcessing}
|
||||||
|
fileSummary={latestFileSummary}
|
||||||
|
errorMessage={errorToast}
|
||||||
|
/>
|
||||||
|
<ChatWindow messages={messages} />
|
||||||
|
<MessageInput onSend={handleSend} disabled={isProcessing} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/components/ui/button/delete-button.jsx
Normal file
19
frontend/src/components/ui/button/delete-button.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Flame } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
export default function FlameButton({ onClick, disabled = false }) {
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`bg-gray-700 p-2 rounded-2xl border-2 border-gray-600 ${
|
||||||
|
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||||
|
}`}
|
||||||
|
whileHover={disabled ? undefined : { scale: 1.1 }}
|
||||||
|
whileTap={disabled ? undefined : { scale: 0.9 }}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
<Flame />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/components/ui/button/down-button.jsx
Normal file
24
frontend/src/components/ui/button/down-button.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
export default function DownButton({ onClick }) {
|
||||||
|
function handleClick(e) {
|
||||||
|
if (onClick) return onClick(e);
|
||||||
|
// default behavior: scroll to bottom of page smoothly
|
||||||
|
const doc = document.documentElement;
|
||||||
|
const top = Math.max(doc.scrollHeight, document.body.scrollHeight);
|
||||||
|
window.scrollTo({ top, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="bg-gray-700 p-2 rounded-2xl file-input border-2 border-gray-600"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
>
|
||||||
|
<ArrowDown />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/ui/button/schematic-button.jsx
Normal file
33
frontend/src/components/ui/button/schematic-button.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import React, { forwardRef, useRef } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
// Hidden file input that exposes an open() method via ref
|
||||||
|
const SchematicButton = forwardRef(function SchematicButton({ onFiles }, ref) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
open: () => inputRef.current && inputRef.current.click(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function handleFiles(e) {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
if (onFiles) onFiles(files);
|
||||||
|
if (inputRef.current) inputRef.current.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
multiple
|
||||||
|
onChange={handleFiles}
|
||||||
|
className="file-input hidden"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SchematicButton;
|
||||||
89
frontend/src/components/ui/chat/chat-header.jsx
Normal file
89
frontend/src/components/ui/chat/chat-header.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Rocket } from "lucide-react";
|
||||||
|
import DeleteButton from "src/components/ui/button/delete-button";
|
||||||
|
import SchematicButton from "src/components/ui/button/schematic-button";
|
||||||
|
|
||||||
|
export default function ChatHeader({
|
||||||
|
title = "Title of Chat",
|
||||||
|
onClear,
|
||||||
|
busy = false,
|
||||||
|
fileSummary,
|
||||||
|
errorMessage,
|
||||||
|
}) {
|
||||||
|
const isDebug = useMemo(() => {
|
||||||
|
const p = new URLSearchParams(window.location.search);
|
||||||
|
return p.get("debug") === "1";
|
||||||
|
}, []);
|
||||||
|
const [ingesting, setIngesting] = useState(false);
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
const [externalToast, setExternalToast] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!errorMessage) return;
|
||||||
|
setExternalToast(errorMessage);
|
||||||
|
const timer = window.setTimeout(() => setExternalToast(""), 5000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [errorMessage]);
|
||||||
|
|
||||||
|
async function triggerDemoIngest() {
|
||||||
|
try {
|
||||||
|
setIngesting(true);
|
||||||
|
const res = await fetch("/api/files/import-demo", { method: "POST" });
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
const imported = json.imported ?? "?";
|
||||||
|
const skipped = json.skipped ?? "?";
|
||||||
|
const summary = `Imported: ${imported}, Skipped: ${skipped}`;
|
||||||
|
setToast(json.error ? `${summary} - ${json.error}` : summary);
|
||||||
|
setTimeout(() => setToast(""), 4000);
|
||||||
|
} catch (e) {
|
||||||
|
setToast("Import failed");
|
||||||
|
setTimeout(() => setToast(""), 4000);
|
||||||
|
} finally {
|
||||||
|
setIngesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex justify-center">
|
||||||
|
<header className="text-slate-100 fixed top-4 max-w-3xl w-full px-4">
|
||||||
|
<div className="flex justify-between items-center gap-4">
|
||||||
|
<SchematicButton />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-lg font-semibold shadow-md shadow-indigo-600 bg-gray-900 px-6 py-2 rounded-4xl border-2 border-gray-800">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{fileSummary && (
|
||||||
|
<div className="text-xs text-slate-300 bg-gray-800/80 border border-gray-700 rounded px-3 py-1">
|
||||||
|
{fileSummary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DeleteButton onClick={onClear} disabled={busy} />
|
||||||
|
{isDebug && (
|
||||||
|
<motion.button
|
||||||
|
onClick={triggerDemoIngest}
|
||||||
|
className="bg-gray-800 border-2 border-gray-700 rounded-xl px-3 py-2 flex items-center gap-2"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
disabled={ingesting}
|
||||||
|
>
|
||||||
|
<Rocket size={16} />
|
||||||
|
{ingesting ? "Seeding…" : "Seed Demo Data"}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{toast && (
|
||||||
|
<div className="mt-2 text-xs text-slate-300 bg-gray-800/80 border border-gray-700 rounded px-2 py-1 inline-block">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{externalToast && (
|
||||||
|
<div className="mt-2 text-xs text-red-300 bg-red-900/40 border border-red-700 rounded px-2 py-1 inline-block">
|
||||||
|
{externalToast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/components/ui/chat/chat-window.jsx
Normal file
44
frontend/src/components/ui/chat/chat-window.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { MARKDOWN_COMPONENTS } from "src/config/markdown";
|
||||||
|
|
||||||
|
function MessageBubble({ message }) {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isError = !!message.error;
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? "justify-end" : "justify-start"} py-2`}>
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-xl ${isUser ? "bg-indigo-600 text-white rounded-tr-sm" : "bg-gray-700 text-slate-100 rounded-tl-sm"} ${isError ? "border border-red-500/60 bg-red-900/50" : ""}`}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
<div className="text-sm">{message.content}</div>
|
||||||
|
) : (
|
||||||
|
<ReactMarkdown components={MARKDOWN_COMPONENTS}>
|
||||||
|
{message.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatWindow({ messages }) {
|
||||||
|
const bottomRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bottomRef.current) {
|
||||||
|
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto px-2 pt-4 pb-32">
|
||||||
|
<div className="">
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<MessageBubble key={m.id ?? i} message={m} />
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/components/ui/chat/message-input.jsx
Normal file
119
frontend/src/components/ui/chat/message-input.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import DownButton from "src/components/ui/button/down-button";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { BotMessageSquare } from "lucide-react";
|
||||||
|
|
||||||
|
export default function MessageInput({ onSend, disabled = false }) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// ensure correct initial height
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!text.trim() || disabled) return;
|
||||||
|
onSend(text.trim());
|
||||||
|
|
||||||
|
// create query on backend
|
||||||
|
try {
|
||||||
|
if (onMessage)
|
||||||
|
onMessage("assistant", "Queued: sending request to server...");
|
||||||
|
const createRes = await fetch(`/api/query/create`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ q: text, top_k: 5 }),
|
||||||
|
});
|
||||||
|
const createJson = await createRes.json();
|
||||||
|
const id = createJson.id;
|
||||||
|
if (!id) throw new Error("no id returned");
|
||||||
|
|
||||||
|
// poll status
|
||||||
|
let status = "Queued";
|
||||||
|
if (onMessage) onMessage("assistant", `Status: ${status}`);
|
||||||
|
while (status !== "Completed" && status !== "Failed") {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
const sRes = await fetch(`/api/query/status?id=${id}`);
|
||||||
|
const sJson = await sRes.json();
|
||||||
|
status = sJson.status;
|
||||||
|
if (onMessage) onMessage("assistant", `Status: ${status}`);
|
||||||
|
if (status === "Cancelled") break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "Completed") {
|
||||||
|
const resultRes = await fetch(`/api/query/result?id=${id}`);
|
||||||
|
const resultJson = await resultRes.json();
|
||||||
|
const final =
|
||||||
|
resultJson?.result?.final_answer ||
|
||||||
|
JSON.stringify(resultJson?.result || {});
|
||||||
|
if (onMessage) onMessage("assistant", final);
|
||||||
|
} else {
|
||||||
|
if (onMessage)
|
||||||
|
onMessage("assistant", `Query status ended as: ${status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (onMessage) onMessage("assistant", `Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex justify-center">
|
||||||
|
<footer className="fixed bottom-6 max-w-3xl w-full px-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<DownButton></DownButton>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-gray-900 rounded-2xl border-2 border-gray-800 shadow-lg shadow-indigo-600"
|
||||||
|
>
|
||||||
|
<div className="flex p-2 shadow-xl items-center">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (disabled) return;
|
||||||
|
setText(e.target.value);
|
||||||
|
// auto-resize
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
ta.style.height = "auto";
|
||||||
|
ta.style.height = `${ta.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter to submit, Shift+Enter for newline
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 mx-2 rounded-md shadow-2sx border-none focus:border-none focus:outline-none resize-none overflow-auto max-h-40"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
className={`flex gap-2 px-4 py-2 bg-gray-700 rounded-xl ml-4 items-center ${
|
||||||
|
disabled ? "cursor-not-allowed" : ""
|
||||||
|
}`}
|
||||||
|
whileHover={disabled ? undefined : { scale: 1.1 }}
|
||||||
|
whileTap={disabled ? undefined : { scale: 0.9 }}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
<BotMessageSquare />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/components/ui/file/file-list.jsx
Normal file
83
frontend/src/components/ui/file/file-list.jsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import SchematicButton from "src/components/ui/button/schematic-button";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { FilePlus2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function FileList() {
|
||||||
|
const pickerRef = useRef(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
if (pickerRef.current && pickerRef.current.open) pickerRef.current.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(selected) {
|
||||||
|
setFiles((s) => [...s, ...selected]);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(i) {
|
||||||
|
setFiles((s) => s.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="p-2 rounded-xl bg-gray-700 border-2 border-gray-600"
|
||||||
|
aria-expanded={open}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTab={{ scale: 0.9 }}
|
||||||
|
>
|
||||||
|
{open ? <X /> : <Menu />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="fixed left-1/2 top-24 transform -translate-x-1/2 z-50 w-full max-w-3xl px-4">
|
||||||
|
<div className="bg-gray-900 border-2 border-gray-800 rounded-2xl p-4 shadow-lg overflow-auto">
|
||||||
|
<div className="flex items-center justify-between mb-2 pr-1">
|
||||||
|
<div className="text-lg font-medium">Files</div>
|
||||||
|
<div>
|
||||||
|
<motion.button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="w-full bg-gray-700 text-sm p-2 rounded-full border-2 border-gray-600"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
>
|
||||||
|
<FilePlus2 />
|
||||||
|
</motion.button>
|
||||||
|
<SchematicButton ref={pickerRef} onFiles={handleFiles} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className="text-md text-slate-400">No files added</div>
|
||||||
|
) : (
|
||||||
|
files.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between bg-gray-800 p-2 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[24rem]">{f.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="text-xs bg-gray-700 rounded-full p-2"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/config/markdown.jsx
Normal file
60
frontend/src/config/markdown.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
export const MARKDOWN_COMPONENTS = {
|
||||||
|
h1: ({ node, ...props }) => (
|
||||||
|
<h1 className="text-xl font-semibold mt-2 mb-1" {...props} />
|
||||||
|
),
|
||||||
|
h2: ({ node, ...props }) => (
|
||||||
|
<h2 className="text-lg font-semibold mt-2 mb-1" {...props} />
|
||||||
|
),
|
||||||
|
h3: ({ node, ...props }) => (
|
||||||
|
<h3 className="text-md font-semibold mt-2 mb-1" {...props} />
|
||||||
|
),
|
||||||
|
p: ({ node, ...props }) => (
|
||||||
|
<p className="text-sm leading-relaxed mb-2" {...props} />
|
||||||
|
),
|
||||||
|
a: ({ node, href, ...props }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-indigo-300 hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
code: ({ node, inline, className, children, ...props }) => {
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className={`bg-slate-800 px-1 py-0.5 rounded text-sm ${className || ""}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
className="bg-slate-800 p-2 rounded overflow-auto text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<code className={className || ""}>{children}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
blockquote: ({ node, ...props }) => (
|
||||||
|
<blockquote
|
||||||
|
className="border-l-2 border-slate-600 pl-4 italic text-slate-200 my-2"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
ul: ({ node, ...props }) => (
|
||||||
|
<ul className="list-disc list-inside ml-4 mb-2 text-sm" {...props} />
|
||||||
|
),
|
||||||
|
ol: ({ node, ...props }) => (
|
||||||
|
<ol className="list-decimal list-inside ml-4 mb-2 text-sm" {...props} />
|
||||||
|
),
|
||||||
|
li: ({ node, ...props }) => <li className="mb-1 text-sm" {...props} />,
|
||||||
|
strong: ({ node, ...props }) => (
|
||||||
|
<strong className="font-semibold" {...props} />
|
||||||
|
),
|
||||||
|
em: ({ node, ...props }) => <em className="italic" {...props} />,
|
||||||
|
};
|
||||||
47
frontend/src/features/gemini/gemini.js
Normal file
47
frontend/src/features/gemini/gemini.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { GoogleGenAI } from "@google/genai"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
const ai = new GoogleGenAI({ apiKey: import.meta.env.GEMINI_API_KEY })
|
||||||
|
|
||||||
|
async function uploadLocalPDFs() {
|
||||||
|
var pdfList = fs.readdirSync("public/pdfs")
|
||||||
|
|
||||||
|
// Upload each file in /public
|
||||||
|
pdfList.forEach(async (path) => {
|
||||||
|
console.log("file names: " + path)
|
||||||
|
console.log("file names: " + path.slice(0, path.length - 4))
|
||||||
|
|
||||||
|
console.log("UPLOADING")
|
||||||
|
const file = await ai.files.upload({
|
||||||
|
file: "public/pdfs/" + path,
|
||||||
|
config: {
|
||||||
|
displayName: path.slice(0, path.length - 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("FETCHING: public/pdfs/" + path)
|
||||||
|
|
||||||
|
// Wait for the file to be processed
|
||||||
|
let getFile = await ai.files.get({
|
||||||
|
name: file.name
|
||||||
|
})
|
||||||
|
|
||||||
|
while (getFile.state === "PROCESSING") {
|
||||||
|
let getFile = await ai.files.get({
|
||||||
|
name: file.name
|
||||||
|
})
|
||||||
|
console.log(`Current file status: ${getFile.state}`)
|
||||||
|
console.log("File is currently processing, retrying in 5 seconds")
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 5000) // Checks every 5 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
if (getFile.state === "FAILED") {
|
||||||
|
throw new Error("File has failed to process!")
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
23
frontend/src/index.css
Normal file
23
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "daisyui";
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--paragraph: 235, 236, 239;
|
||||||
|
--background: 15, 16, 26;
|
||||||
|
--primary: 158, 166, 214;
|
||||||
|
--secondary: 35, 50, 133;
|
||||||
|
--accent: 52, 75, 223;
|
||||||
|
background: rgba(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
"Helvetica Neue",
|
||||||
|
Arial;
|
||||||
|
}
|
||||||
5
frontend/tailwind.config.ts
Normal file
5
frontend/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
} satisfies Config;
|
||||||
26
frontend/vite.config.js
Normal file
26
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import jsconfigPaths from "vite-jsconfig-paths";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.loadEnvFile(".env")
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Env file not found!\n" + error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), react(), jsconfigPaths()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
src: "/src",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Defines envrionmental files across all src code b/c prefix is usually "VITE"
|
||||||
|
define: {
|
||||||
|
'import.meta.env.GEMINI_API_KEY': JSON.stringify(process.env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
allowedHosts: ["astrachat.christbru.services"]
|
||||||
|
}
|
||||||
|
});
|
||||||
2390
rust-engine/Cargo.lock
generated
2390
rust-engine/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "rust-engine"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
|
||||||
warp = { version = "0.4.2", features = ["server"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = "0.3"
|
|
||||||
dotenv = "0.15"
|
|
||||||
cors = "0.1.0"
|
|
||||||
anyhow = "1.0"
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# rust-engine/Dockerfile
|
|
||||||
# --- Stage 1: Builder ---
|
|
||||||
FROM rust:1.82-slim AS builder
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy Cargo files for dependency caching
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
# Create a dummy src/main.rs for dependency build
|
|
||||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
|
||||||
RUN cargo build --release && rm src/main.rs
|
|
||||||
|
|
||||||
# Copy source code and build
|
|
||||||
COPY src ./src
|
|
||||||
RUN cargo build --release
|
|
||||||
|
|
||||||
# --- Stage 2: Final Image ---
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=builder /usr/src/app/target/release/rust-engine /usr/local/bin/rust-engine
|
|
||||||
EXPOSE 8000
|
|
||||||
CMD ["rust-engine"]
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
use std::env;
|
|
||||||
use warp::Filter;
|
|
||||||
use sqlx::mysql::MySqlPool;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct HealthResponse {
|
|
||||||
status: String,
|
|
||||||
timestamp: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct ApiResponse<T> {
|
|
||||||
success: bool,
|
|
||||||
data: Option<T>,
|
|
||||||
message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
// Initialize tracing
|
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
dotenv::dotenv().ok();
|
|
||||||
|
|
||||||
let database_url = env::var("DATABASE_URL")
|
|
||||||
.unwrap_or_else(|_| "mysql://astraadmin:password@mysql:3306/astra".to_string());
|
|
||||||
|
|
||||||
info!("Starting Rust Engine...");
|
|
||||||
info!("Connecting to database: {}", database_url);
|
|
||||||
|
|
||||||
// Connect to database
|
|
||||||
let pool = match MySqlPool::connect(&database_url).await {
|
|
||||||
Ok(pool) => {
|
|
||||||
info!("Successfully connected to database");
|
|
||||||
pool
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to connect to database: {}. Starting without DB connection.", e);
|
|
||||||
// In a hackathon setting, we might want to continue without DB for initial testing
|
|
||||||
return start_server_without_db().await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// CORS configuration
|
|
||||||
let cors = warp::cors()
|
|
||||||
.allow_any_origin()
|
|
||||||
.allow_headers(vec!["content-type", "authorization"])
|
|
||||||
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]);
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
let health = warp::path("health")
|
|
||||||
.and(warp::get())
|
|
||||||
.map(|| {
|
|
||||||
let response = HealthResponse {
|
|
||||||
status: "healthy".to_string(),
|
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
||||||
};
|
|
||||||
warp::reply::json(&ApiResponse {
|
|
||||||
success: true,
|
|
||||||
data: Some(response),
|
|
||||||
message: None,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// API routes - you'll expand these for your hackathon needs
|
|
||||||
let api = warp::path("api")
|
|
||||||
.and(
|
|
||||||
health.or(
|
|
||||||
// Add more routes here as needed
|
|
||||||
warp::path("version")
|
|
||||||
.and(warp::get())
|
|
||||||
.map(|| {
|
|
||||||
warp::reply::json(&ApiResponse {
|
|
||||||
success: true,
|
|
||||||
data: Some("1.0.0"),
|
|
||||||
message: Some("Rust Engine API".to_string()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
let routes = api
|
|
||||||
.with(cors)
|
|
||||||
.with(warp::log("rust_engine"));
|
|
||||||
|
|
||||||
info!("Rust Engine started on http://0.0.0.0:8000");
|
|
||||||
|
|
||||||
warp::serve(routes)
|
|
||||||
.run(([0, 0, 0, 0], 8000))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_server_without_db() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
info!("Starting server in DB-less mode for development");
|
|
||||||
|
|
||||||
let cors = warp::cors()
|
|
||||||
.allow_any_origin()
|
|
||||||
.allow_headers(vec!["content-type", "authorization"])
|
|
||||||
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]);
|
|
||||||
|
|
||||||
let health = warp::path("health")
|
|
||||||
.and(warp::get())
|
|
||||||
.map(|| {
|
|
||||||
let response = HealthResponse {
|
|
||||||
status: "healthy (no db)".to_string(),
|
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
||||||
};
|
|
||||||
warp::reply::json(&ApiResponse {
|
|
||||||
success: true,
|
|
||||||
data: Some(response),
|
|
||||||
message: Some("Running without database connection".to_string()),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let routes = warp::path("api")
|
|
||||||
.and(health)
|
|
||||||
.with(cors)
|
|
||||||
.with(warp::log("rust_engine"));
|
|
||||||
|
|
||||||
info!("Rust Engine started on http://0.0.0.0:8000 (DB-less mode)");
|
|
||||||
|
|
||||||
warp::serve(routes)
|
|
||||||
.run(([0, 0, 0, 0], 8000))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
FROM node:23-alpine
|
|
||||||
|
|
||||||
COPY . /codered-astra
|
|
||||||
|
|
||||||
WORKDIR /codered-astra
|
|
||||||
|
|
||||||
RUN npm i
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
RUN npm run format
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
CMD ["npm", "run", "host"]
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# React + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import js from "@eslint/js";
|
|
||||||
import globals from "globals";
|
|
||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(["dist"]),
|
|
||||||
{
|
|
||||||
files: ["**/*.{js,jsx}"],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
reactHooks.configs["recommended-latest"],
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
ecmaFeatures: { jsx: true },
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "./"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
7692
web-app/package-lock.json
generated
7692
web-app/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,45 +0,0 @@
|
||||||
{
|
|
||||||
"name": "codered-astra",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build",
|
|
||||||
"dev": "vite",
|
|
||||||
"host": "vite host",
|
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
|
||||||
"clean-dist": "find apps/ -type d -name 'dist' -print0 | xargs -r0 -- rm -r",
|
|
||||||
"clean-all": "find apps/ -type d -name 'dist' -print0 | xargs -r0 -- rm -r && find . -path ./node_modules -prune -o -name 'node_modules' | xargs rm -rf "
|
|
||||||
},
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@google/genai": "^1.25.0",
|
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
|
||||||
"bootstrap": "^5.3.8",
|
|
||||||
"bootstrap-icons": "^1.13.1",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"lucide-react": "^0.546.0",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-bootstrap": "^2.10.10",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"react-router": "^7.9.4",
|
|
||||||
"react-router-dom": "^7.9.4",
|
|
||||||
"vite-jsconfig-paths": "^2.0.1"
|
|
||||||
},
|
|
||||||
"packageManager": ">=npm@10.9.0",
|
|
||||||
"devDependencies": {
|
|
||||||
"eslint": "^9.38.0",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"eslint-plugin-react": "^7.37.5",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
|
||||||
"nodemon": "^3.1.10",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"tailwindcss": "^4.1.14",
|
|
||||||
"vite": "^7.1.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,34 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import ChatHeader from "src/components/ui/chat/chat-header";
|
|
||||||
import ChatWindow from "src/components/ui/chat/chat-window";
|
|
||||||
import MessageInput from "src/components/ui/chat/message-input";
|
|
||||||
|
|
||||||
export default function ChatLayout() {
|
|
||||||
const [messages, setMessages] = useState([
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: "Hello — I can help you with code, explanations, and more.",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
function handleSend(text) {
|
|
||||||
const userMsg = { role: "user", content: text };
|
|
||||||
setMessages((s) => [...s, userMsg]);
|
|
||||||
|
|
||||||
// fake assistant reply after short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
setMessages((s) => [
|
|
||||||
...s,
|
|
||||||
{ role: "assistant", content: `You said: ${text}` },
|
|
||||||
]);
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-[80vh] w-full max-w-3xl mx-auto rounded-lg overflow-hidden shadow-lg border border-slate-700">
|
|
||||||
<ChatHeader />
|
|
||||||
<ChatWindow messages={messages} />
|
|
||||||
<MessageInput onSend={handleSend} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import Button from 'react-bootstrap/Button';
|
|
||||||
|
|
||||||
export default function DeleteButton({ onClick, variant = "outline-danger", children, ...props }) {
|
|
||||||
return (
|
|
||||||
<Button onClick={onClick} variant={variant} {...props}>
|
|
||||||
{children || "Delete"}{" "}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
className="bi bi-trash3"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
>
|
|
||||||
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
.custom-btn {
|
|
||||||
background-color: white !important;
|
|
||||||
border: 2px solid #0F2862 !important;
|
|
||||||
color: #0F2862 !important;
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-btn:hover,
|
|
||||||
.custom-btn:focus {
|
|
||||||
background-color: #0F2862 !important;
|
|
||||||
color: white !important;
|
|
||||||
border-color: #0F2862 !important;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import Button from 'react-bootstrap/Button';
|
|
||||||
import './NewChatButton.css'
|
|
||||||
export default function NewChatButton({ onClick, variant = "outline-light", children, ...props }) {
|
|
||||||
return (
|
|
||||||
<Button onClick={onClick} className="custom-btn" {...props}>
|
|
||||||
{children || "New Chat"}{" "}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
className="bi bi-plus-lg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
>
|
|
||||||
<path fillRule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function ChatHeader({ title = "AI Assistant" }) {
|
|
||||||
return (
|
|
||||||
<header className="flex items-center justify-between px-4 py-3 bg-gradient-to-r from-slate-800 to-slate-900 text-white">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-indigo-500 rounded flex items-center justify-center font-bold">
|
|
||||||
AI
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold">{title}</h1>
|
|
||||||
<p className="text-sm text-slate-300">
|
|
||||||
Ask anything — AI is listening
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { useRef } from "react";
|
|
||||||
function MessageBubble({ message }) {
|
|
||||||
const isUser = message.role === "user";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex ${isUser ? "justify-end" : "justify-start"} px-4 py-2`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`max-w-[70%] p-3 rounded-lg ${isUser ? "bg-indigo-600 text-white" : "bg-slate-700 text-slate-100"}`}
|
|
||||||
>
|
|
||||||
<div className="text-sm">{message.content}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatWindow({ messages }) {
|
|
||||||
const chatRef = useRef(null);
|
|
||||||
// Auto-scroll to bottom when new messages appear
|
|
||||||
useEffect(() => {
|
|
||||||
chatRef.current?.scrollTo({
|
|
||||||
top: chatRef.current.scrollHeight,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex-1 overflow-auto p-2 bg-gradient-to-b from-slate-900 to-slate-800">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{messages.map((m, i) => (
|
|
||||||
<MessageBubble key={i} message={m} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
export default function MessageInput({ onSend }) {
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
|
|
||||||
function handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!text.trim()) return;
|
|
||||||
onSend(text.trim());
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="p-3 bg-slate-900">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder="Type a message..."
|
|
||||||
className="flex-1 rounded-md bg-slate-800 border border-slate-700 px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
@import "tailwindcss/preflight";
|
|
||||||
@import "tailwindcss/utilities";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-primary: 15 40 98;
|
|
||||||
--color-secondary: 79 95 118;
|
|
||||||
--color-accent: 158 54 58;
|
|
||||||
--color-paragraph: 255 255 255;
|
|
||||||
--color-background: 9 31 54;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background-color: rgb(var(--color-background));
|
|
||||||
color: rgb(var(--color-paragraph));
|
|
||||||
font-family:
|
|
||||||
ui-sans-serif,
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
"Segoe UI",
|
|
||||||
Roboto,
|
|
||||||
"Helvetica Neue",
|
|
||||||
Arial;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
@theme {
|
|
||||||
--color-primary: rgba(15, 40, 98);
|
|
||||||
--color-secondary: rgba(79, 95, 118);
|
|
||||||
--color-accent: rgba(158, 54, 58);
|
|
||||||
--color-paragraph: rgba(255, 255, 255);
|
|
||||||
--color-background: rgba(9, 31, 54);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import jsconfigPaths from "vite-jsconfig-paths";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwindcss(), react(), jsconfigPaths()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
src: "/src",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue