Initial commit: Sarthi Lab desktop application
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-electron/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
# TTS/STT binaries and models (downloaded at runtime or during build)
|
||||
electron/piper/*
|
||||
electron/whisper/*
|
||||
120
AGENTS.md
Normal file
120
AGENTS.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# AGENTS.md
|
||||
|
||||
> **Quick Reference Card** for Sarthi Lab development
|
||||
|
||||
---
|
||||
|
||||
## What Is This?
|
||||
|
||||
**Sarthi Lab** - Desktop application for computer labs in schools. Students use this offline-capable Electron app to access interactive science experiments and exercises.
|
||||
|
||||
Built with **Electron + React + Vite**.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/sarthi_lab/
|
||||
├── src/
|
||||
│ ├── components/ # React components
|
||||
│ ├── screens/ # Screen components (Login, Dashboard, Exercise, etc.)
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── services/ # API services (labApi.js)
|
||||
│ ├── store/ # Zustand state management
|
||||
│ └── App.jsx # Main app component
|
||||
├── electron/
|
||||
│ └── main.js # Electron main process
|
||||
├── dist/ # Production build
|
||||
├── dist-electron/ # Electron production build
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| **Framework** | Electron 33+ |
|
||||
| **Frontend** | React 19, Vite 6 |
|
||||
| **Styling** | Tailwind CSS |
|
||||
| **State** | Zustand |
|
||||
| **Build** | electron-builder |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd sarthi_lab
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development (runs both Vite + Electron)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
App runs at `http://localhost:5174` in development.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Server Connection** - Connect to backend via URL + room token
|
||||
2. **Student Authentication** - Students log in with room token + credentials
|
||||
3. **Exercise Library** - Browse and launch interactive science experiments
|
||||
4. **Voice Integration** - TTS/STT via desktop backend
|
||||
5. **Heartbeat** - Periodic ping to server to track active students
|
||||
|
||||
---
|
||||
|
||||
## API Integration
|
||||
|
||||
Sarthi Lab connects to the Django backend (`/server`):
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/api/dashboard/student/room/join/` | Join a room with token |
|
||||
| `/api/dashboard/student/dashboard/` | Get student profile & courses |
|
||||
| `/api/exercises` | List available exercises |
|
||||
| `/api/exercises/{id}/access-url` | Get secure exercise URL with token |
|
||||
| `/api/dashboard/student/heartbeat/` | Send periodic heartbeat |
|
||||
|
||||
---
|
||||
|
||||
## Exercise Loading Flow
|
||||
|
||||
1. Student clicks exercise → calls `/api/exercises/{id}/access-url`
|
||||
2. Backend returns URL with token: `/api/exercises/{id}/latest/index.html?t=<token>`
|
||||
3. App loads URL in iframe
|
||||
4. Backend validates token before serving content
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev
|
||||
|
||||
# Build for production (creates .app for macOS)
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Notes
|
||||
|
||||
- Exercise iframes require valid access token - check `ExerciseScreen.jsx`
|
||||
- CSP on server must allow `localhost:5174` and `sarthi.eduspheria.com`
|
||||
- Uses Electron IPC for desktop features (TTS, file system)
|
||||
- Stores config in Electron store (server URL, room token)
|
||||
|
||||
---
|
||||
|
||||
*For root AGENTS.md with company context, see `/AGENTS.md`*
|
||||
133
README.md
Normal file
133
README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Sarthi Lab Desktop
|
||||
|
||||
AI learning desktop application for school computer labs.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone & Install
|
||||
```bash
|
||||
git clone https://github.com/Eduspheria/sarthi_lab.git
|
||||
cd sarthi_lab
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Run Locally
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Electron app will open at `http://localhost:5174`. Exercises load correctly in dev mode.
|
||||
|
||||
---
|
||||
|
||||
## Global Command Setup (Run from Anywhere)
|
||||
|
||||
Set up a global `sarthi-lab` command that works from any terminal directory.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
#### Symlink Setup
|
||||
```bash
|
||||
# Make script executable
|
||||
chmod +x launch.sh
|
||||
|
||||
# Create global symlink (requires sudo)
|
||||
sudo ln -s "$(pwd)/launch.sh" /usr/local/bin/sarthi-lab
|
||||
```
|
||||
|
||||
If `/usr/local/bin` isn't in PATH, add it:
|
||||
```bash
|
||||
echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Windows
|
||||
|
||||
#### Add to PATH
|
||||
1. **Copy** `launch.bat` to a directory already in PATH:
|
||||
- `C:\Windows\System32`
|
||||
- `C:\Program Files\Git\usr\bin` (if Git installed)
|
||||
|
||||
2. **OR add repo to PATH:**
|
||||
- Open **System Properties** → **Environment Variables**
|
||||
- Edit **User variables** → **PATH**
|
||||
- Add `C:\path\to\sarthi_lab`
|
||||
- Restart terminal
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Launch
|
||||
```bash
|
||||
sarthi-lab
|
||||
```
|
||||
Starts the Electron dev app (`npm run dev`).
|
||||
|
||||
### Upgrade Command
|
||||
```bash
|
||||
sarthi-lab upgrade
|
||||
```
|
||||
Pulls latest git changes and updates npm dependencies.
|
||||
|
||||
### Command Reference
|
||||
- `sarthi-lab` – Start Sarthi Lab (default)
|
||||
- `sarthi-lab upgrade` – Pull latest changes and update dependencies
|
||||
- `sarthi-lab --help` or `-h` – Show help
|
||||
- `sarthi-lab --version` or `-v` – Show version
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `sudo: command not found` (mac/Linux) | Use `~/bin`: `ln -s "$(pwd)/launch.sh" ~/bin/sarthi-lab` |
|
||||
| Permission denied (symlink) | Ensure `/usr/local/bin` exists and is writable |
|
||||
| Windows PATH not working | Use full path: `C:\path\to\sarthi_lab\launch.bat` |
|
||||
| `npm run dev` fails | Check Node.js ≥18, run `npm install` |
|
||||
| Exercises not loading | Ensure you're using dev mode (`npm run dev`); packaged DMG may have issues |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Scripts
|
||||
- `npm run dev` – Start dev server + Electron
|
||||
- `npm run build` – Build for production
|
||||
- `npm run build:mac` – Build macOS DMG
|
||||
- `npm run build:win` – Build Windows installer
|
||||
- `npm run build:linux` – Build Linux AppImage
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
sarthi_lab/
|
||||
├── src/ # React frontend
|
||||
├── electron/ # Electron main process
|
||||
├── dist/ # Production build
|
||||
└── dist-electron/ # Packaged apps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Connection
|
||||
|
||||
Sarthi Lab connects to a Django backend (cloud or local):
|
||||
- Default: `https://sarthi.eduspheria.com`
|
||||
- Change via Settings screen in app
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Packaged App Limitation**: Packaged DMG (`file://` origin) may have loading issues. Use `npm run dev` (this launcher) for school deployments.
|
||||
- **Offline Features**: Desktop TTS/STT via Piper/whisper binaries (macOS only in current build).
|
||||
- **Updates**: Use `sarthi-lab upgrade` to pull latest code.
|
||||
|
||||
---
|
||||
|
||||
**Eduspheria** · [eduspheria.com](https://eduspheria.com) · Contact: eduspheria@gmail.com
|
||||
471
electron/main.js
Normal file
471
electron/main.js
Normal file
@@ -0,0 +1,471 @@
|
||||
// @ts-check
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { app, BrowserWindow, ipcMain, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs'); // ← must be at top (used by TTS before the old require position)
|
||||
const https = require('https');
|
||||
const { spawn } = require('child_process');
|
||||
const Store = require('electron-store');
|
||||
|
||||
const store = new Store();
|
||||
|
||||
let mainWindow;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: 'hiddenInset',
|
||||
title: 'Sarthi Lab',
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.loadURL('http://localhost:5174');
|
||||
// mainWindow.webContents.openDevTools(); // Removed: DevTools no longer open by default
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// F12 to open dev tools (useful for debugging in production)
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12') {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
|
||||
// ── Path helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Where DOWNLOADED model files are stored.
|
||||
* - Dev: electron/<type>/ (next to source, survives reloads)
|
||||
* - Packaged: userData/<type>/ (writable; resourcesPath is read-only on macOS)
|
||||
*/
|
||||
function getModelsPath(type) {
|
||||
if (app.isPackaged) {
|
||||
return path.join(app.getPath('userData'), 'models', type);
|
||||
}
|
||||
return path.join(__dirname, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the BINARY executable lives.
|
||||
* - Dev: electron/<type>/ (same folder as models in dev)
|
||||
* - Packaged: resourcesPath/<type>/ (bundled via extraResources)
|
||||
*/
|
||||
function getBinaryDir(type) {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, type);
|
||||
}
|
||||
return path.join(__dirname, type);
|
||||
}
|
||||
|
||||
const BINARY_NAME = {
|
||||
piper: process.platform === 'win32' ? 'piper.exe' : 'piper',
|
||||
whisper: process.platform === 'win32' ? 'whisper-cli.exe' : 'whisper-cli',
|
||||
};
|
||||
|
||||
// ── IPC: Config store ────────────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('config:get', (_, key) => store.get(key));
|
||||
ipcMain.handle('config:set', (_, key, value) => { store.set(key, value); });
|
||||
ipcMain.handle('config:delete', (_, key) => { store.delete(key); });
|
||||
|
||||
// ── IPC: TTS via Piper ───────────────────────────────────────────────────────
|
||||
|
||||
let piperProcess = null;
|
||||
|
||||
ipcMain.handle('tts:speak', async (_, text) => {
|
||||
// Kill any existing speech
|
||||
if (piperProcess) {
|
||||
piperProcess.kill();
|
||||
piperProcess = null;
|
||||
}
|
||||
|
||||
// macOS: use built-in `say` command — no binary/model required, high quality
|
||||
if (process.platform === 'darwin') {
|
||||
return new Promise((resolve) => {
|
||||
piperProcess = spawn('say', [text]);
|
||||
piperProcess.on('close', () => { piperProcess = null; resolve(); });
|
||||
piperProcess.on('error', (err) => {
|
||||
console.warn('say error:', err.message);
|
||||
piperProcess = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const binaryDir = getBinaryDir('piper');
|
||||
const modelsDir = getModelsPath('piper');
|
||||
const piperBin = path.join(binaryDir, BINARY_NAME.piper);
|
||||
const modelPath = path.join(modelsDir, 'en_US-lessac-medium.onnx');
|
||||
const modelConfig = path.join(modelsDir, 'en_US-lessac-medium.onnx.json');
|
||||
|
||||
// Verify binary and model files exist before spawning
|
||||
if (!fs.existsSync(piperBin) || !fs.existsSync(modelPath)) {
|
||||
console.warn('Piper binary or model missing — skipping TTS');
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform === 'linux') {
|
||||
const playerProcess = spawn('aplay', ['-r', '22050', '-f', 'S16_LE', '-c', '1']);
|
||||
|
||||
piperProcess = spawn(piperBin, [
|
||||
'--model', modelPath,
|
||||
'--config', modelConfig,
|
||||
'--output-raw',
|
||||
]);
|
||||
|
||||
piperProcess.stdin.write(text);
|
||||
piperProcess.stdin.end();
|
||||
piperProcess.stdout.pipe(playerProcess.stdin);
|
||||
|
||||
piperProcess.on('error', (err) => {
|
||||
console.warn('Piper TTS error:', err.message);
|
||||
resolve();
|
||||
});
|
||||
|
||||
playerProcess.on('close', () => {
|
||||
piperProcess = null;
|
||||
resolve();
|
||||
});
|
||||
|
||||
playerProcess.on('error', (err) => {
|
||||
console.warn('aplay error:', err.message);
|
||||
resolve();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows: no native Piper audio pipeline — renderer falls back to Web Speech
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('tts:stop', () => {
|
||||
if (piperProcess) {
|
||||
piperProcess.kill();
|
||||
piperProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ── IPC: STT via whisper.cpp ─────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('stt:transcribe', async (_, audioBase64) => {
|
||||
const binaryDir = getBinaryDir('whisper');
|
||||
const modelsDir = getModelsPath('whisper');
|
||||
|
||||
const whisperBin = path.join(binaryDir, BINARY_NAME.whisper);
|
||||
const modelPath = path.join(modelsDir, 'ggml-tiny.bin');
|
||||
|
||||
if (!fs.existsSync(whisperBin) || !fs.existsSync(modelPath)) {
|
||||
return { error: 'whisper not available', text: null };
|
||||
}
|
||||
|
||||
const audioBuffer = Buffer.from(audioBase64, 'base64');
|
||||
const tmpPath = path.join(app.getPath('temp'), 'stt_input.wav');
|
||||
fs.writeFileSync(tmpPath, audioBuffer);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const whisperProcess = spawn(whisperBin, [
|
||||
'--model', modelPath,
|
||||
'--file', tmpPath,
|
||||
'--language', 'en',
|
||||
'--no-prints', // suppress progress/status lines
|
||||
'--no-timestamps', // plain text output only
|
||||
]);
|
||||
|
||||
let output = '';
|
||||
|
||||
whisperProcess.stdout.on('data', (data) => { output += data.toString(); });
|
||||
|
||||
whisperProcess.on('close', (code) => {
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
|
||||
if (code === 0) {
|
||||
resolve({ text: output.trim() });
|
||||
} else {
|
||||
console.warn('whisper-cli exited with code', code);
|
||||
resolve({ error: 'transcription failed', text: null });
|
||||
}
|
||||
});
|
||||
|
||||
whisperProcess.on('error', (err) => {
|
||||
console.warn('whisper-cli process error:', err.message);
|
||||
resolve({ error: err.message, text: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── IPC: Model Download Manager ──────────────────────────────────────────────
|
||||
|
||||
const MODEL_URLS = {
|
||||
piper: [
|
||||
{
|
||||
file: 'en_US-lessac-medium.onnx',
|
||||
url: 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx',
|
||||
size: 63_000_000,
|
||||
},
|
||||
{
|
||||
file: 'en_US-lessac-medium.onnx.json',
|
||||
url: 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json',
|
||||
size: 5_000,
|
||||
},
|
||||
],
|
||||
whisper: [
|
||||
{
|
||||
file: 'ggml-tiny.bin',
|
||||
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
|
||||
size: 77_700_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file with redirect following and per-chunk progress callback.
|
||||
* @param {string} url
|
||||
* @param {string} dest
|
||||
* @param {(percent: number, downloadedBytes: number, totalBytes: number) => void} [onProgress]
|
||||
*/
|
||||
function downloadFile(url, dest, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
https.get(url, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
file.close();
|
||||
try { fs.unlinkSync(dest); } catch {}
|
||||
downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
try { fs.unlinkSync(dest); } catch {}
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
|
||||
let downloadedBytes = 0;
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length;
|
||||
if (onProgress && totalBytes > 0) {
|
||||
onProgress(Math.round((downloadedBytes / totalBytes) * 100), downloadedBytes, totalBytes);
|
||||
}
|
||||
});
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
file.close();
|
||||
try { if (fs.existsSync(dest)) fs.unlinkSync(dest); } catch {}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Binary download URLs (for dev mode and Linux where binary isn't bundled) ─
|
||||
|
||||
const BINARY_DOWNLOAD_URLS = {
|
||||
piper: {
|
||||
'linux-x64': 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz',
|
||||
'linux-arm64': 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_aarch64.tar.gz',
|
||||
'win32-x64': 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_x86_64.zip',
|
||||
},
|
||||
// whisper: macOS binary is compiled and bundled; Linux/Windows get pre-built zip
|
||||
whisper: {
|
||||
'linux-x64': 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.8.3/whisper-blas-bin-x64.zip',
|
||||
'win32-x64': 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.8.3/whisper-blas-bin-win64.zip',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a tar.gz to destDir, stripping the top-level directory.
|
||||
* After extraction, chmod 755 the target binary if it exists.
|
||||
*/
|
||||
function extractTarGz(tarPath, destDir, binaryName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ensureDir(destDir);
|
||||
const tar = spawn('tar', ['-xzf', tarPath, '-C', destDir, '--strip-components=1']);
|
||||
tar.on('close', (code) => {
|
||||
if (code !== 0) { reject(new Error(`tar exited with ${code}`)); return; }
|
||||
const bin = path.join(destDir, binaryName);
|
||||
if (fs.existsSync(bin)) { try { fs.chmodSync(bin, 0o755); } catch {} }
|
||||
resolve();
|
||||
});
|
||||
tar.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a zip file to destDir.
|
||||
* On Windows uses PowerShell Expand-Archive, on macOS/Linux uses unzip.
|
||||
* After extraction, chmod 755 the target binary if it exists.
|
||||
*/
|
||||
function extractZip(zipPath, destDir, binaryName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ensureDir(destDir);
|
||||
|
||||
let command, args;
|
||||
if (process.platform === 'win32') {
|
||||
// PowerShell Expand-Archive
|
||||
command = 'powershell';
|
||||
args = [
|
||||
'-Command',
|
||||
`Expand-Archive -Path "${zipPath}" -DestinationPath "${destDir}" -Force`
|
||||
];
|
||||
} else {
|
||||
// macOS/Linux unzip
|
||||
command = 'unzip';
|
||||
args = ['-o', zipPath, '-d', destDir];
|
||||
}
|
||||
|
||||
const proc = spawn(command, args);
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) { reject(new Error(`${command} exited with ${code}`)); return; }
|
||||
const bin = path.join(destDir, binaryName);
|
||||
if (fs.existsSync(bin)) { try { fs.chmodSync(bin, 0o755); } catch {} }
|
||||
resolve();
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendProgress(type, file, status, percent = 0, error = null) {
|
||||
if (!mainWindow) return;
|
||||
mainWindow.webContents.send('download:progress', { type, file, status, percent, ...(error ? { error } : {}) });
|
||||
}
|
||||
|
||||
ipcMain.handle('download:start', async (_, type) => {
|
||||
// macOS TTS uses built-in 'say' — nothing to download
|
||||
if (type === 'piper' && process.platform === 'darwin') {
|
||||
sendProgress(type, 'voice', 'complete', 100);
|
||||
return { results: [{ file: 'voice', status: 'ready', reason: 'macOS uses built-in say command' }] };
|
||||
}
|
||||
|
||||
const modelDir = getModelsPath(type);
|
||||
const binaryDir = getBinaryDir(type);
|
||||
ensureDir(modelDir);
|
||||
|
||||
const files = MODEL_URLS[type];
|
||||
if (!files) return { error: 'Unknown model type' };
|
||||
|
||||
// ── Step 1: Download binary if missing (packaged app has it in extraResources) ──
|
||||
const binaryPath = path.join(binaryDir, BINARY_NAME[type] || 'main');
|
||||
const platformKey = `${process.platform}-${process.arch}`;
|
||||
const binaryUrl = BINARY_DOWNLOAD_URLS[type]?.[platformKey];
|
||||
|
||||
if (!fs.existsSync(binaryPath) && binaryUrl) {
|
||||
// Determine file extension from URL
|
||||
const isZip = binaryUrl.endsWith('.zip');
|
||||
const ext = isZip ? '.zip' : '.tar.gz';
|
||||
const tmpFile = path.join(app.getPath('temp'), `${type}_binary${ext}`);
|
||||
|
||||
sendProgress(type, 'runtime files', 'downloading', 0);
|
||||
try {
|
||||
await downloadFile(binaryUrl, tmpFile, (percent) => {
|
||||
sendProgress(type, 'runtime files', 'downloading', percent);
|
||||
});
|
||||
|
||||
if (isZip) {
|
||||
await extractZip(tmpFile, binaryDir, BINARY_NAME[type] || 'main');
|
||||
} else {
|
||||
await extractTarGz(tmpFile, binaryDir, BINARY_NAME[type] || 'main');
|
||||
}
|
||||
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
sendProgress(type, 'runtime files', 'complete', 100);
|
||||
} catch (err) {
|
||||
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
|
||||
sendProgress(type, 'runtime files', 'error', 0, err.message);
|
||||
return { error: `Binary download failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Download model files ─────────────────────────────────────────────
|
||||
const results = [];
|
||||
|
||||
for (const { file, url } of files) {
|
||||
const dest = path.join(modelDir, file);
|
||||
|
||||
if (fs.existsSync(dest)) {
|
||||
results.push({ file, status: 'skipped', reason: 'already exists' });
|
||||
sendProgress(type, file, 'exists', 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
sendProgress(type, file, 'downloading', 0);
|
||||
try {
|
||||
await downloadFile(url, dest, (percent) => { sendProgress(type, file, 'downloading', percent); });
|
||||
results.push({ file, status: 'complete' });
|
||||
sendProgress(type, file, 'complete', 100);
|
||||
} catch (err) {
|
||||
results.push({ file, status: 'error', error: err.message });
|
||||
sendProgress(type, file, 'error', 0, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { results };
|
||||
});
|
||||
|
||||
function checkModelStatus(type) {
|
||||
// macOS TTS: always available via built-in 'say' command — no model or binary needed
|
||||
if (type === 'piper' && process.platform === 'darwin') {
|
||||
return { available: true, modelsDownloaded: true, binaryPresent: true, usesSay: true };
|
||||
}
|
||||
|
||||
const modelDir = getModelsPath(type);
|
||||
const binaryDir = getBinaryDir(type);
|
||||
const files = MODEL_URLS[type] || [];
|
||||
|
||||
const modelsDownloaded = fs.existsSync(modelDir) && files.every(f => fs.existsSync(path.join(modelDir, f.file)));
|
||||
const binaryPresent = fs.existsSync(path.join(binaryDir, BINARY_NAME[type] || 'main'));
|
||||
|
||||
return {
|
||||
available: modelsDownloaded && binaryPresent,
|
||||
modelsDownloaded,
|
||||
binaryPresent,
|
||||
};
|
||||
}
|
||||
|
||||
ipcMain.handle('download:check', (_, type) => checkModelStatus(type));
|
||||
ipcMain.handle('download:status', () => ({
|
||||
piper: checkModelStatus('piper'),
|
||||
whisper: checkModelStatus('whisper'),
|
||||
}));
|
||||
1
electron/package.json
Normal file
1
electron/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "type": "commonjs" }
|
||||
34
electron/preload.js
Normal file
34
electron/preload.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Platform info (synchronous — available immediately without IPC)
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
// Persistent config (server URL, session token)
|
||||
config: {
|
||||
get: (key) => ipcRenderer.invoke('config:get', key),
|
||||
set: (key, value) => ipcRenderer.invoke('config:set', key, value),
|
||||
delete: (key) => ipcRenderer.invoke('config:delete', key),
|
||||
},
|
||||
// Text-to-speech via Piper
|
||||
tts: {
|
||||
speak: (text) => ipcRenderer.invoke('tts:speak', text),
|
||||
stop: () => ipcRenderer.invoke('tts:stop'),
|
||||
},
|
||||
// Speech-to-text via whisper.cpp
|
||||
stt: {
|
||||
transcribe: (audioBase64) => ipcRenderer.invoke('stt:transcribe', audioBase64),
|
||||
},
|
||||
// Model downloads
|
||||
download: {
|
||||
start: (type) => ipcRenderer.invoke('download:start', type),
|
||||
check: (type) => ipcRenderer.invoke('download:check', type),
|
||||
status: () => ipcRenderer.invoke('download:status'),
|
||||
onProgress: (callback) => {
|
||||
const listener = (_, data) => callback(data);
|
||||
ipcRenderer.on('download:progress', listener);
|
||||
// Returns a cleanup function so callers can remove the listener
|
||||
return () => ipcRenderer.removeListener('download:progress', listener);
|
||||
},
|
||||
},
|
||||
});
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sarthi Lab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
64
launch.bat
Normal file
64
launch.bat
Normal file
@@ -0,0 +1,64 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Handle commands
|
||||
if "%1"=="upgrade" goto upgrade
|
||||
if "%1"=="--help" goto help
|
||||
if "%1"=="-h" goto help
|
||||
if "%1"=="--version" goto version
|
||||
if "%1"=="-v" goto version
|
||||
|
||||
REM Normal launch
|
||||
echo Starting Sarthi Lab...
|
||||
call npm run dev
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:upgrade
|
||||
echo Upgrading Sarthi Lab...
|
||||
|
||||
REM Check if git is available
|
||||
where git >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Error: git is not installed. Please install git first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Pull latest changes
|
||||
echo Pulling latest changes from repository...
|
||||
git pull
|
||||
|
||||
REM Install/update dependencies
|
||||
echo Installing/updating dependencies...
|
||||
call npm install
|
||||
|
||||
echo Upgrade complete! Starting Sarthi Lab...
|
||||
call npm run dev
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:help
|
||||
echo Sarthi Lab Launcher
|
||||
echo Usage: sarthi-lab [command]
|
||||
echo.
|
||||
echo Commands:
|
||||
echo (no command) Start Sarthi Lab (npm run dev)
|
||||
echo upgrade Pull latest git changes and update dependencies
|
||||
echo --help, -h Show this help
|
||||
echo --version, -v Show version
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:version
|
||||
REM Try to extract version from package.json using node
|
||||
where node >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo Sarthi Lab (version unknown - node not installed)
|
||||
) else (
|
||||
for /f "delims=" %%i in ('node -p "require('./package.json').version"') do (
|
||||
echo Sarthi Lab %%i
|
||||
)
|
||||
)
|
||||
pause
|
||||
exit /b 0
|
||||
46
launch.sh
Executable file
46
launch.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Handle commands
|
||||
case "$1" in
|
||||
upgrade)
|
||||
echo "Upgrading Sarthi Lab..."
|
||||
|
||||
# Check if git is available
|
||||
if ! command -v git &> /dev/null; then
|
||||
echo "Error: git is not installed. Please install git first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull latest changes
|
||||
echo "Pulling latest changes from repository..."
|
||||
git pull
|
||||
|
||||
# Install/update dependencies
|
||||
echo "Installing/updating dependencies..."
|
||||
npm install
|
||||
|
||||
echo "Upgrade complete! Starting Sarthi Lab..."
|
||||
npm run dev
|
||||
exit 0
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Sarthi Lab Launcher"
|
||||
echo "Usage: sarthi-lab [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " (no command) Start Sarthi Lab (npm run dev)"
|
||||
echo " upgrade Pull latest git changes and update dependencies"
|
||||
echo " --help, -h Show this help"
|
||||
echo " --version, -v Show version"
|
||||
exit 0
|
||||
;;
|
||||
--version|-v)
|
||||
echo "Sarthi Lab $(node -p "require('./package.json').version")"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Normal launch
|
||||
echo "Starting Sarthi Lab..."
|
||||
npm run dev
|
||||
9405
package-lock.json
generated
Normal file
9405
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
package.json
Normal file
67
package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "sarthi-lab",
|
||||
"version": "1.0.0",
|
||||
"description": "Sarthi Lab Desktop — AI learning for school computer labs",
|
||||
"type": "module",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"wait-on http://localhost:5174 && electron .\"",
|
||||
"build": "vite build && electron-builder",
|
||||
"build:mac": "vite build && electron-builder --mac",
|
||||
"build:win": "vite build && electron-builder --win",
|
||||
"build:linux": "vite build && electron-builder --linux",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.2.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mermaid": "^11.12.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^31.0.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"vite": "^5.3.1",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.eduspheria.sarthi-lab",
|
||||
"productName": "Sarthi Lab",
|
||||
"directories": {
|
||||
"output": "dist-electron"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis"
|
||||
},
|
||||
"mac": {
|
||||
"target": "dmg",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/piper",
|
||||
"to": "piper",
|
||||
"filter": ["!*.onnx", "!*.onnx.json"]
|
||||
},
|
||||
{
|
||||
"from": "electron/whisper",
|
||||
"to": "whisper",
|
||||
"filter": ["!ggml-*.bin"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
postcss.config.js
Normal file
3
postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
80
src/App.jsx
Normal file
80
src/App.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect } from 'react';
|
||||
import useLabStore from './store/labStore';
|
||||
import { getRoomStatus } from './services/labApi';
|
||||
import { detectOllama, setOllamaModel } from './services/localLLM';
|
||||
|
||||
import SetupScreen from './screens/SetupScreen';
|
||||
import WaitingScreen from './screens/WaitingScreen';
|
||||
import StudentLoginScreen from './screens/StudentLoginScreen';
|
||||
import DashboardScreen from './screens/DashboardScreen';
|
||||
import CourseChatScreen from './screens/CourseChatScreen';
|
||||
import GeneralChatScreen from './screens/GeneralChatScreen';
|
||||
import ExerciseScreen from './screens/ExerciseScreen';
|
||||
import SettingsScreen from './screens/SettingsScreen';
|
||||
|
||||
const screens = {
|
||||
'setup': SetupScreen,
|
||||
'waiting': WaitingScreen,
|
||||
'student-login': StudentLoginScreen,
|
||||
'dashboard': DashboardScreen,
|
||||
'course-chat': CourseChatScreen,
|
||||
'general-chat': GeneralChatScreen,
|
||||
'exercise': ExerciseScreen,
|
||||
'settings': SettingsScreen,
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { screen, theme, setConfig, setRoomStatus, setTheme, navigate } = useLabStore();
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// On startup: load saved config
|
||||
useEffect(() => {
|
||||
async function bootstrap() {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
const savedUrl = await window.electronAPI.config.get('serverUrl');
|
||||
const savedRoomToken = await window.electronAPI.config.get('roomToken');
|
||||
const savedTheme = await window.electronAPI.config.get('theme');
|
||||
|
||||
if (savedTheme) setTheme(savedTheme);
|
||||
|
||||
if (savedUrl && savedRoomToken) {
|
||||
try {
|
||||
const roomStatus = await getRoomStatus(savedUrl, savedRoomToken);
|
||||
setConfig(savedUrl, savedRoomToken);
|
||||
setRoomStatus(roomStatus);
|
||||
} catch {
|
||||
await window.electronAPI.config.delete('serverUrl');
|
||||
await window.electronAPI.config.delete('roomToken');
|
||||
navigate('setup');
|
||||
}
|
||||
}
|
||||
|
||||
detectOllama();
|
||||
|
||||
const savedModel = await window.electronAPI.config.get('ollamaModel');
|
||||
if (savedModel) setOllamaModel(savedModel);
|
||||
}
|
||||
|
||||
// Set default theme if not in electron
|
||||
if (!window.electronAPI) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
}, []);
|
||||
|
||||
const Screen = screens[screen] || SetupScreen;
|
||||
|
||||
return (
|
||||
<div className="font-sans antialiased">
|
||||
<div key={screen} className="animate-fade-in">
|
||||
<Screen />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/AIThinking.jsx
Normal file
53
src/components/AIThinking.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Sparkles, Brain, Zap } from 'lucide-react';
|
||||
|
||||
const AIThinking = memo(({ className = '' }) => {
|
||||
return (
|
||||
<div className={`flex items-center space-x-3 ${className}`} role="status" aria-label="AI is thinking">
|
||||
{/* Animated AI Avatar */}
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500 via-violet-600 to-fuchsia-500 flex items-center justify-center shadow-lg shadow-violet-500/30 animate-glow-pulse" aria-hidden="true">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{/* Orbiting dots */}
|
||||
<div className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<div className="w-14 h-14 border border-violet-300/30 rounded-2xl animate-ping" style={{ animationDuration: '2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thinking Animation */}
|
||||
<div className="flex items-center space-x-3 bg-white/10 backdrop-blur-md rounded-2xl px-5 py-3 border border-white/10 shadow-soft">
|
||||
{/* Animated bars */}
|
||||
<div className="flex items-end space-x-1 h-5" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 bg-gradient-to-t from-violet-400 to-fuchsia-400 rounded-full animate-bounce"
|
||||
style={{
|
||||
height: '100%',
|
||||
animationDuration: '0.6s',
|
||||
animationDelay: `${i * 0.15}s`,
|
||||
transform: 'scaleY(0.4)',
|
||||
transformOrigin: 'bottom',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-slate-300">Sarthi is thinking</span>
|
||||
|
||||
{/* Brain icon with pulse */}
|
||||
<div className="relative" aria-hidden="true">
|
||||
<Brain className="w-4 h-4 text-violet-400" />
|
||||
<div className="absolute inset-0 w-full h-full">
|
||||
<Zap className="w-2 h-2 text-amber-400 absolute -top-1 -right-1 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AIThinking.displayName = 'AIThinking';
|
||||
|
||||
export default AIThinking;
|
||||
161
src/components/MarkdownRenderer.jsx
Normal file
161
src/components/MarkdownRenderer.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
|
||||
const MarkdownRenderer = ({ content, className = '' }) => {
|
||||
const renderMarkdown = (text) => {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
const elements = [];
|
||||
let listItems = [];
|
||||
let tableRows = [];
|
||||
|
||||
const formatInlineMarkdown = (lineText) => {
|
||||
// Bold **text**
|
||||
let formatted = lineText.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-current">$1</strong>');
|
||||
// Italic *text*
|
||||
formatted = formatted.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>');
|
||||
// Inline code `text`
|
||||
formatted = formatted.replace(/`(.*?)`/g, '<code class="bg-white/10 text-emerald-400 px-1.5 py-0.5 rounded-md text-xs font-mono">$1</code>');
|
||||
|
||||
return <span dangerouslySetInnerHTML={{ __html: formatted }} />;
|
||||
};
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`list-${elements.length}`} className="list-disc list-inside space-y-2 my-3">
|
||||
{listItems.map((item, idx) => (
|
||||
<li key={idx} className="text-sm text-current">
|
||||
{formatInlineMarkdown(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
listItems = [];
|
||||
}
|
||||
};
|
||||
|
||||
const flushTable = () => {
|
||||
if (tableRows.length > 0) {
|
||||
const rows = tableRows.map(row => {
|
||||
return row.split('|').map(cell => cell.trim()).filter(cell => cell);
|
||||
}).filter(row => row.length > 0);
|
||||
|
||||
if (rows.length > 0) {
|
||||
elements.push(
|
||||
<div key={`table-${elements.length}`} className="my-4 overflow-x-auto rounded-xl border border-white/10">
|
||||
<table className="min-w-full border-collapse">
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className={rowIndex === 0 ? "bg-white/5" : "bg-transparent"}>
|
||||
{row.map((cell, cellIndex) => {
|
||||
const isHeader = rowIndex === 0;
|
||||
const CellTag = isHeader ? 'th' : 'td';
|
||||
return (
|
||||
<CellTag
|
||||
key={cellIndex}
|
||||
className={`border-b border-white/5 px-4 py-3 text-sm ${isHeader
|
||||
? 'font-semibold text-white'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{formatInlineMarkdown(cell)}
|
||||
</CellTag>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
tableRows = [];
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for table rows
|
||||
if (trimmed.includes('|') && !trimmed.startsWith('#') && !trimmed.startsWith('-') && !trimmed.startsWith('*')) {
|
||||
if (!trimmed.match(/^\|[\s\-:]+\|[\s\-:]+\|/)) {
|
||||
tableRows.push(trimmed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Headers
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
flushTable();
|
||||
elements.push(
|
||||
<h3 key={index} className="text-lg font-bold text-white mt-4 mb-2">
|
||||
{trimmed.substring(4)}
|
||||
</h3>
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
flushTable();
|
||||
elements.push(
|
||||
<h2 key={index} className="text-xl font-bold text-white mt-5 mb-3">
|
||||
{trimmed.substring(3)}
|
||||
</h2>
|
||||
);
|
||||
} else if (trimmed.startsWith('# ')) {
|
||||
flushList();
|
||||
flushTable();
|
||||
elements.push(
|
||||
<h1 key={index} className="text-2xl font-bold text-white mt-5 mb-3">
|
||||
{trimmed.substring(2)}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
// Lists
|
||||
else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
flushTable();
|
||||
listItems.push(trimmed.substring(2));
|
||||
}
|
||||
// Numbered lists
|
||||
else if (trimmed.match(/^\d+\.\s/)) {
|
||||
flushTable();
|
||||
flushList();
|
||||
const item = trimmed.replace(/^\d+\.\s/, '');
|
||||
elements.push(
|
||||
<div key={index} className="flex items-start space-x-2 my-2">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-lg bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs font-semibold">
|
||||
{trimmed.match(/^\d+/)?.[0]}
|
||||
</span>
|
||||
<span className="text-sm text-slate-300 pt-0.5">{formatInlineMarkdown(item)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Empty line
|
||||
else if (trimmed === '') {
|
||||
flushList();
|
||||
flushTable();
|
||||
}
|
||||
// Regular paragraph
|
||||
else if (trimmed) {
|
||||
flushList();
|
||||
flushTable();
|
||||
elements.push(
|
||||
<p key={index} className="text-sm leading-relaxed mb-3 text-slate-300">
|
||||
{formatInlineMarkdown(trimmed)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
flushList();
|
||||
flushTable();
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`prose prose-invert prose-sm max-w-none ${className}`}>
|
||||
{renderMarkdown(content)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
57
src/components/ToolResultPanel.jsx
Normal file
57
src/components/ToolResultPanel.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { X, Wrench } from 'lucide-react';
|
||||
import ToolResultRenderer from './tools/ToolResultRenderer';
|
||||
|
||||
const ToolResultPanel = ({
|
||||
toolResult,
|
||||
onClose,
|
||||
onActionClick,
|
||||
disabled = false,
|
||||
}) => {
|
||||
if (!toolResult || !toolResult.structured_data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolType = toolResult.structured_data.type;
|
||||
|
||||
return (
|
||||
<div className="h-full w-[50%] glass border-l border-white/10 flex flex-col shadow-2xl animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-white/5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center shadow-sm">
|
||||
<Wrench className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-slate-100 uppercase tracking-wider">
|
||||
{toolResult.tool_name?.replace(/_/g, ' ') || 'Tool Result'}
|
||||
</span>
|
||||
{toolType && (
|
||||
<span className="ml-2 text-[10px] px-2 py-0.5 bg-violet-500/20 text-violet-300 rounded-full uppercase font-bold">
|
||||
{toolType.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-white/10 transition-colors text-slate-400 hover:text-white"
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-transparent scrollbar-thin">
|
||||
<ToolResultRenderer
|
||||
data={toolResult.structured_data}
|
||||
onActionClick={onActionClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolResultPanel;
|
||||
45
src/components/tools/ActionButtons.jsx
Normal file
45
src/components/tools/ActionButtons.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { PlayCircle, HelpCircle, Activity } from 'lucide-react';
|
||||
|
||||
const ActionButtons = ({ data, onOptionClick, disabled }) => {
|
||||
if (!data || !data.options) return null;
|
||||
|
||||
const renderIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'exercise':
|
||||
return <PlayCircle className="w-3.5 h-3.5" />;
|
||||
case 'quiz':
|
||||
return <HelpCircle className="w-3.5 h-3.5" />;
|
||||
default:
|
||||
return <Activity className="w-3.5 h-3.5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{data.options.map((option, idx) => {
|
||||
const isObject = typeof option === 'object';
|
||||
const text = isObject ? option.text : option;
|
||||
const action = isObject ? (option.type ? option : text) : text;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onOptionClick(action)}
|
||||
disabled={disabled}
|
||||
className="group px-4 py-2 bg-white/5 border border-white/10 rounded-xl text-sm text-slate-200 hover:bg-emerald-500/10 hover:border-emerald-500/30 hover:text-emerald-400 transition-all duration-300 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isObject && option.type && (
|
||||
<span className="opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{renderIcon(option.type)}
|
||||
</span>
|
||||
)}
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButtons;
|
||||
171
src/components/tools/ConceptExplanation.jsx
Normal file
171
src/components/tools/ConceptExplanation.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
|
||||
const ConceptExplanation = ({ data, onActionClick, disabled }) => {
|
||||
const mermaidRef = useRef(null);
|
||||
const [mermaidLoaded, setMermaidLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMermaid = async () => {
|
||||
try {
|
||||
const mermaid = await import('mermaid');
|
||||
mermaid.default.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
setMermaidLoaded(true);
|
||||
} catch (error) {
|
||||
setMermaidLoaded(true);
|
||||
}
|
||||
};
|
||||
loadMermaid();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mermaidLoaded && data.explanation?.mermaid_diagram && mermaidRef.current) {
|
||||
const renderDiagram = async () => {
|
||||
try {
|
||||
const mermaid = await import('mermaid');
|
||||
const element = mermaidRef.current;
|
||||
if (element) {
|
||||
element.innerHTML = '';
|
||||
const { svg } = await mermaid.default.render(`diagram-${Date.now()}`, data.explanation.mermaid_diagram);
|
||||
element.innerHTML = svg;
|
||||
}
|
||||
} catch (error) {
|
||||
if (mermaidRef.current) {
|
||||
mermaidRef.current.innerHTML = `<div class="text-xs text-slate-500 p-4 border border-white/10 rounded-xl">Diagram: ${data.explanation?.visual_description || 'Visual representation'}</div>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
renderDiagram();
|
||||
}
|
||||
}, [mermaidLoaded, data.explanation?.mermaid_diagram]);
|
||||
|
||||
if (!data.success || !data.explanation) {
|
||||
return (
|
||||
<div className="mt-3 p-4 bg-red-500/10 border border-red-500/20 rounded-xl">
|
||||
<p className="text-red-400 text-sm">{data.message || 'Failed to load explanation.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { explanation } = data;
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-5 bg-white/5 rounded-2xl border border-white/10">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-bold text-white mb-2">{explanation.title}</h3>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 capitalize">
|
||||
{data.explanation_style}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
{data.student_level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 p-4 bg-white/[0.03] rounded-xl border border-white/5">
|
||||
<p className="text-slate-300 text-sm leading-relaxed">{explanation.summary}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<MarkdownRenderer content={explanation.main_content} />
|
||||
</div>
|
||||
|
||||
{explanation.mermaid_diagram && (
|
||||
<div className="mb-6">
|
||||
<div className="bg-white/5 rounded-xl border border-white/10 p-4">
|
||||
<div ref={mermaidRef} className="flex justify-center items-center min-h-[150px]">
|
||||
{!mermaidLoaded && <span className="text-xs text-slate-500 animate-pulse">Loading diagram...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explanation.key_points && explanation.key_points.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-semibold text-white mb-3">Key Takeaways</h4>
|
||||
<div className="space-y-2">
|
||||
{explanation.key_points.map((point, idx) => (
|
||||
<div key={idx} className="flex gap-3 p-3 bg-white/[0.03] rounded-xl border border-white/5">
|
||||
<span className="text-emerald-400 text-sm font-bold">{idx + 1}.</span>
|
||||
<span className="text-slate-300 text-sm">{point}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explanation.checkpoints && explanation.checkpoints.length > 0 && (
|
||||
<div className="mb-6 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-emerald-400 uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-emerald-400 rounded-full animate-ping" />
|
||||
Understanding Checkpoints
|
||||
</h4>
|
||||
{explanation.checkpoints.map((cp, idx) => (
|
||||
<Checkpoint key={idx} cp={cp} idx={idx} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explanation.follow_up_questions && explanation.follow_up_questions.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-white/5">
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Think About This</h4>
|
||||
<div className="space-y-2">
|
||||
{explanation.follow_up_questions.map((q, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onActionClick && onActionClick(q)}
|
||||
disabled={disabled}
|
||||
className="w-full text-left p-3 rounded-xl bg-emerald-500/5 border border-emerald-500/10 text-emerald-400 text-sm hover:bg-emerald-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Checkpoint = ({ cp, idx }) => {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white/[0.03] rounded-2xl border border-white/10 hover:border-emerald-500/30 transition-all group">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-400 font-bold text-xs shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-slate-200 font-medium mb-3">{cp.question}</p>
|
||||
|
||||
{revealed ? (
|
||||
<div className="p-3 bg-emerald-500/10 rounded-xl border border-emerald-500/20 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<p className="text-xs text-emerald-400 leading-relaxed font-medium">
|
||||
<span className="uppercase text-[10px] opacity-70 block mb-1">Feedback</span>
|
||||
{cp.answer}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setRevealed(true)}
|
||||
className="text-[10px] font-bold text-emerald-400 uppercase tracking-widest hover:text-emerald-300 transition-colors flex items-center gap-1 group-hover:translate-x-1 duration-300"
|
||||
>
|
||||
Verify Understanding
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptExplanation;
|
||||
89
src/components/tools/CourseSearch.jsx
Normal file
89
src/components/tools/CourseSearch.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Book, ChevronRight, Star } from 'lucide-react';
|
||||
|
||||
const CourseSearch = ({ data, onActionClick, disabled }) => {
|
||||
const { courses, message, found, recommended_difficulty } = data;
|
||||
|
||||
if (!found || !courses || courses.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center">
|
||||
<p className="text-sm text-slate-400">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
{message && <p className="text-sm text-slate-300 font-medium">{message}</p>}
|
||||
{recommended_difficulty && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
||||
<Star className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-tighter">
|
||||
Adaptive Level: {recommended_difficulty}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{courses.map((course) => (
|
||||
<div
|
||||
key={course.id}
|
||||
className="group relative p-4 bg-white/5 hover:bg-white/10 rounded-2xl border border-white/10 transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-emerald-500/20 to-teal-500/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition duration-500" />
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500/20 to-teal-500/20 flex items-center justify-center border border-white/10">
|
||||
{course.icon ? (
|
||||
<img src={course.icon} alt="" className="w-8 h-8 object-contain opacity-80" />
|
||||
) : (
|
||||
<Book className="w-6 h-6 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-semibold text-white truncate">{course.name}</h4>
|
||||
<span className="px-1.5 py-0.5 rounded-md bg-white/5 border border-white/10 text-[10px] text-emerald-400 font-medium whitespace-nowrap">
|
||||
{course.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed mb-3">
|
||||
{course.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-emerald-500/50" />
|
||||
{course.num_levels} Levels
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-teal-500/50" />
|
||||
{course.num_exercises} Exercises
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onActionClick && onActionClick({ type: 'navigate_course', courseId: course.id })}
|
||||
disabled={disabled}
|
||||
className="mt-1 p-2 rounded-lg bg-white/5 border border-white/10 hover:border-emerald-500/50 text-slate-400 hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 italic px-1">
|
||||
Found {data.total} relevant courses.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseSearch;
|
||||
116
src/components/tools/ExerciseSearch.jsx
Normal file
116
src/components/tools/ExerciseSearch.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { Activity, ChevronRight, Zap } from 'lucide-react';
|
||||
|
||||
const ExerciseSearch = ({ data, onActionClick, disabled }) => {
|
||||
const { exercises, message, found, recommended_difficulty } = data;
|
||||
|
||||
if (!found || !exercises || exercises.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center">
|
||||
<p className="text-sm text-slate-400">{message}</p>
|
||||
{recommended_difficulty && (
|
||||
<p className="text-[10px] text-teal-400/60 mt-2 font-medium">
|
||||
Recommended Difficulty: <span className="uppercase">{recommended_difficulty}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
{message && <p className="text-sm text-slate-300 font-medium">{message}</p>}
|
||||
{recommended_difficulty && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-teal-500/10 border border-teal-500/20">
|
||||
<Zap className="w-3 h-3 text-teal-400" />
|
||||
<span className="text-[10px] font-bold text-teal-400 uppercase tracking-tighter">
|
||||
Target: {recommended_difficulty}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{exercises.map((ex) => {
|
||||
const isRecommended = ex.difficulty.toLowerCase() === recommended_difficulty?.toLowerCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ex.id}
|
||||
className={`group relative p-4 bg-white/5 hover:bg-white/10 rounded-2xl border transition-all duration-300 overflow-hidden ${isRecommended ? 'border-teal-500/30' : 'border-white/10'
|
||||
}`}
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<div className={`absolute -inset-1 bg-gradient-to-r ${isRecommended
|
||||
? 'from-teal-500/30 to-emerald-500/30 opacity-100'
|
||||
: 'from-teal-500/20 to-emerald-500/20 opacity-0 group-hover:opacity-100'
|
||||
} rounded-2xl blur transition duration-500`} />
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center border ${isRecommended
|
||||
? 'bg-gradient-to-br from-teal-500/30 to-emerald-500/30 border-teal-500/40'
|
||||
: 'bg-white/5 border-white/10'
|
||||
}`}>
|
||||
{ex.thumbnail ? (
|
||||
<img src={ex.thumbnail} alt="" className="w-8 h-8 object-contain opacity-80" />
|
||||
) : (
|
||||
<Activity className={`w-6 h-6 ${isRecommended ? 'text-teal-300' : 'text-teal-400/60'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-semibold text-white truncate">{ex.name}</h4>
|
||||
{isRecommended && (
|
||||
<span className="px-1.5 py-0.5 rounded-md bg-teal-500/20 border border-teal-500/30 text-[9px] text-teal-300 font-bold uppercase tracking-tight">
|
||||
Best Fit
|
||||
</span>
|
||||
)}
|
||||
<span className="px-1.5 py-0.5 rounded-md bg-white/5 border border-white/10 text-[10px] text-slate-400 font-medium whitespace-nowrap">
|
||||
{ex.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed mb-3">
|
||||
{ex.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-[10px] text-slate-500">
|
||||
<div className={`flex items-center gap-1 ${isRecommended ? 'text-teal-400' : ''}`}>
|
||||
<Zap className={`w-3 h-3 ${isRecommended ? 'text-teal-400' : 'text-amber-500/60'}`} />
|
||||
<span className="capitalize font-bold">{ex.difficulty}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-slate-700" />
|
||||
{ex.duration} Mins
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onActionClick && onActionClick({ type: 'start_exercise', exerciseId: ex.id })}
|
||||
disabled={disabled}
|
||||
className={`mt-1 p-2 rounded-lg border transition-all ${isRecommended
|
||||
? 'bg-teal-500/20 border-teal-500/40 text-teal-300 hover:bg-teal-500/40'
|
||||
: 'bg-white/5 border-white/10 text-slate-400 hover:text-teal-400 hover:border-teal-500/50'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 italic px-1 flex justify-between">
|
||||
<span>Found {data.total} interactive exercises for you.</span>
|
||||
{recommended_difficulty && (
|
||||
<span className="text-teal-500/60 font-medium">Difficulty adapted to your profile.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExerciseSearch;
|
||||
71
src/components/tools/MathSolution.jsx
Normal file
71
src/components/tools/MathSolution.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Calculator, CheckCircle2, ChevronRight, Hash } from 'lucide-react';
|
||||
|
||||
const MathSolution = ({ data }) => {
|
||||
const { success, message, solution, steps, problem_type, latex } = data;
|
||||
|
||||
if (!success) {
|
||||
return (
|
||||
<div className="p-4 bg-red-500/5 rounded-xl border border-red-500/10">
|
||||
<p className="text-sm text-red-400">{message || 'Could not solve the math problem.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Calculator className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-xs font-semibold text-slate-300 uppercase tracking-widest">
|
||||
{problem_type?.replace('_', ' ') || 'Math Solution'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{steps && steps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<div key={idx} className="flex gap-3 group">
|
||||
<div className="flex flex-col items-center gap-1 mt-1">
|
||||
<div className="w-4 h-4 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-[10px] text-slate-500 group-hover:border-emerald-500/30 group-hover:text-emerald-400 transition-colors">
|
||||
{idx + 1}
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className="w-px flex-1 bg-white/5 group-hover:bg-emerald-500/10" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<p className="text-sm text-slate-400 leading-relaxed group-hover:text-slate-300 transition-colors">
|
||||
{step}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Solution */}
|
||||
<div className="relative p-5 bg-emerald-500/5 rounded-2xl border border-emerald-500/20 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400/30" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-emerald-400/70 font-medium uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
||||
Final Result
|
||||
</p>
|
||||
|
||||
<div className="text-lg font-bold text-white font-mono break-all">
|
||||
{typeof solution === 'object' ? JSON.stringify(solution) : solution}
|
||||
</div>
|
||||
|
||||
{latex && (
|
||||
<div className="mt-3 pt-3 border-t border-emerald-500/10 text-xs text-slate-500 font-mono">
|
||||
{latex}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MathSolution;
|
||||
55
src/components/tools/MathVisualization.jsx
Normal file
55
src/components/tools/MathVisualization.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Maximize2, Image as ImageIcon, FunctionSquare } from 'lucide-react';
|
||||
|
||||
const MathVisualization = ({ data }) => {
|
||||
const { success, message, plot_data, expression, range } = data;
|
||||
|
||||
if (!success || !plot_data) {
|
||||
return (
|
||||
<div className="p-4 bg-red-500/5 rounded-xl border border-red-500/10">
|
||||
<p className="text-sm text-red-400">{message || 'Could not generate visualization.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FunctionSquare className="w-4 h-4 text-teal-400" />
|
||||
<span className="text-xs font-semibold text-slate-300 uppercase tracking-widest">
|
||||
Visualization
|
||||
</span>
|
||||
</div>
|
||||
{range && (
|
||||
<span className="text-[10px] text-slate-500 bg-white/5 px-2 py-0.5 rounded-full border border-white/5">
|
||||
Range: {range}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="group relative rounded-2xl overflow-hidden border border-white/10 bg-slate-950/50">
|
||||
<img
|
||||
src={plot_data}
|
||||
alt={`Plot of ${expression}`}
|
||||
className="w-full h-auto object-contain min-h-[200px] transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* Overlay with details */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4">
|
||||
<div className="p-3 bg-white/5 backdrop-blur-md rounded-xl border border-white/10">
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Function</p>
|
||||
<p className="text-sm font-mono text-teal-400 font-bold">f(x) = {expression}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-1 text-[10px] text-slate-500">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
<span>Generated automatically using Matplotlib/Agg backend.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MathVisualization;
|
||||
150
src/components/tools/QuizResult.jsx
Normal file
150
src/components/tools/QuizResult.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const QuizResult = ({ data, onActionClick, disabled }) => {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [userAnswers, setUserAnswers] = useState([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [quizStarted, setQuizStarted] = useState(false);
|
||||
|
||||
if (!data.success || !data.quiz) {
|
||||
return (
|
||||
<div className="mt-3 p-4 bg-red-500/10 border border-red-500/20 rounded-xl">
|
||||
<p className="text-red-400 text-sm">{data.message || 'Failed to load quiz.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { quiz } = data;
|
||||
const currentQuestion = quiz.questions[currentQuestionIndex];
|
||||
const isLastQuestion = currentQuestionIndex === quiz.questions.length - 1;
|
||||
|
||||
const handleAnswerSelect = (answer) => {
|
||||
const newAnswers = [...userAnswers];
|
||||
newAnswers[currentQuestionIndex] = answer;
|
||||
setUserAnswers(newAnswers);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastQuestion) setShowResults(true);
|
||||
else setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
};
|
||||
|
||||
if (!quizStarted) {
|
||||
return (
|
||||
<div className="mt-4 p-6 bg-white/5 rounded-2xl border border-white/10 text-center">
|
||||
<div className="text-3xl mb-3">📝</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">{quiz.topic} Quiz</h3>
|
||||
<p className="text-slate-400 text-sm mb-6">{quiz.total_questions} questions · {quiz.estimated_time}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuizStarted(true);
|
||||
setUserAnswers(new Array(quiz.questions.length).fill(null));
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-8 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-600 transition-all shadow-lg shadow-emerald-500/20 disabled:opacity-50"
|
||||
>
|
||||
Start Quiz
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showResults) {
|
||||
const correctCount = quiz.questions.filter((q, idx) => {
|
||||
const ans = userAnswers[idx];
|
||||
if (q.type === 'multiple_choice') return ans === String.fromCharCode(65 + q.correct_answer);
|
||||
if (q.type === 'true_false') return ans === q.correct_answer;
|
||||
return true;
|
||||
}).length;
|
||||
const score = Math.round((correctCount / quiz.questions.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="p-6 bg-emerald-500/10 border border-emerald-500/20 rounded-2xl text-center">
|
||||
<h3 className="text-xl font-bold text-white mb-1">Quiz Complete!</h3>
|
||||
<div className="text-3xl font-bold text-emerald-400 my-2">{score}%</div>
|
||||
<p className="text-slate-400 text-sm">{correctCount} of {quiz.questions.length} correct</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setQuizStarted(false); setShowResults(false); setCurrentQuestionIndex(0); }}
|
||||
className="flex-1 py-2.5 bg-white/5 border border-white/10 rounded-xl text-sm text-slate-300 hover:bg-white/10"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onActionClick && onActionClick("Exit quiz")}
|
||||
className="flex-1 py-2.5 bg-white/5 border border-white/10 rounded-xl text-sm text-slate-300 hover:bg-white/10"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-5 bg-white/5 rounded-2xl border border-white/10">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Question {currentQuestionIndex + 1}/{quiz.questions.length}</span>
|
||||
<div className="w-24 h-1.5 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-400 transition-all duration-300" style={{ width: `${((currentQuestionIndex + 1) / quiz.questions.length) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-white font-medium mb-5">{currentQuestion.question}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
{currentQuestion.type === 'multiple_choice' && currentQuestion.options.map((opt, idx) => {
|
||||
const char = String.fromCharCode(65 + idx);
|
||||
const isSelected = userAnswers[currentQuestionIndex] === char;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleAnswerSelect(char)}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${isSelected ? 'bg-emerald-500/10 border-emerald-500/50 text-white' : 'bg-white/[0.03] border-white/5 text-slate-400 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold mr-3">{char}.</span> {opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{currentQuestion.type === 'true_false' && [true, false].map((val) => {
|
||||
const isSelected = userAnswers[currentQuestionIndex] === val;
|
||||
return (
|
||||
<button
|
||||
key={val.toString()}
|
||||
onClick={() => handleAnswerSelect(val)}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-4 py-3 rounded-xl border transition-all ${isSelected ? 'bg-emerald-500/10 border-emerald-500/50 text-white' : 'bg-white/[0.03] border-white/5 text-slate-400 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{val ? 'True' : 'False'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between gap-3">
|
||||
<button
|
||||
onClick={() => setCurrentQuestionIndex(Math.max(0, currentQuestionIndex - 1))}
|
||||
disabled={currentQuestionIndex === 0 || disabled}
|
||||
className="flex-1 py-2 bg-white/5 border border-white/10 rounded-xl text-xs text-slate-400 disabled:opacity-30"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={userAnswers[currentQuestionIndex] === null || disabled}
|
||||
className="flex-1 py-2 bg-emerald-500 text-white font-bold rounded-xl text-xs disabled:opacity-50"
|
||||
>
|
||||
{isLastQuestion ? 'Finish' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizResult;
|
||||
99
src/components/tools/ReadinessAssessment.jsx
Normal file
99
src/components/tools/ReadinessAssessment.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, XCircle, Info, Sparkles } from 'lucide-react';
|
||||
|
||||
const ReadinessAssessment = ({ data, onActionClick, disabled }) => {
|
||||
const { topic, readiness_score, ready_for_advance, recommendation, success, message } = data;
|
||||
|
||||
if (!success) {
|
||||
return (
|
||||
<div className="mt-4 p-5 bg-white/5 border border-white/10 rounded-2xl text-center">
|
||||
<p className="text-slate-400 text-sm">{message || 'Readiness data unavailable.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percentage = (readiness_score * 100).toFixed(0);
|
||||
const strokeDasharray = 251.2; // 2 * PI * 40
|
||||
const strokeDashoffset = strokeDasharray - (readiness_score * strokeDasharray);
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-6 bg-white/5 rounded-3xl border border-white/10 relative overflow-hidden group">
|
||||
{/* Animated background glow */}
|
||||
<div className={`absolute top-0 left-1/2 -translate-x-1/2 w-48 h-48 blur-[100px] transition-colors duration-1000 ${ready_for_advance ? 'bg-emerald-500/20' : 'bg-amber-500/10'
|
||||
}`} />
|
||||
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="relative w-32 h-32 mb-6">
|
||||
{/* Circle Progress */}
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="40"
|
||||
className="fill-none stroke-white/5 stroke-[8]"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="40"
|
||||
className={`fill-none stroke-[8] stroke-round transition-all duration-1000 ease-out ${ready_for_advance ? 'stroke-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.5)]' : 'stroke-amber-500'
|
||||
}`}
|
||||
style={{
|
||||
strokeDasharray,
|
||||
strokeDashoffset
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-black text-white">{percentage}%</span>
|
||||
<span className="text-[10px] text-slate-500 uppercase font-black tracking-tighter">Readiness</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full border mb-4 ${ready_for_advance
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-amber-500/10 border-amber-500/20 text-amber-400'
|
||||
}`}>
|
||||
{ready_for_advance ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Ready to Advance</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">More Practice Needed</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-slate-200 px-4">
|
||||
Status for: <span className="text-white italic">"{topic}"</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="w-full p-4 bg-white/[0.03] rounded-2xl border border-white/5 flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<Info className="w-4 h-4 text-slate-500" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 leading-relaxed italic">
|
||||
{recommendation}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{ready_for_advance && (
|
||||
<button
|
||||
onClick={() => onActionClick && onActionClick({ type: 'advance_level', topic })}
|
||||
disabled={disabled}
|
||||
className="w-full mt-6 py-3.5 bg-gradient-to-r from-teal-500 to-emerald-500 rounded-2xl text-white font-bold text-sm shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/40 hover:-translate-y-0.5 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Unlock Next Level
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadinessAssessment;
|
||||
77
src/components/tools/SkillProgress.jsx
Normal file
77
src/components/tools/SkillProgress.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { BarChart3, TrendingUp, Clock, Target } from 'lucide-react';
|
||||
|
||||
const SkillProgress = ({ data }) => {
|
||||
const { skill_name, mastery_level, total_attempts, overall_velocity, success, message } = data;
|
||||
|
||||
if (!success) {
|
||||
return (
|
||||
<div className="mt-4 p-5 bg-white/5 border border-white/10 rounded-2xl text-center">
|
||||
<p className="text-slate-400 text-sm">{message || 'Progress data unavailable.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percentage = (mastery_level * 100).toFixed(0);
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-6 bg-white/5 rounded-3xl border border-white/10 relative overflow-hidden group">
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="p-2.5 rounded-xl bg-teal-500/10 border border-teal-500/20">
|
||||
<BarChart3 className="w-5 h-5 text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white uppercase tracking-tight">{skill_name}</h3>
|
||||
<p className="text-xs text-slate-400 font-medium">Mastery Analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-8">
|
||||
<div className="p-4 bg-white/[0.03] rounded-2xl border border-white/5 group-hover:border-teal-500/20 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-500">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Total Practice</span>
|
||||
</div>
|
||||
<div className="text-xl font-black text-white">{total_attempts}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Exercises Completed</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white/[0.03] rounded-2xl border border-white/5 group-hover:border-emerald-500/20 transition-all duration-300">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-500">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Learning Velocity</span>
|
||||
</div>
|
||||
<div className="text-xl font-black text-white">{overall_velocity.toFixed(1)}x</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Relative to Average</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-widest">
|
||||
<Target className="w-4 h-4 text-emerald-400" />
|
||||
Current Mastery
|
||||
</div>
|
||||
<div className="text-2xl font-black text-emerald-400 tracking-tighter">{percentage}%</div>
|
||||
</div>
|
||||
|
||||
<div className="h-3 w-full bg-white/5 rounded-full overflow-hidden p-0.5 border border-white/5">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-teal-500 to-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.3)] transition-all duration-1000 ease-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 italic text-center pt-2">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual aesthetic decoration */}
|
||||
<div className="absolute bottom-0 right-0 w-48 h-48 bg-teal-500/5 blur-[100px] -mb-24 -mr-24" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillProgress;
|
||||
84
src/components/tools/ToolResultRenderer.jsx
Normal file
84
src/components/tools/ToolResultRenderer.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import ActionButtons from './ActionButtons';
|
||||
import ConceptExplanation from './ConceptExplanation';
|
||||
import QuizResult from './QuizResult';
|
||||
import VocabularyBuilder from './VocabularyBuilder';
|
||||
import CourseSearch from './CourseSearch';
|
||||
import MathSolution from './MathSolution';
|
||||
import MathVisualization from './MathVisualization';
|
||||
import ExerciseSearch from './ExerciseSearch';
|
||||
import WeaknessAnalysis from './WeaknessAnalysis';
|
||||
import ReadinessAssessment from './ReadinessAssessment';
|
||||
import SkillProgress from './SkillProgress';
|
||||
|
||||
const ToolResultRenderer = ({ data, onActionClick, disabled }) => {
|
||||
if (!data || !data.type) return null;
|
||||
|
||||
switch (data.type) {
|
||||
case 'action_buttons':
|
||||
return (
|
||||
<ActionButtons
|
||||
data={data}
|
||||
onOptionClick={onActionClick || (() => { })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'concept_explanation':
|
||||
return <ConceptExplanation data={data} onActionClick={onActionClick} disabled={disabled} />;
|
||||
|
||||
case 'weakness_analysis':
|
||||
return <WeaknessAnalysis data={data} onActionClick={onActionClick} disabled={disabled} />;
|
||||
|
||||
case 'readiness_assessment':
|
||||
return <ReadinessAssessment data={data} onActionClick={onActionClick} disabled={disabled} />;
|
||||
|
||||
case 'skill_progress':
|
||||
return <SkillProgress data={data} />;
|
||||
|
||||
case 'quiz':
|
||||
return <QuizResult data={data} onActionClick={onActionClick} disabled={disabled} />;
|
||||
|
||||
case 'vocabulary_builder':
|
||||
return <VocabularyBuilder data={data} onActionClick={onActionClick} disabled={disabled} />;
|
||||
|
||||
case 'course_search':
|
||||
return <CourseSearch data={data} onActionClick={onActionClick} disabled={disabled} />;
|
||||
|
||||
case 'exercise_search':
|
||||
return <ExerciseSearch data={data} onActionClick={onActionClick} disabled={disabled} />;
|
||||
|
||||
case 'math_solution':
|
||||
return <MathSolution data={data} />;
|
||||
|
||||
case 'math_visualization':
|
||||
return <MathVisualization data={data} />;
|
||||
|
||||
case 'code_block':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{data.explanation && (
|
||||
<p className="text-sm text-slate-300 mb-2">{data.explanation}</p>
|
||||
)}
|
||||
<div className="bg-slate-950 text-emerald-400 p-4 rounded-xl border border-white/10 font-mono text-xs overflow-x-auto">
|
||||
<div className="text-[10px] text-slate-500 mb-2 uppercase tracking-widest">{data.language}</div>
|
||||
<pre className="whitespace-pre-wrap break-words">{data.code}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="mt-3 p-4 bg-white/5 rounded-xl border border-white/10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-1.5 h-1.5 bg-emerald-400 rounded-full" />
|
||||
<span className="text-xs font-medium text-slate-400 capitalize">
|
||||
{data.type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 italic">This content format is partially supported. Interactive version coming soon.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ToolResultRenderer;
|
||||
56
src/components/tools/VocabularyBuilder.jsx
Normal file
56
src/components/tools/VocabularyBuilder.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
const VocabularyBuilder = ({ data, onActionClick, disabled }) => {
|
||||
if (!data.success || !data.vocabulary) {
|
||||
return (
|
||||
<div className="mt-3 p-4 bg-red-500/10 border border-red-500/20 rounded-xl">
|
||||
<p className="text-red-400 text-sm">{data.message || 'Failed to load vocabulary.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-5 bg-white/5 rounded-2xl border border-white/10">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-bold text-white mb-1">Vocabulary Builder</h3>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">{data.topic} · {data.difficulty}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{data.vocabulary.map((v, idx) => (
|
||||
<div key={idx} className="p-4 bg-white/[0.03] border border-white/5 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="text-lg font-bold text-emerald-400">{v.word}</h4>
|
||||
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-white/5 text-slate-500 border border-white/5">
|
||||
{v.part_of_speech}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 mb-3">{v.definition}</p>
|
||||
<div className="p-2.5 bg-black/20 rounded-lg border border-white/5 italic text-xs text-slate-400">
|
||||
<span className="text-emerald-500/50 mr-1 font-bold">Ex:</span> {v.example_sentence}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => onActionClick && onActionClick(`Quiz me on ${data.topic} vocabulary`)}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-emerald-500 text-white text-xs font-bold rounded-xl hover:bg-emerald-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
📝 Quiz Me
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onActionClick && onActionClick(`More words about ${data.topic}`)}
|
||||
disabled={disabled}
|
||||
className="px-4 py-2 bg-white/10 border border-white/10 text-white text-xs rounded-xl hover:bg-white/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
➕ More Words
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VocabularyBuilder;
|
||||
85
src/components/tools/WeaknessAnalysis.jsx
Normal file
85
src/components/tools/WeaknessAnalysis.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, Target, TrendingDown } from 'lucide-react';
|
||||
|
||||
const WeaknessAnalysis = ({ data, onActionClick, disabled }) => {
|
||||
const { weak_areas, focus_recommendations, message, success } = data;
|
||||
|
||||
if (!success || !weak_areas) {
|
||||
return (
|
||||
<div className="mt-4 p-5 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
<p className="text-red-400 text-sm">{message || 'Analysis unavailable.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-6 bg-white/5 rounded-3xl border border-white/10 overflow-hidden relative">
|
||||
{/* Background design elements */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 blur-[80px] -mr-16 -mt-16" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<TrendingDown className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Focus Areas</h3>
|
||||
<p className="text-xs text-slate-400">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 mb-8">
|
||||
{weak_areas.map((area, idx) => (
|
||||
<div key={idx} className="group">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-sm font-semibold text-slate-200 group-hover:text-white transition-colors">
|
||||
{area.skill}
|
||||
</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded bg-white/5 border border-white/10 uppercase tracking-wider ${area.status === 'Struggling' ? 'text-rose-400' : 'text-amber-400'
|
||||
}`}>
|
||||
{area.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-1000 ease-out ${area.mastery < 0.4 ? 'bg-gradient-to-r from-rose-500 to-orange-500' : 'bg-gradient-to-r from-orange-500 to-amber-500'
|
||||
}`}
|
||||
style={{ width: `${area.mastery * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[10px] text-slate-500 font-medium">Mastery Level</span>
|
||||
<span className="text-[10px] text-slate-400 font-bold">{(area.mastery * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{focus_recommendations && focus_recommendations.length > 0 && (
|
||||
<div className="pt-6 border-t border-white/10">
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<Target className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Recommended Actions
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{focus_recommendations.map((rec, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onActionClick && onActionClick(rec)}
|
||||
disabled={disabled}
|
||||
className="w-full text-left p-3.5 rounded-2xl bg-white/[0.03] border border-white/5 text-slate-300 text-xs hover:bg-emerald-500/10 hover:border-emerald-500/20 hover:text-emerald-400 transition-all group flex items-center gap-3"
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/40 group-hover:bg-emerald-500 group-hover:scale-125 transition-all" />
|
||||
{rec}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeaknessAnalysis;
|
||||
112
src/hooks/useExerciseBridge.js
Normal file
112
src/hooks/useExerciseBridge.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { speak } from '../services/tts';
|
||||
import useLabStore from '../store/labStore';
|
||||
|
||||
/**
|
||||
* Hook that bridges postMessage requests from exercise iframes
|
||||
* to the desktop app's TTS (Piper) and server LLM.
|
||||
*
|
||||
* Usage: call useExerciseBridge() in the component that renders the iframe.
|
||||
*/
|
||||
export default function useExerciseBridge() {
|
||||
const { serverUrl, student } = useLabStore();
|
||||
const authToken = student?.auth_token;
|
||||
// Keep refs so the listener always sees latest values
|
||||
const serverUrlRef = useRef(serverUrl);
|
||||
const authTokenRef = useRef(authToken);
|
||||
useEffect(() => { serverUrlRef.current = serverUrl; }, [serverUrl]);
|
||||
useEffect(() => { authTokenRef.current = authToken; }, [authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg !== 'object' || !msg.type) return;
|
||||
|
||||
const { type, id, data } = msg;
|
||||
const reply = (rtype, rdata) => {
|
||||
if (event.source && typeof event.source.postMessage === 'function') {
|
||||
event.source.postMessage({ type: rtype, id, data: rdata }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
if (type === 'REQUEST_VOICE') {
|
||||
try {
|
||||
await speak(data?.text || '');
|
||||
reply('VOICE_GENERATED', { success: true, text: data?.text });
|
||||
} catch (err) {
|
||||
reply('VOICE_ERROR', { error: err?.message || 'TTS failed' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'REQUEST_BATCH_VOICES') {
|
||||
const items = data?.items || [];
|
||||
const results = [];
|
||||
for (const item of items) {
|
||||
try {
|
||||
await speak(item.text || '');
|
||||
results.push({ text: item.text, success: true });
|
||||
} catch (err) {
|
||||
results.push({ text: item.text, success: false, error: err?.message || 'TTS failed' });
|
||||
}
|
||||
}
|
||||
reply('BATCH_VOICES_GENERATED', { items: results });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'REQUEST_LLM') {
|
||||
try {
|
||||
const res = await fetch(`${serverUrlRef.current}/api/llm/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authTokenRef.current ? { Authorization: `Token ${authTokenRef.current}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: data?.prompt || '',
|
||||
type: data?.type || 'question',
|
||||
difficulty: data?.difficulty || 'medium',
|
||||
language: data?.language || 'en',
|
||||
topic: data?.topic || '',
|
||||
...data?.extra_params,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
reply('LLM_GENERATED', { success: true, response: json.response || json });
|
||||
} catch (err) {
|
||||
reply('LLM_ERROR', { error: err?.message || 'LLM request failed' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'RECORD_ACTIVITY') {
|
||||
// Record student activity to server if needed
|
||||
console.log('[ExerciseBridge] Activity:', data);
|
||||
try {
|
||||
if (authTokenRef.current) {
|
||||
await fetch(`${serverUrlRef.current}/api/lab/record-activity/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Token ${authTokenRef.current}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to report activity:', err);
|
||||
}
|
||||
reply('ACTIVITY_RECORDED', { success: true, action: data?.action });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'EXERCISE_STARTED' || type === 'EXERCISE_COMPLETED') {
|
||||
console.log(`[ExerciseBridge] ${type}:`, data);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, []);
|
||||
}
|
||||
312
src/index.css
Normal file
312
src/index.css
Normal file
@@ -0,0 +1,312 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input, textarea, select, [contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* ── Theme: Indigo (default) ── */
|
||||
[data-theme="indigo"] {
|
||||
--bg-base: #020617;
|
||||
--mesh-1: rgba(99, 102, 241, 0.08);
|
||||
--mesh-2: rgba(139, 92, 246, 0.06);
|
||||
--mesh-3: rgba(59, 130, 246, 0.04);
|
||||
--accent: #6366f1;
|
||||
--accent-light: #818cf8;
|
||||
--accent-dark: #4f46e5;
|
||||
--accent-glow: rgba(99, 102, 241, 0.20);
|
||||
--accent-glow-subtle: rgba(99, 102, 241, 0.10);
|
||||
--accent-gradient-from: #6366f1;
|
||||
--accent-gradient-to: #8b5cf6;
|
||||
--accent-gradient-from-hover: #818cf8;
|
||||
--accent-gradient-to-hover: #a78bfa;
|
||||
--accent-surface: rgba(99, 102, 241, 0.12);
|
||||
--accent-surface-border: rgba(99, 102, 241, 0.25);
|
||||
--accent-text: #818cf8;
|
||||
--bubble-user-from: #4f46e5;
|
||||
--bubble-user-to: #6366f1;
|
||||
--avatar-from: #6366f1;
|
||||
--avatar-to: #8b5cf6;
|
||||
--btn-gradient-from: #4f46e5;
|
||||
--btn-gradient-to: #6366f1;
|
||||
}
|
||||
|
||||
/* ── Theme: Emerald ── */
|
||||
[data-theme="emerald"] {
|
||||
--bg-base: #030f0a;
|
||||
--mesh-1: rgba(16, 185, 129, 0.07);
|
||||
--mesh-2: rgba(6, 182, 212, 0.05);
|
||||
--mesh-3: rgba(20, 184, 166, 0.04);
|
||||
--accent: #10b981;
|
||||
--accent-light: #34d399;
|
||||
--accent-dark: #059669;
|
||||
--accent-glow: rgba(16, 185, 129, 0.20);
|
||||
--accent-glow-subtle: rgba(16, 185, 129, 0.10);
|
||||
--accent-gradient-from: #059669;
|
||||
--accent-gradient-to: #10b981;
|
||||
--accent-gradient-from-hover: #10b981;
|
||||
--accent-gradient-to-hover: #34d399;
|
||||
--accent-surface: rgba(16, 185, 129, 0.12);
|
||||
--accent-surface-border: rgba(16, 185, 129, 0.25);
|
||||
--accent-text: #34d399;
|
||||
--bubble-user-from: #059669;
|
||||
--bubble-user-to: #10b981;
|
||||
--avatar-from: #059669;
|
||||
--avatar-to: #0d9488;
|
||||
--btn-gradient-from: #059669;
|
||||
--btn-gradient-to: #10b981;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
/* ── Custom scrollbar ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
/* ── Gradient mesh background ── */
|
||||
.bg-mesh {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 10% 20%, var(--mesh-1) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 90% 80%, var(--mesh-2) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 50%, var(--mesh-3) 0%, transparent 60%),
|
||||
var(--bg-base);
|
||||
}
|
||||
|
||||
/* ── Glass card ── */
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply bg-white/[0.04] backdrop-blur-md border border-white/[0.06] shadow-lg shadow-black/20;
|
||||
}
|
||||
.glass-hover {
|
||||
@apply hover:bg-white/[0.07] hover:border-white/[0.1] transition-all duration-200;
|
||||
}
|
||||
.glass-strong {
|
||||
@apply bg-white/[0.06] backdrop-blur-xl border border-white/[0.08] shadow-xl shadow-black/30;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Themed accent utilities ── */
|
||||
.accent-gradient {
|
||||
background: linear-gradient(135deg, var(--btn-gradient-from), var(--btn-gradient-to));
|
||||
}
|
||||
.accent-gradient:hover {
|
||||
background: linear-gradient(135deg, var(--accent-gradient-from-hover), var(--accent-gradient-to-hover));
|
||||
}
|
||||
.accent-gradient:disabled {
|
||||
background: linear-gradient(135deg, var(--btn-gradient-from), var(--btn-gradient-to));
|
||||
opacity: 0.5;
|
||||
}
|
||||
.accent-glow {
|
||||
box-shadow: 0 8px 24px -4px var(--accent-glow);
|
||||
}
|
||||
.accent-glow-sm {
|
||||
box-shadow: 0 4px 12px -2px var(--accent-glow);
|
||||
}
|
||||
.accent-text {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.accent-surface {
|
||||
background: var(--accent-surface);
|
||||
}
|
||||
.accent-surface-border {
|
||||
border-color: var(--accent-surface-border);
|
||||
}
|
||||
.accent-border-active {
|
||||
border-color: var(--accent-surface-border);
|
||||
background: var(--accent-glow-subtle);
|
||||
}
|
||||
.avatar-gradient {
|
||||
background: linear-gradient(135deg, var(--avatar-from), var(--avatar-to));
|
||||
}
|
||||
.bubble-user {
|
||||
background: linear-gradient(135deg, var(--bubble-user-from), var(--bubble-user-to));
|
||||
}
|
||||
.progress-bar-fill {
|
||||
background: linear-gradient(90deg, var(--accent-gradient-from), var(--accent-gradient-to));
|
||||
}
|
||||
.accent-text-gradient {
|
||||
background: linear-gradient(90deg, var(--accent-gradient-from), var(--accent-gradient-to));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ── Chat markdown ── */
|
||||
.chat-markdown p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.chat-markdown p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.chat-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.chat-markdown ul, .chat-markdown ol {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.chat-markdown li {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
.chat-markdown code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
.chat-markdown pre {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 0.75em 1em;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.chat-markdown pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.chat-markdown strong {
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chat-markdown h1, .chat-markdown h2, .chat-markdown h3 {
|
||||
font-weight: 700;
|
||||
margin: 0.6em 0 0.3em;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.chat-markdown h1 { font-size: 1.1em; }
|
||||
.chat-markdown h2 { font-size: 1.05em; }
|
||||
.chat-markdown h3 { font-size: 1em; }
|
||||
.chat-markdown blockquote {
|
||||
border-left: 3px solid var(--accent-glow);
|
||||
padding-left: 0.75em;
|
||||
margin: 0.4em 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.chat-markdown a {
|
||||
color: var(--accent-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* User message markdown overrides */
|
||||
.chat-markdown-user code {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.chat-markdown-user pre {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.chat-markdown-user strong {
|
||||
color: #fff;
|
||||
}
|
||||
.chat-markdown-user a {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.animate-bounce-subtle {
|
||||
animation: bounceSubtle 1.5s infinite;
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
.animate-glow-pulse {
|
||||
animation: glowPulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes bounceSubtle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { boxShadow: 0 0 20px rgba(124, 58, 237, 0.3); }
|
||||
50% { boxShadow: 0 0 40px rgba(124, 58, 237, 0.5); }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Glassmorphism utilities */
|
||||
.backdrop-blur-glass {
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
}
|
||||
|
||||
/* Shimmer effect for loading states */
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* Streaming Text Animation */
|
||||
@keyframes streamIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-text {
|
||||
animation: streamIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
390
src/screens/CourseChatScreen.jsx
Normal file
390
src/screens/CourseChatScreen.jsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { sendHeartbeat, sendSarthiCourseMessage } from '../services/labApi';
|
||||
import { chatWithOllama, isOllamaAvailable } from '../services/localLLM';
|
||||
import { speak, stopSpeaking, extractTtsText } from '../services/tts';
|
||||
import { transcribeAudio, startRecording, isSTTAvailable } from '../services/stt';
|
||||
import { loadChatHistory, saveChatMessage } from '../services/chatHistory';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||
import ToolResultRenderer from '../components/tools/ToolResultRenderer';
|
||||
import AIThinking from '../components/AIThinking';
|
||||
import ToolResultPanel from '../components/ToolResultPanel';
|
||||
import { Volume2, ChevronLeft, Mic, Send } from 'lucide-react';
|
||||
|
||||
export default function CourseChatScreen() {
|
||||
const { student, activeCourse, serverUrl, sessionToken, useLocalAI, navigate } = useLabStore();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [levelSystemPrompt, setLevelSystemPrompt] = useState(null);
|
||||
const [levelInfo, setLevelInfo] = useState(null);
|
||||
const [activeToolResult, setActiveToolResult] = useState(null);
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
const recorderRef = useRef(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadChatHistory('course', activeCourse?.id).then(setMessages);
|
||||
|
||||
if (activeCourse?.id && student?.auth_token) {
|
||||
fetch(`${serverUrl}/api/student/course/${activeCourse.id}/level-context/`, {
|
||||
headers: { 'Authorization': `Token ${student.auth_token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.system_prompt) setLevelSystemPrompt(data.system_prompt);
|
||||
if (data) setLevelInfo(data);
|
||||
})
|
||||
.catch(() => { });
|
||||
}
|
||||
}, [activeCourse?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
sendHeartbeat(serverUrl, sessionToken, student.auth_token, 'chat', activeCourse?.id);
|
||||
const interval = setInterval(async () => {
|
||||
const ok = await sendHeartbeat(serverUrl, sessionToken, student.auth_token, 'chat', activeCourse?.id);
|
||||
if (!ok) setSessionExpired(true);
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serverUrl, sessionToken, student, activeCourse]);
|
||||
|
||||
const handleSend = async (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || streaming) return;
|
||||
|
||||
setInput('');
|
||||
const userMsg = { role: 'user', content: text };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setStreaming(true);
|
||||
stopSpeaking();
|
||||
|
||||
await saveChatMessage('course', activeCourse?.id, userMsg);
|
||||
|
||||
let assistantContent = '';
|
||||
let currentStructuredData = null;
|
||||
const appendChunk = (chunk) => {
|
||||
// ONLY append if it's a message/text and NOT part of a tool_result or other type
|
||||
if (chunk.type === 'message' || chunk.type === 'text') {
|
||||
const delta = chunk.content || chunk.text || '';
|
||||
assistantContent += delta;
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...last, content: assistantContent }];
|
||||
}
|
||||
return [...prev, { role: 'assistant', content: assistantContent }];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (useLocalAI && isOllamaAvailable()) {
|
||||
const systemPrompt = levelSystemPrompt
|
||||
|| `You are Sarthi, an AI tutor for ${activeCourse?.name}. Teach the NCERT curriculum clearly and encouragingly.`;
|
||||
const ollamaMessages = messages.map(m => ({ role: m.role, content: m.content }));
|
||||
ollamaMessages.push({ role: 'user', content: text });
|
||||
await chatWithOllama(systemPrompt, ollamaMessages, appendChunk);
|
||||
} else {
|
||||
await sendSarthiCourseMessage(serverUrl, student.auth_token, activeCourse.id, text, conversationId, (chunk) => {
|
||||
if (chunk.type === 'metadata') {
|
||||
setConversationId(chunk.content?.conversation_id || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool_start') {
|
||||
// Show that a tool is being called
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `Using tool: ${chunk.tool}...`,
|
||||
message_type: 'tool_call',
|
||||
tool_name: chunk.tool
|
||||
}]);
|
||||
} else if (chunk.type === 'tool_result') {
|
||||
// Unpack tool result - can be in content (JSON string) or structured_data (object)
|
||||
let resultData = chunk.structured_data || chunk.content;
|
||||
if (typeof resultData === 'string') {
|
||||
try { resultData = JSON.parse(resultData); } catch (e) {
|
||||
console.error('Failed to parse tool result:', e);
|
||||
}
|
||||
}
|
||||
|
||||
currentStructuredData = resultData;
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant' && last.message_type === 'tool_call') {
|
||||
return [...prev.slice(0, -1), {
|
||||
...last,
|
||||
content: '',
|
||||
structured_data: resultData,
|
||||
message_type: 'tool_result'
|
||||
}];
|
||||
}
|
||||
return [...prev, { role: 'assistant', content: '', structured_data: resultData, message_type: 'tool_result' }];
|
||||
});
|
||||
// Open side panel for complex tools
|
||||
if (resultData && resultData.type && ['quiz', 'concept_explanation', 'vocabulary_builder'].includes(resultData.type)) {
|
||||
setActiveToolResult({ tool_name: chunk.tool, structured_data: resultData });
|
||||
}
|
||||
} else if (chunk.type === 'message' || chunk.type === 'text') {
|
||||
appendChunk(chunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = { role: 'assistant', content: `Error: ${err.message}` };
|
||||
setMessages(prev => [...prev, errMsg]);
|
||||
await saveChatMessage('course', activeCourse?.id, errMsg);
|
||||
} finally {
|
||||
setStreaming(false);
|
||||
if (assistantContent || currentStructuredData) {
|
||||
const assistantMsg = {
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
...(currentStructuredData ? { structured_data: currentStructuredData } : {})
|
||||
};
|
||||
await saveChatMessage('course', activeCourse?.id, assistantMsg);
|
||||
if (ttsEnabled && assistantContent) {
|
||||
speak(extractTtsText(assistantContent));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording) {
|
||||
setRecording(false);
|
||||
if (recorderRef.current) {
|
||||
const wavBlob = await recorderRef.current.stop();
|
||||
recorderRef.current = null;
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const text = await transcribeAudio(wavBlob);
|
||||
if (text) setInput(text);
|
||||
} catch (err) {
|
||||
console.warn('STT failed:', err);
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
recorderRef.current = await startRecording();
|
||||
setRecording(true);
|
||||
} catch (err) {
|
||||
console.warn('Microphone access denied:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const progressPct = levelInfo
|
||||
? Math.round(((levelInfo.current_level - 1) / levelInfo.total_levels) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-mesh text-white flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="glass border-0 border-b border-white/[0.06] px-5 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { stopSpeaking(); navigate('dashboard'); }}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1.5 rounded-lg hover:bg-white/[0.05]"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate text-white">{activeCourse?.name}</p>
|
||||
{levelInfo ? (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">
|
||||
Lvl {levelInfo.current_level}/{levelInfo.total_levels}
|
||||
</span>
|
||||
<div className="flex-1 h-1 bg-white/[0.06] rounded-full overflow-hidden max-w-[100px]">
|
||||
<div
|
||||
className="h-full progress-bar-fill rounded-full transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 truncate hidden sm:inline">
|
||||
{levelInfo.level_name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
{useLocalAI && isOllamaAvailable() ? 'Local AI' : 'Sarthi Cloud'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setTtsEnabled(v => !v)}
|
||||
className={`p-2 rounded-lg border transition-all ${ttsEnabled
|
||||
? 'accent-border-active accent-text'
|
||||
: 'border-white/[0.06] bg-white/[0.03] text-slate-500'
|
||||
}`}
|
||||
title="Toggle voice"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
{ttsEnabled ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.531V19.94a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.506-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.395C2.806 8.757 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sessionExpired && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs px-5 py-2 text-center">
|
||||
Lab session has ended. Your work is saved.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Messages */}
|
||||
<div className={`flex-1 flex flex-col min-w-0 transition-all duration-300 ${activeToolResult ? 'w-1/2' : 'w-full'}`}>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-6">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-slate-500 mt-20 animate-fade-in">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl accent-surface flex items-center justify-center">
|
||||
<svg className="w-8 h-8 accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm">Ask anything about <strong className="text-slate-300">{activeCourse?.name}</strong></p>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-fade-in`}>
|
||||
{msg.role !== 'user' && (
|
||||
<div className="w-7 h-7 rounded-lg avatar-gradient flex items-center justify-center text-xs font-bold mr-2.5 mt-1 flex-shrink-0 accent-glow-sm">
|
||||
S
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-xl rounded-2xl px-4 py-3 text-sm leading-relaxed ${msg.role === 'user'
|
||||
? 'bubble-user text-white rounded-br-md accent-glow-sm'
|
||||
: 'glass text-slate-100 rounded-bl-md'
|
||||
}`}
|
||||
>
|
||||
{msg.content && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={`chat-markdown ${msg.role === 'user' ? 'chat-markdown-user' : ''}`}>
|
||||
<MarkdownRenderer content={msg.content} />
|
||||
</div>
|
||||
{msg.role === 'assistant' && msg.content && (
|
||||
<button
|
||||
onClick={() => speak(extractTtsText(msg.content))}
|
||||
className="w-fit p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-violet-400 transition-colors"
|
||||
title="Play voice"
|
||||
>
|
||||
<Volume2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.structured_data && (
|
||||
<div className={['quiz', 'concept_explanation', 'vocabulary_builder'].includes(msg.structured_data.type) ? 'opacity-50 grayscale scale-95 transition-all' : ''}>
|
||||
<ToolResultRenderer
|
||||
data={msg.structured_data}
|
||||
onActionClick={(action) => {
|
||||
setInput(action);
|
||||
setTimeout(() => document.querySelector('form')?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })), 100);
|
||||
}}
|
||||
/>
|
||||
{['quiz', 'concept_explanation', 'vocabulary_builder'].includes(msg.structured_data.type) && (
|
||||
<button
|
||||
onClick={() => setActiveToolResult(msg)}
|
||||
className="mt-2 text-xs text-violet-400 hover:text-violet-300 font-medium underline"
|
||||
>
|
||||
Re-open in side panel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{streaming && messages[messages.length - 1]?.role !== 'assistant' && (
|
||||
<AIThinking className="mt-4" />
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="glass border-0 border-t border-white/[0.06] p-4">
|
||||
<form onSubmit={handleSend} className="max-w-3xl mx-auto">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 bg-white/[0.05] border border-white/[0.08] rounded-xl px-4 focus-within:ring-2 focus-within:ring-violet-500/30 focus-within:border-violet-500/30 transition-all">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
disabled={streaming || recording || transcribing}
|
||||
className="flex-1 bg-transparent text-white py-3 text-sm focus:outline-none disabled:opacity-50 placeholder:text-slate-600"
|
||||
placeholder={recording ? 'Recording… click to stop' : transcribing ? 'Transcribing…' : 'Ask a question...'}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRecording}
|
||||
disabled={streaming || transcribing}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${recording
|
||||
? 'bg-red-500 text-white animate-pulse'
|
||||
: transcribing
|
||||
? 'text-amber-400 animate-pulse'
|
||||
: 'text-slate-500 hover:text-slate-300 hover:bg-white/[0.05]'
|
||||
} disabled:opacity-40`}
|
||||
title={isSTTAvailable() ? 'Voice input' : 'Voice input (not available)'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={streaming || !input.trim() || recording}
|
||||
className="accent-gradient accent-glow-sm disabled:opacity-30 text-white p-3 rounded-xl transition-all"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Side Panel */}
|
||||
{activeToolResult && (
|
||||
<ToolResultPanel
|
||||
toolResult={activeToolResult}
|
||||
onClose={() => setActiveToolResult(null)}
|
||||
onActionClick={(action) => {
|
||||
setInput(action);
|
||||
setTimeout(() => document.querySelector('form')?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })), 100);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
280
src/screens/DashboardScreen.jsx
Normal file
280
src/screens/DashboardScreen.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { sendHeartbeat, studentLogout, getStudentProgress } from '../services/labApi';
|
||||
import { isOllamaAvailable } from '../services/localLLM';
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { student, labInfo, serverUrl, sessionToken, theme, setTheme, navigate, logoutStudent } = useLabStore();
|
||||
const courses = student?.allowed_courses || [];
|
||||
const [progressData, setProgressData] = useState(null);
|
||||
const [loadingProgress, setLoadingProgress] = useState(true);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProgress() {
|
||||
try {
|
||||
const data = await getStudentProgress(serverUrl, sessionToken, student.auth_token);
|
||||
setProgressData(data);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load progress:', err);
|
||||
} finally {
|
||||
setLoadingProgress(false);
|
||||
}
|
||||
}
|
||||
loadProgress();
|
||||
}, [serverUrl, sessionToken, student.auth_token]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
const ok = await sendHeartbeat(serverUrl, sessionToken, student.auth_token, 'idle', null);
|
||||
if (!ok) setSessionExpired(true);
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serverUrl, sessionToken, student]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await studentLogout(serverUrl, sessionToken, student.auth_token);
|
||||
logoutStudent();
|
||||
};
|
||||
|
||||
const toggleTheme = async () => {
|
||||
const next = theme === 'indigo' ? 'emerald' : 'indigo';
|
||||
setTheme(next);
|
||||
if (window.electronAPI) await window.electronAPI.config.set('theme', next);
|
||||
};
|
||||
|
||||
const courseProgressMap = {};
|
||||
if (progressData?.courses) {
|
||||
for (const c of progressData.courses) {
|
||||
courseProgressMap[c.course_id] = c;
|
||||
}
|
||||
}
|
||||
|
||||
const recentAchievements = [];
|
||||
if (progressData?.courses) {
|
||||
for (const c of progressData.courses) {
|
||||
for (const a of c.achievements || []) {
|
||||
recentAchievements.push({ ...a, courseName: c.course_name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh text-white flex flex-col">
|
||||
{sessionExpired && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs px-6 py-2.5 text-center animate-fade-in">
|
||||
The lab session has been ended by your teacher. Please log out when you are done.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="glass border-0 border-b border-white/[0.06] px-6 py-4">
|
||||
<div className="flex items-center justify-between max-w-5xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl avatar-gradient flex items-center justify-center text-sm font-bold accent-glow-sm">
|
||||
{student.student_name?.charAt(0)?.toUpperCase() || 'S'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white text-sm">{student.student_name}</p>
|
||||
<p className="text-xs text-slate-400">{student.class_name} · {labInfo?.school_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full ${
|
||||
sessionExpired
|
||||
? 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sessionExpired ? 'bg-red-400' : 'bg-emerald-400 animate-pulse'}`} />
|
||||
{sessionExpired ? 'Ended' : 'Live'}
|
||||
</span>
|
||||
{isOllamaAvailable() && (
|
||||
<span className="text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2.5 py-1 rounded-full">
|
||||
Local AI
|
||||
</span>
|
||||
)}
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-slate-400 hover:text-white transition-colors p-2 rounded-lg hover:bg-white/[0.05]"
|
||||
title={`Switch to ${theme === 'indigo' ? 'Emerald' : 'Indigo'} theme`}
|
||||
>
|
||||
{theme === 'indigo' ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('settings')}
|
||||
className="text-slate-400 hover:text-white transition-colors p-2 rounded-lg hover:bg-white/[0.05]"
|
||||
title="Settings"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-slate-400 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/[0.05]"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 p-6 max-w-5xl mx-auto w-full">
|
||||
<div className="animate-slide-up">
|
||||
<h1 className="text-xl font-bold mb-1 tracking-tight">What do you want to learn today?</h1>
|
||||
<p className="text-slate-400 text-sm mb-8">Choose a course or start a free chat with your AI tutor.</p>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{!loadingProgress && progressData && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
{[
|
||||
{ label: 'Streak', value: `${progressData.current_streak || 0}`, unit: 'days', color: 'from-orange-500 to-amber-500', glow: 'shadow-orange-500/10' },
|
||||
{ label: 'Time Spent', value: `${progressData.total_time_minutes || 0}`, unit: 'min', color: 'from-blue-500 to-cyan-500', glow: 'shadow-blue-500/10' },
|
||||
{ label: 'Achievements', value: `${recentAchievements.length}`, unit: '', color: 'from-yellow-500 to-orange-500', glow: 'shadow-yellow-500/10' },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className={`glass glass-hover rounded-2xl p-5 shadow-lg ${stat.glow}`}>
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wider mb-2">{stat.label}</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={`text-2xl font-bold bg-gradient-to-r ${stat.color} bg-clip-text text-transparent`}>
|
||||
{stat.value}
|
||||
</span>
|
||||
{stat.unit && <span className="text-xs text-slate-500">{stat.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Courses with Progress */}
|
||||
{courses.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-4">Your Courses</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{courses.map((course, idx) => {
|
||||
const progress = courseProgressMap[course.id];
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
onClick={() => navigate('course-chat', { activeCourse: course })}
|
||||
className="text-left glass glass-hover rounded-2xl p-5 group relative overflow-hidden"
|
||||
style={{ animationDelay: `${idx * 60}ms` }}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: `linear-gradient(135deg, var(--accent-glow-subtle), transparent)` }} />
|
||||
|
||||
<div className="relative">
|
||||
{course.icon ? (
|
||||
<img src={course.icon} alt="" className="w-10 h-10 mb-3 object-contain" />
|
||||
) : (
|
||||
<div className="w-10 h-10 mb-3 rounded-lg accent-surface flex items-center justify-center">
|
||||
<svg className="w-5 h-5 accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<p className="font-semibold text-sm text-white">{course.name}</p>
|
||||
{course.description && (
|
||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2">{course.description}</p>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs mb-1.5">
|
||||
<span className="text-slate-500">Level {progress.current_level}/{progress.total_levels}</span>
|
||||
<span className="accent-text font-medium">{progress.progress_percentage}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full progress-bar-fill rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress.progress_percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress.streak > 0 && (
|
||||
<p className="text-xs text-orange-400 mt-1.5 font-medium">{progress.streak} day streak</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{course.num_levels > 0 && !progress && (
|
||||
<div className="mt-3">
|
||||
<span className="text-xs bg-white/[0.06] text-slate-400 px-2 py-0.5 rounded-md">{course.num_levels} levels</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent Achievements */}
|
||||
{!loadingProgress && recentAchievements.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-4">Recent Achievements</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{recentAchievements.slice(0, 5).map((a, i) => (
|
||||
<div key={i} className="flex-shrink-0 glass rounded-2xl p-4 min-w-[150px] border-yellow-500/10">
|
||||
<div className="text-2xl mb-2">{a.icon || '🏆'}</div>
|
||||
<p className="text-xs font-semibold text-white">{a.name}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{a.courseName}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => navigate('general-chat')}
|
||||
className="text-left glass glass-hover rounded-2xl p-5 group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: `linear-gradient(135deg, var(--accent-glow-subtle), transparent)` }} />
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 mb-3 rounded-lg accent-surface flex items-center justify-center">
|
||||
<svg className="w-5 h-5 accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold text-sm text-white">Free Chat</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Ask your AI tutor anything</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('exercise')}
|
||||
className="text-left glass glass-hover rounded-2xl p-5 group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/[0.06] to-teal-500/[0.06] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 mb-3 rounded-lg bg-emerald-500/15 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold text-sm text-white">Exercises</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Practice with interactive exercises</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
src/screens/ExerciseScreen.jsx
Normal file
256
src/screens/ExerciseScreen.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { sendHeartbeat } from '../services/labApi';
|
||||
import useExerciseBridge from '../hooks/useExerciseBridge';
|
||||
|
||||
export default function ExerciseScreen() {
|
||||
const { student, serverUrl, sessionToken, navigate } = useLabStore();
|
||||
// Bridge postMessage from exercise iframes to desktop TTS/LLM
|
||||
useExerciseBridge();
|
||||
const courses = student?.allowed_courses || [];
|
||||
const [exercises, setExercises] = useState([]);
|
||||
const [filteredExercises, setFilteredExercises] = useState([]);
|
||||
const [loadingExercises, setLoadingExercises] = useState(true);
|
||||
const [activeExerciseUrl, setActiveExerciseUrl] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState('all');
|
||||
const [selectedCourseFilter, setSelectedCourseFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
sendHeartbeat(serverUrl, sessionToken, student.auth_token, 'exercise', selectedCourseFilter !== 'all' ? selectedCourseFilter : null);
|
||||
const interval = setInterval(() => {
|
||||
sendHeartbeat(serverUrl, sessionToken, student.auth_token, 'exercise', selectedCourseFilter !== 'all' ? selectedCourseFilter : null);
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serverUrl, sessionToken, student, selectedCourseFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadExercises();
|
||||
}, [serverUrl, student.auth_token]);
|
||||
|
||||
// Filtering logic
|
||||
useEffect(() => {
|
||||
let filtered = exercises;
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(ex =>
|
||||
(ex.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(ex.description || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
if (selectedCategory !== 'all') {
|
||||
filtered = filtered.filter(ex => ex.category === selectedCategory);
|
||||
}
|
||||
if (selectedDifficulty !== 'all') {
|
||||
filtered = filtered.filter(ex => ex.difficulty === selectedDifficulty);
|
||||
}
|
||||
if (selectedCourseFilter !== 'all') {
|
||||
filtered = filtered.filter(ex => ex.course_id === selectedCourseFilter);
|
||||
}
|
||||
setFilteredExercises(filtered);
|
||||
}, [exercises, searchQuery, selectedCategory, selectedDifficulty, selectedCourseFilter]);
|
||||
|
||||
const loadExercises = async () => {
|
||||
setLoadingExercises(true);
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/api/exercises`, {
|
||||
headers: { 'Authorization': `Token ${student.auth_token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load exercises');
|
||||
const data = await res.json();
|
||||
const items = data.items || data.exercises || [];
|
||||
setExercises(items);
|
||||
setFilteredExercises(items);
|
||||
} catch (err) {
|
||||
setExercises([]);
|
||||
setFilteredExercises([]);
|
||||
} finally {
|
||||
setLoadingExercises(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openExercise = async (exerciseId) => {
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/api/exercises/${exerciseId}/access-url`, {
|
||||
headers: { 'Authorization': `Token ${student.auth_token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to get access URL');
|
||||
const data = await res.json();
|
||||
setActiveExerciseUrl(data.url);
|
||||
} catch (err) {
|
||||
console.error('Error opening exercise:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ['all', ...new Set(exercises.map(ex => ex.category).filter(Boolean))];
|
||||
|
||||
// Fullscreen exercise view
|
||||
if (activeExerciseUrl) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-slate-950">
|
||||
<div className="glass border-0 border-b border-white/[0.06] flex items-center gap-3 px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => setActiveExerciseUrl(null)}
|
||||
className="flex items-center gap-2 text-slate-400 hover:text-white text-sm transition-colors p-1.5 rounded-lg hover:bg-white/[0.05]"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Back to library
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
src={activeExerciseUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="Exercise"
|
||||
allow="camera; microphone"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh text-white pb-10">
|
||||
<header className="glass border-0 border-b border-white/[0.06] px-5 py-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('dashboard')}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1.5 rounded-lg hover:bg-white/[0.05]"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<p className="font-semibold text-sm">Practice Lab</p>
|
||||
</header>
|
||||
|
||||
<main className="p-6 max-w-6xl mx-auto animate-slide-up">
|
||||
{/* Library Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white mb-1">Laboratory Library</h2>
|
||||
<p className="text-xs text-slate-500">{filteredExercises.length} labs available</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search labs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-4 py-2 bg-white/[0.03] border border-white/10 rounded-xl text-xs text-white focus:ring-1 focus:ring-emerald-500/30 outline-none w-48 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Course Filter */}
|
||||
<select
|
||||
value={selectedCourseFilter}
|
||||
onChange={(e) => setSelectedCourseFilter(e.target.value)}
|
||||
className="px-3 py-2 bg-white/[0.03] border border-white/10 rounded-xl text-xs text-slate-300 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="all" className="bg-slate-900">All Courses</option>
|
||||
{courses.map(course => (
|
||||
<option key={course.id} value={course.id} className="bg-slate-900">{course.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{categories.length > 2 && (
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 bg-white/[0.03] border border-white/10 rounded-xl text-xs text-slate-300 outline-none cursor-pointer"
|
||||
>
|
||||
{categories.map(c => <option key={c} value={c} className="bg-slate-900">{c === 'all' ? 'All Categories' : c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={selectedDifficulty}
|
||||
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
||||
className="px-3 py-2 bg-white/[0.03] border border-white/10 rounded-xl text-xs text-slate-300 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="all" className="bg-slate-900">All Levels</option>
|
||||
<option value="beginner" className="bg-slate-900">Beginner</option>
|
||||
<option value="intermediate" className="bg-slate-900">Intermediate</option>
|
||||
<option value="advanced" className="bg-slate-900">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingExercises ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
|
||||
<span className="w-8 h-8 border-2 border-emerald-500/20 border-t-emerald-500 rounded-full animate-spin mb-4" />
|
||||
<p className="text-sm">Finding laboratories...</p>
|
||||
</div>
|
||||
) : filteredExercises.length === 0 ? (
|
||||
<div className="text-center py-20 glass rounded-3xl border border-white/[0.05]">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/[0.03] flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-slate-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">No labs match your search.</p>
|
||||
<button onClick={() => { setSearchQuery(''); setSelectedCategory('all'); setSelectedDifficulty('all'); setSelectedCourseFilter('all'); }} className="mt-4 text-emerald-400 text-xs font-bold hover:underline">Clear all filters</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{filteredExercises.map((ex, idx) => (
|
||||
<div
|
||||
key={ex.id}
|
||||
onClick={() => openExercise(ex.id)}
|
||||
className="group flex flex-col glass glass-hover rounded-2xl p-5 relative overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative flex-1">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center group-hover:bg-emerald-500/20 transition-colors">
|
||||
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
|
||||
</svg>
|
||||
</div>
|
||||
{ex.difficulty && (
|
||||
<span className={`text-[10px] uppercase font-bold px-2 py-0.5 rounded border ${ex.difficulty === 'beginner' ? 'bg-emerald-500/5 text-emerald-500 border-emerald-500/10' :
|
||||
ex.difficulty === 'intermediate' ? 'bg-amber-500/5 text-amber-500 border-amber-500/10' :
|
||||
'bg-red-500/5 text-red-500 border-red-500/10'
|
||||
}`}>
|
||||
{ex.difficulty}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 className="font-bold text-white mb-2 group-hover:text-emerald-400 transition-colors line-clamp-1">{ex.name || 'Interactive Lab'}</h4>
|
||||
<p className="text-xs text-slate-400 leading-relaxed mb-4 line-clamp-3">{ex.description || 'Practice your skills with this interactive NCERT-aligned laboratory exercise.'}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/[0.03] mt-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-500">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
{ex.duration_minutes || 15}m
|
||||
</div>
|
||||
{ex.category && ex.category !== 'all' && (
|
||||
<div className="text-[10px] text-slate-500 font-medium px-2 py-0.5 bg-white/[0.02] rounded-md border border-white/[0.05]">
|
||||
{ex.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-emerald-500 opacity-0 group-hover:opacity-100 transition-all translate-x-2 group-hover:translate-x-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
src/screens/GeneralChatScreen.jsx
Normal file
372
src/screens/GeneralChatScreen.jsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { sendHeartbeat, sendSarthiGeneralMessage } from '../services/labApi';
|
||||
import { chatWithOllama, isOllamaAvailable } from '../services/localLLM';
|
||||
import { speak, stopSpeaking, extractTtsText } from '../services/tts';
|
||||
import { transcribeAudio, startRecording, isSTTAvailable } from '../services/stt';
|
||||
import { loadChatHistory, saveChatMessage } from '../services/chatHistory';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||
import ToolResultRenderer from '../components/tools/ToolResultRenderer';
|
||||
import AIThinking from '../components/AIThinking';
|
||||
import ToolResultPanel from '../components/ToolResultPanel';
|
||||
import { Volume2 } from 'lucide-react';
|
||||
|
||||
export default function GeneralChatScreen() {
|
||||
const { student, serverUrl, sessionToken, useLocalAI, navigate } = useLabStore();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [activeToolResult, setActiveToolResult] = useState(null);
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
const recorderRef = useRef(null);
|
||||
useEffect(() => {
|
||||
loadChatHistory('general').then(setMessages);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
sendHeartbeat(serverUrl, sessionToken, student.auth_token, 'general_chat', null);
|
||||
const interval = setInterval(async () => {
|
||||
const ok = await sendHeartbeat(serverUrl, sessionToken, student.auth_token, 'general_chat', null);
|
||||
if (!ok) setSessionExpired(true);
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [serverUrl, sessionToken, student]);
|
||||
const handleSend = async (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || streaming) return;
|
||||
|
||||
setInput('');
|
||||
const userMsg = { role: 'user', content: text };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setStreaming(true);
|
||||
stopSpeaking();
|
||||
|
||||
// If it's a new chat session, don't save the message locally yet?
|
||||
// Actually, saveChatMessage will keep appending.
|
||||
// We should clear the history file if it's a new chat.
|
||||
await saveChatMessage('general', null, userMsg);
|
||||
|
||||
let assistantContent = '';
|
||||
let currentStructuredData = null;
|
||||
const appendChunk = (chunk) => {
|
||||
// ONLY append if it's a message/text and NOT part of a tool_result or other type
|
||||
if (chunk.type === 'message' || chunk.type === 'text') {
|
||||
const delta = chunk.content || chunk.text || '';
|
||||
if (delta) {
|
||||
assistantContent += delta;
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...last, content: assistantContent }];
|
||||
}
|
||||
return [...prev, { role: 'assistant', content: assistantContent }];
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (useLocalAI && isOllamaAvailable()) {
|
||||
const systemPrompt = `You are Sarthi, an AI tutor. Help the student learn anything they ask about with clear explanations.`;
|
||||
const ollamaMessages = messages.map(m => ({ role: m.role, content: m.content }));
|
||||
ollamaMessages.push({ role: 'user', content: text });
|
||||
await chatWithOllama(systemPrompt, ollamaMessages, appendChunk);
|
||||
} else {
|
||||
await sendSarthiGeneralMessage(serverUrl, student.auth_token, text, conversationId, (chunk) => {
|
||||
if (chunk.type === 'metadata') {
|
||||
setConversationId(chunk.content?.conversation_id || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk.type === 'tool_start') {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `Using tool: ${chunk.tool}...`,
|
||||
message_type: 'tool_call',
|
||||
tool_name: chunk.tool
|
||||
}]);
|
||||
} else if (chunk.type === 'tool_result') {
|
||||
// Unpack tool result - can be in content (JSON string) or structured_data (object)
|
||||
let resultData = chunk.structured_data || chunk.content;
|
||||
if (typeof resultData === 'string') {
|
||||
try { resultData = JSON.parse(resultData); } catch (e) {
|
||||
console.error('Failed to parse tool result:', e);
|
||||
}
|
||||
}
|
||||
|
||||
currentStructuredData = resultData;
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === 'assistant' && last.message_type === 'tool_call') {
|
||||
return [...prev.slice(0, -1), {
|
||||
...last,
|
||||
content: '',
|
||||
structured_data: resultData,
|
||||
message_type: 'tool_result'
|
||||
}];
|
||||
}
|
||||
return [...prev, { role: 'assistant', content: '', structured_data: resultData, message_type: 'tool_result' }];
|
||||
});
|
||||
// Open side panel for complex tools
|
||||
if (resultData && resultData.type && ['quiz', 'concept_explanation', 'vocabulary_builder'].includes(resultData.type)) {
|
||||
setActiveToolResult({ tool_name: chunk.tool, structured_data: resultData });
|
||||
}
|
||||
} else if (chunk.type === 'message' || chunk.type === 'text') {
|
||||
appendChunk(chunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = { role: 'assistant', content: `Error: ${err.message}` };
|
||||
setMessages(prev => [...prev, errMsg]);
|
||||
await saveChatMessage('general', null, errMsg);
|
||||
} finally {
|
||||
setStreaming(false);
|
||||
if (assistantContent || currentStructuredData) {
|
||||
const assistantMsg = {
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
...(currentStructuredData ? { structured_data: currentStructuredData } : {})
|
||||
};
|
||||
await saveChatMessage('general', null, assistantMsg);
|
||||
if (ttsEnabled && assistantContent) {
|
||||
speak(extractTtsText(assistantContent));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording) {
|
||||
setRecording(false);
|
||||
if (recorderRef.current) {
|
||||
const wavBlob = await recorderRef.current.stop();
|
||||
recorderRef.current = null;
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const text = await transcribeAudio(wavBlob);
|
||||
if (text) setInput(text);
|
||||
} catch (err) {
|
||||
console.warn('STT failed:', err);
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
recorderRef.current = await startRecording();
|
||||
setRecording(true);
|
||||
} catch (err) {
|
||||
console.warn('Microphone access denied:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewChat = async () => {
|
||||
if (window.confirm('Are you sure you want to start a new chat? This will clear current messages.')) {
|
||||
setMessages([]);
|
||||
setActiveToolResult(null);
|
||||
setConversationId(null);
|
||||
stopSpeaking();
|
||||
const { clearChatHistory } = await import('../services/chatHistory');
|
||||
await clearChatHistory('general');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-mesh text-white flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="glass border-0 border-b border-white/[0.06] px-5 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { stopSpeaking(); navigate('dashboard'); }}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1.5 rounded-lg hover:bg-white/[0.05]"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm text-white">Free Chat</p>
|
||||
<p className="text-xs text-slate-500">{useLocalAI && isOllamaAvailable() ? 'Local AI' : 'Sarthi Cloud'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="p-2 rounded-lg border border-white/[0.06] bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/[0.08] transition-all"
|
||||
title="New Chat"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTtsEnabled(v => !v)}
|
||||
className={`p-2 rounded-lg border transition-all ${ttsEnabled
|
||||
? 'accent-border-active accent-text'
|
||||
: 'border-white/[0.06] bg-white/[0.03] text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
{ttsEnabled ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.531V19.94a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.506-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.395C2.806 8.757 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sessionExpired && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs px-5 py-2 text-center">
|
||||
Lab session has ended. Your work is saved.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Messages */}
|
||||
<div className={`flex-1 flex flex-col min-w-0 transition-all duration-300 ${activeToolResult ? 'w-1/2' : 'w-full'}`}>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-6">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-slate-500 mt-20 animate-fade-in">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl accent-surface flex items-center justify-center">
|
||||
<svg className="w-8 h-8 accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm">Ask your AI tutor anything!</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-fade-in`}>
|
||||
{msg.role !== 'user' && (
|
||||
<div className="w-7 h-7 rounded-lg avatar-gradient flex items-center justify-center text-xs font-bold mr-2.5 mt-1 flex-shrink-0 accent-glow-sm">
|
||||
S
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-xl rounded-2xl px-4 py-3 text-sm leading-relaxed ${msg.role === 'user'
|
||||
? 'bubble-user text-white rounded-br-md accent-glow-sm'
|
||||
: 'glass text-slate-100 rounded-bl-md'
|
||||
}`}
|
||||
>
|
||||
{msg.content && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={`chat-markdown ${msg.role === 'user' ? 'chat-markdown-user' : ''}`}>
|
||||
<MarkdownRenderer content={msg.content} />
|
||||
</div>
|
||||
{msg.role === 'assistant' && msg.content && (
|
||||
<button
|
||||
onClick={() => speak(extractTtsText(msg.content))}
|
||||
className="w-fit p-1.5 rounded-lg hover:bg-white/10 text-slate-400 hover:text-violet-400 transition-colors"
|
||||
title="Play voice"
|
||||
>
|
||||
<Volume2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.structured_data && (
|
||||
<div className={['quiz', 'concept_explanation', 'vocabulary_builder'].includes(msg.structured_data.type) ? 'opacity-50 grayscale scale-95 transition-all' : ''}>
|
||||
<ToolResultRenderer
|
||||
data={msg.structured_data}
|
||||
onActionClick={(action) => {
|
||||
setInput(action);
|
||||
setTimeout(() => document.querySelector('form')?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })), 100);
|
||||
}}
|
||||
/>
|
||||
{['quiz', 'concept_explanation', 'vocabulary_builder'].includes(msg.structured_data.type) && (
|
||||
<button
|
||||
onClick={() => setActiveToolResult(msg)}
|
||||
className="mt-2 text-xs text-violet-400 hover:text-violet-300 font-medium underline"
|
||||
>
|
||||
Re-open in side panel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{streaming && messages[messages.length - 1]?.role !== 'assistant' && (
|
||||
<AIThinking className="mt-4" />
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="glass border-0 border-t border-white/[0.06] p-4">
|
||||
<form onSubmit={handleSend} className="max-w-3xl mx-auto">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 bg-white/[0.05] border border-white/[0.08] rounded-xl px-4 focus-within:ring-2 focus-within:ring-violet-500/30 focus-within:border-violet-500/30 transition-all">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
disabled={streaming || recording || transcribing}
|
||||
className="flex-1 bg-transparent text-white py-3 text-sm focus:outline-none disabled:opacity-50 placeholder:text-slate-600"
|
||||
placeholder={recording ? 'Recording… click to stop' : transcribing ? 'Transcribing…' : 'Ask anything...'}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRecording}
|
||||
disabled={streaming || transcribing}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${recording
|
||||
? 'bg-red-500 text-white animate-pulse'
|
||||
: transcribing
|
||||
? 'text-amber-400 animate-pulse'
|
||||
: 'text-slate-500 hover:text-slate-300 hover:bg-white/[0.05]'
|
||||
} disabled:opacity-40`}
|
||||
title={isSTTAvailable() ? 'Voice input' : 'Voice input (not available)'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={streaming || !input.trim() || recording}
|
||||
className="accent-gradient accent-glow-sm disabled:opacity-30 text-white p-3 rounded-xl transition-all"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Side Panel */}
|
||||
{activeToolResult && (
|
||||
<ToolResultPanel
|
||||
toolResult={activeToolResult}
|
||||
onClose={() => setActiveToolResult(null)}
|
||||
onActionClick={(action) => {
|
||||
setInput(action);
|
||||
setTimeout(() => document.querySelector('form')?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })), 100);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
444
src/screens/SettingsScreen.jsx
Normal file
444
src/screens/SettingsScreen.jsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { listOllamaModels, getOllamaModel, setOllamaModel } from '../services/localLLM';
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
if (status.usesSay) {
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
Ready (macOS Voice)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.available) {
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
Ready
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status.modelsDownloaded && !status.binaryPresent) {
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
Binary missing
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!status.modelsDownloaded && status.binaryPresent) {
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
Model not installed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-white/[0.04] text-slate-500 border border-white/[0.06]">
|
||||
Not installed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ progress }) {
|
||||
const { status, percent, file, error } = progress || {};
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<p className="text-xs text-red-400 mb-3">
|
||||
Installation failed{error ? `: ${error}` : ''}. Check your internet connection and try again.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'exists' || status === 'complete') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-4 h-4 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-2.5 h-2.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-emerald-400">{file ? `${file} — ` : ''}{status === 'exists' ? 'already installed' : 'done'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'downloading' || status === 'starting') {
|
||||
const pct = typeof percent === 'number' ? percent : null;
|
||||
return (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="flex-1 h-1.5 bg-white/[0.06] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full progress-bar-fill rounded-full transition-all duration-300 ${pct === null ? 'animate-pulse' : ''}`}
|
||||
style={{ width: pct !== null ? `${pct}%` : '40%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
{pct !== null ? `${pct}%` : 'Preparing…'}
|
||||
{file ? ` — ${file}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Main screen ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { navigate, useLocalAI, setUseLocalAI, theme, setTheme } = useLabStore();
|
||||
const [downloading, setDownloading] = useState({ piper: false, whisper: false });
|
||||
const [status, setStatus] = useState({
|
||||
piper: { available: false, modelsDownloaded: false, binaryPresent: false },
|
||||
whisper: { available: false, modelsDownloaded: false, binaryPresent: false },
|
||||
});
|
||||
const [progress, setProgress] = useState({});
|
||||
const [ollamaModels, setOllamaModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState(getOllamaModel());
|
||||
const [modelSaved, setModelSaved] = useState(false);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
if (!window.electronAPI?.download) return;
|
||||
try {
|
||||
const s = await window.electronAPI.download.status();
|
||||
setStatus({
|
||||
piper: s.piper || { available: false, modelsDownloaded: false, binaryPresent: false },
|
||||
whisper: s.whisper || { available: false, modelsDownloaded: false, binaryPresent: false },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to check download status:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
listOllamaModels().then(setOllamaModels);
|
||||
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.config.get('ollamaModel').then(saved => {
|
||||
if (saved) { setOllamaModel(saved); setSelectedModel(saved); }
|
||||
});
|
||||
window.electronAPI.config.get('useLocalAI').then(saved => {
|
||||
if (saved != null) setUseLocalAI(saved);
|
||||
});
|
||||
}
|
||||
|
||||
// Register progress listener — capture the returned cleanup fn
|
||||
let cleanup;
|
||||
if (window.electronAPI?.download?.onProgress) {
|
||||
cleanup = window.electronAPI.download.onProgress((data) => {
|
||||
setProgress(prev => ({ ...prev, [data.type]: data }));
|
||||
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
setDownloading(prev => ({ ...prev, [data.type]: false }));
|
||||
checkStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => { if (typeof cleanup === 'function') cleanup(); };
|
||||
}, [checkStatus, setUseLocalAI]);
|
||||
|
||||
const downloadModel = async (type) => {
|
||||
if (!window.electronAPI?.download) return;
|
||||
setDownloading(prev => ({ ...prev, [type]: true }));
|
||||
setProgress(prev => ({ ...prev, [type]: { status: 'starting', percent: 0 } }));
|
||||
try {
|
||||
await window.electronAPI.download.start(type);
|
||||
await checkStatus();
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
setProgress(prev => ({ ...prev, [type]: { status: 'error', error: err.message } }));
|
||||
} finally {
|
||||
setDownloading(prev => ({ ...prev, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const saveOllamaModel = async (model) => {
|
||||
setOllamaModel(model);
|
||||
setSelectedModel(model);
|
||||
if (window.electronAPI) await window.electronAPI.config.set('ollamaModel', model);
|
||||
setModelSaved(true);
|
||||
setTimeout(() => setModelSaved(false), 2000);
|
||||
};
|
||||
|
||||
const toggleLocalAI = async (value) => {
|
||||
setUseLocalAI(value);
|
||||
if (window.electronAPI) await window.electronAPI.config.set('useLocalAI', value);
|
||||
};
|
||||
|
||||
const isElectron = !!window.electronAPI;
|
||||
|
||||
// ── Model card helper ───────────────────────────────────────────────────────
|
||||
|
||||
const platform = window.electronAPI?.platform;
|
||||
|
||||
const ModelCard = ({ type, label, subtitle, iconColor, iconBg, icon, size }) => {
|
||||
const s = status[type];
|
||||
const isDownloading = downloading[type];
|
||||
const prog = progress[type];
|
||||
// macOS TTS is always ready via built-in 'say' — no install needed
|
||||
const isAutoReady = s.usesSay;
|
||||
const alreadyInstalled = s.modelsDownloaded && s.binaryPresent;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-5 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl ${iconBg} flex items-center justify-center`}>
|
||||
<svg className={`w-5 h-5 ${iconColor}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
{icon}
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{label}</h3>
|
||||
<p className="text-xs text-slate-500">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={s} />
|
||||
</div>
|
||||
|
||||
{/* Binary missing warning */}
|
||||
{!isAutoReady && s.modelsDownloaded && !s.binaryPresent && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl px-3 py-2 mb-3">
|
||||
<p className="text-xs text-amber-400">
|
||||
Model installed but the {type === 'piper' ? 'Piper' : 'Whisper'} runtime is missing.
|
||||
Click Install to download it automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-400 mb-4">
|
||||
{isAutoReady
|
||||
? 'Uses macOS built-in voice synthesis — works offline with no setup required.'
|
||||
: `Installs an offline ${type === 'piper' ? 'voice synthesis' : 'speech recognition'} engine (${size}). Enables ${type === 'piper' ? 'voice output' : 'voice input'} without internet after installation.`
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Progress bar (shown while installing) */}
|
||||
{isDownloading && <ProgressBar progress={prog} />}
|
||||
|
||||
{/* Install button — hidden for auto-ready and while installing */}
|
||||
{!isAutoReady && !isDownloading && (
|
||||
<button
|
||||
onClick={() => downloadModel(type)}
|
||||
disabled={!isElectron || alreadyInstalled}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
alreadyInstalled
|
||||
? 'bg-white/[0.04] text-slate-500 cursor-not-allowed'
|
||||
: 'accent-gradient accent-glow-sm text-white'
|
||||
}`}
|
||||
>
|
||||
{alreadyInstalled ? 'Installed' : 'Install'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh text-white flex flex-col">
|
||||
<header className="glass border-0 border-b border-white/[0.06] px-5 py-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('dashboard')}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1.5 rounded-lg hover:bg-white/[0.05]"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<p className="font-semibold text-sm">Settings</p>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6 max-w-2xl mx-auto w-full animate-slide-up">
|
||||
<h1 className="text-xl font-bold mb-6 tracking-tight">AI Models</h1>
|
||||
|
||||
{!isElectron && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-2xl p-4 mb-6 animate-fade-in">
|
||||
<p className="text-amber-400 text-sm">
|
||||
Model downloads are only available in the desktop app. You are currently running in browser mode.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS */}
|
||||
<ModelCard
|
||||
type="piper"
|
||||
label="Text-to-Speech (TTS)"
|
||||
subtitle="Piper — Offline voice synthesis"
|
||||
iconColor="text-blue-400"
|
||||
iconBg="bg-blue-500/15"
|
||||
size="~63 MB"
|
||||
icon={
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* STT */}
|
||||
<ModelCard
|
||||
type="whisper"
|
||||
label="Speech-to-Text (STT)"
|
||||
subtitle="Whisper — Offline voice recognition"
|
||||
iconColor="text-rose-400"
|
||||
iconBg="bg-rose-500/15"
|
||||
size="~75 MB"
|
||||
icon={
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* AI Source */}
|
||||
<div className="glass rounded-2xl p-5 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-500/15 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">AI Source</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
{useLocalAI ? 'Using local Ollama model' : 'Using Sarthi Cloud AI (default)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs ${!useLocalAI ? 'text-indigo-400 font-semibold' : 'text-slate-600'}`}>Cloud</span>
|
||||
<button
|
||||
onClick={() => toggleLocalAI(!useLocalAI)}
|
||||
disabled={useLocalAI && ollamaModels.length === 0}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
useLocalAI ? 'bg-emerald-500' : 'bg-white/[0.1]'
|
||||
} disabled:opacity-40`}
|
||||
>
|
||||
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-all ${
|
||||
useLocalAI ? 'left-6' : 'left-1'
|
||||
}`} />
|
||||
</button>
|
||||
<span className={`text-xs ${useLocalAI ? 'text-emerald-400 font-semibold' : 'text-slate-600'}`}>Local</span>
|
||||
</div>
|
||||
</div>
|
||||
{useLocalAI && ollamaModels.length === 0 && (
|
||||
<p className="text-xs text-amber-400 mt-3">
|
||||
Ollama not detected at localhost:11434. Install Ollama and pull a model to use local AI.
|
||||
</p>
|
||||
)}
|
||||
{!useLocalAI && (
|
||||
<p className="text-xs text-slate-500 mt-3">
|
||||
Sarthi Cloud uses the full NCERT curriculum prompt and tracks progress. Recommended for most students.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ollama model selector */}
|
||||
{useLocalAI && ollamaModels.length > 0 && (
|
||||
<div className="glass rounded-2xl p-5 mb-4 animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Local AI Model (Ollama)</h3>
|
||||
<p className="text-xs text-slate-500">Choose which model to use for offline chat</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ollamaModels.map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => saveOllamaModel(m)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
|
||||
selectedModel === m
|
||||
? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20'
|
||||
: 'bg-white/[0.05] text-slate-300 hover:bg-white/[0.08] border border-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{modelSaved && <p className="text-xs text-emerald-400 mt-2 animate-fade-in">Saved!</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme */}
|
||||
<div className="glass rounded-2xl p-5 mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Theme</h3>
|
||||
<p className="text-xs text-slate-500">Change the look and feel</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ id: 'indigo', name: 'Nebula', desc: 'Indigo & purple', colors: ['from-indigo-500 to-purple-600', 'from-purple-500 to-indigo-600', 'from-blue-500 to-indigo-500'] },
|
||||
{ id: 'emerald', name: 'Forest', desc: 'Emerald & teal', colors: ['from-emerald-500 to-teal-600', 'from-teal-500 to-cyan-600', 'from-cyan-500 to-emerald-500'] },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={async () => { setTheme(t.id); if (window.electronAPI) await window.electronAPI.config.set('theme', t.id); }}
|
||||
className={`relative rounded-xl p-4 border-2 transition-all ${
|
||||
theme === t.id
|
||||
? `border-${t.id === 'indigo' ? 'indigo' : 'emerald'}-500 bg-${t.id === 'indigo' ? 'indigo' : 'emerald'}-500/10`
|
||||
: 'border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{t.colors.map((c, i) => (
|
||||
<div key={i} className={`w-4 h-4 rounded-full bg-gradient-to-br ${c}`} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-white">{t.name}</p>
|
||||
<p className="text-xs text-slate-500">{t.desc}</p>
|
||||
{theme === t.id && (
|
||||
<div className={`absolute top-2 right-2 w-5 h-5 rounded-full ${t.id === 'indigo' ? 'bg-indigo-500' : 'bg-emerald-500'} flex items-center justify-center`}>
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="glass rounded-2xl p-5">
|
||||
<h3 className="font-semibold text-sm mb-2">About Offline Setup</h3>
|
||||
<ul className="text-xs text-slate-400 space-y-1.5 mb-3">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-emerald-500/60 flex-shrink-0 mt-1.5" />
|
||||
<span><strong className="text-slate-300">TTS (macOS):</strong> Uses the macOS built-in voice engine — no installation needed, works offline automatically.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-slate-600 flex-shrink-0 mt-1.5" />
|
||||
<span><strong className="text-slate-300">TTS (Linux):</strong> Click Install to download the Piper engine and voice model (~63 MB).</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-slate-600 flex-shrink-0 mt-1.5" />
|
||||
<span><strong className="text-slate-300">STT:</strong> Click Install to download the Whisper speech recognition model (~75 MB). Works fully offline after setup.</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-slate-500">
|
||||
Models are stored in your user data folder and persist across app updates.
|
||||
One-time install per device — no internet required after setup.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/screens/SetupScreen.jsx
Normal file
118
src/screens/SetupScreen.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { getRoomStatus } from '../services/labApi';
|
||||
import { detectOllama } from '../services/localLLM';
|
||||
|
||||
export default function SetupScreen() {
|
||||
const { setConfig, setRoomStatus } = useLabStore();
|
||||
const [serverUrl, setServerUrl] = useState('https://eduspheria.com');
|
||||
const [roomToken, setRoomToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [modelStatus, setModelStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.electronAPI?.download) {
|
||||
window.electronAPI.download.status().then(setModelStatus).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConnect = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const url = serverUrl.replace(/\/$/, '');
|
||||
const roomStatus = await getRoomStatus(url, roomToken.trim());
|
||||
|
||||
detectOllama();
|
||||
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.config.set('serverUrl', url);
|
||||
await window.electronAPI.config.set('roomToken', roomToken.trim());
|
||||
}
|
||||
|
||||
setConfig(url, roomToken.trim());
|
||||
setRoomStatus(roomStatus);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md animate-slide-up">
|
||||
<div className="text-center mb-10">
|
||||
<div className="w-20 h-20 mx-auto mb-5 rounded-2xl avatar-gradient flex items-center justify-center accent-glow">
|
||||
<span className="text-4xl font-bold text-white">S</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">Sarthi Lab</h1>
|
||||
<p className="text-slate-400 mt-2 text-sm">One-time setup for this computer lab</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect} className="glass-strong rounded-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">Server URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={serverUrl}
|
||||
onChange={e => setServerUrl(e.target.value)}
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]/40 focus:border-[var(--accent)]/40 transition-all placeholder:text-slate-600"
|
||||
placeholder="https://eduspheria.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">Room Token</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roomToken}
|
||||
onChange={e => setRoomToken(e.target.value)}
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--accent)]/40 focus:border-[var(--accent)]/40 transition-all placeholder:text-slate-600"
|
||||
placeholder="Paste the room token from Saksham..."
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-2">Set once per computer. Find it in Saksham → Labs → Lab Rooms.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl px-4 py-3 text-sm animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full accent-gradient accent-glow disabled:opacity-50 text-white font-semibold rounded-xl py-3.5 transition-all"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connecting...
|
||||
</span>
|
||||
) : 'Set Up Lab'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Offline model hint — shown when models are not yet downloaded */}
|
||||
{modelStatus && (!modelStatus.piper?.modelsDownloaded || !modelStatus.whisper?.modelsDownloaded) && (
|
||||
<div className="mt-4 bg-white/[0.04] border border-white/[0.08] rounded-2xl p-4 text-center animate-fade-in">
|
||||
<p className="text-xs text-slate-400 mb-1">
|
||||
<span className="text-slate-300 font-medium">Tip:</span>{' '}
|
||||
Download offline TTS & STT models (~140 MB total) for fully offline voice support.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Go to <strong className="text-slate-400">Settings → AI Models</strong> after connecting.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/screens/StudentLoginScreen.jsx
Normal file
120
src/screens/StudentLoginScreen.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { studentLogin, getRoomClasses } from '../services/labApi';
|
||||
|
||||
export default function StudentLoginScreen() {
|
||||
const { serverUrl, roomToken, sessionToken, labInfo, setStudent } = useLabStore();
|
||||
const [rollNumber, setRollNumber] = useState('');
|
||||
const [className, setClassName] = useState('');
|
||||
const [classes, setClasses] = useState([]);
|
||||
const [loadingClasses, setLoadingClasses] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchClasses() {
|
||||
try {
|
||||
const list = await getRoomClasses(serverUrl, roomToken);
|
||||
setClasses(list);
|
||||
if (list.length === 1) setClassName(list[0]);
|
||||
} catch {
|
||||
} finally {
|
||||
setLoadingClasses(false);
|
||||
}
|
||||
}
|
||||
fetchClasses();
|
||||
}, [serverUrl, roomToken]);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const student = await studentLogin(serverUrl, sessionToken, rollNumber.trim(), className);
|
||||
setStudent(student);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl avatar-gradient flex items-center justify-center accent-glow-sm">
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">{labInfo?.lab_name}</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">{labInfo?.school_name}</p>
|
||||
<p className="text-slate-500 text-xs mt-3">Enter your details to start learning</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="glass-strong rounded-2xl p-7 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">Roll Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rollNumber}
|
||||
onChange={e => setRollNumber(e.target.value)}
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]/40 focus:border-[var(--accent)]/40 transition-all placeholder:text-slate-600"
|
||||
placeholder="e.g. 42"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">Class</label>
|
||||
{!loadingClasses && classes.length > 0 ? (
|
||||
<select
|
||||
value={className}
|
||||
onChange={e => setClassName(e.target.value)}
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]/40 focus:border-[var(--accent)]/40 transition-all appearance-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-slate-900">Select your class...</option>
|
||||
{classes.map(c => (
|
||||
<option key={c} value={c} className="bg-slate-900">{c}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={className}
|
||||
onChange={e => setClassName(e.target.value)}
|
||||
className="w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]/40 focus:border-[var(--accent)]/40 transition-all placeholder:text-slate-600"
|
||||
placeholder={loadingClasses ? 'Loading classes...' : 'e.g. Grade 10 - A'}
|
||||
required
|
||||
disabled={loadingClasses}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl px-4 py-3 text-sm animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || loadingClasses}
|
||||
className="w-full accent-gradient accent-glow disabled:opacity-50 text-white font-semibold rounded-xl py-3.5 transition-all"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Logging in...
|
||||
</span>
|
||||
) : 'Start Learning'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/screens/WaitingScreen.jsx
Normal file
69
src/screens/WaitingScreen.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import useLabStore from '../store/labStore';
|
||||
import { getRoomStatus } from '../services/labApi';
|
||||
|
||||
const POLL_INTERVAL = 30_000;
|
||||
|
||||
export default function WaitingScreen() {
|
||||
const { serverUrl, roomToken, labInfo, sessionActivated, navigate } = useLabStore();
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function poll() {
|
||||
try {
|
||||
const roomStatus = await getRoomStatus(serverUrl, roomToken);
|
||||
if (roomStatus.status === 'active') {
|
||||
clearInterval(intervalRef.current);
|
||||
sessionActivated(roomStatus);
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup — keep polling silently
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
intervalRef.current = setInterval(poll, POLL_INTERVAL);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [serverUrl, roomToken]);
|
||||
|
||||
const handleReconfigure = async () => {
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.config.delete('serverUrl');
|
||||
await window.electronAPI.config.delete('roomToken');
|
||||
}
|
||||
navigate('setup');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mesh flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className="animate-slide-up">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl avatar-gradient flex items-center justify-center accent-glow-sm">
|
||||
<span className="text-3xl">S</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-1 tracking-tight">
|
||||
{labInfo?.lab_name || 'Lab'}
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mb-10">{labInfo?.school_name}</p>
|
||||
|
||||
<div className="glass-strong rounded-2xl px-10 py-8 mb-8 inline-flex flex-col items-center">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-amber-500" />
|
||||
</span>
|
||||
<p className="text-slate-200 text-sm font-medium">Waiting for teacher to start a session</p>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs">Checking automatically every 30 seconds</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReconfigure}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors underline underline-offset-2"
|
||||
>
|
||||
Reconfigure this lab computer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/services/chatHistory.js
Normal file
55
src/services/chatHistory.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// Chat history persistence using electron-store
|
||||
|
||||
const HISTORY_KEY_PREFIX = 'chat_history_';
|
||||
|
||||
export function getChatHistoryKey(type, id = null) {
|
||||
if (type === 'general') {
|
||||
return `${HISTORY_KEY_PREFIX}general`;
|
||||
}
|
||||
return `${HISTORY_KEY_PREFIX}course_${id}`;
|
||||
}
|
||||
|
||||
export async function loadChatHistory(type, id = null) {
|
||||
const key = getChatHistoryKey(type, id);
|
||||
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
const history = await window.electronAPI.config.get(key);
|
||||
return history || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localStorage in browser
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveChatMessage(type, id, message) {
|
||||
const key = getChatHistoryKey(type, id);
|
||||
const history = await loadChatHistory(type, id);
|
||||
|
||||
// Keep only last 50 messages to avoid storage bloat
|
||||
const updated = [...history, message].slice(-50);
|
||||
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.config.set(key, updated);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(updated));
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearChatHistory(type, id = null) {
|
||||
const key = getChatHistoryKey(type, id);
|
||||
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.config.delete(key);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
164
src/services/downloadManager.js
Normal file
164
src/services/downloadManager.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// Download manager for AI models (TTS, STT)
|
||||
import { app } from '@electron/remote' || {};
|
||||
import { spawn } from 'child_process';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const MODEL_VERSIONS = {
|
||||
piper: {
|
||||
// Piper TTS model - en_US lessac medium
|
||||
url: 'https://github.com/rhasspy/piper/releases/download/2024.01.17-2/en_US-lessac-medium.onnx',
|
||||
configUrl: 'https://github.com/rhasspy/piper/releases/download/2024.01.17-2/en_US-lessac-medium.onnx.json',
|
||||
files: ['en_US-lessac-medium.onnx', 'en_US-lessac-medium.onnx.json'],
|
||||
size: 76 * 1024 * 1024, // ~76MB
|
||||
},
|
||||
whisper: {
|
||||
// Whisper tiny model
|
||||
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
|
||||
files: ['ggml-tiny.bin'],
|
||||
size: 75 * 1024 * 1024, // ~75MB
|
||||
},
|
||||
};
|
||||
|
||||
function getModelDir(type) {
|
||||
const { app } = window.require ? window.require('@electron/remote') : {};
|
||||
if (app) {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, type)
|
||||
: path.join(__dirname, type);
|
||||
}
|
||||
return path.join(__dirname, '..', type);
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
|
||||
protocol.get(url, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
// Follow redirect
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
reject(new Error(`Download failed: ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
file.close();
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.unlinkSync(dest);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadPiperModel(onProgress) {
|
||||
const dir = getModelDir('piper');
|
||||
ensureDir(dir);
|
||||
|
||||
const model = MODEL_VERSIONS.piper;
|
||||
|
||||
for (const file of model.files) {
|
||||
const dest = path.join(dir, file);
|
||||
if (fs.existsSync(dest)) {
|
||||
console.log(`${file} already exists, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = file.endsWith('.json') ? model.configUrl : model.url;
|
||||
onProgress?.({ file, status: 'downloading' });
|
||||
|
||||
try {
|
||||
await downloadFile(url, dest);
|
||||
onProgress?.({ file, status: 'complete' });
|
||||
} catch (err) {
|
||||
onProgress?.({ file, status: 'error', error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function downloadWhisperModel(onProgress) {
|
||||
const dir = getModelDir('whisper');
|
||||
ensureDir(dir);
|
||||
|
||||
const model = MODEL_VERSIONS.whisper;
|
||||
|
||||
for (const file of model.files) {
|
||||
const dest = path.join(dir, file);
|
||||
if (fs.existsSync(dest)) {
|
||||
console.log(`${file} already exists, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = model.url;
|
||||
onProgress?.({ file, status: 'downloading' });
|
||||
|
||||
try {
|
||||
await downloadFile(url, dest);
|
||||
onProgress?.({ file, status: 'complete' });
|
||||
} catch (err) {
|
||||
onProgress?.({ file, status: 'error', error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkModelStatus() {
|
||||
const status = {
|
||||
piper: { available: false, files: [] },
|
||||
whisper: { available: false, files: [] },
|
||||
};
|
||||
|
||||
try {
|
||||
// Check Piper
|
||||
const piperDir = getModelDir('piper');
|
||||
if (fs.existsSync(piperDir)) {
|
||||
status.piper.files = fs.readdirSync(piperDir);
|
||||
status.piper.available = MODEL_VERSIONS.piper.files.every(f =>
|
||||
status.piper.files.includes(f)
|
||||
);
|
||||
}
|
||||
|
||||
// Check Whisper
|
||||
const whisperDir = getModelDir('whisper');
|
||||
if (fs.existsSync(whisperDir)) {
|
||||
status.whisper.files = fs.readdirSync(whisperDir);
|
||||
status.whisper.available = MODEL_VERSIONS.whisper.files.every(f =>
|
||||
status.whisper.files.includes(f)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error checking model status:', err);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
205
src/services/labApi.js
Normal file
205
src/services/labApi.js
Normal file
@@ -0,0 +1,205 @@
|
||||
// Lab API — communicates with Django server
|
||||
|
||||
// Unified Agent endpoints (new)
|
||||
export const AGENT_ENDPOINTS = {
|
||||
SARTHI: '/agents/sarthi/chat/',
|
||||
SARTHI_GENERAL: '/agents/sarthi/general/chat/',
|
||||
};
|
||||
|
||||
// Room-based auth (permanent token, set once on desktop)
|
||||
export async function getRoomStatus(serverUrl, roomToken) {
|
||||
const res = await fetch(`${serverUrl}/api/lab/room/status/?room_token=${encodeURIComponent(roomToken)}`);
|
||||
const data = await res.json();
|
||||
if (res.status === 403) throw new Error(data.error || 'Invalid room token');
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to check room status');
|
||||
return data; // { status: 'active'|'waiting', lab_name, school_name, session_token?, allowed_courses? }
|
||||
}
|
||||
|
||||
export async function getRoomClasses(serverUrl, roomToken) {
|
||||
const res = await fetch(`${serverUrl}/api/lab/room/classes/?room_token=${encodeURIComponent(roomToken)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to fetch classes');
|
||||
return data.classes; // string[]
|
||||
}
|
||||
|
||||
export async function studentLogin(serverUrl, sessionToken, rollNumber, className) {
|
||||
const res = await fetch(`${serverUrl}/api/lab/student-login/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_token: sessionToken,
|
||||
roll_number: rollNumber,
|
||||
class_name: className,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
// Returns false if the lab session has expired (403), true otherwise
|
||||
export async function sendHeartbeat(serverUrl, sessionToken, authToken, activity, courseId) {
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/api/lab/heartbeat/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_token: sessionToken,
|
||||
auth_token: authToken,
|
||||
activity,
|
||||
current_course_id: courseId || null,
|
||||
}),
|
||||
});
|
||||
return res.status !== 403;
|
||||
} catch {
|
||||
return true; // Network error — don't flag as expired
|
||||
}
|
||||
}
|
||||
|
||||
export async function studentLogout(serverUrl, sessionToken, authToken) {
|
||||
try {
|
||||
await fetch(`${serverUrl}/api/lab/student-logout/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: sessionToken, auth_token: authToken }),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort logout
|
||||
}
|
||||
}
|
||||
|
||||
// Get exercise access URL
|
||||
export async function getExerciseUrl(serverUrl, authToken, exerciseId) {
|
||||
const res = await fetch(`${serverUrl}/api/exercises/${exerciseId}/access-url`, {
|
||||
headers: { 'Authorization': `Token ${authToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to get exercise URL');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Lab student progress & achievements
|
||||
export async function getStudentProgress(serverUrl, sessionToken, authToken) {
|
||||
const res = await fetch(`${serverUrl}/api/lab/student-progress/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: sessionToken, auth_token: authToken }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to get progress');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NEW: Unified Agent API - uses /agents/ endpoints
|
||||
// =============================================================================
|
||||
|
||||
// Send message to Sarthi agent (course-specific) using new unified endpoint
|
||||
export async function sendSarthiCourseMessage(serverUrl, authToken, courseId, message, conversationId, onChunk) {
|
||||
const res = await fetch(`${serverUrl}${AGENT_ENDPOINTS.SARTHI}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Token ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
course_id: courseId,
|
||||
conversation_id: conversationId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Chat error: ${res.status}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try { onChunk(JSON.parse(line.slice(6))); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to Sarthi agent (general chat) using new unified endpoint
|
||||
export async function sendSarthiGeneralMessage(serverUrl, authToken, message, conversationId, onChunk) {
|
||||
const res = await fetch(`${serverUrl}${AGENT_ENDPOINTS.SARTHI_GENERAL}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Token ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
conversation_id: conversationId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Chat error: ${res.status}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try { onChunk(JSON.parse(line.slice(6))); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of available agents
|
||||
export async function getAgents(serverUrl, authToken) {
|
||||
const res = await fetch(`${serverUrl}/api/agents/`, {
|
||||
headers: { 'Authorization': `Token ${authToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to get agents');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Get agent conversations
|
||||
export async function getAgentConversations(serverUrl, authToken, agentName) {
|
||||
let url = `${serverUrl}/api/agents/conversations/`;
|
||||
if (agentName) url += `?agent=${agentName}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Authorization': `Token ${authToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to get conversations');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Get specific conversation
|
||||
export async function getAgentConversation(serverUrl, authToken, conversationId) {
|
||||
const res = await fetch(`${serverUrl}/api/agents/conversations/${conversationId}/`, {
|
||||
headers: { 'Authorization': `Token ${authToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to get conversation');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Create new agent conversation
|
||||
export async function createAgentConversation(serverUrl, authToken, title, agentName) {
|
||||
const res = await fetch(`${serverUrl}/api/agents/conversations/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Token ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ title, agent_name: agentName }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create conversation');
|
||||
return res.json();
|
||||
}
|
||||
81
src/services/localLLM.js
Normal file
81
src/services/localLLM.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Local LLM via Ollama — falls back to server if not available
|
||||
|
||||
const OLLAMA_URL = 'http://localhost:11434';
|
||||
let ollamaAvailable = null; // null = unchecked, true/false after detection
|
||||
let ollamaModel = 'llama3.2';
|
||||
|
||||
export function getOllamaModel() { return ollamaModel; }
|
||||
export function setOllamaModel(model) { ollamaModel = model; }
|
||||
|
||||
export async function listOllamaModels() {
|
||||
try {
|
||||
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(2000) });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.models || []).map(m => m.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectOllama() {
|
||||
try {
|
||||
const res = await fetch(`${OLLAMA_URL}/api/tags`, {
|
||||
signal: AbortSignal.timeout(1500),
|
||||
});
|
||||
ollamaAvailable = res.ok;
|
||||
} catch {
|
||||
ollamaAvailable = false;
|
||||
}
|
||||
return ollamaAvailable;
|
||||
}
|
||||
|
||||
export function isOllamaAvailable() {
|
||||
return ollamaAvailable === true;
|
||||
}
|
||||
|
||||
// Stream a chat response from Ollama
|
||||
export async function chatWithOllama(systemPrompt, messages, onChunk) {
|
||||
const model = ollamaModel;
|
||||
|
||||
const ollamaMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages,
|
||||
];
|
||||
|
||||
const res = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.message?.content) {
|
||||
onChunk({ type: 'text', content: data.message.content });
|
||||
}
|
||||
if (data.done) {
|
||||
onChunk({ type: 'done' });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
src/services/stt.js
Normal file
131
src/services/stt.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// STT service — uses whisper.cpp via Electron IPC, falls back to Web Speech API
|
||||
|
||||
/**
|
||||
* Encode raw PCM Float32 chunks captured from AudioContext into a WAV Blob.
|
||||
* Whisper requires wav/mp3/ogg/flac — MediaRecorder produces webm which whisper rejects.
|
||||
*/
|
||||
function encodeWAV(chunks, sampleRate) {
|
||||
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const samples = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const c of chunks) { samples.set(c, offset); offset += c.length; }
|
||||
|
||||
const pcm = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(samples[i] * 32767)));
|
||||
}
|
||||
|
||||
const header = new ArrayBuffer(44);
|
||||
const v = new DataView(header);
|
||||
const s = (off, str) => { for (let i = 0; i < str.length; i++) v.setUint8(off + i, str.charCodeAt(i)); };
|
||||
|
||||
s(0, 'RIFF'); v.setUint32(4, 36 + pcm.byteLength, true);
|
||||
s(8, 'WAVE'); s(12, 'fmt ');
|
||||
v.setUint32(16, 16, true); // chunk size
|
||||
v.setUint16(20, 1, true); // PCM
|
||||
v.setUint16(22, 1, true); // mono
|
||||
v.setUint32(24, sampleRate, true);
|
||||
v.setUint32(28, sampleRate * 2, true); // byte rate
|
||||
v.setUint16(32, 2, true); // block align
|
||||
v.setUint16(34, 16, true); // bits per sample
|
||||
s(36, 'data'); v.setUint32(40, pcm.byteLength, true);
|
||||
|
||||
return new Blob([header, pcm.buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording from the microphone using AudioContext (produces proper WAV).
|
||||
* Returns a recorder handle with a stop() method that resolves to a WAV Blob.
|
||||
*/
|
||||
export async function startRecording() {
|
||||
const sampleRate = 16000;
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const ctx = new AudioContext({ sampleRate });
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
// ScriptProcessor is deprecated but still works reliably in Electron
|
||||
const processor = ctx.createScriptProcessor(4096, 1, 1);
|
||||
const chunks = [];
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(ctx.destination);
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
processor.disconnect();
|
||||
source.disconnect();
|
||||
await ctx.close();
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
return encodeWAV(chunks, sampleRate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe an audio blob.
|
||||
*
|
||||
* In Electron: sends blob to whisper.cpp for offline transcription.
|
||||
* In browser: Web Speech API is the primary STT (live mic, not blob-based).
|
||||
* NOTE — Web Speech listens to the microphone in real time, so
|
||||
* calling this from a browser without Electron will start a new
|
||||
* listening session rather than processing the provided blob.
|
||||
* This is intentional: browsers cannot re-process a recorded blob
|
||||
* with Web Speech API.
|
||||
*/
|
||||
export async function transcribeAudio(audioBlob) {
|
||||
if (!audioBlob) return '';
|
||||
|
||||
// ── Electron path: send blob to whisper.cpp ──────────────────────────────
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.stt) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
const base64Promise = new Promise((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result.split(',')[1]);
|
||||
reader.onerror = reject;
|
||||
});
|
||||
reader.readAsDataURL(audioBlob);
|
||||
|
||||
const base64Audio = await base64Promise;
|
||||
const result = await window.electronAPI.stt.transcribe(base64Audio);
|
||||
|
||||
if (result?.error && !result?.text) {
|
||||
// whisper reported a hard error (binary missing, etc.) — return empty
|
||||
console.warn('Whisper STT error:', result.error);
|
||||
return '';
|
||||
}
|
||||
|
||||
return result?.text ?? '';
|
||||
} catch (err) {
|
||||
console.warn('Whisper STT IPC error:', err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Browser fallback: Web Speech API (live mic) ──────────────────────────
|
||||
const SpeechRecognition =
|
||||
(typeof window !== 'undefined') &&
|
||||
(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
|
||||
if (SpeechRecognition) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = false;
|
||||
recognition.lang = 'en-US';
|
||||
recognition.onresult = (event) => resolve(event.results[0][0].transcript);
|
||||
recognition.onerror = (err) => reject(err);
|
||||
recognition.start();
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('No STT available');
|
||||
}
|
||||
|
||||
export function isSTTAvailable() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (window.electronAPI?.stt) return true;
|
||||
return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
}
|
||||
41
src/services/tts.js
Normal file
41
src/services/tts.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// TTS service — uses Piper via Electron IPC, falls back to Web Speech API
|
||||
|
||||
// Extract text up to the first clean sentence boundary for TTS
|
||||
export function extractTtsText(text, maxLen = 450) {
|
||||
const flat = text.replace(/\n+/g, ' ').slice(0, maxLen);
|
||||
const end = Math.max(flat.lastIndexOf('. '), flat.lastIndexOf('! '), flat.lastIndexOf('? '));
|
||||
return end > 30 ? flat.slice(0, end + 1) : flat;
|
||||
}
|
||||
|
||||
let usePiper = typeof window !== 'undefined' && !!window.electronAPI;
|
||||
|
||||
export async function speak(text) {
|
||||
if (!text) return;
|
||||
|
||||
if (usePiper) {
|
||||
try {
|
||||
await window.electronAPI.tts.speak(text);
|
||||
return;
|
||||
} catch {
|
||||
usePiper = false; // Piper failed, fall back to Web Speech
|
||||
}
|
||||
}
|
||||
|
||||
// Web Speech API fallback
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.rate = 0.95;
|
||||
utterance.pitch = 1;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopSpeaking() {
|
||||
if (usePiper && window.electronAPI) {
|
||||
window.electronAPI.tts.stop();
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
}
|
||||
68
src/store/labStore.js
Normal file
68
src/store/labStore.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useLabStore = create((set) => ({
|
||||
// Persisted config (loaded from electron-store on startup)
|
||||
serverUrl: '',
|
||||
roomToken: '', // permanent — entered once, never changes
|
||||
useLocalAI: false, // false = Sarthi cloud (default), true = Ollama local
|
||||
theme: 'indigo', // 'indigo' | 'emerald'
|
||||
|
||||
// In-memory session info (from room/status API when a session is active)
|
||||
sessionToken: '', // changes each lab session, NOT persisted
|
||||
labInfo: null, // { lab_name, school_name, allowed_courses }
|
||||
|
||||
// Logged-in student (lives in memory only)
|
||||
student: null, // { student_id, student_name, class_name, auth_token, allowed_courses }
|
||||
|
||||
// App state
|
||||
// 'setup' | 'waiting' | 'student-login' | 'dashboard' | 'course-chat' | 'exercise' | 'general-chat' | 'settings'
|
||||
screen: 'setup',
|
||||
activeCourse: null,
|
||||
activeExercise: null,
|
||||
|
||||
setConfig: (serverUrl, roomToken) => set({ serverUrl, roomToken }),
|
||||
setUseLocalAI: (value) => set({ useLocalAI: value }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
|
||||
// Called when room status is checked — moves to waiting or student-login
|
||||
setRoomStatus: (roomStatus) => {
|
||||
if (roomStatus.status === 'active') {
|
||||
set({
|
||||
sessionToken: roomStatus.session_token,
|
||||
labInfo: {
|
||||
lab_name: roomStatus.lab_name,
|
||||
school_name: roomStatus.school_name,
|
||||
allowed_courses: roomStatus.allowed_courses,
|
||||
},
|
||||
screen: 'student-login',
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
sessionToken: '',
|
||||
labInfo: {
|
||||
lab_name: roomStatus.lab_name,
|
||||
school_name: roomStatus.school_name,
|
||||
},
|
||||
screen: 'waiting',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Called when a new session becomes active while on WaitingScreen
|
||||
sessionActivated: (roomStatus) => set({
|
||||
sessionToken: roomStatus.session_token,
|
||||
labInfo: {
|
||||
lab_name: roomStatus.lab_name,
|
||||
school_name: roomStatus.school_name,
|
||||
allowed_courses: roomStatus.allowed_courses,
|
||||
},
|
||||
screen: 'student-login',
|
||||
}),
|
||||
|
||||
setStudent: (student) => set({ student, screen: 'dashboard' }),
|
||||
logoutStudent: () => set({ student: null, screen: 'student-login', activeCourse: null, activeExercise: null }),
|
||||
|
||||
navigate: (screen, params = {}) => set({ screen, ...params }),
|
||||
}));
|
||||
|
||||
export default useLabStore;
|
||||
23
src/utils/helpers.js
Normal file
23
src/utils/helpers.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Utility functions for common operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format tool name from snake_case to Title Case
|
||||
* Example: "practice_pronunciation" -> "Practice Pronunciation"
|
||||
*/
|
||||
export function formatToolName(toolName) {
|
||||
if (!toolName) return '';
|
||||
return toolName
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a string
|
||||
*/
|
||||
export function capitalize(str) {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
116
tailwind.config.js
Normal file
116
tailwind.config.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#7c3aed',
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
200: '#ddd6fe',
|
||||
300: '#c4b5fd',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
800: '#5b21b6',
|
||||
900: '#4c1d95',
|
||||
},
|
||||
secondary: {
|
||||
50: '#fafafa',
|
||||
100: '#f4f4f5',
|
||||
200: '#e4e4e7',
|
||||
300: '#d4d4d8',
|
||||
400: '#a1a1aa',
|
||||
500: '#71717a',
|
||||
600: '#52525b',
|
||||
700: '#3f3f46',
|
||||
800: '#27272a',
|
||||
900: '#18181b',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: 'rgba(15, 23, 42, 0.6)',
|
||||
solid: '#0f172a',
|
||||
hover: 'rgba(30, 41, 59, 0.7)',
|
||||
border: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#6366f1',
|
||||
light: '#818cf8',
|
||||
dark: '#4f46e5',
|
||||
glow: 'rgba(99, 102, 241, 0.15)',
|
||||
},
|
||||
error: {
|
||||
DEFAULT: '#ef4444',
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
'128': '32rem',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 4px 20px -2px rgba(0, 0, 0, 0.05), 0 10px 15px -3px rgba(0, 0, 0, 0.02)',
|
||||
'medium': '0 10px 30px -4px rgba(0, 0, 0, 0.08), 0 4px 12px -2px rgba(0, 0, 0, 0.03)',
|
||||
'large': '0 20px 40px -4px rgba(0, 0, 0, 0.12), 0 8px 16px -4px rgba(0, 0, 0, 0.04)',
|
||||
'glow': '0 0 25px rgba(124, 58, 237, 0.25)',
|
||||
'glass': '0 8px 32px 0 rgba(31, 38, 135, 0.07)',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'slide-up': 'slideUp 0.35s ease-out',
|
||||
'slide-in-right': 'slideInRight 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s ease-in-out infinite',
|
||||
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||
'float': 'float 3s ease-in-out infinite',
|
||||
'float-slow': 'floatSlow 5s ease-in-out infinite',
|
||||
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(12px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideInRight: {
|
||||
'0%': { opacity: '0', transform: 'translateX(12px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
glow: {
|
||||
'0%': { boxShadow: '0 0 5px rgba(99, 102, 241, 0.2)' },
|
||||
'100%': { boxShadow: '0 0 20px rgba(99, 102, 241, 0.4)' },
|
||||
},
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-6px)' },
|
||||
},
|
||||
floatSlow: {
|
||||
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
|
||||
'50%': { transform: 'translateY(-10px) rotate(1deg)' },
|
||||
},
|
||||
glowPulse: {
|
||||
'0%, 100%': { boxShadow: '0 0 20px rgba(124, 58, 237, 0.3)' },
|
||||
'50%': { boxShadow: '0 0 40px rgba(124, 58, 237, 0.5)' },
|
||||
},
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
9
vite.config.js
Normal file
9
vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: { port: 5174 },
|
||||
build: { outDir: 'dist' },
|
||||
});
|
||||
Reference in New Issue
Block a user