Initial commit: Sarthi Lab desktop application

This commit is contained in:
2026-03-11 03:59:38 +05:30
commit bb1ec0a584
49 changed files with 15191 additions and 0 deletions

10
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
{ "type": "commonjs" }

34
electron/preload.js Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

67
package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};

80
src/App.jsx Normal file
View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 &middot; {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;

View 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;

View 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;

View 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;

View 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} &middot; {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;

View 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;

View 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
View 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
View 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>
);

View 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>
);
}

View 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} &middot; {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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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 &rarr; Labs &rarr; 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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' },
});