Initial commit: Learning Lab Express

Includes fixes found while reviewing the app:
- Results: open each result by a type+agent+id key, so a video and a
  material draft with the same id no longer open each other's popup
- AuthGate: clear the query cache during render, before pages build their
  queries, and remount pages on organization switch (pages no longer stay
  on skeletons for up to a minute after sign-in or refresh)
- Instruction: keep only failed rules after a partial approve, so retrying
  does not create the successful schedules twice; drop the monthly note
  once count or frequency is edited
- Parser/schedules: never schedule a count below one
- Audio: ignore the end event of a cancelled utterance when changing speed
- Downloads: revoke the object URL later so Safari/Firefox can read it
- Results: refetch SCORM content when any part's revision changes; show a
  failed video as failed instead of "still rendering"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

6
.claude/launch.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": "0.0.1",
"configurations": [
{ "name": "express", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 5174 }
]
}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# Learning Lab Express
Express Mode untuk learning-lab: UI sederhana di mana tim L&D mengunggah materi ke **Brain**, memberi satu **Instruction** ("bikin 3 video per minggu"), lalu AI menjadwalkan dan terus membuat konten yang tampil di **Results**. Semua logika AI ada di engine **learning-lab** (FastAPI, `/api/v1`); app ini hanya klien.
Stack: React 19, Vite 7, TypeScript, React Router 7, TanStack Query, Clerk (instance yang sama dengan learning-lab). Gaya diambil apa adanya dari `mockup.html` (`src/styles/mockup.css`).
```bash
# 1. jalankan engine: cd ../learning-lab && docker compose up → http://localhost:8000
# 2. jalankan Express
npm install
npm run dev # http://localhost:5174
npm test # tes parser, jadwal, SCORM, dan ekspor (Vitest)
```
- `/api/*` di-proxy oleh Vite ke `ENGINE_URL` (default `http://localhost:8000`), jadi engine tidak perlu CORS.
- Clerk key dibaca dari `VITE_CLERK_PUBLISHABLE_KEY`, atau otomatis dari `../learning-lab/backend/.env`.
- Tombol "Open in Learning Studio" di Results membuka `VITE_STUDIO_URL` (default `http://localhost:5173`).
- Dua bahasa: English (default) dan Indonesia, lewat tombol EN/ID di topbar dan halaman login. Teks ada di `src/i18n.ts`; pilihan disimpan per perangkat.
- Login wajib memakai akun Clerk dengan organisasi aktif: agents, jadwal, dan kredit semuanya per organisasi.
| Folder | Isi |
|---|---|
| `src/api/` | `client.ts` (fetch + token Clerk), `engine.ts` (dokumen, compose, agents) |
| `src/auth/` | Gerbang login dan pemilihan organisasi |
| `src/pages/` | Brain (`/brain`), Instruction (`/instruction`), Schedule (`/schedule`), Results (`/results`) |
| `src/express/` | Parser instruksi (EN/ID), pembuat jadwal di atas Agents engine, hasil, dan ekspor SCORM (`scorm.ts`) |
| `src/components/` | Layout dengan panduan alur, toast, pemutar audio |
| `prototype/` | Prototipe HTML satu file sebelumnya (referensi alur) |
Status: semua fase sudah dikerjakan.
- **Fase 0–3 dan 6**: kerangka, auth, Brain, Instruction → jadwal, Schedule dengan "Produce now", dan Results.
- **Fase 4 · Audio**: jadwal konten `summary` dengan brief naskah narasi. Results memutarnya dengan suara perangkat (Web Speech API) dan mengunduh naskahnya (.txt). Belum ada MP3, karena engine belum punya keluaran audio untuk agen; yang ada hanya TTS di dalam Video Studio.
- **Fase 5 · Video**: agen dengan `studio.create_video` dan `run_schedule`. Setiap proses membuat satu video (4 adegan, 40 detik), jadi pilihannya hanya 1 per hari atau 1 per minggu. Batas kredit diambil dari `agents/video-quote`. Video yang sudah siap bisa diputar, disetujui atau ditolak, dan diunduh sebagai MP4. Ada juga tombol "Buat video sekarang" di Schedule.
- **Fase 7 · SCORM**: jadwal konten dengan dua keluaran (`summary` + `quiz` 10 soal) per item, digabung menjadi satu modul di Results. Setiap hasil teks bisa diunduh sebagai paket SCORM 1.2 (.zip) yang dibuat di browser. Paket ini melaporkan selesai, atau nilai dan lulus/tidak (batas lulus 70%), ke LMS.
- **Pemantapan**: toast menggantikan `alert()`, ada panduan alur di sidebar, dan ada tes Vitest.
Produksi konten otomatis di engine baru jalan bila harga `agent.content.plan` > 0 (admin Learning Studio → Pricing).

35
index.html Normal file
View File

@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="description" content="Learning Lab Express by Bawana: Express Mode for learning-lab." />
<title>Learning Lab Express</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400..800&display=swap" />
<!-- Loading screen styles live here, not in theme.css, so they paint before the app bundle loads. -->
<style>
body{margin:0;background:#f5f8ff}
.boot{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;font-family:'Plus Jakarta Sans',system-ui,-apple-system,"Segoe UI",sans-serif;color:#101828;background:radial-gradient(600px 400px at 50% 38%,rgba(46,144,250,.18),rgba(46,144,250,0) 70%),#f5f8ff;animation:boot-in .3s ease-out}
.boot-mark{position:relative;display:grid;place-items:center;width:64px;height:64px;border-radius:20px;color:#fff;background:linear-gradient(140deg,#53b1fd,#2e90fa 45%,#1570ef);box-shadow:inset 0 1px 0 rgba(255,255,255,.3),0 14px 30px -10px rgba(21,112,239,.7)}
.boot-mark svg{width:32px;height:32px}
.boot-mark::before,.boot-mark::after{content:"";position:absolute;inset:-2px;border-radius:22px;border:2px solid rgba(46,144,250,.45);animation:boot-ring 1.8s ease-out infinite}
.boot-mark::after{animation-delay:.9s}
.boot-name{font-size:17px;font-weight:600;letter-spacing:-.02em}
.boot-name b{font-weight:800;color:#1570ef}
.boot-bar{position:relative;width:160px;height:4px;border-radius:4px;background:#d1e9ff;overflow:hidden}
.boot-bar i{position:absolute;inset:0 auto 0 0;width:40%;border-radius:4px;background:linear-gradient(90deg,#2e90fa,#1570ef);animation:boot-slide 1.1s ease-in-out infinite}
.boot-text{font-size:13px;color:#667085}
@keyframes boot-ring{from{transform:scale(1);opacity:1}to{transform:scale(1.55);opacity:0}}
@keyframes boot-slide{from{transform:translateX(-100%)}to{transform:translateX(250%)}}
@keyframes boot-in{from{opacity:0}}
@media (prefers-reduced-motion:reduce){.boot,.boot-mark::before,.boot-mark::after,.boot-bar i{animation:none}.boot-bar i{width:100%}}
</style>
</head>
<body>
<!-- Shown until React mounts; AuthGate renders the same screen while Clerk loads. -->
<div id="root"><div class="boot" role="status"><span class="boot-mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5c3-1 6 0 9 2 3-2 6-3 9-2v14c-3-1-6 0-9 2-3-2-6-3-9-2z"/><path d="M12 7v14M6 9l3 1M15 10l3-1"/></svg></span><span class="boot-name">Learning Lab <b>Express</b></span><span class="boot-bar"><i></i></span></div></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

615
mockup.html Normal file

File diff suppressed because one or more lines are too long

2328
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "learning-lab-express",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@clerk/localizations": "^4.24.0",
"@clerk/react": "^6.2.0",
"@tanstack/react-query": "^5.90.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vitest": "^5.0.5"
}
}

27
prototype/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Learning Lab Express
UI fase development (`index.html`, satu file, tanpa dependensi) dengan desain yang sama dengan `mockup.html` (CSS dipakai apa adanya) tetapi isi/alurnya memakai data engine yang terhubung ke engine **learning-lab** (FastAPI, `/api/v1`).
```bash
# 1. jalankan engine di ../learning-lab (docker compose up) → http://localhost:8000
# 2. jalankan Express
node server.mjs # http://localhost:3000 ; ENGINE_URL / PORT dapat diubah
```
`server.mjs` menyajikan file statis dan mem-proxy `/api/*` ke engine (tanpa CORS).
`engine.js` adalah client engine.
| Alur | Engine |
|---|---|
| Login / logout / refresh token | `/auth/login`, `/auth/refresh`, `/auth/logout` |
| Upload materi → Brain | `/upload/`, `/upload/list`, `DELETE /upload/{id}` |
| Knowledge base (Brain) | `/upload/{id}/knowledge/build` (memakai kredit workspace) |
| Login Clerk (akun organisasi) | `clerk-js` dari instance Clerk milik engine (key dibaca dari `../learning-lab/backend/.env`, atau `CLERK_PUBLISHABLE_KEY`) |
| Riset konten | agen "Learning Lab Express" di `/organizations/current/agents` → `runs` → `source-drafts` |
| Jadwal riset | `PATCH agents/{id}` `run_schedule` (harian/mingguan; bulanan & hari kerja dijalankan harian) |
| Generate Text | `/compose/sets` + `/compose/sets/{id}/generate/summary` |
Agents API hanya berjalan dalam konteks organisasi, jadi riset dan jadwal memerlukan tombol **Masuk dengan Clerk** dengan akun yang menjadi owner organisasi. Dengan login email/password biasa, riset tetap simulasi.
Agen dibuat otomatis (budget 50 kredit/run, 1000/bulan; ubah di `Engine.ensureAgent`).
Belum ada: video, audio, dan SCORM. Hanya format yang didukung engine yang bisa diunggah.

192
prototype/engine.js vendored Normal file
View File

@@ -0,0 +1,192 @@
// Client for the learning-lab engine (FastAPI, /api/v1).
// Auth, document upload/Brain and text generation go through the real backend.
(function (root) {
const BASE = '/api/v1', KEY = 'lle-engine-tokens';
const Engine = {};
let tokens = null;
try { tokens = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { tokens = null; }
const save = t => { tokens = t; try { t ? localStorage.setItem(KEY, JSON.stringify(t)) : localStorage.removeItem(KEY); } catch {} };
class EngineError extends Error { constructor(message, status) { super(message); this.status = status; } }
const detail = d => typeof d === 'string' ? d : Array.isArray(d) ? d.map(x => x.msg || JSON.stringify(x)).join('; ') : (d && d.message) || 'Permintaan ke engine gagal.';
// ── Clerk: the only source of organization-scoped tokens (needed by the agents API) ──
let clerk = null;
Engine.initClerk = async () => {
const key = root.LLE_CONFIG?.clerkKey;
if (!key) return false;
if (clerk) return true;
const domain = atob(key.split('_')[2]).replace(/\$$/, '');
await new Promise((ok, fail) => {
const s = document.createElement('script');
s.src = `https://${domain}/npm/@clerk/clerk-js@5/dist/clerk.browser.js`;
s.async = true; s.crossOrigin = 'anonymous'; s.setAttribute('data-clerk-publishable-key', key);
s.onload = ok; s.onerror = () => fail(new EngineError('Clerk tidak dapat dimuat.', 0));
document.head.appendChild(s);
});
await root.Clerk.load();
clerk = root.Clerk;
return true;
};
Engine.clerkEnabled = () => !!clerk;
Engine.clerkUser = () => clerk?.user && { email: clerk.user.primaryEmailAddress?.emailAddress, name: clerk.user.fullName || clerk.user.firstName };
Engine.onClerkChange = cb => clerk?.addListener(() => cb());
Engine.clerkSignIn = () => clerk.openSignIn({});
// Agents belong to an organization: make sure one is active on the session before asking for a token.
Engine.ensureOrg = async () => {
if (!clerk?.session) return null;
if (!clerk.organization) {
const memberships = (await clerk.user.getOrganizationMemberships()).data || [];
if (!memberships.length) return null;
await clerk.setActive({ organization: memberships[0].organization.id });
}
return clerk.organization;
};
async function accessToken() {
if (clerk?.session) return clerk.session.getToken();
return tokens?.access;
}
async function raw(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
const access = await accessToken();
if (access) headers.Authorization = 'Bearer ' + access;
if (opts.json !== undefined) { headers['Content-Type'] = 'application/json'; opts = { ...opts, body: JSON.stringify(opts.json) }; }
let res;
try { res = await fetch(BASE + path, { ...opts, headers }); }
catch { throw new EngineError('Engine learning-lab tidak dapat dihubungi.', 0); }
return res;
}
let refreshing = null;
async function refresh() {
if (!tokens?.refresh) return false;
refreshing ||= (async () => {
const res = await fetch(BASE + '/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: tokens.refresh }) });
if (!res.ok) { save(null); return false; }
const body = await res.json();
save({ access: body.access_token, refresh: body.refresh_token || tokens.refresh });
return true;
})().finally(() => { refreshing = null; });
return refreshing;
}
async function call(path, opts = {}) {
let res = await raw(path, opts);
if (res.status === 401 && !path.startsWith('/auth/') && await refresh()) res = await raw(path, opts);
if (res.status === 204) return null;
const body = await res.json().catch(() => null);
if (!res.ok) throw new EngineError(detail(body?.detail), res.status);
return body;
}
Engine.hasSession = () => !!(clerk?.session || tokens?.access);
Engine.login = async (email, password) => {
const res = await raw('/auth/login', { method: 'POST', json: { email, password } });
const body = await res.json().catch(() => null);
if (!res.ok) throw new EngineError(res.status === 401 ? 'Email atau password salah.' : detail(body?.detail), res.status);
save({ access: body.access_token, refresh: body.refresh_token });
return body.user;
};
Engine.me = () => call('/auth/me');
Engine.logout = async () => {
try { if (tokens?.refresh) await call('/auth/logout', { method: 'POST', json: { refresh_token: tokens.refresh } }); } catch {}
save(null);
if (clerk?.session) await clerk.signOut();
};
let formats = null;
Engine.supportedExtensions = async () => (formats ||= call('/upload/supported-formats').then(f => new Set(f.extensions)).catch(() => { formats = null; return new Set(); }));
const asSource = d => ({
id: 'doc-' + d.id, docId: d.id, remote: true, sample: false,
title: (d.original_filename || d.filename).replace(/\.[^.]+$/, ''), originalName: d.original_filename || d.filename,
format: (d.file_type || (d.original_filename || '').split('.').pop() || '').toUpperCase(), tags: [], text: '',
createdAt: d.created_at, status: d.status, knowledge: d.knowledge_status, progress: d.progress_percentage, stage: d.processing_stage,
});
Engine.listDocuments = async () => (await call('/upload/list')).map(asSource);
Engine.documentStatus = async id => call('/upload/' + id);
Engine.upload = async file => {
const form = new FormData(); form.append('file', file, file.name);
const body = await call('/upload/', { method: 'POST', body: form });
return { id: body.id ?? body.document_id, raw: body };
};
Engine.deleteDocument = id => call('/upload/' + id, { method: 'DELETE' });
// Building a knowledge base spends workspace credits; a 402 surfaces as an EngineError with the shortfall.
const BUILDABLE = new Set(['not_built', 'awaiting_confirmation', 'needs_credits', 'failed']);
Engine.buildKnowledge = async id => {
const s = await Engine.documentStatus(id);
if (!BUILDABLE.has(s.knowledge_status)) return s;
try { await call(`/upload/${id}/knowledge/build`, { method: 'POST' }); }
catch (e) { throw e.status === 402 ? new EngineError('Kredit tidak cukup untuk membangun knowledge base: ' + e.message, 402) : e; }
return s;
};
// Polls until the document's knowledge base is built (what generation needs).
Engine.waitReady = async (id, onProgress, { timeoutMs = 10 * 60 * 1000 } = {}) => {
const t0 = Date.now();
for (;;) {
const s = await Engine.documentStatus(id);
onProgress?.(s);
if (s.error || s.status === 'failed') throw new EngineError(s.error || 'Dokumen gagal diproses.', 422);
if (s.knowledge_status === 'ready') return s;
if (Date.now() - t0 > timeoutMs) throw new EngineError('Pemrosesan dokumen melebihi batas waktu.', 408);
await new Promise(r => setTimeout(r, 2500));
}
};
// Text content: a compose set over whole documents, then a generated summary.
Engine.generateText = async (docIds, name) => {
const set = await call('/compose/sets', { method: 'POST', json: { name, mode: 'knowledge_documents', source_document_ids: docIds } });
const summary = await call(`/compose/sets/${set.id}/generate/summary`, { method: 'POST', json: { operation_key: `lle-${set.id}-${Date.now()}` } });
return { setId: set.id, summary };
};
Engine.generateQuiz = (setId, n = 5) => call(`/compose/sets/${setId}/generate/quiz`, { method: 'POST', json: { num_questions: n, question_type: 'mixed', operation_key: `lle-q-${setId}-${Date.now()}` } });
// ── Agents (organization-scoped): research runs and the recurring schedule ──
const AGENTS = '/organizations/current/agents', AGENT_NAME = 'Learning Lab Express';
Engine.agentsAvailable = async () => !!(await Engine.ensureOrg());
let agentCache = null;
Engine.ensureAgent = async () => {
if (agentCache) return agentCache;
let agent = (await call(AGENTS)).find(a => a.name === AGENT_NAME && a.status !== 'archived');
agent ||= await call(AGENTS, { method: 'POST', json: {
name: AGENT_NAME, goal: 'Riset topik pembelajaran dan temukan sumber publik baru untuk Brain tim LND.',
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url'],
research_options: { max_results: 5 }, timezone: 'Asia/Jakarta', run_budget: 50, monthly_budget: 1000,
} });
if (agent.status !== 'active') agent = await call(`${AGENTS}/${agent.id}/activate`, { method: 'POST' });
return (agentCache = agent);
};
Engine.attachSources = async (agentId, docIds) => {
for (const id of docIds) { try { await call(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } }); } catch (e) { if (e.status !== 404) throw e; } }
};
// Server-side recurring run. weekday: UI uses 0=Sunday, the engine 0=Monday.
Engine.setSchedule = async ({ enabled, frequency, time, weekday }) => {
const agent = await Engine.ensureAgent(), [hour, minute] = String(time || '09:00').split(':').map(Number);
const cadence = !enabled ? 'off' : frequency === 'weekly' ? 'weekly' : 'daily';
agentCache = await call(`${AGENTS}/${agent.id}`, { method: 'PATCH', json: { run_schedule: { cadence, hour, minute, weekday: (Number(weekday) + 6) % 7 } } });
return agentCache;
};
Engine.research = async ({ topic, prompt, urls = [], docIds = [] }, onStatus) => {
const agent = await Engine.ensureAgent();
await Engine.attachSources(agent.id, docIds);
const focus = [topic && `Topik: ${topic}`, prompt, urls.length && `Referensi: ${urls.join(' ')}`].filter(Boolean).join('\n');
await call(`${AGENTS}/${agent.id}`, { method: 'PATCH', json: { goal: (topic ? `Riset topik: ${topic}` : agent.goal).slice(0, 4000), instructions: focus || null } });
const run = await call(`${AGENTS}/${agent.id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': `lle-${agent.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }, json: {} });
for (let detail = run; ; ) {
onStatus?.(detail);
if (['completed', 'failed', 'cancelled'].includes(detail.status)) { if (detail.status !== 'completed') throw new EngineError('Riset gagal dijalankan (' + (detail.safe_error_code || detail.status) + ').', 422); break; }
await new Promise(r => setTimeout(r, 3000));
detail = await call(`${AGENTS}/${agent.id}/runs/${run.id}`);
}
const detail = await call(`${AGENTS}/${agent.id}/runs/${run.id}`);
const drafts = (await call(`${AGENTS}/${agent.id}/source-drafts`)).filter(d => d.run_id === run.id);
return { run: detail, drafts };
};
root.Engine = Engine; root.EngineError = EngineError;
})(window);

333
prototype/index.html Normal file

File diff suppressed because one or more lines are too long

52
prototype/server.mjs Normal file
View File

@@ -0,0 +1,52 @@
// Static host for Learning Lab Express. Proxies /api to the learning-lab engine
// so the browser stays same-origin (no CORS setup needed on the engine).
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = Number(process.env.PORT || 3000);
const ENGINE = new URL(process.env.ENGINE_URL || 'http://localhost:8000');
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css', '.svg': 'image/svg+xml' };
function proxy(req, res) {
const upstream = http.request({
host: ENGINE.hostname, port: ENGINE.port, path: req.url, method: req.method,
headers: { ...req.headers, host: ENGINE.host },
}, up => { res.writeHead(up.statusCode, up.headers); up.pipe(res); });
upstream.on('error', () => {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ detail: `Engine learning-lab tidak dapat dihubungi di ${ENGINE.origin}` }));
});
req.pipe(upstream);
}
// Clerk publishable keys are public; default to the engine's own dev key so both share one Clerk instance.
async function clerkKey() {
if (process.env.CLERK_PUBLISHABLE_KEY) return process.env.CLERK_PUBLISHABLE_KEY;
try {
const env = await readFile(join(ROOT, '../../learning-lab/backend/.env'), 'utf8');
return env.match(/^VITE_CLERK_PUBLISHABLE_KEY=["']?([^"'\s]+)/m)?.[1] || '';
} catch { return ''; }
}
async function serve(req, res) {
const path = new URL(req.url, 'http://x').pathname;
if (path === '/config.js') {
res.writeHead(200, { 'content-type': TYPES['.js'] });
return res.end(`window.LLE_CONFIG=${JSON.stringify({ clerkKey: await clerkKey() })};`);
}
const name = path === '/' ? 'index.html' : normalize(path).replace(/^([/\\])+/, '');
if (name.startsWith('..')) { res.writeHead(403); return res.end(); }
try {
const body = await readFile(join(ROOT, name));
res.writeHead(200, { 'content-type': TYPES[extname(name)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404); res.end('Not found');
}
}
http.createServer((req, res) => (req.url.startsWith('/api/') ? proxy(req, res) : serve(req, res)))
.listen(PORT, () => console.log(`Learning Lab Express: http://localhost:${PORT} → engine ${ENGINE.origin}`));

28
src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthGate } from './auth/AuthGate'
import { AppLayout } from './components/AppLayout'
import { BrainPage } from './pages/Brain'
import { InstructionPage } from './pages/Instruction'
import { SchedulePage } from './pages/Schedule'
import { ResultsPage } from './pages/Results'
export default function App() {
return (
<AuthGate>
<Routes>
<Route element={<AppLayout />}>
<Route path="/brain" element={<BrainPage />} />
<Route path="/instruction" element={<InstructionPage />} />
<Route path="/schedule" element={<SchedulePage />} />
<Route path="/results" element={<ResultsPage />} />
</Route>
{/* Indonesian paths from before the routes were renamed, so old links keep working. */}
<Route path="/otak" element={<Navigate replace to="/brain" />} />
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
<Route path="/hasil" element={<Navigate replace to="/results" />} />
<Route path="*" element={<Navigate replace to="/brain" />} />
</Routes>
</AuthGate>
)
}

78
src/api/client.ts Normal file
View File

@@ -0,0 +1,78 @@
import { t } from '../i18n'
// HTTP client for the learning-lab engine (FastAPI, /api/v1).
// Auth is a Clerk session token: Express Mode runs on organization-scoped APIs (agents, credits).
const BASE = '/api/v1'
export class EngineError extends Error {
status: number
/** The engine's error body, e.g. `required`/`balance` on a 402. */
data: Record<string, unknown> | null
constructor(message: string, status: number, data: Record<string, unknown> | null = null) {
super(message)
this.status = status
this.data = data
}
}
// A 402 from any credit-spending call; the engine includes what was needed and what the wallet has.
export function creditMessage(e: EngineError): string {
const { required, balance } = e.data ?? {}
return typeof required === 'number' && typeof balance === 'number'
? t('err.credits', { required, balance })
: t('err.creditsShort')
}
type TokenGetter = () => Promise<string | null>
let getToken: TokenGetter = async () => null
export function setTokenGetter(getter: TokenGetter) {
getToken = getter
}
function detail(d: unknown): string {
if (typeof d === 'string') return d
if (Array.isArray(d)) return d.map(x => (x && typeof x === 'object' && 'msg' in x ? String(x.msg) : JSON.stringify(x))).join('; ')
if (d && typeof d === 'object' && 'message' in d) return String(d.message)
return t('err.generic')
}
export interface RequestOptions extends Omit<RequestInit, 'body'> {
json?: unknown
body?: BodyInit
/** 'blob' for file downloads; errors still come back as JSON. */
as?: 'json' | 'blob'
}
export async function request<T>(path: string, { json, headers, as = 'json', ...opts }: RequestOptions = {}): Promise<T> {
const h = new Headers(headers)
const token = await getToken()
if (token) h.set('Authorization', `Bearer ${token}`)
let body = opts.body
if (json !== undefined) {
h.set('Content-Type', 'application/json')
body = JSON.stringify(json)
}
let res: Response
try {
res = await fetch(BASE + path, { ...opts, headers: h, body })
} catch {
throw new EngineError(t('err.unreachable'), 0)
}
if (res.status === 204) return null as T
if (as === 'blob' && res.ok) return (await res.blob()) as T
const data = await res.json().catch(() => null)
if (!res.ok) {
if (res.status === 502 || res.status === 504) throw new EngineError(t('err.unreachable'), res.status)
const err = new EngineError(detail(data?.detail), res.status, data && typeof data === 'object' ? data : null)
if (res.status === 402) err.message = creditMessage(err)
throw err
}
return data as T
}
// Idempotency keys for operations that spend credits, so a retried click is not billed twice.
export const opKey = (prefix: string) => `lle-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))

193
src/api/engine.ts Normal file
View File

@@ -0,0 +1,193 @@
// Typed engine calls used by Express Mode. Ported from prototype/engine.js.
import { EngineError, opKey, request, sleep } from './client'
import { t } from '../i18n'
// ── Documents (Brain) ──
export type KnowledgeStatus = 'not_built' | 'awaiting_confirmation' | 'needs_credits' | 'building' | 'ready' | 'failed' | string
export interface EngineDocument {
id: number
filename: string
original_filename?: string | null
file_type?: string | null
status: string
knowledge_status?: KnowledgeStatus | null
progress_percentage?: number | null
processing_stage?: string | null
error?: string | null
created_at: string
}
export interface Source {
docId: number
title: string
originalName: string
format: string
createdAt: string
status: string
knowledge: KnowledgeStatus | null
progress: number | null
stage: string | null
}
function asSource(d: EngineDocument): Source {
const name = d.original_filename || d.filename
return {
docId: d.id,
title: name.replace(/\.[^.]+$/, ''),
originalName: name,
format: (d.file_type || name.split('.').pop() || '').toUpperCase(),
createdAt: d.created_at,
status: d.status,
knowledge: d.knowledge_status ?? null,
progress: d.progress_percentage ?? null,
stage: d.processing_stage ?? null,
}
}
export const listDocuments = async () => (await request<EngineDocument[]>('/upload/list')).map(asSource)
export const documentStatus = (id: number) => request<EngineDocument>(`/upload/${id}`)
export const deleteDocument = (id: number) => request<null>(`/upload/${id}`, { method: 'DELETE' })
export async function supportedExtensions(): Promise<Set<string>> {
const f = await request<{ extensions: string[] }>('/upload/supported-formats')
return new Set(f.extensions)
}
export async function uploadDocument(file: File): Promise<number> {
const form = new FormData()
form.append('file', file, file.name)
const body = await request<{ id?: number; document_id?: number }>('/upload/', { method: 'POST', body: form })
return (body.id ?? body.document_id)!
}
// After processing, the engine builds the knowledge base itself when the price is small.
// Otherwise it stops in one of these states and waits for the user.
export const NEEDS_ANSWER = new Set(['not_built', 'awaiting_confirmation', 'needs_credits', 'awaiting_version_answer', 'failed'])
export interface KnowledgeDetail {
status: KnowledgeStatus
document_status: string
stage: string | null
quote: { credits: number; activity_key: string } | null
candidate: { id: number; filename: string; created_at: string | null } | null
error: string | null
}
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' })
// The engine asks whether an upload is the next version of a similar document before building it.
export const answerVersion = (id: number, isNewVersion: boolean) =>
request<{ status: string }>(`/upload/${id}/versions/${isNewVersion ? 'link' : 'decline'}`, { method: 'POST', json: isNewVersion ? {} : undefined })
// Polls until the document's knowledge base is built (what generation needs).
export async function waitReady(id: number, onProgress?: (d: EngineDocument) => void, timeoutMs = 10 * 60 * 1000) {
const t0 = Date.now()
for (;;) {
const s = await documentStatus(id)
onProgress?.(s)
if (s.error || s.status === 'failed') throw new EngineError(s.error || t('err.docFailed'), 422)
if (s.knowledge_status === 'ready') return s
if (Date.now() - t0 > timeoutMs) throw new EngineError(t('err.timeout'), 408)
await sleep(2500)
}
}
// ── Compose (text content) ──
export interface ComposeSet { id: number; name: string }
export async function generateText(docIds: number[], name: string) {
const set = await request<ComposeSet>('/compose/sets', { method: 'POST', json: { name, mode: 'knowledge_documents', source_document_ids: docIds } })
const summary = await request<unknown>(`/compose/sets/${set.id}/generate/summary`, { method: 'POST', json: { operation_key: opKey(`s-${set.id}`) } })
return { setId: set.id, summary }
}
export const generateQuiz = (setId: number, n = 5) =>
request<unknown>(`/compose/sets/${setId}/generate/quiz`, { method: 'POST', json: { num_questions: n, question_type: 'mixed', operation_key: opKey(`q-${setId}`) } })
// ── Agents (organization-scoped): research runs and recurring schedule ──
const AGENTS = '/organizations/current/agents'
export const AGENT_NAME = 'Learning Lab Express'
export interface RunSchedule { cadence: 'off' | 'daily' | 'weekly'; hour?: number; minute?: number; weekday?: number }
export interface Agent {
id: number
name: string
goal: string
status: string
instructions?: string | null
run_schedule?: RunSchedule | null
}
export interface AgentRun { id: number; status: string; safe_error_code?: string | null }
export interface SourceDraft { id: number; run_id: number; [key: string]: unknown }
let agentCache: Agent | null = null
export const resetAgentCache = () => { agentCache = null }
export async function ensureAgent(): Promise<Agent> {
if (agentCache) return agentCache
let agent = (await request<Agent[]>(AGENTS)).find(a => a.name === AGENT_NAME && a.status !== 'archived')
agent ??= await request<Agent>(AGENTS, {
method: 'POST',
json: {
name: AGENT_NAME,
goal: 'Riset topik pembelajaran dan temukan sumber publik baru untuk Brain tim LND.',
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url'],
research_options: { max_results: 5 },
timezone: 'Asia/Jakarta',
run_budget: 50,
monthly_budget: 1000,
},
})
if (agent.status !== 'active') agent = await request<Agent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
return (agentCache = agent)
}
export async function attachSources(agentId: number, docIds: number[]) {
for (const id of docIds) {
try {
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } })
} catch (e) {
if (!(e instanceof EngineError && e.status === 404)) throw e
}
}
}
// Server-side recurring run. weekday follows the engine: 0 = Monday.
export async function setSchedule(schedule: RunSchedule) {
const agent = await ensureAgent()
agentCache = await request<Agent>(`${AGENTS}/${agent.id}`, { method: 'PATCH', json: { run_schedule: schedule } })
return agentCache
}
export async function research(
{ topic, prompt, urls = [], docIds = [] }: { topic?: string; prompt?: string; urls?: string[]; docIds?: number[] },
onStatus?: (run: AgentRun) => void,
) {
const agent = await ensureAgent()
await attachSources(agent.id, docIds)
const focus = [topic && `Topik: ${topic}`, prompt, urls.length && `Referensi: ${urls.join(' ')}`].filter(Boolean).join('\n')
await request(`${AGENTS}/${agent.id}`, {
method: 'PATCH',
json: { goal: (topic ? `Riset topik: ${topic}` : agent.goal).slice(0, 4000), instructions: focus || null },
})
const run = await request<AgentRun>(`${AGENTS}/${agent.id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`run-${agent.id}`) }, json: {} })
let detail = run
for (;;) {
onStatus?.(detail)
if (['completed', 'failed', 'cancelled'].includes(detail.status)) break
await sleep(3000)
detail = await request<AgentRun>(`${AGENTS}/${agent.id}/runs/${run.id}`)
}
if (detail.status !== 'completed') throw new EngineError(t('err.researchFailed', { code: detail.safe_error_code || detail.status }), 422)
const drafts = (await request<SourceDraft[]>(`${AGENTS}/${agent.id}/source-drafts`)).filter(d => d.run_id === run.id)
return { run: detail, drafts }
}

77
src/auth/AuthGate.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Fragment, useRef, type ReactNode } from 'react'
import { OrganizationList, SignIn, useAuth } from '@clerk/react'
import { useQueryClient } from '@tanstack/react-query'
import { setTokenGetter } from '../api/client'
import { resetAgentCache } from '../api/engine'
import { LanguageSwitch } from '../components/AppLayout'
import { Brand, Icon } from '../components/Icon'
import { t, useLang } from '../i18n'
// Express Mode only runs inside an organization: agents, schedules and credits are all org-scoped.
export function AuthGate({ children }: { children: ReactNode }) {
useLang()
const { isLoaded, isSignedIn, orgId, getToken } = useAuth()
const queryClient = useQueryClient()
// Set during render, not in an effect: child queries fire before a parent's effects run.
setTokenGetter(() => getToken())
// Cached engine data belongs to one organization. Cleared during render, before the pages build
// their queries: in an effect it would run after the pages' first fetch and orphan their queries.
const lastOrg = useRef(orgId)
if (lastOrg.current !== orgId) {
lastOrg.current = orgId
queryClient.clear()
resetAgentCache()
}
if (!isLoaded) return <LoadingScreen />
if (!isSignedIn) return <AuthScreen><SignIn routing="hash" /></AuthScreen>
if (!orgId) {
return (
<AuthScreen>
<div>
<h2>{t('auth.pickOrg')}</h2>
<p>{t('auth.pickOrgCopy')}</p>
<OrganizationList hidePersonal afterSelectOrganizationUrl="/brain" afterCreateOrganizationUrl="/brain" />
</div>
</AuthScreen>
)
}
// Keyed by organization so a switch remounts the pages with fresh queries.
return <Fragment key={orgId}>{children}</Fragment>
}
// Same screen as the boot markup in index.html (styles there), so a refresh doesn't flicker between two loaders.
function LoadingScreen() {
return (
<div className="boot" role="status">
<span className="boot-mark"><Icon name="book" /></span>
<span className="boot-name">Learning Lab <b>Express</b></span>
<span className="boot-bar"><i></i></span>
<span className="boot-text">{t('auth.loading')}</span>
</div>
)
}
function AuthScreen({ children }: { children: ReactNode }) {
return (
<section className="login-layout">
<div className="login-story">
<div className="login-top"><Brand /><LanguageSwitch /></div>
<div className="login-copy">
<span className="eyebrow">{t('auth.eyebrow')}</span>
<h1>{t('auth.headline1')}<br />{t('auth.headline2')}<br />{t('auth.headline3')}</h1>
<p>{t('auth.copy')}</p>
<div className="login-benefits">
<span><i><Icon name="upload" /></i> {t('auth.benefitUpload')}</span>
<span><i><Icon name="clock" /></i> {t('auth.benefitSchedule')}</span>
<span><i><Icon name="download" /></i> {t('auth.benefitDownload')}</span>
</div>
</div>
<div className="login-footer">LEARNING LAB EXPRESS <span>{t('auth.footer')}</span></div>
</div>
<div className="login-panel"><div className="login-form-wrap">{children}</div></div>
</section>
)
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
import { useClerk, useOrganization, useUser } from '@clerk/react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
import { Brand, Icon, type IconName } from './Icon'
import { Toaster } from './Toast'
// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results.
export const NAV: { to: string; label: Key; icon: IconName }[] = [
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
{ to: '/results', label: 'nav.results', icon: 'layers' },
]
export function LanguageSwitch() {
const lang = useLang()
return (
<div className="lang-switch" role="group" aria-label={t('layout.language')}>
{LANGS.map(l => (
<button key={l.code} type="button" aria-pressed={lang === l.code} className={lang === l.code ? 'active' : undefined}
onClick={() => setLang(l.code)}>{l.label}</button>
))}
</div>
)
}
// The four steps of Express, from the mockup's "how it works" guide.
const HELP_STEPS: { icon: IconName; title: Key; body: Key }[] = [
{ icon: 'brain', title: 'help.brainTitle', body: 'help.brainBody' },
{ icon: 'spark', title: 'help.instructionTitle', body: 'help.instructionBody' },
{ icon: 'calendar', title: 'help.scheduleTitle', body: 'help.scheduleBody' },
{ icon: 'layers', title: 'help.resultsTitle', body: 'help.resultsBody' },
]
function HelpDialog({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
// Same pattern as the Results preview: open once, let the dialog's own close event unmount it.
useEffect(() => { if (ref.current && !ref.current.open) ref.current.showModal() }, [])
return (
<dialog ref={ref} aria-labelledby="help-title" onClose={onClose} onClick={e => e.target === ref.current && ref.current.close()}>
<div className="modal-head">
<div><h2 id="help-title">{t('help.title')}</h2><p>{t('help.subtitle')}</p></div>
<button className="icon-btn" aria-label={t('res.close')} onClick={() => ref.current?.close()}><Icon name="close" /></button>
</div>
<div className="modal-body">
<div className="help-steps">
{HELP_STEPS.map((s, i) => (
<div key={s.title} className="help-step"><span>{i + 1}</span><div><h3>{t(s.title)}</h3><p>{t(s.body)}</p></div></div>
))}
</div>
<div className="spacer" />
<div className="inline-note"><Icon name="info" />{t('help.note')}</div>
</div>
<div className="modal-foot"><button className="btn primary" onClick={() => ref.current?.close()}>{t('help.done')}<Icon name="arrow-right" /></button></div>
</dialog>
)
}
export function AppLayout() {
useLang()
const [help, setHelp] = useState(false)
const { user } = useUser()
const { organization } = useOrganization()
const { signOut } = useClerk()
const { pathname } = useLocation()
const current = NAV.find(n => pathname.startsWith(n.to))
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
return (
<div className="app">
<aside className="sidebar">
<NavLink to="/brain"><Brand /></NavLink>
<div className="workspace">
<span className="workspace-icon">{(organization?.name || 'L').charAt(0).toUpperCase()}</span>
<div>{t('layout.learningSpace')}<small>{organization?.name ?? '-'}</small></div>
</div>
<div className="nav-label">{t('nav.workspace')}</div>
<nav aria-label={t('layout.mainNav')}>
{NAV.map(n => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
<Icon name={n.icon} />{t(n.label)}
</NavLink>
))}
</nav>
<div className="sidebar-help">
<Icon name="info" />
<b>{t('help.boxTitle')}</b>
<p>{t('help.boxBody')}</p>
<button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button>
</div>
<div className="profile">
<span className="avatar">{name.charAt(0).toUpperCase()}</span>
<div><b>{name}</b><small>{user?.primaryEmailAddress?.emailAddress}</small></div>
<button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button>
</div>
</aside>
<div className="workspace-main">
<header className="topbar">
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
<div className="topbar-right">
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
<span>{today.format(new Date())}</span>
<LanguageSwitch />
</div>
</header>
<div className="demo-strip">
<Icon name="info" />
<span>{t('layout.devStrip')}</span>
<span className="demo-strip-tag">DEV</span>
</div>
{/* Keyed by route so each page plays the fade-in from theme.css. */}
<main tabIndex={-1} key={pathname}><Outlet /></main>
<footer className="app-footer">Learning Lab Express <span>by Bawana</span><span>{t('layout.footer')}</span></footer>
</div>
{help && <HelpDialog onClose={() => setHelp(false)} />}
<Toaster />
</div>
)
}

View File

@@ -0,0 +1,114 @@
// Plays an audio lesson's narration script with the device's own voice (Web Speech API).
// The engine has no audio output for agents yet, so there is no MP3: the script is the deliverable,
// read aloud here and downloadable as text for recording or a TTS tool.
import { useEffect, useMemo, useRef, useState } from 'react'
import { t } from '../i18n'
import { Icon } from './Icon'
// Long utterances stop silently in some browsers (Chrome after ~15 s), so the script is read
// one sentence at a time.
export function sentencesOf(text: string): string[] {
return text
.replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes
.replace(/[*_`]+/g, '')
.split(/(?<=[.!?…])\s+|\n+/)
.map(s => s.replace(/\s+/g, ' ').trim())
.filter(s => /\p{L}/u.test(s))
}
// The script's language decides the voice; the UI language may differ from the content's.
const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi
const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi
export const scriptLang = (text: string) => ((text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en')
const supported = typeof window !== 'undefined' && 'speechSynthesis' in window
const BARS = [16, 29, 45, 22, 37, 56, 31, 19, 43]
export function AudioPlayer({ title, text }: { title: string; text: string }) {
const sentences = useMemo(() => sentencesOf(text), [text])
const lang = useMemo(() => scriptLang(text), [text])
const [state, setState] = useState<'idle' | 'playing' | 'paused'>('idle')
const [index, setIndex] = useState(0)
const [rate, setRate] = useState(1)
// The sentence to speak next; a ref so the utterance callbacks see the latest value.
const at = useRef(0)
const playing = useRef(false)
// The utterance being spoken: cancel() also fires "end" on the one it stops, which must not advance.
const current = useRef<SpeechSynthesisUtterance | null>(null)
const voice = () => speechSynthesis.getVoices().find(v => v.lang.toLowerCase().startsWith(lang)) ?? null
function speak(i: number) {
if (i >= sentences.length) { stop(); return }
at.current = i
setIndex(i)
const u = new SpeechSynthesisUtterance(sentences[i])
u.lang = lang === 'id' ? 'id-ID' : 'en-US'
u.voice = voice()
u.rate = rate
u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) }
current.current = u
speechSynthesis.speak(u)
}
function play() {
if (state === 'paused') { speechSynthesis.resume(); setState('playing'); playing.current = true; return }
current.current = null
speechSynthesis.cancel()
playing.current = true
setState('playing')
speak(at.current)
}
function pause() { speechSynthesis.pause(); playing.current = false; setState('paused') }
function stop() {
playing.current = false
current.current = null
speechSynthesis.cancel()
at.current = 0
setIndex(0)
setState('idle')
}
// Leaving the preview must not leave the device talking.
useEffect(() => () => { playing.current = false; if (supported) speechSynthesis.cancel() }, [])
// A new speed applies from the current sentence.
useEffect(() => {
if (!playing.current) return
current.current = null
speechSynthesis.cancel()
speak(at.current)
}, [rate])
const progress = sentences.length ? Math.round(((index + (state === 'idle' ? 0 : 1)) / sentences.length) * 100) : 0
return (
<div className="audio-preview">
<span className="audio-orb"><Icon name="audio" /></span>
<div className={`waveform${state === 'playing' ? ' playing' : ''}`} aria-hidden="true">
{Array.from({ length: 36 }, (_, i) => <i key={i} style={{ height: BARS[i % BARS.length], animationDelay: `${(i % 9) * 0.08}s` }} />)}
</div>
<h2>{title}</h2>
<p>{supported ? t('audio.copy') : t('audio.unsupported')}</p>
{supported && sentences.length > 0 && (
<>
<div className="head-actions">
{state === 'playing'
? <button className="btn primary" onClick={pause}>{t('audio.pause')}</button>
: <button className="btn primary" onClick={play}><Icon name="audio" />{state === 'paused' ? t('audio.resume') : t('audio.play')}</button>}
<button className="btn" disabled={state === 'idle'} onClick={stop}><Icon name="close" />{t('audio.stop')}</button>
<select aria-label={t('audio.speed')} value={rate} onChange={e => setRate(Number(e.target.value))} className="audio-rate">
{[0.85, 1, 1.15, 1.3].map(r => <option key={r} value={r}>{r}×</option>)}
</select>
</div>
<div className="audio-progress"><i style={{ width: `${progress}%` }} /></div>
<small role="status">
{state === 'idle' ? t('audio.ready', { n: sentences.length }) : t('audio.sentence', { n: index + 1, total: sentences.length })}
</small>
{state !== 'idle' && <blockquote className="audio-now">{sentences[index]}</blockquote>}
</>
)}
</div>
)
}

51
src/components/Icon.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { ContentKind, Rule } from '../express/parse'
// Line icons from the mockup (24×24, stroke = currentColor).
const paths: Record<string, string> = {
book: '<path d="M3 5c3-1 6 0 9 2 3-2 6-3 9-2v14c-3-1-6 0-9 2-3-2-6-3-9-2z"/><path d="M12 7v14M6 9l3 1M15 10l3-1"/>',
home: '<path d="m3 10 9-7 9 7v10H3z"/><path d="M9 20v-7h6v7"/>',
spark: '<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5z"/><path d="m20 2 .5 1.5L22 4l-1.5.5L20 6l-.5-1.5L18 4l1.5-.5z"/>',
calendar: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4M17 3v4M3 11h18M7 15h3M14 15h3"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v6l4 2"/>',
layers: '<path d="m12 3 10 5-10 5L2 8zM2 12l10 5 10-5M2 16l10 5 10-5"/>',
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
file: '<path d="M5 3h9l5 5v13H5zM14 3v6h5M8 13h8M8 17h6"/>',
'arrow-right': '<path d="M4 12h16m-6-6 6 6-6 6"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
check: '<path d="m5 12 4 4L19 6"/>',
logout: '<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
plus: '<path d="M12 4v16M4 12h16"/>',
trash: '<path d="M3 6h18M9 3h6l1 3M6 6l1 15h10l1-15M10 10v7M14 10v7"/>',
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
}
export type IconName = keyof typeof paths
export const KIND_ICON: Record<ContentKind, IconName> = {
summary: 'file', quiz: 'check', flashcards: 'layers', roleplay: 'spark', simulator: 'spark', slide_deck: 'layers',
video: 'video', audio: 'audio', scorm: 'scorm',
}
export const ruleIcon = (rule: Rule): IconName => (rule.type === 'research' ? 'search' : KIND_ICON[rule.kind])
export function Icon({ name }: { name: IconName }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
dangerouslySetInnerHTML={{ __html: paths[name] ?? paths.file }} />
)
}
export function Brand() {
return (
<span className="brand">
<span className="brand-mark"><Icon name="book" /></span>
<span>Learning Lab <b>Express</b><small>by Bawana</small></span>
</span>
)
}

71
src/components/Page.tsx Normal file
View File

@@ -0,0 +1,71 @@
import type { ReactNode } from 'react'
import { Icon, type IconName } from './Icon'
export function PageHead({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
return (
<div className="page-head">
<div><h1>{title}</h1><p>{description}</p></div>
{actions && <div className="head-actions">{actions}</div>}
</div>
)
}
// Shimmer placeholders shaped like the content they stand in for, so a page keeps its layout while loading.
export function Skeleton({ w, h, className }: { w?: number | string; h?: number | string; className?: string }) {
return <span className={className ? `skel ${className}` : 'skel'} style={{ width: w, height: h }} aria-hidden="true" />
}
const LINE_WIDTHS = ['62%', '48%', '70%', '55%']
export function SkeletonRows({ count = 3, label }: { count?: number; label: string }) {
return (
<div className="result-list" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="result-row skel-row">
<Skeleton className="skel-cover" />
<div className="result-main">
<Skeleton w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={14} />
<Skeleton w="34%" h={11} />
</div>
<Skeleton className="skel-pill" />
</div>
))}
</div>
)
}
export function SkeletonCards({ count = 4, label }: { count?: number; label: string }) {
return (
<div className="content-grid" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="content-card">
<Skeleton className="skel-block" />
<div className="content-card-body skel-stack">
<Skeleton w="40%" h={20} className="skel-pill" />
<Skeleton w="90%" h={14} />
<Skeleton w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={14} />
<Skeleton w="50%" h={11} />
</div>
</div>
))}
</div>
)
}
export function SkeletonLines({ count = 2, label }: { count?: number; label: string }) {
return (
<div className="skel-stack" role="status" aria-label={label}>
{Array.from({ length: count }, (_, i) => <Skeleton key={i} w={LINE_WIDTHS[i % LINE_WIDTHS.length]} h={12} />)}
</div>
)
}
export function Empty({ icon = 'book', title, children }: { icon?: IconName; title: string; children?: ReactNode }) {
return (
<div className="empty">
<span className="empty-icon"><Icon name={icon} /></span>
<h3>{title}</h3>
{children && <p>{children}</p>}
</div>
)
}

28
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,28 @@
// Short notices at the bottom of the screen (styles from the mockup's .toast), instead of alert().
// A module-level store, so mutations can report from anywhere without a context provider.
import { useSyncExternalStore } from 'react'
interface Notice { id: number; text: string; error: boolean }
let current: Notice | null = null
let nextId = 1
let timer: ReturnType<typeof setTimeout> | undefined
const listeners = new Set<() => void>()
const emit = () => listeners.forEach(l => l())
export function toast(text: string, { error = false } = {}) {
current = { id: nextId++, text, error }
clearTimeout(timer)
// Errors stay a little longer: they usually need reading.
timer = setTimeout(() => { current = null; emit() }, error ? 7000 : 3500)
emit()
}
export function Toaster() {
const notice = useSyncExternalStore(cb => { listeners.add(cb); return () => listeners.delete(cb) }, () => current)
return (
<div className={`toast${notice ? ' show' : ''}${notice?.error ? ' error' : ''}`} role={notice?.error ? 'alert' : 'status'} aria-live="polite">
{notice?.text}
</div>
)
}

80
src/express/parse.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { parseInstruction, videoCadence, weeklyTarget, type ContentRule } from './parse'
const content = (text: string) => parseInstruction(text).rules as ContentRule[]
describe('parseInstruction', () => {
it('reads several English content rules with a shared topic', () => {
const { rules, unknown } = parseInstruction('make 2 summaries per day and 1 quiz per week about leadership')
expect(unknown).toEqual([])
expect(rules).toEqual([
{ type: 'content', kind: 'summary', count: 2, per: 'day', topic: 'leadership' },
{ type: 'content', kind: 'quiz', count: 1, per: 'week', topic: 'leadership' },
])
})
it('reads Indonesian rules, number words and research', () => {
const { rules } = parseInstruction('bikin tiga kuis per minggu soal negosiasi, riset internet tiap hari soal sales')
expect(rules[0]).toMatchObject({ type: 'content', kind: 'quiz', count: 3, per: 'week', topic: 'negosiasi' })
expect(rules[1]).toMatchObject({ type: 'research', cadence: 'daily', topic: 'sales', autoApprove: true })
})
it('reads a weekday for weekly research', () => {
const [rule] = parseInstruction('riset tiap hari minggu soal tren HR').rules
expect(rule).toMatchObject({ type: 'research', cadence: 'weekly', weekday: 6 })
})
it('keeps "and" inside a topic', () => {
const [rule] = content('3 flashcards per week about sales and marketing')
expect(rule).toMatchObject({ kind: 'flashcards', count: 3, topic: 'sales and marketing' })
})
it('turns a monthly count into a weekly one and remembers it', () => {
const [rule] = content('8 summaries per month')
expect(rule).toMatchObject({ count: 2, per: 'week', monthly: 8 })
})
it('recognises video, audio and SCORM requests', () => {
const rules = content('1 video per week, 2 podcasts per week and 1 SCORM module per week about onboarding')
expect(rules.map(r => [r.kind, r.count, r.per, r.topic])).toEqual([
['video', 1, 'week', 'onboarding'],
['audio', 2, 'week', 'onboarding'],
['scorm', 1, 'week', 'onboarding'],
])
})
it('treats a SCORM module with a quiz as SCORM', () => {
expect(content('1 modul scorm dengan kuis per minggu')[0].kind).toBe('scorm')
})
it('reports clauses it did not understand', () => {
const { rules, unknown } = parseInstruction('2 summaries per day. hello there')
expect(rules).toHaveLength(1)
expect(unknown).toEqual(['hello there'])
})
})
describe('videoCadence', () => {
const video = (count: number, per: 'day' | 'week'): ContentRule => ({ type: 'content', kind: 'video', count, per, topic: '' })
it('maps one per day and one per week to the engine cadences', () => {
expect(videoCadence(video(1, 'day'))).toBe('daily')
expect(videoCadence(video(7, 'week'))).toBe('daily')
expect(videoCadence(video(1, 'week'))).toBe('weekly')
})
it('has no cadence for other counts', () => {
expect(videoCadence(video(3, 'week'))).toBeNull()
expect(videoCadence(video(2, 'day'))).toBeNull()
})
it('counts daily rules as seven a week', () => {
expect(weeklyTarget(video(2, 'day'))).toBe(14)
})
})
describe('counts', () => {
it('never parses a count below one', () => {
expect(parseInstruction('buat 0 ringkasan per minggu').rules[0]).toMatchObject({ kind: 'summary', count: 1 })
})
})

179
src/express/parse.ts Normal file
View File

@@ -0,0 +1,179 @@
// Turns an everyday instruction (English or Indonesian) into schedule rules, e.g.
// "make 2 summaries per day and 1 quiz per week about leadership, research the internet daily about sales"
// "bikin 2 summary per hari dan 1 kuis per minggu soal leadership, riset internet tiap hari soal sales".
// Deterministic on purpose: the user always sees and can correct the result before anything is created.
import { t, type Key } from '../i18n'
export type ContentKind = 'summary' | 'quiz' | 'flashcards' | 'roleplay' | 'simulator' | 'slide_deck' | 'video' | 'audio' | 'scorm'
export const KINDS: ContentKind[] = ['summary', 'quiz', 'flashcards', 'roleplay', 'simulator', 'slide_deck', 'video', 'audio', 'scorm']
// Every kind runs on the engine's agents, in one of two ways:
// - recurring content production (content_goal): the six Compose kinds, plus audio (a narration
// script, played and downloaded in Express) and SCORM (a lesson + quiz pair packaged in Express);
// - one scheduled run per day or week (run_schedule): video, since the engine renders one video per run.
export const SCHEDULABLE: ReadonlySet<ContentKind> = new Set(KINDS)
export const kindLabel = (kind: ContentKind) => t(`kind.${kind}` as Key)
// Checked in order; the first match wins, so multi-word phrases come before their parts.
const KIND_WORDS: [ContentKind, RegExp][] = [
// SCORM first: a SCORM module includes a quiz, so "SCORM module with a quiz" is still SCORM.
['scorm', /\b(scorm|skorm|skong|e-?learning)\b/],
['flashcards', /\b(flash ?cards?|kartu (belajar|hafalan))\b/],
['quiz', /\b(kuis|quiz|quizzes|soal latihan|tes|tests?|ujian|exams?)\b/],
['roleplay', /\b(role ?-?plays?|bermain peran)\b/],
['simulator', /\b(simulasi|simulations?|simulators?|skenario|scenarios?)\b/],
['slide_deck', /\b(slides?|presentasi|presentations?|decks?|ppt)\b/],
['video', /\b(videos?)\b/],
['audio', /\b(audio|podcasts?|siniar|narasi|narrations?)\b/],
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
]
const NUMBER_WORDS: Record<string, number> = {
satu: 1, se: 1, sebuah: 1, dua: 2, tiga: 3, empat: 4, lima: 5, enam: 6, tujuh: 7, delapan: 8, sembilan: 9, sepuluh: 10,
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
}
// Monday = 0, as the engine counts.
const WEEKDAYS: [RegExp, number][] = [
[/\b(senin|monday)\b/, 0], [/\b(selasa|tuesday)\b/, 1], [/\b(rabu|wednesday)\b/, 2], [/\b(kamis|thursday)\b/, 3],
[/\b(jum'?at|friday)\b/, 4], [/\b(sabtu|saturday)\b/, 5],
// "minggu" is both Sunday and "week", so Indonesian Sunday needs "hari minggu".
[/\b(hari minggu|sunday)\b/, 6],
]
export type Per = 'day' | 'week'
export interface ContentRule {
type: 'content'
kind: ContentKind
count: number
per: Per
topic: string
/** The monthly count the instruction asked for, when it had to be turned into a weekly one. */
monthly?: number
}
export interface ResearchRule {
type: 'research'
cadence: 'daily' | 'weekly'
weekday: number
topic: string
autoApprove: boolean
}
export type Rule = ContentRule | ResearchRule
export interface ParseResult {
rules: Rule[]
/** Clauses that said something but matched no rule, so the user can rephrase them. */
unknown: string[]
}
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
function topicOf(clause: string): string {
return clause.match(TOPIC)?.[1].replace(FREQUENCY_TAIL, '').replace(/[\s,]+$/, '').trim() ?? ''
}
function countOf(clause: string, kindAt: number): number {
const before = clause.slice(0, kindAt)
const digits = before.match(/(\d+)\s*(?:buah|x|kali|new)?\s*$/)
// "0" schedules nothing; the preview shows 1, which the user can still change.
if (digits) return Math.max(1, Number(digits[1]))
const word = before.match(/(\p{L}+)\s*(?:buah|new)?\s*$/u)?.[1]
return (word && NUMBER_WORDS[word]) || 1
}
function perOf(clause: string): Per | 'month' {
if (/\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/.test(clause)) return 'day'
if (/\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/.test(clause)) return 'month'
return 'week'
}
function weekdayOf(clause: string): number | null {
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
return null
}
const CONNECTORS = 'dan|lalu|serta|terus|kemudian|juga|and|then|also|plus'
// A connector or comma starts a new rule only when a new request follows; "about sales and marketing" stays one topic.
const NEXT_REQUEST = String.raw`(?=(?:\d|satu|dua|tiga|empat|lima|enam|tujuh|delapan|sembilan|sepuluh|se(?:buah)?\s|one|two|three|four|five|six|seven|eight|nine|ten|an?\s|riset|research|bikin|buat|cari|telusuri|pantau|make|create|generate|search|monitor|tiap|setiap|every|each))`
const SPLIT = new RegExp(String.raw`[\n.;]+|\s*,\s*(?:(?:${CONNECTORS})\s+)?${NEXT_REQUEST}|\s+(?:${CONNECTORS})\s+${NEXT_REQUEST}`)
function parseClause(clause: string): Rule | null {
if (/\b(riset|research|telusuri|cari(?:kan)?|pantau|look up|search|monitor)\b/.test(clause)) {
const per = perOf(clause)
const day = weekdayOf(clause)
const cadence = per === 'day' && day === null ? 'daily' : 'weekly'
return { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true }
}
for (const [kind, re] of KIND_WORDS) {
const m = re.exec(clause)
if (!m) continue
const count = countOf(clause, m.index)
const per = perOf(clause)
if (per === 'month') {
return { type: 'content', kind, count: Math.max(1, Math.round(count / 4)), per: 'week', topic: topicOf(clause), monthly: count }
}
return { type: 'content', kind, count, per, topic: topicOf(clause) }
}
return null
}
export function parseInstruction(text: string): ParseResult {
const clauses = text
.toLowerCase()
.replace(/[“”"]/g, '')
.split(SPLIT)
.map(s => s.trim())
.filter(Boolean)
const rules: Rule[] = []
const unknown: string[] = []
let shared = ''
for (const clause of clauses) {
const rule = parseClause(clause)
if (rule) rules.push(rule)
// A clause that only names a topic ("about leadership") applies to the rules around it.
else if (topicOf(clause) || /^(soal|tentang|topik|about|topic)\b/.test(clause)) shared = topicOf(clause) || clause.replace(/^\S+\s*/, '')
else if (!/^(tolong|bikin|buat|buatkan|ya|please|make|create|ok)$/.test(clause)) unknown.push(clause)
}
// One topic mentioned anywhere also covers rules that did not name their own.
const topics = new Set(rules.map(r => r.topic).filter(Boolean))
const fallback = shared || (topics.size === 1 ? [...topics][0] : '')
for (const r of rules) if (!r.topic) r.topic = fallback
return { rules, unknown }
}
export function weeklyTarget(rule: ContentRule): number {
return rule.per === 'day' ? rule.count * 7 : rule.count
}
/** A video schedule is one engine run per day or per week; other counts have no matching cadence. */
export function videoCadence(rule: ContentRule): 'daily' | 'weekly' | null {
const target = weeklyTarget(rule)
return target === 7 ? 'daily' : target === 1 ? 'weekly' : null
}
export const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key)
export function describeRule(rule: Rule): string {
const about = rule.topic ? t('rule.about', { topic: rule.topic }) : ''
if (rule.type === 'research') {
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) })
return t('rule.research', { when, about })
}
return t('rule.content', {
kind: kindLabel(rule.kind),
count: rule.count,
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
about: about || t('rule.wholeBrain'),
})
}
export const monthNote = (rule: ContentRule) =>
rule.monthly ? t('rule.monthNote', { count: rule.monthly, weekly: rule.count }) : null

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { correctIndex, questionsOf, toMarkdown, type Material } from './results'
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
describe('correctIndex', () => {
const options = ['Listen first', 'Interrupt', 'Leave']
it('finds the answer as option text, letter or index', () => {
expect(correctIndex(options, 'listen first')).toBe(0)
expect(correctIndex(options, 'B')).toBe(1)
expect(correctIndex(options, 'C. Leave')).toBe(2)
expect(correctIndex(options, '1')).toBe(1)
})
it('returns -1 for an answer outside the options', () => {
expect(correctIndex(options, 'D')).toBe(-1)
expect(correctIndex([], 'An essay answer')).toBe(-1)
})
})
describe('questionsOf', () => {
it('reads the engine’s question shapes', () => {
const [q] = questionsOf({ questions: [{ text: 'Q', choices: [{ text: 'x' }, { label: 'y' }], correct_answer: 'y', explanation: 'because' }] })
expect(q).toEqual({ question: 'Q', options: ['x', 'y'], answer: 'y', explanation: 'because', correct: 1 })
})
})
describe('toMarkdown', () => {
const material = (kind: Material['kind']): Material => ({
id: 1, agentId: 1, kind, status: 'ready', revision: 1, parts: [], planTitle: 'Onboarding', composeSetId: null,
sources: ['Handbook'], createdAt: '2026-10-01T00:00:00Z', rule: { type: 'content', kind, count: 1, per: 'week', topic: '' },
})
it('writes a SCORM module as the lesson followed by its quiz', () => {
const md = toMarkdown(material('scorm'), { title: 'Onboarding', text: 'Welcome.', questions: [{ question: 'Q?', options: ['a', 'b'], answer: 'b' }] })
expect(md).toMatch(/^# Onboarding\n\nWelcome\.\n\n## Quiz\n\n1\. Q\?\n {3}- a\n {3}- b\n/)
expect(md).toContain('Sources: Handbook')
})
it('writes an audio lesson as its script', () => {
expect(toMarkdown(material('audio'), { text: 'Hello team.' })).toBe('# Onboarding\n\nHello team.\n\n---\nSources: Handbook\n')
})
})
describe('audio script helpers', () => {
it('splits a script into sentences without Markdown', () => {
expect(sentencesOf('# Intro\nHello **team**. Ready? Let’s go!\n\n- one')).toEqual(['Intro', 'Hello team.', 'Ready?', 'Let’s go!', 'one'])
})
it('guesses the script language for the voice', () => {
expect(scriptLang('Ini adalah pelajaran yang penting untuk kita semua dan tim.')).toBe('id')
expect(scriptLang('This is the lesson that we will share with the team.')).toBe('en')
})
})

331
src/express/results.ts Normal file
View File

@@ -0,0 +1,331 @@
// Results = everything the Express agents produced: Compose materials (material-drafts, titled by the
// content plan item that ordered them), rendered videos (video-drafts) and web sources found by
// research (source-drafts). Audio and SCORM are Express kinds built from Compose outputs: an audio
// lesson is a narration summary, a SCORM module is the summary + quiz pair one plan item produced.
import { request } from '../api/client'
import { t } from '../i18n'
import { kindLabel, type ContentKind, type Rule } from './parse'
import { expressAgents, PLAN_ACTIVE, productionStatus, type Schedule } from './schedules'
const AGENTS = '/organizations/current/agents'
export type MaterialStatus = 'preparing' | 'generating' | 'ready' | 'failed' | 'accepted' | 'rejected'
interface RawMaterial {
id: number
agent_id: number
run_id: number
output_kind: ContentKind
status: MaterialStatus
revision: number
compose_set_id: number | null
sources: { document_id: number; title: string }[]
created_at: string
}
/** One engine draft behind a result; a SCORM module has two (lesson and quiz). */
export interface Part { id: number; revision: number; kind: ContentKind; status: MaterialStatus }
export interface Material {
id: number
agentId: number
/** What the user asked for, which for audio and SCORM is not the engine's output kind. */
kind: ContentKind
status: MaterialStatus
revision: number
parts: Part[]
planTitle: string | null
composeSetId: number | null
sources: string[]
createdAt: string
rule: Rule
video?: { url: string | null; progress: number; credits: number | null }
}
// Labels are resolved at render time so they follow the UI language.
export const titleOf = (m: Material) => m.planTitle ?? [kindLabel(m.kind), m.rule.topic].filter(Boolean).join(' · ')
// Ids come from separate engine tables (material drafts, video drafts, plan items) and repeat across them.
export const materialKey = (m: Material) => `${m.kind === 'video' ? 'video' : 'material'}-${m.agentId}-${m.id}`
export type MaterialContent = Record<string, unknown>
export interface FoundSource {
id: number
agentId: number
title: string
url: string | null
status: 'pending' | 'accepted' | 'rejected' | string
revision: number
preview: string
documentId: number | null
createdAt: string
rule: Rule
}
interface RawSource {
id: number
agent_id: number
title: string
source_url: string | null
status: string
revision: number
preview: string
document_id: number | null
captured_at: string
}
// The least finished part decides: a module is ready only when both its lesson and quiz are.
const ORDER: MaterialStatus[] = ['failed', 'rejected', 'preparing', 'generating', 'ready', 'accepted']
const combined = (parts: Part[]) => ORDER.find(s => parts.some(p => p.status === s)) ?? 'preparing'
async function materialsOf(schedule: Schedule): Promise<Material[]> {
if (schedule.rule.type === 'content' && schedule.rule.kind === 'video') return videosOf(schedule)
const { agent, rule } = schedule
const kind = rule.type === 'content' ? rule.kind : 'summary'
const [drafts, status] = await Promise.all([
request<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`),
productionStatus(agent.id).catch(() => null),
])
const titles = new Map<number, string>()
for (const plan of status?.plans ?? []) {
for (const item of plan.items ?? []) if (item.run_id) titles.set(item.run_id, item.title)
}
// One run makes one result; its drafts are its parts (one, or the lesson + quiz of a SCORM module).
const byRun = new Map<number, RawMaterial[]>()
for (const d of drafts) byRun.set(d.run_id, [...(byRun.get(d.run_id) ?? []), d])
const made = [...byRun.values()].map((group): Material => {
// The lesson comes first: it carries the title, sources and Compose set of the whole module.
group.sort((a, b) => Number(b.output_kind === 'summary') - Number(a.output_kind === 'summary'))
const [d] = group
const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status }))
return {
id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind,
status: combined(parts), revision: d.revision, parts,
planTitle: titles.get(d.run_id) ?? null,
composeSetId: d.compose_set_id, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
}
})
// Planned pieces have no draft until generation starts; show them as in progress so a running
// production is visible here from the moment the topics are chosen.
const started = new Set(drafts.map(d => d.run_id))
const planned = (status?.plans ?? []).filter(p => PLAN_ACTIVE.has(p.status)).flatMap(p => p.items)
.filter(i => ['proposed', 'approved', 'queued', 'generating'].includes(i.status) && !(i.run_id && started.has(i.run_id)))
.map((i): Material => ({
id: -i.id, agentId: agent.id, kind, status: 'preparing', revision: 0, parts: [],
planTitle: i.title, composeSetId: null, sources: [], createdAt: new Date().toISOString(), rule,
}))
return [...planned, ...made]
}
interface RawVideo {
id: number
run_id: number
status: string
title: string | null
progress_percentage: number
video_url: string | null
sources: { document_id: number; title: string }[]
actual_credits: number | null
created_at: string
}
// Video drafts move through script, storyboard, clips and render before they are ready to review.
function videoStatus(s: string): MaterialStatus {
if (s === 'ready' || s === 'accepted' || s === 'rejected') return s
if (s === 'failed' || s === 'cancelled') return 'failed'
return 'generating'
}
async function videosOf({ agent, rule }: Schedule): Promise<Material[]> {
const drafts = await request<RawVideo[]>(`${AGENTS}/${agent.id}/video-drafts`)
return drafts.map(d => {
const status = videoStatus(d.status)
return {
id: d.id, agentId: agent.id, kind: 'video', status, revision: 0,
parts: [{ id: d.id, revision: 0, kind: 'video', status }],
planTitle: d.title, composeSetId: null, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
video: { url: d.video_url, progress: d.progress_percentage, credits: d.actual_credits },
}
})
}
async function sourcesOf({ agent, rule }: Schedule): Promise<FoundSource[]> {
const drafts = await request<RawSource[]>(`${AGENTS}/${agent.id}/source-drafts`)
return drafts.map(d => ({
id: d.id, agentId: agent.id, title: d.title, url: d.source_url, status: d.status, revision: d.revision,
preview: d.preview, documentId: d.document_id, createdAt: d.captured_at, rule,
}))
}
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
const schedules = await expressAgents({ archived: true })
const [materials, sources] = await Promise.all([
Promise.all(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
Promise.all(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
])
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
return { materials: materials.flat().sort(newest), sources: sources.flat().sort(newest) }
}
const draftContent = (m: Material, id: number) =>
request<{ content?: MaterialContent; output_id?: number }>(`${AGENTS}/${m.agentId}/material-drafts/${id}`)
// outputId is the generated Compose output (e.g. the SlideDeck id), which owns the file exports.
// A SCORM module merges its parts: the lesson's title and text with the quiz's questions.
export async function materialContent(m: Material): Promise<{ content: MaterialContent | null; outputId: number | null }> {
if (m.kind === 'video') return { content: { title: m.planTitle ?? '' }, outputId: null }
const [first, ...rest] = await Promise.all(m.parts.map(p => draftContent(m, p.id)))
if (!first?.content) return { content: null, outputId: null }
const content = { ...first.content }
for (const r of rest) if (r.content?.questions) content.questions = r.content.questions
return { content, outputId: first.output_id ?? null }
}
// The engine renders slide decks to PPTX itself; other kinds have no file export there.
export const exportPptx = (deckId: number) => request<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
// Every part waiting for review gets the same decision, so a SCORM module is approved as one.
export async function decideMaterial(m: Material, decision: 'accept' | 'reject') {
for (const p of m.parts.filter(p => p.status === 'ready')) {
if (m.kind === 'video') await request(`${AGENTS}/${m.agentId}/video-drafts/${p.id}/review`, { method: 'POST', json: { decision } })
else await request(`${AGENTS}/${m.agentId}/material-drafts/${p.id}/decision`, { method: 'POST', json: { expected_revision: p.revision, decision } })
}
}
// Accepting a source only adds it to the Library; its knowledge build is confirmed on the Brain page.
export const reviewSource = (s: FoundSource, decision: 'accept' | 'reject') =>
request(`${AGENTS}/${s.agentId}/source-drafts/${s.id}/review`, { method: 'POST', json: { expected_revision: s.revision, decision } })
export const isWorking = (m: Material) => m.status === 'preparing' || m.status === 'generating'
// ── Export ──
const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v])
export interface Question {
question: string
options: string[]
answer: string
explanation: string
/** Index of the correct option, or -1 when the answer is not one of the options (an essay). */
correct: number
}
// The engine gives the answer as the option text, its letter ("B", "B. …") or its index.
export function correctIndex(options: string[], answer: string): number {
const exact = options.findIndex(o => o.trim().toLowerCase() === answer.trim().toLowerCase())
if (exact >= 0) return exact
const letter = answer.trim().match(/^([A-Ha-h])(?:[.)\s:]|$)/)?.[1]
if (letter) {
const i = letter.toUpperCase().charCodeAt(0) - 65
if (i < options.length) return i
}
const index = /^\d+$/.test(answer.trim()) ? Number(answer) : -1
return index >= 0 && index < options.length ? index : -1
}
export function questionsOf(content: MaterialContent): Question[] {
return list(content.questions).map(raw => {
const q = (raw ?? {}) as Record<string, unknown>
const options = list(q.options ?? q.choices).map(o => (typeof o === 'object' && o ? str((o as Record<string, unknown>).text ?? (o as Record<string, unknown>).label) : str(o)))
const answer = str(q.correct_answer ?? q.answer)
return { question: str(q.question ?? q.text ?? q.prompt), options, answer, explanation: str(q.explanation), correct: correctIndex(options, answer) }
})
}
export interface Slide { title: string; bullets: string[]; notes: string }
export function slidesOf(content: MaterialContent): Slide[] {
return list(content.slides).map(raw => {
const s = (raw ?? {}) as Record<string, unknown>
const c = ((s.content ?? s) as Record<string, unknown>) ?? {}
return { title: str(c.title ?? s.title), bullets: list(c.bullets).map(str), notes: str(s.speaker_notes ?? s.notes) }
})
}
export const textOf = str
export const listOf = list
export function toMarkdown(m: Material, content: MaterialContent): string {
const out = [`# ${str(content.title) || titleOf(m)}`, '']
if (m.kind === 'scorm') out.push(str(content.text), '', `## ${t('res.quiz')}`, '')
if (m.kind === 'summary' || m.kind === 'audio') out.push(str(content.text))
else if (m.kind === 'quiz' || m.kind === 'scorm') {
questionsOf(content).forEach((q, i) => {
out.push(`${i + 1}. ${q.question}`, ...q.options.map(o => ` - ${o}`))
if (q.answer) out.push(` ${t('res.answer')}: ${q.answer}`)
if (q.explanation) out.push(` ${q.explanation}`)
out.push('')
})
} else if (m.kind === 'flashcards') {
for (const c of list(content.cards) as Record<string, unknown>[]) out.push(`**${str(c.front)}**`, str(c.back), '')
} else if (m.kind === 'slide_deck') {
if (content.description) out.push(str(content.description), '')
slidesOf(content).forEach((s, i) => out.push(`## ${i + 1}. ${s.title}`, ...s.bullets.map(b => `- ${b}`), ''))
} else {
for (const [key, value] of Object.entries(content)) {
if (key === 'title') continue
out.push(`## ${key.replace(/_/g, ' ')}`, ...list(value).map(v => (Array.isArray(value) ? `- ${str(v)}` : str(v))), '')
}
}
if (m.sources.length) out.push('', '---', `${t('res.sources')}: ${m.sources.join(', ')}`)
return out.join('\n').trim() + '\n'
}
export function download(name: string, data: string | Blob) {
const url = URL.createObjectURL(typeof data === 'string' ? new Blob([data], { type: 'text/markdown;charset=utf-8' }) : data)
const a = Object.assign(document.createElement('a'), { href: url, download: name })
a.click()
// Revoked later: Safari and Firefox may not have started reading the file when click() returns.
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
export const fileName = (m: Material, ext: 'md' | 'pptx' | 'txt' | 'zip' | 'mp4') =>
`${titleOf(m).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || m.kind}.${ext}`
// PDF without a PDF library: print a standalone page from a hidden iframe, so the browser's own
// "Save as PDF" lays out the text (real, selectable text with proper fonts and page breaks).
export function printPdf(title: string, bodyHtml: string) {
const frame = Object.assign(document.createElement('iframe'), { title: 'print' })
frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0'
document.body.append(frame)
const doc = frame.contentDocument!
doc.open()
doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>${PRINT_CSS}</style></head><body>${bodyHtml}</body></html>`)
doc.close()
const win = frame.contentWindow!
// Remove the frame once the print dialog is done; the timeout covers browsers without afterprint.
const cleanup = () => setTimeout(() => frame.remove(), 500)
win.addEventListener('afterprint', cleanup, { once: true })
setTimeout(() => { win.focus(); win.print(); cleanup() }, 100)
}
const escapeHtml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
const PRINT_CSS = `
@page{size:A4;margin:18mm 16mm}
*{box-sizing:border-box}
body{font:11pt/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1d2a3b;margin:0}
header{border-bottom:2px solid #2563eb;padding-bottom:10px;margin-bottom:18px}
header small{display:block;font-size:8.5pt;letter-spacing:.08em;text-transform:uppercase;color:#2563eb;font-weight:600}
header p{margin:6px 0 0;font-size:9pt;color:#6b7a90}
h1{font-size:20pt;line-height:1.3;margin:4px 0 0}
article>h1{display:none}
h2{font-size:13pt;margin:18px 0 6px;break-after:avoid}
p{margin:0 0 9px}
ul,ol{margin:0 0 10px;padding-left:20px}
li{margin-bottom:4px}
.quiz-list>li{margin-bottom:14px;break-inside:avoid}
.quiz-list li.correct{font-weight:600;color:#127a55}
.quiz-list li.correct:after{content:" ✓"}
.muted{color:#6b7a90;font-size:10pt}
.flashcards{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.flashcard{border:1px solid #d9e2ef;border-radius:8px;padding:10px 12px;break-inside:avoid;display:flex;flex-direction:column;gap:6px}
.flashcard span{color:#4d5d73}
.slide-preview{border:1px solid #d9e2ef;border-radius:8px;padding:12px 14px;margin-bottom:10px;break-inside:avoid}
.slide-preview small{font-size:8pt;letter-spacing:.08em;text-transform:uppercase;color:#8a97a8}
.slide-preview h2{margin-top:2px}
footer{margin-top:24px;padding-top:8px;border-top:1px solid #e3e9f2;font-size:8.5pt;color:#8a97a8}
`

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding'): ContentRule =>
({ type: 'content', kind, count, per, topic })
// What the engine sends back for an agent created from configFor.
function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent {
const c = config as Record<string, unknown>
return {
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', auto_approve_sources: false,
name: c.name as string, goal: c.goal as string,
enabled_tools: c.enabled_tools as string[],
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
content_goal: (c.content_goal as ExpressAgent['content_goal']) ?? null,
video_options: (c.video_options as ExpressAgent['video_options']) ?? null,
run_schedule: (c.run_schedule as ExpressAgent['run_schedule']) ?? null,
run_budget: c.run_budget as number, monthly_budget: c.monthly_budget as number,
}
}
describe('configFor', () => {
it('makes an audio schedule a narration summary', () => {
const config = configFor(rule('audio')) as Record<string, unknown>
expect(config.material_outputs).toEqual([{ kind: 'summary', options: {} }])
expect(config.instructions).toMatch(/narration script/)
expect(config.name).toBe('Express · 2 audio per week · onboarding')
})
it('makes a SCORM schedule a lesson + quiz pair', () => {
const config = configFor(rule('scorm')) as Record<string, unknown>
expect((config.material_outputs as { kind: string }[]).map(o => o.kind)).toEqual(['summary', 'quiz'])
})
it('makes a video schedule a run-scheduled Video Studio agent', () => {
const config = configFor(rule('video', 1, 'day'), 100) as Record<string, unknown>
expect(config.enabled_tools).toContain('studio.create_video')
expect(config).not.toHaveProperty('content_goal')
expect(config.run_schedule).toMatchObject({ cadence: 'daily' })
expect(config.video_options).toMatchObject({ max_scenes: VIDEO.scenes, max_duration_seconds: VIDEO.seconds })
expect(config.run_budget).toBe(110)
expect(config.monthly_budget).toBe(110 * 31)
})
it('keeps budgets ordered run ≤ weekly ≤ monthly', () => {
for (const kind of ['summary', 'quiz', 'audio', 'scorm'] as const) {
for (const count of [1, 5, 50]) {
const b = budgetFor(rule(kind, count))
expect(b.run).toBeLessThanOrEqual(b.weekly!)
expect(b.weekly!).toBeLessThanOrEqual(b.monthly)
}
}
})
})
describe('ruleOf', () => {
it.each([
rule('summary', 3),
rule('audio', 2),
rule('scorm', 1),
rule('quiz', 1, 'day'),
rule('video', 1, 'week'),
rule('video', 1, 'day'),
])('reads back the rule a schedule was created from: $count $kind per $per', r => {
expect(ruleOf(agentFrom(configFor(r, 50)))).toEqual(r)
})
it('reads back a research rule', () => {
const r: ResearchRule = { type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: true }
const agent = { ...agentFrom(configFor(r)), auto_approve_sources: true }
expect(ruleOf(agent)).toEqual(r)
})
})
describe('canSchedule', () => {
it('accepts every kind within limits', () => {
expect(canSchedule(rule('audio', 5))).toBe(true)
expect(canSchedule(rule('scorm', 2))).toBe(true)
expect(canSchedule(rule('video', 1))).toBe(true)
})
it('rejects video counts without a cadence and production over 50 a week', () => {
expect(canSchedule(rule('video', 3))).toBe(false)
expect(canSchedule(rule('summary', 8, 'day'))).toBe(false)
expect(canSchedule(rule('summary', 0))).toBe(false)
})
})

316
src/express/schedules.ts Normal file
View File

@@ -0,0 +1,316 @@
// Each Express schedule is one engine agent. Content rules use the engine's recurring production
// (content_goal: weekly plans with novelty checks, started automatically); research and video rules
// use run_schedule (one run per day or week; a video run renders one video).
import { opKey, request } from '../api/client'
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
import { getLang } from '../i18n'
import { KINDS, SCHEDULABLE, videoCadence, weeklyTarget, type ContentKind, type ContentRule, type Per, type ResearchRule, type Rule } from './parse'
const AGENTS = '/organizations/current/agents'
export const PREFIX = 'Express · '
const TIMEZONE = 'Asia/Jakarta'
export interface ContentGoal {
enabled: boolean
weekly_target: number
execution_mode: 'automatic' | 'review'
production_cadence: 'spread' | 'weekly' | 'manual'
production_hour: number
production_minute: number
production_weekday: number
weekly_budget: number
}
export interface VideoOptions {
max_scenes: number
max_duration_seconds: number
art_style: 'realistic' | 'cartoon' | 'anime' | 'watercolor' | '3d'
language: 'English' | 'Bahasa Indonesia'
}
export interface ExpressAgent extends Agent {
next_run_at: string | null
enabled_tools: string[]
material_outputs: { kind: ContentKind; options: Record<string, unknown> }[]
content_goal: ContentGoal | null
video_options: VideoOptions | null
auto_approve_sources: boolean
run_budget: number
monthly_budget: number
created_at: string
}
export interface Schedule {
agent: ExpressAgent
rule: Rule
active: boolean
}
// The per-day/per-week wording is not part of the engine config, so it travels in the agent name.
// Names stay in English whatever the UI language, so they parse the same for every viewer.
function scheduleName(rule: Rule): string {
const topic = rule.topic ? ` · ${rule.topic}` : ''
if (rule.type === 'research') return `${PREFIX}Research ${rule.cadence}${topic}`.slice(0, 120)
return `${PREFIX}${rule.count} ${rule.kind} per ${rule.per}${topic}`.slice(0, 120)
}
const isKind = (k: string | undefined): k is ContentKind => KINDS.includes(k as ContentKind)
export function ruleOf(agent: ExpressAgent): Rule {
const topic = agent.name.split(' · ').slice(2).join(' · ')
if (agent.enabled_tools.includes('studio.create_video')) {
const daily = agent.run_schedule?.cadence === 'daily'
return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic }
}
if (agent.content_goal?.enabled && agent.material_outputs[0]) {
// " per hari" comes from schedules created before names were in English.
const per: Per = / per (day|hari)\b/.test(agent.name) ? 'day' : 'week'
const target = agent.content_goal.weekly_target
// Audio and SCORM are Express kinds made of Compose outputs, so the name carries the kind.
const named = agent.name.match(/^Express · \d+ (\w+) per /)?.[1]
const kind = isKind(named) ? named : agent.material_outputs[0].kind
return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic }
}
const s = agent.run_schedule ?? { cadence: 'off' }
return { type: 'research', cadence: s.cadence === 'weekly' ? 'weekly' : 'daily', weekday: s.weekday ?? 0, topic, autoApprove: agent.auto_approve_sources }
}
// Archived schedules are gone from the Schedule page, but what they produced still belongs on Results.
export async function expressAgents({ archived = false } = {}): Promise<Schedule[]> {
const agents = await request<ExpressAgent[]>(AGENTS)
return agents
.filter(a => a.name.startsWith(PREFIX) && (archived || a.status !== 'archived'))
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map(agent => ({ agent, rule: ruleOf(agent), active: agent.status === 'active' }))
}
export const listSchedules = () => expressAgents()
/** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */
export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video'
// Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene).
export const VIDEO = { scenes: 4, seconds: 40 }
export interface VideoQuote { max_scenes: number; max_total_seconds: number; max_credits: number }
/** The current price ceiling of one video; the engine pins and rechecks it when a run starts. */
export const videoQuote = () =>
request<VideoQuote>(`${AGENTS}/video-quote?scenes=${VIDEO.scenes}&duration_seconds=${VIDEO.seconds}`)
// Credit limits. The engine requires run ≤ weekly ≤ monthly; a run is one planned item, one research
// pass, or one video. A SCORM item is two Compose outputs (lesson + quiz), so it gets twice the room.
export function budgetFor(rule: Rule, videoCredits = 0) {
if (rule.type === 'research') {
const run = 50
return { run, weekly: null, monthly: rule.cadence === 'daily' ? run * 31 : run * 5 }
}
if (rule.kind === 'video') {
// A little headroom over today's quote, so a small price change does not stop the schedule.
const run = Math.max(1, Math.ceil((videoCredits * 11) / 10))
return { run, weekly: null, monthly: videoCadence(rule) === 'daily' ? run * 31 : run * 5 }
}
const perItem = rule.kind === 'scorm' ? 40 : 20
const weekly = Math.max(rule.kind === 'scorm' ? 120 : 60, weeklyTarget(rule) * perItem)
return { run: Math.min(rule.kind === 'scorm' ? 60 : 30, weekly), weekly, monthly: weekly * 5 }
}
const KIND_TEXT: Record<ContentKind, string> = {
summary: 'summaries', quiz: 'quizzes', flashcards: 'flashcard sets', roleplay: 'roleplay scenarios',
simulator: 'simulations', slide_deck: 'slide decks', video: 'short explainer videos', audio: 'audio lessons',
scorm: 'e-learning modules (a lesson with a quiz that checks it)',
}
// What each Express kind asks of the engine's Compose outputs.
const KIND_BRIEF: Partial<Record<ContentKind, string>> = {
audio: 'Each summary is the narration script of an audio lesson of about 3 to 5 minutes. Write it to be read aloud: '
+ 'conversational, short sentences, a friendly opening and a short recap at the end. No headings, bullet points, tables or Markdown.',
scorm: 'Each lesson summary and its quiz form one e-learning module: the quiz checks exactly what the summary teaches.',
video: 'Each video explains one practical idea from the Brain in plain language for employees.',
}
function outputsFor(kind: ContentKind): ExpressAgent['material_outputs'] {
if (kind === 'audio') return [{ kind: 'summary', options: {} }]
if (kind === 'scorm') return [{ kind: 'summary', options: {} }, { kind: 'quiz', options: { question_count: 10, question_type: 'multiple_choice_only' } }]
return [{ kind, options: {} }]
}
// The agent's brief is in English; the content language follows the UI language at creation time.
function goalText(rule: Rule): string {
const about = rule.topic ? ` about ${rule.topic}` : ''
if (rule.type === 'research') {
return `Research the public internet${about || ' for learning topics relevant to the L&D team'} and add new, relevant public sources to the team's Brain.`
}
return `Create new learning ${KIND_TEXT[rule.kind]}${about} from the L&D team's Brain, without repeating content that already exists.`
}
function instructionsFor(rule: Rule): string {
const language = getLang() === 'id' ? 'Write all content in Bahasa Indonesia.' : 'Write all content in English.'
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
}
export function configFor(rule: Rule, videoCredits = 0) {
const budget = budgetFor(rule, videoCredits)
const common = {
name: scheduleName(rule),
goal: goalText(rule),
instructions: instructionsFor(rule),
timezone: TIMEZONE,
run_budget: budget.run,
monthly_budget: budget.monthly,
}
if (rule.type === 'research') {
return {
...common,
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url'],
research_options: { max_results: 5 },
// Express is hands-off: accepted sources go straight into the Brain unless the user unticks this.
auto_approve_sources: rule.autoApprove,
run_schedule: researchSchedule(rule),
}
}
if (rule.kind === 'video') {
return {
...common,
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'studio.create_video'],
video_options: {
max_scenes: VIDEO.scenes,
max_duration_seconds: VIDEO.seconds,
art_style: 'realistic',
language: getLang() === 'id' ? 'Bahasa Indonesia' : 'English',
} satisfies VideoOptions,
run_schedule: { cadence: videoCadence(rule) ?? 'weekly', hour: 9, minute: 0, weekday: 0 } satisfies RunSchedule,
}
}
return {
...common,
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'compose.create_material'],
material_outputs: outputsFor(rule.kind),
content_goal: {
enabled: true,
weekly_target: weeklyTarget(rule),
execution_mode: 'automatic',
// "spread": the engine picks delivery days; a daily rule (target ≥ 7) produces every day.
production_cadence: 'spread',
production_hour: 9,
production_minute: 0,
weekly_budget: budget.weekly!,
} satisfies Partial<ContentGoal>,
}
}
const researchSchedule = (rule: ResearchRule): RunSchedule =>
({ cadence: rule.cadence, hour: 7, minute: 0, weekday: rule.weekday })
export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule {
if (rule.type === 'research') return true
if (rule.count < 1) return false
if (rule.kind === 'video') return videoCadence(rule) !== null
return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50
}
// The production corpus is the agent's own sources, so every ready Brain document is attached.
async function attachBrain(agentId: number) {
const docs = (await listDocuments()).filter(d => d.knowledge === 'ready')
for (const d of docs) {
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: d.docId } })
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
}
return docs.length
}
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
// A video schedule's run budget is today's price of one video, so it is fetched first.
const credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits) })
await attachBrain(agent.id)
// Activating approves the production rules as they stand; until then nothing runs.
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
}
/** Changing a rule changes what was approved, so the agent is re-activated right after. */
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
const rule = { ...schedule.rule, topic: topic.trim() }
const { name, goal, instructions } = configFor(rule)
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
await attachBrain(schedule.agent.id)
return request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
}
export const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
export async function resumeSchedule(id: number) {
await attachBrain(id)
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
}
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })
/** Starts one run of a research or video schedule now, outside its daily or weekly slot. */
export const runNow = (id: number) =>
request<{ id: number; status: string }>(`${AGENTS}/${id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`run-${id}`) }, json: {} })
// ── Status and history ──
export interface ProductionStatus {
enabled: boolean
goal_status: string | null
planning_available: boolean
period: { week_start: string; target: number; ready: number; claimed: number; failed: number; shortfall: number; spent_credits: number } | null
plans: ContentPlan[]
}
export interface ContentPlan {
id: number
revision: number
status: 'quoted' | 'planning' | 'review' | 'approved' | 'running' | 'completed' | 'paused' | 'failed' | 'cancelled'
target: number
error_code: string | null
planning_quote: { credits: number | null }
items: PlanItem[]
}
export interface PlanItem {
id: number
status: 'proposed' | 'approved' | 'queued' | 'generating' | 'ready' | 'blocked' | 'failed' | 'skipped' | 'cancelled' | 'rejected'
run_id: number | null
title: string
required_outputs: { kind: ContentKind }[]
}
const DROPPED = new Set<PlanItem['status']>(['skipped', 'cancelled', 'rejected'])
// One plan's progress, for the Schedule row and the Results banner.
export function planProgress(plan: ContentPlan) {
const items = plan.items.filter(i => !DROPPED.has(i.status))
return {
total: items.length,
ready: items.filter(i => i.status === 'ready').length,
failed: items.filter(i => i.status === 'failed' || i.status === 'blocked').length,
// The engine may find fewer new topics than asked for; the rest is a shortfall, not an error.
shortfall: plan.status === 'planning' ? 0 : Math.max(0, plan.target - items.length),
}
}
export const productionStatus = (id: number) => request<ProductionStatus>(`${AGENTS}/${id}/content`)
// Produce now = the same plan the 09:00 tick would create, just earlier. The engine's tick then
// approves and starts it within a minute because Express schedules run in automatic mode.
export const quoteProduction = (id: number, target: number) =>
request<ContentPlan>(`${AGENTS}/${id}/content/planning-quotes`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`produce-${id}`) }, json: { target } })
export const confirmProduction = (id: number, plan: ContentPlan) =>
request<ContentPlan>(`${AGENTS}/${id}/content/plans/${plan.id}/confirm-planning`, { method: 'POST', json: { revision: plan.revision } })
export const PLAN_ACTIVE = new Set<ContentPlan['status']>(['planning', 'review', 'approved', 'running'])
export interface RunSummary {
id: number
status: string
trigger_kind: string
safe_error_code: string | null
budget_spent: number | null
created_at: string
finished_at: string | null
}
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)

78
src/express/scorm.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { crc32, manifest, MASTERY, scoHtml, zip, type ScormLabels } from './scorm'
import type { Question } from './results'
const labels: ScormLabels = {
quiz: 'Quiz', submit: 'Check', retry: 'Retry', complete: 'Complete', completed: 'Done', score: 'Score: {score}%.',
passed: 'Passed', failed: 'Failed', answerAll: 'Answer all', noLms: 'No LMS',
}
const question = (q: Partial<Question>): Question =>
({ question: 'Q?', options: ['A', 'B'], answer: 'B', explanation: '', correct: 1, ...q })
// Reads a stored ZIP back through its central directory, as an LMS importer would.
function unzip(bytes: Uint8Array): Record<string, string> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const end = bytes.length - 22
expect(view.getUint32(end, true)).toBe(0x06054b50)
const count = view.getUint16(end + 10, true)
let at = view.getUint32(end + 16, true)
const files: Record<string, string> = {}
const dec = new TextDecoder()
for (let i = 0; i < count; i++) {
expect(view.getUint32(at, true)).toBe(0x02014b50)
const size = view.getUint32(at + 24, true)
const nameLength = view.getUint16(at + 28, true)
const offset = view.getUint32(at + 42, true)
const name = dec.decode(bytes.subarray(at + 46, at + 46 + nameLength))
expect(view.getUint32(offset, true)).toBe(0x04034b50)
const dataStart = offset + 30 + view.getUint16(offset + 26, true)
const data = bytes.subarray(dataStart, dataStart + size)
expect(crc32(data)).toBe(view.getUint32(at + 16, true))
files[name] = dec.decode(data)
at += 46 + nameLength
}
return files
}
describe('crc32', () => {
it('matches the standard check value', () => {
expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926)
})
})
describe('zip', () => {
it('round-trips files, including UTF-8 names and content', () => {
const files = unzip(zip([{ name: 'a.txt', data: 'hello' }, { name: 'é/b.html', data: '<p>Ringkasan ✓</p>' }]))
expect(files).toEqual({ 'a.txt': 'hello', 'é/b.html': '<p>Ringkasan ✓</p>' })
})
})
describe('manifest', () => {
it('declares one SCO and the mastery score only when there is a quiz', () => {
const withQuiz = manifest('Kepemimpinan & Tim', true, 'm1')
expect(withQuiz).toContain('adlcp:scormtype="sco" href="index.html"')
expect(withQuiz).toContain(`<adlcp:masteryscore>${MASTERY}</adlcp:masteryscore>`)
expect(withQuiz).toContain('<title>Kepemimpinan &amp; Tim</title>')
expect(manifest('Lesson', false)).not.toContain('masteryscore')
})
})
describe('scoHtml', () => {
it('keeps only scorable questions and cannot break out of its data script', () => {
const html = scoHtml({
title: 'T', lessonHtml: '<p>x</p>', lang: 'id', labels,
questions: [question({ question: '</script><b>' }), question({ options: [], correct: -1, answer: 'essay' })],
})
const data = JSON.parse(html.match(/<script type="application\/json" id="sco-data">(.*?)<\/script>/s)![1])
expect(data.quiz).toEqual([{ q: '</script><b>', o: ['A', 'B'], c: 1, e: '' }])
expect(data.mastery).toBe(MASTERY)
expect(html).toContain('id="quiz-form"')
})
it('offers a completion button when there is no quiz', () => {
const html = scoHtml({ title: 'T', lessonHtml: '', questions: [], lang: 'en', labels })
expect(html).toContain('id="complete"')
expect(html).not.toContain('id="quiz-form"')
})
})

265
src/express/scorm.ts Normal file
View File

@@ -0,0 +1,265 @@
// SCORM 1.2 export of a finished result: one self-contained SCO (index.html) that reports completion,
// and for content with a quiz also the score and pass/fail, to any SCORM 1.2 LMS.
// Built in the browser because the engine has no SCORM export; the ZIP writer below has no dependencies.
import type { Question } from './results'
export interface ScormLesson {
title: string
/** Static HTML of the lesson (already escaped by React's renderer); empty for a quiz-only package. */
lessonHtml: string
questions: Question[]
lang: 'en' | 'id'
labels: ScormLabels
}
export interface ScormLabels {
quiz: string
submit: string
retry: string
complete: string
completed: string
score: string
passed: string
failed: string
answerAll: string
noLms: string
}
/** Share of correct answers needed to pass, in percent (adlcp:masteryscore). */
export const MASTERY = 70
const escapeXml = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!)
export function manifest(title: string, hasQuiz: boolean, id = `lle-${Date.now().toString(36)}`): string {
const t = escapeXml(title)
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="${id}" version="1.0"
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
<metadata><schema>ADL SCORM</schema><schemaversion>1.2</schemaversion></metadata>
<organizations default="org">
<organization identifier="org">
<title>${t}</title>
<item identifier="item" identifierref="sco" isvisible="true">
<title>${t}</title>${hasQuiz ? `\n <adlcp:masteryscore>${MASTERY}</adlcp:masteryscore>` : ''}
</item>
</organization>
</organizations>
<resources>
<resource identifier="sco" type="webcontent" adlcp:scormtype="sco" href="index.html">
<file href="index.html"/>
</resource>
</resources>
</manifest>
`
}
// Inside <script>, "</" would end the element early; < keeps the JSON intact.
const jsonForScript = (v: unknown) => JSON.stringify(v).replace(/</g, '\\u003c')
export function scoHtml({ title, lessonHtml, questions, lang, labels }: ScormLesson): string {
// Only multiple-choice questions can be scored; essay questions stay out of the package's quiz.
const quiz = questions.filter(q => q.options.length > 1 && q.correct >= 0)
.map(q => ({ q: q.question, o: q.options, c: q.correct, e: q.explanation }))
return `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeXml(title)}</title>
<style>${SCO_CSS}</style>
</head>
<body>
<main>
<h1>${escapeXml(title)}</h1>
${lessonHtml}
${quiz.length ? `<section id="quiz"><h2>${escapeXml(labels.quiz)}</h2><form id="quiz-form"></form><p id="quiz-result" role="status"></p></section>` : `<p class="end"><button id="complete" type="button">${escapeXml(labels.complete)}</button></p>`}
<p id="lms-note" class="note" hidden>${escapeXml(labels.noLms)}</p>
</main>
<script type="application/json" id="sco-data">${jsonForScript({ quiz, labels, mastery: MASTERY })}</script>
<script>${SCO_JS}</script>
</body>
</html>
`
}
const SCO_CSS = `
*{box-sizing:border-box}
body{margin:0;background:#f5f8fc;color:#1d2a3b;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
main{max-width:760px;margin:0 auto;padding:32px 20px 60px;background:#fff;min-height:100vh}
h1{font-size:28px;line-height:1.3;margin:0 0 20px}
article>h1{display:none}
h2{font-size:20px;margin:28px 0 10px}
ul,ol{padding-left:22px}
.muted,.note{color:#6b7a90;font-size:14px}
.flashcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
.flashcard{border:1px solid #d9e2ef;border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:6px}
.slide-preview{border:1px solid #d9e2ef;border-radius:8px;padding:12px 16px;margin-bottom:10px}
.slide-preview small{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:#8a97a8}
fieldset{border:1px solid #d9e2ef;border-radius:10px;padding:14px 16px;margin:0 0 14px}
legend{font-weight:600;padding:0 6px}
label{display:flex;gap:10px;align-items:flex-start;padding:6px 4px;border-radius:6px;cursor:pointer}
label.correct{background:#e8f7f0}
label.wrong{background:#fdecec}
.explain{font-size:14px;color:#4d5d73;margin:6px 0 0}
button{font:inherit;border:0;border-radius:8px;padding:10px 18px;background:#2563eb;color:#fff;cursor:pointer}
button:disabled{opacity:.5;cursor:default}
#quiz-result{font-weight:600}
.end{margin-top:32px}
`
// SCORM 1.2 runtime: find the LMS's API object, mark the lesson incomplete on open, and report
// completion (no quiz) or score + passed/failed (quiz). Works without an LMS too, for a preview.
const SCO_JS = `
(function(){
var data=JSON.parse(document.getElementById('sco-data').textContent),L=data.labels,start=Date.now(),api=null,done=false;
function find(w){for(var n=0;w&&n<12;n++){if(w.API)return w.API;if(!w.parent||w.parent===w)break;w=w.parent}return null}
api=find(window)||(window.opener?find(window.opener):null);
function set(k,v){if(api)api.LMSSetValue(k,String(v))}
function commit(){if(api)api.LMSCommit('')}
function pad(n,w){n=String(n);while(n.length<w)n='0'+n;return n}
function elapsed(){var s=Math.round((Date.now()-start)/1000);return pad(Math.floor(s/3600),4)+':'+pad(Math.floor(s/60)%60,2)+':'+pad(s%60,2)}
if(api){
api.LMSInitialize('');
var status=api.LMSGetValue('cmi.core.lesson_status');
if(!status||status==='not attempted')set('cmi.core.lesson_status','incomplete');
commit();
}else document.getElementById('lms-note').hidden=false;
function finish(){if(!api||done)return;done=true;set('cmi.core.session_time',elapsed());set('cmi.core.exit','');commit();api.LMSFinish('')}
window.addEventListener('pagehide',finish);
window.addEventListener('beforeunload',finish);
var complete=document.getElementById('complete');
if(complete)complete.onclick=function(){set('cmi.core.lesson_status','completed');commit();complete.disabled=true;complete.textContent=L.completed};
var form=document.getElementById('quiz-form');
if(!form)return;
data.quiz.forEach(function(q,i){
var f=document.createElement('fieldset'),lg=document.createElement('legend');
lg.textContent=(i+1)+'. '+q.q;f.appendChild(lg);
q.o.forEach(function(o,j){
var l=document.createElement('label'),r=document.createElement('input'),s=document.createElement('span');
r.type='radio';r.name='q'+i;r.value=j;s.textContent=o;l.appendChild(r);l.appendChild(s);f.appendChild(l);
});
form.appendChild(f);
});
var submit=document.createElement('button');submit.type='submit';submit.textContent=L.submit;form.appendChild(submit);
var result=document.getElementById('quiz-result');
form.onsubmit=function(e){
e.preventDefault();
if(submit.dataset.retry){form.reset();form.querySelectorAll('label').forEach(function(l){l.className=''});
form.querySelectorAll('.explain').forEach(function(x){x.remove()});form.querySelectorAll('input').forEach(function(x){x.disabled=false});
delete submit.dataset.retry;submit.textContent=L.submit;result.textContent='';return}
var answers=data.quiz.map(function(q,i){var c=form.querySelector('input[name=q'+i+']:checked');return c?Number(c.value):-1});
if(answers.indexOf(-1)>=0){result.textContent=L.answerAll;return}
var right=0;
data.quiz.forEach(function(q,i){
var set_=form.querySelectorAll('fieldset')[i],labels=set_.querySelectorAll('label');
labels[q.c].className='correct';if(answers[i]!==q.c)labels[answers[i]].className='wrong';else right++;
if(q.e){var p=document.createElement('p');p.className='explain';p.textContent=q.e;set_.appendChild(p)}
set_.querySelectorAll('input').forEach(function(x){x.disabled=true});
});
var score=Math.round(right/data.quiz.length*100),passed=score>=data.mastery;
set('cmi.core.score.min',0);set('cmi.core.score.max',100);set('cmi.core.score.raw',score);
set('cmi.core.lesson_status',passed?'passed':'failed');commit();
result.textContent=L.score.replace('{score}',score)+' '+(passed?L.passed:L.failed);
submit.dataset.retry='1';submit.textContent=L.retry;
};
})();
`
// ── ZIP (stored, no compression: the package is small text) ──
const CRC_TABLE = (() => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
table[n] = c >>> 0
}
return table
})()
export function crc32(bytes: Uint8Array): number {
let c = 0xffffffff
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
function dosDateTime(d: Date) {
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
date: ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
}
}
export function zip(files: { name: string; data: string | Uint8Array }[], now = new Date()): Uint8Array {
const enc = new TextEncoder()
const { time, date } = dosDateTime(now)
const locals: Uint8Array[] = []
const centrals: Uint8Array[] = []
let offset = 0
for (const f of files) {
const name = enc.encode(f.name)
const data = typeof f.data === 'string' ? enc.encode(f.data) : f.data
const crc = crc32(data)
const local = new Uint8Array(30 + name.length + data.length)
const lv = new DataView(local.buffer)
lv.setUint32(0, 0x04034b50, true)
lv.setUint16(4, 20, true) // version needed
lv.setUint16(6, 0x0800, true) // UTF-8 names
lv.setUint16(8, 0, true) // stored
lv.setUint16(10, time, true)
lv.setUint16(12, date, true)
lv.setUint32(14, crc, true)
lv.setUint32(18, data.length, true)
lv.setUint32(22, data.length, true)
lv.setUint16(26, name.length, true)
local.set(name, 30)
local.set(data, 30 + name.length)
const central = new Uint8Array(46 + name.length)
const cv = new DataView(central.buffer)
cv.setUint32(0, 0x02014b50, true)
cv.setUint16(4, 20, true)
cv.setUint16(6, 20, true)
cv.setUint16(8, 0x0800, true)
cv.setUint16(10, 0, true)
cv.setUint16(12, time, true)
cv.setUint16(14, date, true)
cv.setUint32(16, crc, true)
cv.setUint32(20, data.length, true)
cv.setUint32(24, data.length, true)
cv.setUint16(28, name.length, true)
cv.setUint32(42, offset, true)
central.set(name, 46)
locals.push(local)
centrals.push(central)
offset += local.length
}
const centralSize = centrals.reduce((n, c) => n + c.length, 0)
const end = new Uint8Array(22)
const ev = new DataView(end.buffer)
ev.setUint32(0, 0x06054b50, true)
ev.setUint16(8, files.length, true)
ev.setUint16(10, files.length, true)
ev.setUint32(12, centralSize, true)
ev.setUint32(16, offset, true)
const out = new Uint8Array(offset + centralSize + end.length)
let at = 0
for (const part of [...locals, ...centrals, end]) { out.set(part, at); at += part.length }
return out
}
export function scormPackage(lesson: ScormLesson): Blob {
const hasQuiz = lesson.questions.some(q => q.options.length > 1 && q.correct >= 0)
const bytes = zip([
{ name: 'imsmanifest.xml', data: manifest(lesson.title, hasQuiz) },
{ name: 'index.html', data: scoHtml(lesson) },
])
return new Blob([bytes as BlobPart], { type: 'application/zip' })
}

717
src/i18n.ts Normal file
View File

@@ -0,0 +1,717 @@
// UI language: English by default, Indonesian on request. A module-level store so API helpers
// and the instruction parser can translate too, not just components.
import { useSyncExternalStore } from 'react'
export type Lang = 'en' | 'id'
export const LANGS: { code: Lang; label: string }[] = [{ code: 'en', label: 'EN' }, { code: 'id', label: 'ID' }]
const en = {
// Layout
'nav.brain': 'Brain',
'nav.instruction': 'Instruction',
'nav.schedule': 'Schedule',
'nav.results': 'Results',
'nav.workspace': 'WORKSPACE',
'layout.learningSpace': 'Learning space',
'layout.breadcrumb': 'Workspace',
'layout.signOut': 'Sign out',
'layout.engine': 'learning-lab engine',
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation',
'layout.language': 'Language',
// Auth
'auth.loading': 'Loading…',
'auth.eyebrow': 'NEW LEARNING, WITHOUT STARTING FROM SCRATCH',
'auth.headline1': 'Team knowledge.',
'auth.headline2': 'New ideas.',
'auth.headline3': 'Learning that never stops.',
'auth.copy': 'Upload material to the Brain, give one instruction, and AI keeps creating new learning content on schedule.',
'auth.benefitUpload': 'Upload material to the Brain',
'auth.benefitSchedule': 'Schedule research and content',
'auth.benefitDownload': 'Open and download the results',
'auth.footer': 'Your knowledge. New possibilities.',
'auth.pickOrg': 'Choose an organization',
'auth.pickOrgCopy': 'Express Mode runs inside an organization so the Brain, schedules, and credits are shared with your team.',
// Shared
'common.loading': 'Loading…',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.retry': 'Try again',
'common.credits': '{n} credits',
// Brain
'brain.title': 'Brain',
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.',
'brain.metricMaterial': 'Material',
'brain.metricMaterialSub': 'documents in the workspace',
'brain.metricReady': 'In the Brain',
'brain.metricReadySub': 'knowledge base ready',
'brain.metricAction': 'Needs action',
'brain.metricActionSub': 'waiting for your approval',
'brain.listTitle': 'Material',
'brain.loading': 'Loading material…',
'brain.engineDown': 'Cannot reach the engine',
'brain.empty': 'No material yet',
'brain.emptyCopy': 'Upload documents to start filling the Brain.',
'brain.uploadTitle': 'Upload material to the Brain',
'brain.uploadCopy': 'Drag files anywhere on this page, or choose them from your computer. The engine reads them and adds them to the Brain.',
'brain.formats': 'Formats: {list}',
'brain.formatsLoading': 'Loading supported formats…',
'brain.chooseFiles': 'Choose files',
'brain.uploading': 'Uploading…',
'brain.uploaded': 'Uploaded',
'brain.unsupported': 'Unsupported format',
'brain.dropHere': 'Drop files to upload them to the Brain',
'brain.statusReady': 'In the Brain',
'brain.statusBuilding': 'Building Brain',
'brain.statusProcessing': 'Processing',
'brain.statusAnswer': 'Needs action',
'brain.statusFailed': 'Failed',
'brain.queued': 'Waiting in queue…',
'brain.versionWithCandidate': 'Looks like “{name}”. Is this a new version of it?',
'brain.versionGeneric': 'Is this a new version of an existing document?',
'brain.versionYes': 'Yes, new version',
'brain.versionNo': 'No, keep separate',
'brain.needsCredits': 'Not enough credits to add this to the Brain{need}. Top up, then try again.',
'brain.needsCreditsNeed': ' (needs {credits})',
'brain.failed': 'Could not add this to the Brain.',
'brain.retryWith': 'Try again ({credits})',
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
'brain.confirmCreditsFallback': 'credits',
'brain.addToBrain': 'Add to Brain',
'brain.delete': 'Delete {name}',
'brain.deleteConfirm': 'Delete “{name}” from the Brain?',
// Instruction
'ins.title': 'Instruction',
'ins.description': 'Write a plain instruction. The AI turns it into research and content schedules that keep running until you stop them.',
'ins.cardTitle': 'What should be created?',
'ins.label': 'Instruction',
'ins.placeholder': 'Example: make 3 quizzes per week and 2 summaries per day about leadership',
'ins.hint': 'Name the content type, how many, how often (per day / per week), and the topic. Internet research can be scheduled too.',
'ins.compose': 'Build schedule',
'ins.ex1': 'Make 2 summaries per day and 1 quiz per week about leadership',
'ins.ex2': 'Research the internet every day about leadership',
'ins.ex3': '3 roleplays per week about negotiating with customers',
'ins.ex4': 'Research every Monday about sales trends, then make 5 flashcards per week',
'ins.ex5': '1 video per week, 2 podcasts per week and 1 SCORM module per week about onboarding',
'ins.previewTitle': 'Schedules to create',
'ins.readyCount': '{n} ready',
'ins.notUnderstood': 'Instruction not understood',
'ins.notUnderstoodCopy': 'Try naming the content type and how often, e.g. “2 summaries per day”.',
'ins.skipped': 'Not understood and skipped: {parts}',
'ins.approveNote': 'Once approved, schedules start right away. Content is created from the Brain without repeating existing content, using workspace credits within the limits above.',
'ins.approve': 'Approve {n} schedules',
'ins.approving': 'Creating schedules…',
'ins.active': 'Active',
'ins.failed': 'Failed',
'ins.viewSchedule': 'View in Schedule',
'ins.kind': 'Content type',
'ins.count': 'Count',
'ins.frequency': 'Frequency',
'ins.perDay': 'per day',
'ins.perWeek': 'per week',
'ins.researchFrequency': 'Research frequency',
'ins.everyDay': 'Every day',
'ins.everyWeek': 'Every week',
'ins.weekday': 'Day',
'ins.topic': 'Topic',
'ins.topicPlaceholder': 'Topic (empty = whole Brain)',
'ins.autoApprove': 'New sources go straight into the Brain (no review first)',
'ins.notCreated': 'This schedule will not be created yet.',
'ins.tooMany': 'At most 50 items per week for one schedule.',
'ins.budget': 'Credit limit: {first}, {monthly} per month.',
'ins.budgetWeekly': '{n} per week',
'ins.budgetRun': '{n} per research run',
'ins.removeRule': 'Remove from list',
// Rules (parser output)
'rule.everyDay': 'Every day',
'rule.everyWeekday': 'Every {day}',
'rule.research': '{when}: internet research{about}',
'rule.about': ' about {topic}',
'rule.wholeBrain': ' from the whole Brain',
'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
'kind.summary': 'Summary',
'kind.quiz': 'Quiz',
'kind.flashcards': 'Flashcards',
'kind.roleplay': 'Roleplay',
'kind.simulator': 'Simulation',
'kind.slide_deck': 'Slides',
'kind.video': 'Video',
'kind.audio': 'Audio',
'kind.scorm': 'SCORM',
'day.0': 'Monday', 'day.1': 'Tuesday', 'day.2': 'Wednesday', 'day.3': 'Thursday', 'day.4': 'Friday', 'day.5': 'Saturday', 'day.6': 'Sunday',
// Schedule
'sch.title': 'Schedule',
'sch.description': 'Research and content schedules that are running. Change the topic any time; the next content follows the new topic.',
'sch.newInstruction': 'New instruction',
'sch.cardTitle': 'Schedules',
'sch.loading': 'Loading schedules…',
'sch.loadFailed': 'Could not load schedules',
'sch.empty': 'No schedules yet',
'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.',
'sch.nextResearch': 'Next research: {when}',
'sch.paused': 'Paused',
'sch.active': 'Active',
'sch.newTopic': 'New topic',
'sch.changeTopic': 'Change topic',
'sch.pause': 'Pause',
'sch.resume': 'Resume',
'sch.runNow': 'Run now',
'sch.history': 'History',
'sch.hideHistory': 'Hide history',
'sch.delete': 'Delete',
'sch.deleteConfirm': 'Delete the schedule “{name}”? Content already created stays.',
'sch.loadingProgress': 'Loading progress…',
'sch.planningOff': 'Automatic production is off in the engine: an admin needs to set a price for “agent.content.plan”.',
'sch.produceNow': 'Produce now',
'prod.runningTitle': 'Producing {n} new pieces',
'prod.lastTitle': 'Last production',
'prod.count': '{ready} of {total} ready',
'prod.stepTopics': 'AI chooses new topics',
'prod.stepStart': 'Starting',
'prod.stepCreate': 'Creating content ({ready}/{total})',
'prod.stepDone': 'Done',
'prod.failed': 'Production failed ({code}).',
'prod.someFailed': '{n} failed.',
'prod.shortfall': 'The Brain had new material for fewer pieces than asked ({n} short); add documents for more topics.',
'prod.viewResults': 'View in Results',
'sch.produceHowMany': 'How many pieces now? (up to {max} left this week)',
'sch.checkPrice': 'Check price',
'sch.checkingPrice': 'Checking…',
'sch.produceQuote': 'Planning {n} pieces costs {credits} credits. Each piece is then charged as it is created, within this schedule’s weekly limit of {budget} credits. Production starts within about a minute.',
'sch.startProduction': 'Start production',
'sch.starting': 'Starting…',
'sch.alreadyProducing': 'This schedule is already producing. Wait for it to finish first.',
'sch.weekFull': 'This week’s target is already used up. The next pieces come with next week’s schedule.',
'sch.thisWeek': 'This week: {ready} of {target} ready{failed}',
'sch.thisWeekFailed': ', {n} failed',
'sch.waitingFirst': 'Waiting for the first production slot',
'sch.loadingHistory': 'Loading history…',
'sch.neverRan': 'Has not run yet.',
'sch.scheduled': 'Scheduled',
'sch.manual': 'Manual',
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
// Results
'res.title': 'Results',
'res.description': 'Everything Express creates: text, quizzes, roleplays, video, audio, and SCORM.',
'res.empty': 'No results yet',
'res.emptyCopy': 'Content and sources from your schedules appear here. Give an instruction to start a schedule.',
'res.tabContent': 'Content',
'res.tabResearch': 'Research',
'res.pendingCount': '{n} to review',
'res.allContent': 'All content',
'res.foundSources': 'Sources found by research',
'res.search': 'Search titles…',
'res.filterKind': 'Filter by type',
'res.filterStatus': 'Filter by status',
'res.allKinds': 'All types',
'res.status.all': 'All statuses',
'res.status.review': 'Needs review',
'res.status.approved': 'Approved',
'res.status.working': 'In progress',
'res.status.failed': 'Failed or rejected',
'res.state.preparing': 'Preparing',
'res.state.generating': 'Generating',
'res.state.ready': 'Needs review',
'res.state.accepted': 'Approved',
'res.state.failed': 'Failed',
'res.state.rejected': 'Rejected',
'res.source.pending': 'Needs review',
'res.source.accepted': 'Added to Library',
'res.source.rejected': 'Rejected',
'res.loadFailed': 'Results could not be loaded',
'res.noMatch': 'Nothing matches',
'res.noMatchCopy': 'Try another keyword, type, or status.',
'res.noteContent': 'Download as PDF, Markdown or SCORM; slide decks also as PowerPoint, videos as MP4, audio scripts as text. Roleplay and simulation run interactively in Learning Studio.',
'res.noteResearch': 'Research finds web sources. Add the useful ones to the Library, then build their knowledge on the Brain page.',
'res.sourceCount': '{n} sources',
'res.sourceOne': '1 source',
'res.working': 'Being created…',
'res.open': 'Open',
'res.close': 'Close',
'res.reviewNotice': 'AI-generated draft. Check it, then approve it or reject it.',
'res.noContent': 'No content to show',
'res.noContentCopy': 'This item has not finished or its output is no longer available.',
'res.info': 'Content details',
'res.statusLabel': 'Status',
'res.schedule': 'Schedule',
'res.created': 'Created',
'res.sources': 'Sources',
'res.footNote': 'Created by Express from your Brain.',
'res.openStudio': 'Open in Learning Studio',
'res.download': 'Download',
'res.preparingFile': 'Preparing file…',
'res.pdfHint': 'Opens the print dialog: choose “Save as PDF”',
'res.pptxHint': 'Slides rendered by Learning Studio',
'res.mdHint': 'Plain text with headings, for editing',
'res.accept': 'Approve',
'res.reject': 'Reject',
'res.addToBrain': 'Add to Library',
'res.inBrain': 'see Brain',
'res.answer': 'Answer',
'res.slide': 'Slide {n}',
'res.field.description': 'Description',
'res.field.topic': 'Topic',
'res.field.objective': 'Objective',
'res.field.learning_objectives': 'Learning objectives',
'res.field.user_role': 'Learner role',
'res.field.ai_character': 'AI character',
'res.field.difficulty': 'Difficulty',
'res.field.context': 'Context',
// Media and SCORM (Phases 4, 5, 7)
'ins.noteVideo': 'One video per run, about {seconds} seconds in {scenes} scenes, made by Video Studio. Each video waits for your review in Results.',
'ins.noteAudio': 'Written as a narration script. Results plays it with the device voice and downloads it as text (no MP3 yet: the engine has no audio service for agents).',
'ins.noteScorm': 'Each module is a lesson with a 10-question quiz, downloaded from Results as a SCORM 1.2 package for your LMS.',
'ins.videoCadence': 'A video schedule makes one video per run: choose 1 per day or 1 per week.',
'ins.videoQuoteFailed': 'The video price could not be checked: {message}',
'ins.budgetVideo': '{n} per video',
'sch.nextVideo': 'Next video: {when}',
'sch.videoNow': 'Make a video now',
'sch.videoStarted': 'Video started. It shows in Results while it is being made.',
'sch.runStarted': 'Research started.',
'sch.deleted': 'Schedule deleted.',
'res.videoProgress': 'Rendering… {n}%',
'res.videoNotReady': 'The video is not ready yet',
'res.videoFailed': 'The video could not be made',
'res.videoFailedCopy': 'Rendering stopped before the video was finished.',
'res.downloadVideo': 'Download video (MP4)',
'res.creditsUsed': 'Credits used',
'res.accepted': 'Approved.',
'res.rejected': 'Rejected.',
'res.audioNotice': 'Played with this device’s voice, which can differ between devices. Download the script to record it or use it in a TTS tool.',
'res.fullScript': 'Show the full script',
'res.scriptTxt': 'Script (.txt)',
'res.scriptHint': 'Narration text, for recording or a TTS tool',
'res.quiz': 'Quiz',
'res.quizPending': 'The quiz is not ready yet.',
'res.scormModule': 'SCORM 1.2 module',
'res.scormMeta': 'Lesson + {n} questions · pass mark {mastery}%',
'res.scormHint': 'For an LMS: the lesson, marked complete when finished',
'res.scormQuizHint': 'For an LMS: lesson and quiz, reports score and pass/fail',
'res.scormReady': 'SCORM package downloaded. Upload the .zip to your LMS.',
'scorm.submit': 'Check answers',
'scorm.retry': 'Try again',
'scorm.complete': 'Mark as complete',
'scorm.completed': 'Completed ✓',
'scorm.score': 'Score: {score}%.',
'scorm.passed': 'Passed (pass mark {n}%).',
'scorm.failed': 'Not passed yet (pass mark {n}%).',
'scorm.answerAll': 'Answer every question first.',
'scorm.noLms': 'Opened outside an LMS: progress is not saved.',
'audio.copy': 'The narration script, read aloud by this device.',
'audio.unsupported': 'This browser cannot read text aloud. Download the script instead.',
'audio.play': 'Listen',
'audio.pause': 'Pause',
'audio.resume': 'Resume',
'audio.stop': 'Stop',
'audio.speed': 'Speed',
'audio.ready': '{n} sentences · press Listen to start',
'audio.sentence': 'Sentence {n} of {total}',
'brain.deleted': '“{name}” deleted.',
// Guide
'help.boxTitle': 'How Express works',
'help.boxBody': 'Four steps from your material to finished content.',
'help.open': 'View the guide',
'help.title': 'How Learning Lab Express works',
'help.subtitle': 'Brain → Instruction → Schedule → Results',
'help.brainTitle': 'Brain',
'help.brainBody': 'Upload your training material. Once its knowledge is built, the AI creates everything from it.',
'help.instructionTitle': 'Instruction',
'help.instructionBody': 'Say what you need in one sentence, e.g. “3 quizzes per week and 1 video per week about leadership”. Check the schedules, then approve them.',
'help.scheduleTitle': 'Schedule',
'help.scheduleBody': 'Schedules run on their own. Pause them, change the topic, or produce now; each stays within its credit limit.',
'help.resultsTitle': 'Results',
'help.resultsBody': 'Review what was made, approve or reject it, and download it: PDF, Markdown, PowerPoint, MP4, audio script, or SCORM for your LMS.',
'help.note': 'Content is AI-generated: always review it before using it. Credits come from your organization’s workspace.',
'help.done': 'Got it',
// Errors from the engine client
'err.unreachable': 'Cannot reach the learning-lab engine.',
'err.generic': 'The request to the engine failed.',
'err.credits': 'Not enough credits: needs {required}, balance {balance}.',
'err.creditsShort': 'Not enough credits.',
'err.docFailed': 'The document could not be processed.',
'err.timeout': 'Document processing took too long.',
'err.researchFailed': 'Research could not run ({code}).',
} as const
export type Key = keyof typeof en
const id: Record<Key, string> = {
'nav.brain': 'Otak',
'nav.instruction': 'Perintah',
'nav.schedule': 'Jadwal',
'nav.results': 'Hasil',
'nav.workspace': 'RUANG KERJA',
'layout.learningSpace': 'Ruang belajar',
'layout.breadcrumb': 'Ruang kerja',
'layout.signOut': 'Keluar',
'layout.engine': 'Engine learning-lab',
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama',
'layout.language': 'Bahasa',
'auth.loading': 'Memuat…',
'auth.eyebrow': 'BELAJAR BARU, TANPA MULAI DARI NOL',
'auth.headline1': 'Pengetahuan tim.',
'auth.headline2': 'Ide baru.',
'auth.headline3': 'Pembelajaran tanpa henti.',
'auth.copy': 'Unggah materi ke Otak, beri satu perintah, dan AI terus membuat konten pembelajaran baru sesuai jadwal.',
'auth.benefitUpload': 'Unggah materi ke Otak',
'auth.benefitSchedule': 'Jadwalkan riset dan konten',
'auth.benefitDownload': 'Buka dan unduh hasilnya',
'auth.footer': 'Pengetahuan Anda. Kemungkinan baru.',
'auth.pickOrg': 'Pilih organisasi',
'auth.pickOrgCopy': 'Express Mode berjalan di dalam organisasi agar Otak, jadwal, dan kredit dipakai bersama tim.',
'common.loading': 'Memuat…',
'common.save': 'Simpan',
'common.cancel': 'Batal',
'common.retry': 'Coba lagi',
'common.credits': '{n} kredit',
'brain.title': 'Otak',
'brain.description': 'Semua materi tim diproses menjadi satu Otak yang terus bertambah. Konten baru yang dihasilkan juga masuk ke sini.',
'brain.metricMaterial': 'Materi',
'brain.metricMaterialSub': 'dokumen di ruang kerja',
'brain.metricReady': 'Masuk Otak',
'brain.metricReadySub': 'knowledge base siap',
'brain.metricAction': 'Perlu tindakan',
'brain.metricActionSub': 'menunggu persetujuan Anda',
'brain.listTitle': 'Materi',
'brain.loading': 'Memuat materi…',
'brain.engineDown': 'Engine tidak dapat dihubungi',
'brain.empty': 'Belum ada materi',
'brain.emptyCopy': 'Unggah dokumen untuk mulai mengisi Otak.',
'brain.uploadTitle': 'Unggah materi ke Otak',
'brain.uploadCopy': 'Tarik file ke mana saja di halaman ini, atau pilih dari komputer. Engine membaca isinya lalu menambahkannya ke Otak.',
'brain.formats': 'Format: {list}',
'brain.formatsLoading': 'Memuat format yang didukung…',
'brain.chooseFiles': 'Pilih file',
'brain.uploading': 'Mengunggah…',
'brain.uploaded': 'Terunggah',
'brain.unsupported': 'Format tidak didukung',
'brain.dropHere': 'Lepaskan file untuk diunggah ke Otak',
'brain.statusReady': 'Masuk Otak',
'brain.statusBuilding': 'Membangun Otak',
'brain.statusProcessing': 'Diproses',
'brain.statusAnswer': 'Perlu tindakan',
'brain.statusFailed': 'Gagal',
'brain.queued': 'Menunggu antrean…',
'brain.versionWithCandidate': 'Mirip dengan “{name}”. Apakah ini versi barunya?',
'brain.versionGeneric': 'Apakah ini versi baru dari dokumen yang sudah ada?',
'brain.versionYes': 'Ya, versi baru',
'brain.versionNo': 'Bukan, simpan terpisah',
'brain.needsCredits': 'Kredit tidak cukup untuk memasukkan ke Otak{need}. Isi ulang kredit, lalu coba lagi.',
'brain.needsCreditsNeed': ' (butuh {credits})',
'brain.failed': 'Gagal memasukkan ke Otak.',
'brain.retryWith': 'Coba lagi ({credits})',
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
'brain.confirmCreditsFallback': 'kredit',
'brain.addToBrain': 'Masukkan ke Otak',
'brain.delete': 'Hapus {name}',
'brain.deleteConfirm': 'Hapus “{name}” dari Otak?',
'ins.title': 'Perintah',
'ins.description': 'Tulis instruksi biasa. AI menyusunnya menjadi jadwal riset dan pembuatan konten, lalu terus berjalan sampai Anda menghentikannya.',
'ins.cardTitle': 'Apa yang perlu dibuat?',
'ins.label': 'Instruksi',
'ins.placeholder': 'Contoh: bikin 3 kuis per minggu dan 2 ringkasan per hari soal leadership',
'ins.hint': 'Sebut jenis konten, jumlah, frekuensi (per hari / per minggu), dan topiknya. Riset internet juga bisa dijadwalkan.',
'ins.compose': 'Susun jadwal',
'ins.ex1': 'Bikin 2 ringkasan per hari dan 1 kuis per minggu soal leadership',
'ins.ex2': 'Riset internet tiap hari soal leadership',
'ins.ex3': '3 roleplay per minggu tentang negosiasi dengan pelanggan',
'ins.ex4': 'Riset tiap Senin soal tren sales, lalu buat 5 flashcard per minggu',
'ins.ex5': '1 video per minggu, 2 podcast per minggu dan 1 modul SCORM per minggu soal onboarding',
'ins.previewTitle': 'Jadwal yang akan dibuat',
'ins.readyCount': '{n} siap',
'ins.notUnderstood': 'Instruksi belum dipahami',
'ins.notUnderstoodCopy': 'Coba sebut jenis konten dan frekuensinya, misalnya “2 ringkasan per hari”.',
'ins.skipped': 'Bagian ini belum dipahami dan dilewati: {parts}',
'ins.approveNote': 'Setelah disetujui, jadwal langsung aktif. Konten dibuat dari materi di Otak tanpa mengulang yang sudah ada, dan memakai kredit ruang kerja sesuai batas di atas.',
'ins.approve': 'Setujui {n} jadwal',
'ins.approving': 'Membuat jadwal…',
'ins.active': 'Aktif',
'ins.failed': 'Gagal',
'ins.viewSchedule': 'Lihat di Jadwal',
'ins.kind': 'Jenis konten',
'ins.count': 'Jumlah',
'ins.frequency': 'Frekuensi',
'ins.perDay': 'per hari',
'ins.perWeek': 'per minggu',
'ins.researchFrequency': 'Frekuensi riset',
'ins.everyDay': 'Setiap hari',
'ins.everyWeek': 'Setiap minggu',
'ins.weekday': 'Hari',
'ins.topic': 'Topik',
'ins.topicPlaceholder': 'Topik (kosong = seluruh Otak)',
'ins.autoApprove': 'Sumber baru langsung masuk ke Otak (tanpa ditinjau dulu)',
'ins.notCreated': 'Jadwal ini belum dibuat.',
'ins.tooMany': 'Maksimal 50 konten per minggu untuk satu jadwal.',
'ins.budget': 'Batas kredit: {first}, {monthly} per bulan.',
'ins.budgetWeekly': '{n} per minggu',
'ins.budgetRun': '{n} per riset',
'ins.removeRule': 'Hapus dari daftar',
'rule.everyDay': 'Setiap hari',
'rule.everyWeekday': 'Setiap {day}',
'rule.research': '{when}: riset internet{about}',
'rule.about': ' tentang {topic}',
'rule.wholeBrain': ' dari seluruh Otak',
'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
'kind.summary': 'Ringkasan',
'kind.quiz': 'Kuis',
'kind.flashcards': 'Flashcard',
'kind.roleplay': 'Roleplay',
'kind.simulator': 'Simulasi',
'kind.slide_deck': 'Slide',
'kind.video': 'Video',
'kind.audio': 'Audio',
'kind.scorm': 'SCORM',
'day.0': 'Senin', 'day.1': 'Selasa', 'day.2': 'Rabu', 'day.3': 'Kamis', 'day.4': 'Jumat', 'day.5': 'Sabtu', 'day.6': 'Minggu',
'sch.title': 'Jadwal',
'sch.description': 'Jadwal riset dan pembuatan konten yang sedang berjalan. Ubah topik kapan saja; konten berikutnya mengikuti topik baru.',
'sch.newInstruction': 'Perintah baru',
'sch.cardTitle': 'Jadwal',
'sch.loading': 'Memuat jadwal…',
'sch.loadFailed': 'Jadwal tidak dapat dimuat',
'sch.empty': 'Belum ada jadwal',
'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.',
'sch.nextResearch': 'Riset berikutnya: {when}',
'sch.paused': 'Dijeda',
'sch.active': 'Aktif',
'sch.newTopic': 'Topik baru',
'sch.changeTopic': 'Ganti topik',
'sch.pause': 'Jeda',
'sch.resume': 'Lanjutkan',
'sch.runNow': 'Jalankan sekarang',
'sch.history': 'Riwayat',
'sch.hideHistory': 'Sembunyikan riwayat',
'sch.delete': 'Hapus',
'sch.deleteConfirm': 'Hapus jadwal “{name}”? Konten yang sudah dibuat tetap ada.',
'sch.loadingProgress': 'Memuat progres…',
'sch.planningOff': 'Produksi otomatis belum aktif di engine: admin perlu mengatur harga “agent.content.plan”.',
'sch.produceNow': 'Produksi sekarang',
'prod.runningTitle': 'Memproduksi {n} konten baru',
'prod.lastTitle': 'Produksi terakhir',
'prod.count': '{ready} dari {total} siap',
'prod.stepTopics': 'AI memilih topik baru',
'prod.stepStart': 'Memulai',
'prod.stepCreate': 'Membuat konten ({ready}/{total})',
'prod.stepDone': 'Selesai',
'prod.failed': 'Produksi gagal ({code}).',
'prod.someFailed': '{n} gagal.',
'prod.shortfall': 'Brain hanya punya materi baru untuk lebih sedikit konten dari yang diminta (kurang {n}); tambah dokumen untuk topik lain.',
'prod.viewResults': 'Lihat di Results',
'sch.produceHowMany': 'Berapa konten sekarang? (sisa {max} untuk minggu ini)',
'sch.checkPrice': 'Cek harga',
'sch.checkingPrice': 'Mengecek…',
'sch.produceQuote': 'Merencanakan {n} konten memakai {credits} kredit. Setiap konten lalu dipotong kreditnya saat dibuat, dalam batas mingguan jadwal ini sebesar {budget} kredit. Produksi mulai dalam sekitar satu menit.',
'sch.startProduction': 'Mulai produksi',
'sch.starting': 'Memulai…',
'sch.alreadyProducing': 'Jadwal ini sedang berproduksi. Tunggu sampai selesai dulu.',
'sch.weekFull': 'Target minggu ini sudah terpakai. Konten berikutnya ikut jadwal minggu depan.',
'sch.thisWeek': 'Minggu ini: {ready} dari {target} siap{failed}',
'sch.thisWeekFailed': ', {n} gagal',
'sch.waitingFirst': 'Menunggu jadwal produksi pertama',
'sch.loadingHistory': 'Memuat riwayat…',
'sch.neverRan': 'Belum pernah berjalan.',
'sch.scheduled': 'Terjadwal',
'sch.manual': 'Manual',
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
'res.title': 'Hasil',
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
'res.empty': 'Belum ada hasil',
'res.emptyCopy': 'Konten dan sumber dari jadwal Anda tampil di sini. Beri perintah untuk memulai jadwal.',
'res.tabContent': 'Konten',
'res.tabResearch': 'Riset',
'res.pendingCount': '{n} perlu ditinjau',
'res.allContent': 'Semua konten',
'res.foundSources': 'Sumber hasil riset',
'res.search': 'Cari judul…',
'res.filterKind': 'Filter jenis',
'res.filterStatus': 'Filter status',
'res.allKinds': 'Semua jenis',
'res.status.all': 'Semua status',
'res.status.review': 'Perlu ditinjau',
'res.status.approved': 'Disetujui',
'res.status.working': 'Sedang dibuat',
'res.status.failed': 'Gagal atau ditolak',
'res.state.preparing': 'Menyiapkan',
'res.state.generating': 'Membuat',
'res.state.ready': 'Perlu ditinjau',
'res.state.accepted': 'Disetujui',
'res.state.failed': 'Gagal',
'res.state.rejected': 'Ditolak',
'res.source.pending': 'Perlu ditinjau',
'res.source.accepted': 'Masuk Library',
'res.source.rejected': 'Ditolak',
'res.loadFailed': 'Hasil tidak dapat dimuat',
'res.noMatch': 'Tidak ada yang cocok',
'res.noMatchCopy': 'Coba kata kunci, jenis, atau status lain.',
'res.noteContent': 'Unduh sebagai PDF, Markdown, atau SCORM; slide deck juga sebagai PowerPoint, video sebagai MP4, naskah audio sebagai teks. Roleplay dan simulasi dijalankan secara interaktif di Learning Studio.',
'res.noteResearch': 'Riset menemukan sumber dari web. Masukkan yang berguna ke Library, lalu bangun pengetahuannya di halaman Brain.',
'res.sourceCount': '{n} sumber',
'res.sourceOne': '1 sumber',
'res.working': 'Sedang dibuat…',
'res.open': 'Buka',
'res.close': 'Tutup',
'res.reviewNotice': 'Draft buatan AI. Periksa dulu, lalu setujui atau tolak.',
'res.noContent': 'Tidak ada konten untuk ditampilkan',
'res.noContentCopy': 'Item ini belum selesai atau hasilnya sudah tidak tersedia.',
'res.info': 'Informasi konten',
'res.statusLabel': 'Status',
'res.schedule': 'Jadwal',
'res.created': 'Dibuat',
'res.sources': 'Sumber',
'res.footNote': 'Dibuat oleh Express dari Brain Anda.',
'res.openStudio': 'Buka di Learning Studio',
'res.download': 'Unduh',
'res.preparingFile': 'Menyiapkan file…',
'res.pdfHint': 'Membuka dialog cetak: pilih “Save as PDF” / “Simpan sebagai PDF”',
'res.pptxHint': 'Slide dari Learning Studio',
'res.mdHint': 'Teks dengan judul, untuk disunting',
'res.accept': 'Setujui',
'res.reject': 'Tolak',
'res.addToBrain': 'Masukkan ke Library',
'res.inBrain': 'lihat Brain',
'res.answer': 'Jawaban',
'res.slide': 'Slide {n}',
'res.field.description': 'Deskripsi',
'res.field.topic': 'Topik',
'res.field.objective': 'Tujuan',
'res.field.learning_objectives': 'Tujuan pembelajaran',
'res.field.user_role': 'Peran peserta',
'res.field.ai_character': 'Karakter AI',
'res.field.difficulty': 'Tingkat kesulitan',
'res.field.context': 'Konteks',
// Media dan SCORM (Fase 4, 5, 7)
'ins.noteVideo': 'Satu video per proses, sekitar {seconds} detik dalam {scenes} adegan, dibuat oleh Video Studio. Setiap video menunggu tinjauan Anda di Hasil.',
'ins.noteAudio': 'Dibuat sebagai naskah narasi. Hasil memutarnya dengan suara perangkat dan mengunduhnya sebagai teks (belum MP3: engine belum punya layanan audio untuk agen).',
'ins.noteScorm': 'Setiap modul berisi materi dan kuis 10 soal, diunduh dari Hasil sebagai paket SCORM 1.2 untuk LMS Anda.',
'ins.videoCadence': 'Jadwal video membuat satu video per proses: pilih 1 per hari atau 1 per minggu.',
'ins.videoQuoteFailed': 'Harga video tidak bisa dicek: {message}',
'ins.budgetVideo': '{n} per video',
'sch.nextVideo': 'Video berikutnya: {when}',
'sch.videoNow': 'Buat video sekarang',
'sch.videoStarted': 'Video mulai dibuat. Video tampil di Hasil selama proses berjalan.',
'sch.runStarted': 'Riset dimulai.',
'sch.deleted': 'Jadwal dihapus.',
'res.videoProgress': 'Merender… {n}%',
'res.videoNotReady': 'Video belum siap',
'res.videoFailed': 'Video gagal dibuat',
'res.videoFailedCopy': 'Proses render berhenti sebelum video selesai.',
'res.downloadVideo': 'Unduh video (MP4)',
'res.creditsUsed': 'Kredit terpakai',
'res.accepted': 'Disetujui.',
'res.rejected': 'Ditolak.',
'res.audioNotice': 'Diputar dengan suara perangkat ini, yang bisa berbeda antarperangkat. Unduh naskahnya untuk direkam atau dipakai di alat TTS.',
'res.fullScript': 'Lihat naskah lengkap',
'res.scriptTxt': 'Naskah (.txt)',
'res.scriptHint': 'Teks narasi, untuk direkam atau alat TTS',
'res.quiz': 'Kuis',
'res.quizPending': 'Kuisnya belum siap.',
'res.scormModule': 'Modul SCORM 1.2',
'res.scormMeta': 'Materi + {n} soal · nilai lulus {mastery}%',
'res.scormHint': 'Untuk LMS: materi, ditandai selesai setelah dibaca',
'res.scormQuizHint': 'Untuk LMS: materi dan kuis, melaporkan nilai dan lulus/tidak',
'res.scormReady': 'Paket SCORM terunduh. Unggah file .zip ke LMS Anda.',
'scorm.submit': 'Periksa jawaban',
'scorm.retry': 'Coba lagi',
'scorm.complete': 'Tandai selesai',
'scorm.completed': 'Selesai ✓',
'scorm.score': 'Nilai: {score}%.',
'scorm.passed': 'Lulus (nilai lulus {n}%).',
'scorm.failed': 'Belum lulus (nilai lulus {n}%).',
'scorm.answerAll': 'Jawab semua soal terlebih dahulu.',
'scorm.noLms': 'Dibuka di luar LMS: progres tidak disimpan.',
'audio.copy': 'Naskah narasi, dibacakan oleh perangkat ini.',
'audio.unsupported': 'Browser ini tidak bisa membacakan teks. Unduh naskahnya saja.',
'audio.play': 'Dengarkan',
'audio.pause': 'Jeda',
'audio.resume': 'Lanjutkan',
'audio.stop': 'Hentikan',
'audio.speed': 'Kecepatan',
'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai',
'audio.sentence': 'Kalimat {n} dari {total}',
'brain.deleted': '“{name}” dihapus.',
// Panduan
'help.boxTitle': 'Cara kerja Express',
'help.boxBody': 'Empat langkah dari materi sampai konten jadi.',
'help.open': 'Lihat panduan',
'help.title': 'Cara kerja Learning Lab Express',
'help.subtitle': 'Otak → Perintah → Jadwal → Hasil',
'help.brainTitle': 'Otak',
'help.brainBody': 'Unggah materi pelatihan. Setelah pengetahuannya dibangun, AI membuat semua konten dari situ.',
'help.instructionTitle': 'Perintah',
'help.instructionBody': 'Tulis kebutuhan Anda dalam satu kalimat, misalnya “3 kuis per minggu dan 1 video per minggu soal leadership”. Periksa jadwalnya, lalu setujui.',
'help.scheduleTitle': 'Jadwal',
'help.scheduleBody': 'Jadwal berjalan sendiri. Jeda, ganti topik, atau produksi sekarang; masing-masing tetap dalam batas kreditnya.',
'help.resultsTitle': 'Hasil',
'help.resultsBody': 'Tinjau hasilnya, setujui atau tolak, lalu unduh: PDF, Markdown, PowerPoint, MP4, naskah audio, atau SCORM untuk LMS.',
'help.note': 'Konten dibuat AI: selalu tinjau sebelum dipakai. Kredit diambil dari ruang kerja organisasi Anda.',
'help.done': 'Mengerti',
'err.unreachable': 'Engine learning-lab tidak dapat dihubungi.',
'err.generic': 'Permintaan ke engine gagal.',
'err.credits': 'Kredit tidak cukup: butuh {required}, saldo {balance}.',
'err.creditsShort': 'Kredit tidak cukup.',
'err.docFailed': 'Dokumen gagal diproses.',
'err.timeout': 'Pemrosesan dokumen melebihi batas waktu.',
'err.researchFailed': 'Riset gagal dijalankan ({code}).',
}
const dictionaries: Record<Lang, Record<Key, string>> = { en, id }
const STORAGE = 'lle-lang'
function initial(): Lang {
try {
const saved = localStorage.getItem(STORAGE)
if (saved === 'en' || saved === 'id') return saved
} catch { /* storage blocked: fall back to the default */ }
return 'en'
}
let lang: Lang = initial()
const listeners = new Set<() => void>()
if (typeof document !== 'undefined') document.documentElement.lang = lang
export const getLang = () => lang
export function setLang(next: Lang) {
if (next === lang) return
lang = next
if (typeof document !== 'undefined') document.documentElement.lang = next
try { localStorage.setItem(STORAGE, next) } catch { /* per-device preference only */ }
listeners.forEach(l => l())
}
export function t(key: Key, vars?: Record<string, string | number>): string {
const text = dictionaries[lang][key] ?? en[key]
return vars ? text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : text
}
/** Re-renders the component when the language changes; returns the active language. */
export function useLang(): Lang {
return useSyncExternalStore(cb => { listeners.add(cb); return () => listeners.delete(cb) }, getLang)
}
/** BCP 47 locale for dates and numbers. */
export const locale = () => (lang === 'id' ? 'id-ID' : 'en-US')

49
src/main.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/react'
import { enUS, idID } from '@clerk/localizations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { EngineError } from './api/client'
import App from './App'
import { useLang } from './i18n'
import './styles/mockup.css'
import './styles/app.css'
import './styles/theme.css'
const clerkPubKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
if (!clerkPubKey) {
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)')
}
const queryClient = new QueryClient({
defaultOptions: {
// Retrying a 4xx (auth, credits, validation) only repeats the same answer.
queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) },
},
})
// Clerk's sign-in and organization screens match the theme in styles/theme.css.
const clerkAppearance = {
variables: { colorPrimary: '#1570ef', colorText: '#101828', fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", borderRadius: '12px' },
}
// Clerk's own sign-in and organization screens follow the app language.
function Root() {
const lang = useLang()
return (
<ClerkProvider publishableKey={clerkPubKey} afterSignOutUrl="/" localization={lang === 'id' ? idID : enUS} appearance={clerkAppearance}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</ClerkProvider>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Root />
</StrictMode>,
)

250
src/pages/Brain.tsx Normal file
View File

@@ -0,0 +1,250 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
answerVersion, buildKnowledge, deleteDocument, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
type Source,
} from '../api/engine'
import { Icon } from '../components/Icon'
import { Empty, PageHead, Skeleton, SkeletonLines, SkeletonRows } from '../components/Page'
import { toast } from '../components/Toast'
import { locale, t } from '../i18n'
type Phase = 'ready' | 'working' | 'answer' | 'failed'
function phaseOf(s: Source): Phase {
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
if (s.knowledge === 'ready') return 'ready'
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
}
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
export function BrainPage() {
const docs = useQuery({
queryKey: ['documents'],
queryFn: listDocuments,
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
})
const ready = docs.data?.filter(d => phaseOf(d) === 'ready').length ?? 0
const waiting = docs.data?.filter(d => phaseOf(d) === 'answer').length ?? 0
return (
<>
<PageHead title={t('brain.title')} description={t('brain.description')} />
<div className="metrics">
<div className="metric"><span>{t('brain.metricMaterial')}</span>{docs.data ? <strong>{docs.data.length}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricMaterialSub')}</small></div>
<div className="metric"><span>{t('brain.metricReady')}</span>{docs.data ? <strong>{ready}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricReadySub')}</small></div>
<div className="metric"><span>{t('brain.metricAction')}</span>{docs.data ? <strong>{waiting}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricActionSub')}</small></div>
</div>
<UploadBox />
<div className="card">
<div className="card-head"><h3>{t('brain.listTitle')}</h3><Icon name="brain" /></div>
{docs.isPending && <SkeletonRows label={t('brain.loading')} />}
{docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>}
{docs.data?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>}
{!!docs.data?.length && <div className="result-list">{docs.data.map(d => <DocumentRow key={d.docId} doc={d} />)}</div>}
</div>
</>
)
}
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
let nextUploadId = 1
const hasFiles = (e: globalThis.DragEvent) => !!e.dataTransfer?.types.includes('Files')
function UploadBox() {
const queryClient = useQueryClient()
const input = useRef<HTMLInputElement>(null)
const [drag, setDrag] = useState(false)
const [items, setItems] = useState<UploadItem[]>([])
const exts = useQuery({ queryKey: ['supported-extensions'], queryFn: supportedExtensions, staleTime: Infinity })
const accept = exts.data ? [...exts.data].join(',') : undefined
const update = (id: number, patch: Partial<UploadItem>) => setItems(prev => prev.map(it => (it.id === id ? { ...it, ...patch } : it)))
async function upload(files: File[]) {
if (!files.length) return
const allowed = exts.data
const batch: UploadItem[] = files.map(f => (allowed && !allowed.has(extOf(f.name))
? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.unsupported') }
: { id: nextUploadId++, name: f.name, state: 'uploading' }))
// Appended, so files dropped while an earlier batch is still uploading keep their own rows.
setItems(prev => [...prev.filter(it => it.state === 'uploading'), ...batch])
// One at a time: each upload enqueues a processing job, and the list refreshes after each.
for (const [i, file] of files.entries()) {
if (batch[i].state === 'error') continue
try {
await uploadDocument(file)
update(batch[i].id, { state: 'done' })
} catch (e) {
update(batch[i].id, { state: 'error', message: (e as Error).message })
}
await queryClient.invalidateQueries({ queryKey: ['documents'] })
}
}
const uploadRef = useRef(upload)
uploadRef.current = upload
// The whole page is the drop target, and a file dropped anywhere is uploaded instead of the
// browser opening it and leaving the app. The counter absorbs dragenter/leave from child elements.
useEffect(() => {
let depth = 0
const enter = (e: globalThis.DragEvent) => { if (hasFiles(e)) { depth++; setDrag(true) } }
const leave = (e: globalThis.DragEvent) => { if (hasFiles(e) && --depth <= 0) { depth = 0; setDrag(false) } }
const over = (e: globalThis.DragEvent) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer!.dropEffect = 'copy' } }
const drop = (e: globalThis.DragEvent) => {
if (!hasFiles(e)) return
e.preventDefault()
depth = 0
setDrag(false)
uploadRef.current([...e.dataTransfer!.files])
}
window.addEventListener('dragenter', enter)
window.addEventListener('dragleave', leave)
window.addEventListener('dragover', over)
window.addEventListener('drop', drop)
return () => {
window.removeEventListener('dragenter', enter)
window.removeEventListener('dragleave', leave)
window.removeEventListener('dragover', over)
window.removeEventListener('drop', drop)
}
}, [])
const busy = items.some(i => i.state === 'uploading')
return (
<div className={`upload-box upload-compact${drag ? ' drag' : ''}`}>
{drag && (
<div className="drop-overlay" aria-hidden="true">
<div><Icon name="upload" /><b>{t('brain.dropHere')}</b></div>
</div>
)}
<span className="upload-symbol"><Icon name="upload" /></span>
<div>
<h3>{t('brain.uploadTitle')}</h3>
<p>{t('brain.uploadCopy')}</p>
<small>{exts.data ? t('brain.formats', { list: [...exts.data].map(x => x.slice(1).toUpperCase()).join(', ') }) : t('brain.formatsLoading')}</small>
{items.length > 0 && (
<ul className="upload-progress">
{items.map(it => (
<li key={it.id}>
<b>{it.name}</b>{' '}
{it.state === 'uploading' && <span className="status draft">{t('brain.uploading')}</span>}
{it.state === 'done' && <span className="status ready">{t('brain.uploaded')}</span>}
{it.state === 'error' && <span className="error">{it.message}</span>}
</li>
))}
</ul>
)}
</div>
<button className="btn primary" disabled={busy} onClick={() => input.current?.click()}>
<Icon name="plus" />{busy ? t('brain.uploading') : t('brain.chooseFiles')}
</button>
<input ref={input} type="file" multiple hidden accept={accept}
onChange={e => { upload([...(e.target.files ?? [])]); e.target.value = '' }} />
</div>
)
}
function DocumentRow({ doc }: { doc: Source }) {
const phase = phaseOf(doc)
return (
<div className="result-row">
<span className={`file-icon ${doc.format.toLowerCase()}`}>{doc.format.slice(0, 4)}</span>
<div style={{ minWidth: 0, flex: 1 }}>
<b>{doc.title}</b>
<div><small>{new Date(doc.createdAt).toLocaleDateString(locale())}</small></div>
{phase === 'working' && <Progress doc={doc} />}
{(phase === 'answer' || phase === 'failed') && <KnowledgeAction doc={doc} />}
</div>
<StatusBadge doc={doc} phase={phase} />
<DeleteButton doc={doc} />
</div>
)
}
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
const label = {
ready: t('brain.statusReady'),
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
answer: t('brain.statusAnswer'),
failed: t('brain.statusFailed'),
}[phase]
return <span className={`status ${phase === 'ready' ? 'ready' : 'draft'}`}><i className="dot"></i>{label}</span>
}
function Progress({ doc }: { doc: Source }) {
const pct = Math.max(5, Math.min(100, doc.progress ?? 5))
return (
<div className="doc-progress">
<div className="progress-track"><i style={{ width: `${pct}%` }} /></div>
<small>{doc.stage || t('brain.queued')}</small>
</div>
)
}
// Each stop the engine can make before building, with the one answer it needs.
function KnowledgeAction({ doc }: { doc: Source }) {
const queryClient = useQueryClient()
const detail = useQuery({ queryKey: ['knowledge', doc.docId, doc.knowledge], queryFn: () => knowledgeDetail(doc.docId) })
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
const error = build.error ?? version.error
const busy = build.isPending || version.isPending
const d = detail.data
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
if (!d) return <div className="doc-action"><span className="error">{detail.error?.message}</span></div>
const credits = d.quote ? t('common.credits', { n: d.quote.credits }) : null
let text: string
let actions: ReactNode = null
switch (d.status) {
case 'awaiting_version_answer':
text = d.candidate ? t('brain.versionWithCandidate', { name: d.candidate.filename }) : t('brain.versionGeneric')
actions = (
<>
<button className="btn small" disabled={busy} onClick={() => version.mutate(true)}>{t('brain.versionYes')}</button>
<button className="btn small" disabled={busy} onClick={() => version.mutate(false)}>{t('brain.versionNo')}</button>
</>
)
break
case 'needs_credits':
text = t('brain.needsCredits', { need: credits ? t('brain.needsCreditsNeed', { credits }) : '' })
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
break
case 'failed':
text = d.error || t('brain.failed')
if (d.quote) actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('brain.retryWith', { credits: credits ?? '' })}</button>
break
default:
text = t('brain.confirmCopy', { credits: credits ?? t('brain.confirmCreditsFallback') })
actions = <button className="btn small primary" disabled={busy} onClick={() => build.mutate()}>{t('brain.addToBrain')}{credits && ` · ${credits}`}</button>
}
return (
<div className="doc-action">
<small>{text}</small>
{actions && <div className="doc-action-buttons">{actions}</div>}
{error && <span className="error">{error.message}</span>}
</div>
)
}
function DeleteButton({ doc }: { doc: Source }) {
const queryClient = useQueryClient()
const del = useMutation({
mutationFn: () => deleteDocument(doc.docId),
onSuccess: () => { toast(t('brain.deleted', { name: doc.title })); queryClient.invalidateQueries({ queryKey: ['documents'] }) },
onError: e => toast(e.message, { error: true }),
})
return (
<button className="icon-btn" aria-label={t('brain.delete', { name: doc.title })} disabled={del.isPending}
onClick={() => confirm(t('brain.deleteConfirm', { name: doc.title })) && del.mutate()}>
<Icon name="trash" />
</button>
)
}

194
src/pages/Instruction.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon'
import { PageHead } from '../components/Page'
import {
dayName, describeRule, kindLabel, KINDS, monthNote, parseInstruction, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type ResearchRule, type Rule,
} from '../express/parse'
import { t, type Key } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
// How the kinds that are not a plain Compose output are made, shown under the rule.
const KIND_NOTE: Partial<Record<ContentKind, Key>> = { video: 'ins.noteVideo', audio: 'ins.noteAudio', scorm: 'ins.noteScorm' }
type Outcome = { rule: Rule; ok: boolean; message?: string }
export function InstructionPage() {
const queryClient = useQueryClient()
const [text, setText] = useState('')
const [rules, setRules] = useState<Rule[] | null>(null)
const [unknown, setUnknown] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null)
function compose() {
const result = parseInstruction(text)
setRules(result.rules)
setUnknown(result.unknown)
setOutcomes(null)
}
// A count or frequency typed in by hand replaces the monthly request it was derived from.
const update = (i: number, patch: Partial<ContentRule> | Partial<ResearchRule>) =>
setRules(rs => rs!.map((r, j) => (j === i
? ({ ...r, ...patch, ...('count' in patch || 'per' in patch ? { monthly: undefined } : {}) } as Rule)
: r)))
const remove = (i: number) => setRules(rs => rs!.filter((_, j) => j !== i))
const ready = rules?.filter(canSchedule) ?? []
async function approve() {
setSaving(true)
const done: Outcome[] = []
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
for (const rule of ready) {
try {
await createSchedule(rule)
done.push({ rule, ok: true })
} catch (e) {
done.push({ rule, ok: false, message: (e as Error).message })
}
setOutcomes([...done])
}
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
if (done.every(o => o.ok)) { setRules(null); setText('') }
// Only the failed rules stay, so approving again does not create the others twice.
else setRules(rs => rs!.filter(r => !done.some(o => o.ok && o.rule === r)))
}
return (
<>
<PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="card">
<div className="card-head"><h3>{t('ins.cardTitle')}</h3><Icon name="spark" /></div>
<div style={{ padding: '20px 22px 8px' }}>
<label htmlFor="instruction">{t('ins.label')}</label>
<textarea id="instruction" rows={3} value={text} placeholder={t('ins.placeholder')}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && text.trim()) compose() }} />
<div className="ai-compose-footer">
<small>{t('ins.hint')}</small>
<button className="btn primary" disabled={!text.trim()} onClick={compose}><Icon name="calendar" />{t('ins.compose')}</button>
</div>
</div>
<div className="prompt-chips" style={{ paddingTop: 12 }}>
{EXAMPLES.map(key => <button key={key} onClick={() => { setText(t(key)); setRules(null); setOutcomes(null) }}>{t(key)}</button>)}
</div>
</div>
{rules && (
<div className="card" style={{ marginTop: 22 }}>
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{rules.length === 0 && (
<div className="empty"><h3>{t('ins.notUnderstood')}</h3><p>{t('ins.notUnderstoodCopy')}</p></div>
)}
<div className="rule-list">
{rules.map((rule, i) => <RuleCard key={i} rule={rule} onChange={p => update(i, p)} onRemove={() => remove(i)} />)}
</div>
{unknown.length > 0 && (
<div className="notice-row" style={{ margin: '0 22px 18px' }}>
<Icon name="info" /><span>{t('ins.skipped', { parts: unknown.map(u => `“${u}”`).join(', ') })}</span>
</div>
)}
{ready.length > 0 && (
<div className="rule-approve">
<small>{t('ins.approveNote')}</small>
<button className="btn primary" disabled={saving} onClick={approve}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
)}
{outcomes && (
<ul className="rule-outcomes">
{outcomes.map((o, i) => (
<li key={i}>
<span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span>
<span>{describeRule(o.rule)}</span>
{o.message && <span className="error">{o.message}</span>}
</li>
))}
{outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
</ul>
)}
</div>
)}
</>
)
}
function RuleCard({ rule, onChange, onRemove }: {
rule: Rule
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
onRemove: () => void
}) {
const video = rule.type === 'content' && rule.kind === 'video'
const badCadence = video && videoCadence(rule) === null
const tooMany = rule.type === 'content' && !video && weeklyTarget(rule) > 50
// A video's credit limit follows today's price of one video.
const quote = useQuery({ queryKey: ['video-quote'], queryFn: videoQuote, enabled: video, staleTime: 5 * 60_000 })
const budget = canSchedule(rule) && (!video || quote.data) ? budgetFor(rule, quote.data?.max_credits) : null
return (
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
<div className="rule-body">
<b>{describeRule(rule)}</b>
<div className="rule-fields">
{rule.type === 'content' ? (
<>
<select aria-label={t('ins.kind')} value={rule.kind} onChange={e => onChange({ kind: e.target.value as ContentKind })}>
{KINDS.map(k => <option key={k} value={k}>{kindLabel(k)}</option>)}
</select>
<input aria-label={t('ins.count')} type="number" min={1} max={50} value={rule.count}
onChange={e => onChange({ count: Math.max(1, Number(e.target.value) || 1) })} />
<select aria-label={t('ins.frequency')} value={rule.per} onChange={e => onChange({ per: e.target.value as ContentRule['per'] })}>
<option value="day">{t('ins.perDay')}</option>
<option value="week">{t('ins.perWeek')}</option>
</select>
</>
) : (
<>
<select aria-label={t('ins.researchFrequency')} value={rule.cadence} onChange={e => onChange({ cadence: e.target.value as ResearchRule['cadence'] })}>
<option value="daily">{t('ins.everyDay')}</option>
<option value="weekly">{t('ins.everyWeek')}</option>
</select>
{rule.cadence === 'weekly' && (
<select aria-label={t('ins.weekday')} value={rule.weekday} onChange={e => onChange({ weekday: Number(e.target.value) })}>
{[0, 1, 2, 3, 4, 5, 6].map(d => <option key={d} value={d}>{dayName(d)}</option>)}
</select>
)}
</>
)}
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t('ins.topicPlaceholder')}
onChange={e => onChange({ topic: e.target.value })} />
</div>
{rule.type === 'research' && (
<label className="rule-check">
<input type="checkbox" checked={rule.autoApprove} onChange={e => onChange({ autoApprove: e.target.checked })} />
{t('ins.autoApprove')}
</label>
)}
{rule.type === 'content' && KIND_NOTE[rule.kind] && <small className="rule-note">{t(KIND_NOTE[rule.kind]!, { seconds: VIDEO.seconds, scenes: VIDEO.scenes })}</small>}
{badCadence && <small className="error">{t('ins.videoCadence')} {t('ins.notCreated')}</small>}
{video && quote.isError && <small className="error">{t('ins.videoQuoteFailed', { message: quote.error.message })}</small>}
{tooMany && <small className="error">{t('ins.tooMany')}</small>}
{rule.type === 'content' && rule.monthly && <small className="rule-note">{monthNote(rule)}</small>}
{budget && (
<small className="rule-note">
{t('ins.budget', {
first: budget.weekly ? t('ins.budgetWeekly', { n: budget.weekly }) : t(video ? 'ins.budgetVideo' : 'ins.budgetRun', { n: budget.run }),
monthly: budget.monthly,
})}
</small>
)}
</div>
<button className="icon-btn" aria-label={t('ins.removeRule')} onClick={onRemove}><Icon name="trash" /></button>
</div>
)
}

423
src/pages/Results.tsx Normal file
View File

@@ -0,0 +1,423 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AudioPlayer } from '../components/AudioPlayer'
import { Icon, KIND_ICON, type IconName } from '../components/Icon'
import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../components/Page'
import { toast } from '../components/Toast'
import { describeRule, kindLabel, type ContentKind } from '../express/parse'
import {
decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, materialKey, questionsOf, reviewSource, slidesOf, textOf,
titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus,
} from '../express/results'
import { MASTERY, scormPackage } from '../express/scorm'
import { getLang, locale, t, type Key } from '../i18n'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
// Setup fields of roleplay and simulator content, in display order.
const FIELDS = ['description', 'topic', 'objective', 'learning_objectives', 'user_role', 'ai_character', 'difficulty', 'context'] as const
type StatusFilter = 'all' | 'review' | 'approved' | 'working' | 'failed'
const STATUS_GROUP: Record<MaterialStatus, StatusFilter> = {
preparing: 'working', generating: 'working', ready: 'review', accepted: 'approved', failed: 'failed', rejected: 'failed',
}
const fmt = (iso: string) => new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
export function ResultsPage() {
const results = useQuery({
queryKey: ['results'],
queryFn: listResults,
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
})
const [tab, setTab] = useState<'content' | 'research'>('content')
const [query, setQuery] = useState('')
const [kind, setKind] = useState<ContentKind | 'all'>('all')
const [status, setStatus] = useState<StatusFilter>('all')
const [openId, setOpenId] = useState<string | null>(null)
const materials = results.data?.materials ?? []
const sources = results.data?.sources ?? []
const kinds = useMemo(() => [...new Set(materials.map(m => m.kind))], [materials])
const needle = query.trim().toLowerCase()
const shownMaterials = materials.filter(m => (kind === 'all' || m.kind === kind) && (status === 'all' || STATUS_GROUP[m.status] === status)
&& (!needle || titleOf(m).toLowerCase().includes(needle)))
const shownSources = sources.filter(s => !needle || s.title.toLowerCase().includes(needle) || s.url?.toLowerCase().includes(needle))
const open = materials.find(m => materialKey(m) === openId) ?? null
const pending = sources.filter(s => s.status === 'pending').length
return (
<>
<PageHead title={t('res.title')} description={t('res.description')} />
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={tab === 'content'} className={tab === 'content' ? 'active' : undefined} onClick={() => setTab('content')}>
{t('res.tabContent')} <b>{materials.length}</b>
</button>
<button role="tab" aria-selected={tab === 'research'} className={tab === 'research' ? 'active' : undefined} onClick={() => setTab('research')}>
{t('res.tabResearch')} <b>{pending ? t('res.pendingCount', { n: pending }) : sources.length}</b>
</button>
</div>
<section className="card">
<div className="toolbar">
<h3>{tab === 'content' ? t('res.allContent') : t('res.foundSources')} <span className="badge neutral">{tab === 'content' ? shownMaterials.length : shownSources.length}</span></h3>
<div className="toolbar-controls">
<div className="search"><Icon name="search" />
<input aria-label={t('res.search')} placeholder={t('res.search')} value={query} onChange={e => setQuery(e.target.value)} />
</div>
{tab === 'content' && (
<>
<select aria-label={t('res.filterKind')} value={kind} onChange={e => setKind(e.target.value as ContentKind | 'all')}>
<option value="all">{t('res.allKinds')}</option>
{kinds.map(k => <option key={k} value={k}>{kindLabel(k)}</option>)}
</select>
<select aria-label={t('res.filterStatus')} value={status} onChange={e => setStatus(e.target.value as StatusFilter)}>
{(['all', 'review', 'approved', 'working', 'failed'] as const).map(s => <option key={s} value={s}>{t(`res.status.${s}` as Key)}</option>)}
</select>
</>
)}
</div>
</div>
{results.isPending && (tab === 'content' ? <SkeletonCards label={t('common.loading')} /> : <SkeletonRows label={t('common.loading')} />)}
{results.isError && !results.data && <Empty icon="info" title={t('res.loadFailed')}>{results.error.message}</Empty>}
{results.data && tab === 'content' && (
shownMaterials.length
? <div className="content-grid">{shownMaterials.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}</div>
: <NoResults filtered={materials.length > 0} icon="layers" />
)}
{results.data && tab === 'research' && (
shownSources.length
? <div className="research-list">{shownSources.map(s => <SourceRow key={`${s.agentId}-${s.id}`} source={s} />)}</div>
: <NoResults filtered={sources.length > 0} icon="search" />
)}
<div className="table-note">{tab === 'content' ? t('res.noteContent') : t('res.noteResearch')}</div>
</section>
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
</>
)
}
function NoResults({ filtered, icon }: { filtered: boolean; icon: IconName }) {
if (filtered) return <Empty icon="search" title={t('res.noMatch')}>{t('res.noMatchCopy')}</Empty>
return (
<div className="empty">
<span className="empty-icon"><Icon name={icon} /></span>
<h3>{t('res.empty')}</h3>
<p>{t('res.emptyCopy')}</p>
<Link className="btn light" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>
</div>
)
}
function StatusBadge({ status }: { status: MaterialStatus }) {
const group = STATUS_GROUP[status]
const tone = group === 'approved' ? 'ready' : group === 'failed' ? 'failed' : 'draft'
return <span className={`status ${tone}`}><i className="dot"></i>{t(`res.state.${status}` as Key)}</span>
}
function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
const working = isWorking(m)
return (
<article className="content-card">
<div className={`content-cover kind-${m.kind}`}>
<Icon name={KIND_ICON[m.kind]} /><span>{kindLabel(m.kind)}</span>
<small>{m.rule.topic ? m.rule.topic.toUpperCase() : 'EXPRESS'}</small>
</div>
<div className="content-card-body">
<div className="content-card-top"><span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span><StatusBadge status={m.status} /></div>
<button className="content-title" disabled={working} onClick={onOpen}>{titleOf(m)}</button>
<p>{t(m.sources.length === 1 ? 'res.sourceOne' : 'res.sourceCount', { n: m.sources.length })} · {fmt(m.createdAt)}</p>
<div className="content-card-foot">
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
{working
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
: <button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>}
</div>
</div>
</article>
)
}
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
const queryClient = useQueryClient()
// Every part's revision, so a SCORM module refetches when only its quiz changes.
const content = useQuery({ queryKey: ['material', materialKey(m), m.parts.map(p => p.revision).join('.')], queryFn: () => materialContent(m) })
const decide = useMutation({
mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision),
onSuccess: (_, decision) => {
toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected'))
queryClient.invalidateQueries({ queryKey: ['results'] })
},
})
// No close() on cleanup: its async "close" event would call onClose, and StrictMode's
// mount → cleanup → mount in dev would then shut the dialog right after it opened.
// Unmounting removes the dialog element, which ends the modal anyway.
useEffect(() => {
if (ref.current && !ref.current.open) ref.current.showModal()
}, [])
return (
<dialog ref={ref} className="preview" aria-labelledby="preview-title" onClose={onClose}
onClick={e => e.target === ref.current && ref.current.close()}>
<div className="modal-head">
<div><h2 id="preview-title">{titleOf(m)}</h2><p>{describeRule(m.rule)}</p></div>
<button className="icon-btn" aria-label={t('res.close')} onClick={() => ref.current?.close()}><Icon name="close" /></button>
</div>
<div className="modal-body">
{m.status === 'ready' && <div className="preview-notice"><Icon name="info" /><span>{t('res.reviewNotice')}</span></div>}
{m.kind === 'audio' && <div className="preview-notice concept"><Icon name="info" /><span>{t('res.audioNotice')}</span></div>}
<div className="preview-layout">
<div>
{content.isPending && <SkeletonLines count={6} label={t('common.loading')} />}
{content.isError && <Empty icon="info" title={t('res.loadFailed')}>{content.error.message}</Empty>}
{content.isSuccess && (content.data.content ? <Body material={m} content={content.data.content} /> : <Empty icon="info" title={t('res.noContent')}>{t('res.noContentCopy')}</Empty>)}
</div>
<aside className="preview-side">
<h3>{t('res.info')}</h3>
<span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span>
<div className="detail-label">{t('res.statusLabel')}</div><StatusBadge status={m.status} />
<div className="detail-label">{t('res.schedule')}</div><b>{describeRule(m.rule)}</b>
<div className="detail-label">{t('res.created')}</div><b>{fmt(m.createdAt)}</b>
{m.video?.credits != null && m.video.credits > 0 && <><div className="detail-label">{t('res.creditsUsed')}</div><b>{t('common.credits', { n: m.video.credits })}</b></>}
<div className="detail-label">{t('res.sources')}</div>
{m.sources.length ? m.sources.map(s => <div key={s} className="preview-source"><Icon name="file" />{s}</div>) : <b>–</b>}
</aside>
</div>
</div>
<div className="modal-foot">
{decide.error ? <small className="error">{decide.error.message}</small> : <small>{t('res.footNote')}</small>}
{m.composeSetId && <a className="btn" href={`${STUDIO_URL}/compose/${m.composeSetId}`} target="_blank" rel="noreferrer">{t('res.openStudio')}</a>}
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
{m.status === 'ready' && (
<>
<button className="btn danger" disabled={decide.isPending} onClick={() => decide.mutate('reject')}>{t('res.reject')}</button>
<button className="btn primary" disabled={decide.isPending} onClick={() => decide.mutate('accept')}><Icon name="check" />{t('res.accept')}</button>
</>
)}
</div>
</dialog>
)
}
// What a learner sees in a SCORM package: the lesson without the answer key (the quiz is interactive there).
function lessonHtml(m: Material, content: MaterialContent): string {
if (m.kind === 'quiz') return ''
if (m.kind === 'summary' || m.kind === 'audio' || m.kind === 'scorm') {
return renderToStaticMarkup(<article className="prose"><Paragraphs text={summaryText(m, content)} /></article>)
}
return renderToStaticMarkup(<Body material={m} content={content} />)
}
function scorm(m: Material, content: MaterialContent) {
const blob = scormPackage({
title: textOf(content.title) || titleOf(m),
lessonHtml: lessonHtml(m, content),
questions: m.kind === 'quiz' || m.kind === 'scorm' ? questionsOf(content) : [],
lang: getLang(),
labels: {
quiz: t('res.quiz'), submit: t('scorm.submit'), retry: t('scorm.retry'), complete: t('scorm.complete'),
completed: t('scorm.completed'), score: t('scorm.score'), passed: t('scorm.passed', { n: MASTERY }),
failed: t('scorm.failed', { n: MASTERY }), answerAll: t('scorm.answerAll'), noLms: t('scorm.noLms'),
},
})
download(fileName(m, 'zip'), blob)
toast(t('res.scormReady'))
}
function DownloadMenu({ material: m, content, outputId }: { material: Material; content: MaterialContent; outputId: number | null }) {
const ref = useRef<HTMLDetailsElement>(null)
const pptx = useMutation({
mutationFn: () => exportPptx(outputId!),
onSuccess: blob => download(fileName(m, 'pptx'), blob),
onError: e => toast(e.message, { error: true }),
})
const close = () => ref.current?.removeAttribute('open')
if (m.kind === 'video') {
return m.video?.url
? <a className="btn" href={m.video.url} download={fileName(m, 'mp4')}><Icon name="download" />{t('res.downloadVideo')}</a>
: null
}
const scormItem = (
<button role="menuitem" onClick={() => { close(); scorm(m, content) }}>
<b>SCORM 1.2 (.zip)</b><small>{t(m.kind === 'scorm' || m.kind === 'quiz' ? 'res.scormQuizHint' : 'res.scormHint')}</small>
</button>
)
const pdf = () => {
const title = textOf(content.title) || titleOf(m)
const meta = [describeRule(m.rule), fmt(m.createdAt)].join(' · ')
const html = renderToStaticMarkup(
<>
<header><small>Learning Lab Express</small><h1>{title}</h1><p>{meta}</p></header>
{/* The audio player is for the screen; on paper the script is the lesson. */}
{m.kind === 'audio' ? <article className="prose"><Paragraphs text={summaryText(m, content)} /></article> : <Body material={m} content={content} />}
{!!m.sources.length && <footer>{t('res.sources')}: {m.sources.join(', ')}</footer>}
</>,
)
printPdf(title, html)
}
return (
<details className="download-menu" ref={ref}>
<summary className="btn"><Icon name="download" />{pptx.isPending ? t('res.preparingFile') : t('res.download')}</summary>
<div role="menu">
{m.kind === 'scorm' && scormItem}
{m.kind === 'audio' && (
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'txt'), new Blob([summaryText(m, content)], { type: 'text/plain;charset=utf-8' })) }}>
<b>{t('res.scriptTxt')}</b><small>{t('res.scriptHint')}</small>
</button>
)}
<button role="menuitem" onClick={() => { close(); pdf() }}><b>PDF</b><small>{t('res.pdfHint')}</small></button>
{m.kind === 'slide_deck' && outputId && (
<button role="menuitem" disabled={pptx.isPending} onClick={() => { close(); pptx.mutate() }}><b>PowerPoint (.pptx)</b><small>{t('res.pptxHint')}</small></button>
)}
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'md'), toMarkdown(m, content)) }}><b>Markdown (.md)</b><small>{t('res.mdHint')}</small></button>
{m.kind !== 'scorm' && scormItem}
</div>
</details>
)
}
function Paragraphs({ text }: { text: string }) {
// Generated text is light Markdown: headings, bullets and paragraphs are enough to read it.
const blocks: ReactNode[] = []
let bullets: string[] = []
const flush = () => { if (bullets.length) blocks.push(<ul key={blocks.length}>{bullets.map((b, i) => <li key={i}>{b}</li>)}</ul>); bullets = [] }
for (const line of text.split('\n').map(l => l.trim())) {
if (/^[-*•]\s+/.test(line)) { bullets.push(line.replace(/^[-*•]\s+/, '').replace(/\*\*/g, '')); continue }
flush()
if (!line) continue
const heading = line.match(/^#{1,6}\s+(.*)/)
blocks.push(heading ? <h2 key={blocks.length}>{heading[1]}</h2> : <p key={blocks.length}>{line.replace(/\*\*/g, '')}</p>)
}
flush()
return <>{blocks}</>
}
// Generated summaries usually open with their own title as a heading; it is already the h1.
function summaryText(m: Material, content: MaterialContent) {
const title = textOf(content.title) || titleOf(m)
return textOf(content.text).replace(/^\s*#{1,6}\s+(.+)\n/, (line, heading: string) =>
heading.trim().toLowerCase() === title.trim().toLowerCase() ? '' : line)
}
function QuizList({ content }: { content: MaterialContent }) {
return (
<ol className="quiz-list">
{questionsOf(content).map((q, i) => (
<li key={i}><b>{q.question}</b>
{!!q.options.length && <ul>{q.options.map((o, j) => <li key={j} className={j === q.correct ? 'correct' : undefined}>{o}</li>)}</ul>}
{q.answer && q.correct < 0 && <p><b>{t('res.answer')}:</b> {q.answer}</p>}
{q.explanation && <p className="muted">{q.explanation}</p>}
</li>
))}
</ol>
)
}
function Body({ material: m, content }: { material: Material; content: MaterialContent }) {
const title = textOf(content.title) || titleOf(m)
if (m.kind === 'video') {
if (m.video?.url) return <article className="prose"><h1>{title}</h1><video className="source-player" controls preload="metadata" src={m.video.url} /></article>
if (m.status === 'failed') return <Empty icon="video" title={t('res.videoFailed')}>{t('res.videoFailedCopy')}</Empty>
return <Empty icon="video" title={t('res.videoNotReady')}>{t('res.videoProgress', { n: m.video?.progress ?? 0 })}</Empty>
}
if (m.kind === 'summary') {
return <article className="prose"><h1>{title}</h1><Paragraphs text={summaryText(m, content)} /></article>
}
if (m.kind === 'audio') {
const text = summaryText(m, content)
return (
<>
<AudioPlayer title={title} text={text} />
<details className="script-details" style={{ marginTop: 20 }}><summary>{t('res.fullScript')}</summary>
<article className="prose"><Paragraphs text={text} /></article>
</details>
</>
)
}
if (m.kind === 'scorm') {
const questions = questionsOf(content).length
return (
<article className="prose scorm-preview">
<div className="scorm-preview-head"><Icon name="scorm" /><div><b>{t('res.scormModule')}</b><small>{t('res.scormMeta', { n: questions, mastery: MASTERY })}</small></div></div>
<h1>{title}</h1>
<Paragraphs text={summaryText(m, content)} />
<h2>{t('res.quiz')}</h2>
{questions ? <QuizList content={content} /> : <p className="muted">{t('res.quizPending')}</p>}
</article>
)
}
if (m.kind === 'quiz') {
return <article className="prose"><h1>{title}</h1><QuizList content={content} /></article>
}
if (m.kind === 'flashcards') {
return (
<article className="prose"><h1>{title}</h1>
<div className="flashcards">
{(listOf(content.cards) as Record<string, unknown>[]).map((c, i) => (
<div key={i} className="flashcard"><b>{textOf(c.front)}</b><span>{textOf(c.back)}</span></div>
))}
</div>
</article>
)
}
if (m.kind === 'slide_deck') {
return (
<article className="prose"><h1>{title}</h1>
{!!content.description && <p>{textOf(content.description)}</p>}
{slidesOf(content).map((s, i) => (
<section key={i} className="slide-preview"><small>{t('res.slide', { n: i + 1 })}</small><h2>{s.title}</h2>
{!!s.bullets.length && <ul>{s.bullets.map((b, j) => <li key={j}>{b}</li>)}</ul>}
</section>
))}
</article>
)
}
// Roleplay and simulator: show their setup fields; the interactive versions live in Learning Studio.
return (
<article className="prose"><h1>{title}</h1>
{FIELDS.map(k => [k, content[k]] as const).filter(([, v]) => v != null && v !== '').map(([k, v]) => (
<div key={k}><h2>{t(`res.field.${k}`)}</h2>
{Array.isArray(v) ? <ul>{v.map((x, i) => <li key={i}>{textOf(x)}</li>)}</ul> : <Paragraphs text={textOf(v)} />}
</div>
))}
</article>
)
}
function SourceRow({ source: s }: { source: FoundSource }) {
const queryClient = useQueryClient()
const review = useMutation({
mutationFn: (decision: 'accept' | 'reject') => reviewSource(s, decision),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['results'] })
queryClient.invalidateQueries({ queryKey: ['documents'] })
},
})
const tone = s.status === 'accepted' ? 'ready' : s.status === 'pending' ? 'draft' : 'failed'
const label = ['pending', 'accepted', 'rejected'].includes(s.status) ? t(`res.source.${s.status}` as Key) : s.status
return (
<article className="research-row">
<span><Icon name="search" /></span>
<div>
<span className={`status ${tone}`}><i className="dot"></i>{label}</span>
<h3>{s.url ? <a href={s.url} target="_blank" rel="noreferrer">{s.title}</a> : s.title}</h3>
<p>{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · <Link to="/brain">{t('res.inBrain')}</Link></>}</p>
{s.preview && <p className="source-preview">{s.preview}</p>}
{review.error && <span className="error">{review.error.message}</span>}
</div>
{s.status === 'pending' && (
<div className="research-actions">
<button className="btn small danger" disabled={review.isPending} onClick={() => review.mutate('reject')}>{t('res.reject')}</button>
<button className="btn small primary" disabled={review.isPending} onClick={() => review.mutate('accept')}>{t('res.addToBrain')}</button>
</div>
)}
</article>
)
}

222
src/pages/Schedule.tsx Normal file
View File

@@ -0,0 +1,222 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon'
import { Empty, PageHead, SkeletonLines, SkeletonRows } from '../components/Page'
import { toast } from '../components/Toast'
import { describeRule } from '../express/parse'
import { locale, t, type Key } from '../i18n'
import {
archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule,
} from '../express/schedules'
const fmt = (iso: string | null | undefined) => (iso
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
: '–')
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled'])
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
export function SchedulePage() {
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
return (
<>
<PageHead title={t('sch.title')} description={t('sch.description')}
actions={<Link className="btn primary" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
<div className="card">
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />}
{schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>}
{schedules.data?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
{!!schedules.data?.length && <div className="result-list">{schedules.data.map(s => <ScheduleRow key={s.agent.id} schedule={s} />)}</div>}
</div>
</>
)
}
function ScheduleRow({ schedule }: { schedule: Schedule }) {
const { agent, rule, active } = schedule
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const [topic, setTopic] = useState(rule.topic)
const [open, setOpen] = useState(false)
const [producing, setProducing] = useState(false)
const refresh = () => queryClient.invalidateQueries({ queryKey: ['schedules'] })
const video = rule.type === 'content' && rule.kind === 'video'
// Content kinds run as weekly production plans; research and video as one run per slot.
const production = !isRunSchedule(rule)
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(agent.id)), onSuccess: refresh })
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
const runNow = useMutation({
mutationFn: () => startRun(agent.id),
onSuccess: () => {
toast(t(video ? 'sch.videoStarted' : 'sch.runStarted'))
setOpen(true)
queryClient.invalidateQueries({ queryKey: ['runs', agent.id] })
queryClient.invalidateQueries({ queryKey: ['results'] })
},
})
const error = toggle.error ?? retopic.error ?? archive.error ?? runNow.error
const busy = toggle.isPending || retopic.isPending || archive.isPending || runNow.isPending
return (
<div className="schedule-row">
<div className="result-row">
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
<div style={{ minWidth: 0, flex: 1 }}>
<b>{describeRule(rule)}</b>
<div><small>{production
? <ProductionLine agentId={agent.id} active={active} />
: t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div>
{editing && (
<form className="topic-edit" onSubmit={e => { e.preventDefault(); retopic.mutate() }}>
<input aria-label={t('sch.newTopic')} value={topic} placeholder={t('ins.topicPlaceholder')} autoFocus onChange={e => setTopic(e.target.value)} />
<button className="btn small primary" disabled={busy}>{t('common.save')}</button>
<button type="button" className="btn small" onClick={() => { setEditing(false); setTopic(rule.topic) }}>{t('common.cancel')}</button>
</form>
)}
{error && <span className="error">{error.message}</span>}
</div>
<span className={`status ${active ? 'ready' : 'draft'}`}><i className="dot"></i>{active ? t('sch.active') : t('sch.paused')}</span>
</div>
<div className="schedule-actions">
{!editing && <button className="btn small" disabled={busy} onClick={() => setEditing(true)}>{t('sch.changeTopic')}</button>}
<button className="btn small" disabled={busy} onClick={() => toggle.mutate()}>{active ? t('sch.pause') : t('sch.resume')}</button>
{!production && active && <button className="btn small" disabled={busy} onClick={() => runNow.mutate()}>{t(video ? 'sch.videoNow' : 'sch.runNow')}</button>}
{production && active && !producing && <button className="btn small" disabled={busy} onClick={() => setProducing(true)}>{t('sch.produceNow')}</button>}
<button className="btn small" onClick={() => setOpen(o => !o)}>{open ? t('sch.hideHistory') : t('sch.history')}</button>
<button className="btn small danger" disabled={busy}
onClick={() => confirm(t('sch.deleteConfirm', { name: describeRule(rule) })) && archive.mutate()}>{t('sch.delete')}</button>
</div>
{production && !producing && <ProductionProgress agentId={agent.id} />}
{producing && production && <ProducePanel schedule={schedule} onClose={() => setProducing(false)} />}
{open && <RunHistory agentId={agent.id} />}
</div>
)
}
// Content schedules report this week's progress from the engine's production period.
const useProduction = (agentId: number) => useQuery({
queryKey: ['production', agentId],
queryFn: () => productionStatus(agentId),
refetchInterval: q => (q.state.data?.plans.some(p => PLAN_ACTIVE.has(p.status)) ? 5000 : 60_000),
})
function ProductionLine({ agentId, active }: { agentId: number; active: boolean }) {
const status = useProduction(agentId)
const s = status.data
if (!s) return <>{status.isError ? status.error.message : t('sch.loadingProgress')}</>
if (!s.planning_available) {
return <span className="error">{t('sch.planningOff')}</span>
}
if (!active) return <>{t('sch.paused')}</>
const p = s.period
return <>{p ? t('sch.thisWeek', { ready: p.ready, target: p.target, failed: p.failed ? t('sch.thisWeekFailed', { n: p.failed }) : '' }) : t('sch.waitingFirst')}</>
}
// Starts this week's production early: quote the planning step, show its price, then confirm it.
// The latest production of this schedule, step by step: topics → creating → done.
function ProductionProgress({ agentId }: { agentId: number }) {
const plan = useProduction(agentId).data?.plans[0]
if (!plan || plan.status === 'quoted' || plan.status === 'cancelled') return null
const { total, ready, failed, shortfall } = planProgress(plan)
const step = plan.status === 'planning' ? 0 : plan.status === 'running' || plan.status === 'paused' ? 2 : plan.status === 'completed' ? 3 : 1
const steps = [t('prod.stepTopics'), t('prod.stepStart'), t('prod.stepCreate', { ready, total }), t('prod.stepDone')]
const finished = plan.status === 'completed' || plan.status === 'failed'
return (
<div className={`production-progress${finished ? ' finished' : ''}`} role="status" aria-live="polite">
<div className="production-head">
{!finished && <span className="spinner" aria-hidden="true" />}
<b>{finished ? t('prod.lastTitle') : t('prod.runningTitle', { n: plan.target })}</b>
{plan.status !== 'planning' && total > 0 && <span className="production-count">{t('prod.count', { ready, total })}</span>}
</div>
{plan.status === 'failed' ? (
<span className="error">{t('prod.failed', { code: plan.error_code ?? '–' })}</span>
) : (
<ol className="production-steps">
{steps.map((label, i) => (
<li key={i} className={i < step ? 'done' : i === step ? (finished ? 'done' : 'current') : undefined}>{label}</li>
))}
</ol>
)}
{!!total && plan.status !== 'planning' && <div className="production-bar"><i style={{ width: `${Math.round(((ready + failed) / total) * 100)}%` }} /></div>}
{(failed > 0 || (shortfall > 0 && plan.status !== 'planning')) && (
<small>{[failed ? t('prod.someFailed', { n: failed }) : '', shortfall ? t('prod.shortfall', { n: shortfall }) : ''].filter(Boolean).join(' ')}</small>
)}
{(ready > 0 || plan.status === 'running') && <Link className="text-btn" to="/results">{t('prod.viewResults')} <Icon name="arrow-right" /></Link>}
</div>
)
}
function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedule; onClose: () => void }) {
const queryClient = useQueryClient()
const status = useProduction(agent.id)
const period = status.data?.period
const remaining = period ? Math.max(0, period.target - period.claimed) : agent.content_goal?.weekly_target ?? 0
const [count, setCount] = useState(rule.type === 'content' ? rule.count : 1)
const n = Math.min(Math.max(1, count), Math.max(1, remaining))
const quote = useMutation({ mutationFn: () => quoteProduction(agent.id, n) })
const start = useMutation({
mutationFn: (plan: ContentPlan) => confirmProduction(agent.id, plan),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['production', agent.id] })
queryClient.invalidateQueries({ queryKey: ['results'] })
onClose()
},
})
const error = quote.error ?? start.error
const running = status.data?.plans.some(p => PLAN_ACTIVE.has(p.status))
if (status.isPending) return <div className="produce-panel"><SkeletonLines label={t('sch.loadingProgress')} /></div>
return (
<div className="produce-panel">
{running ? (
<p>{t('sch.alreadyProducing')}</p>
) : remaining === 0 ? (
<p>{t('sch.weekFull')}</p>
) : quote.data ? (
<p>{t('sch.produceQuote', { n: quote.data.target, credits: quote.data.planning_quote.credits ?? 0, budget: agent.content_goal?.weekly_budget ?? 0 })}</p>
) : (
<label>{t('sch.produceHowMany', { max: remaining })}
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
</label>
)}
{error && <span className="error">{error.message}</span>}
<div className="produce-actions">
{!running && remaining > 0 && (quote.data
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>
: <button className="btn small primary" disabled={quote.isPending} onClick={() => quote.mutate()}>{quote.isPending ? t('sch.checkingPrice') : t('sch.checkPrice')}</button>)}
<button className="btn small" onClick={onClose}>{running || remaining === 0 ? t('res.close') : t('common.cancel')}</button>
</div>
</div>
)
}
function RunHistory({ agentId }: { agentId: number }) {
const runs = useQuery({
queryKey: ['runs', agentId],
queryFn: () => listRuns(agentId),
refetchInterval: q => (q.state.data?.some(r => ['queued', 'planning', 'running'].includes(r.status)) ? 3000 : false),
})
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
return (
<ul className="run-history">
{runs.data.slice(0, 8).map(r => (
<li key={r.id}>
<span>{fmt(r.created_at)}</span>
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
{r.safe_error_code && <span className="error">{r.safe_error_code}</span>}
</li>
))}
</ul>
)
}

128
src/styles/app.css Normal file
View File

@@ -0,0 +1,128 @@
/* Express additions on top of the mockup styles. */
.upload-progress{list-style:none;padding:0;margin:12px 0 0;display:grid;gap:6px;font-size:11px}
.upload-progress li{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.upload-progress .error{margin:0;font-size:11px}
.upload-progress .status{margin-left:0}
.doc-progress{margin-top:8px;max-width:360px}
.doc-progress .progress-track{width:100%;max-width:none;margin:0 0 5px}
.doc-progress small,.doc-action small{font-size:10px;color:#7a8ba5}
.doc-action{margin-top:8px;display:grid;gap:7px;max-width:520px}
.doc-action-buttons{display:flex;gap:7px;flex-wrap:wrap}
.doc-action .error{margin:0;font-size:11px}
.result-row b{overflow-wrap:anywhere}
.drop-overlay{position:fixed;inset:0;z-index:50;display:grid;place-items:center;background:#142d5059;backdrop-filter:blur(2px);pointer-events:none}
.drop-overlay>div{display:flex;flex-direction:column;align-items:center;gap:12px;padding:34px 46px;border:2px dashed var(--blue);border-radius:16px;background:white;color:var(--blue);font-size:14px}
.drop-overlay svg{width:34px;height:34px}
textarea{resize:vertical;line-height:1.6}
.rule-list{padding:6px 22px}
.rule-card{display:flex;gap:14px;align-items:flex-start;padding:16px 0;border-bottom:1px solid var(--line)}
.rule-card:last-child{border-bottom:0}
.rule-card.muted .rule-body>b{color:#9aa5b5}
.rule-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:#edf4ff;color:var(--blue);flex-shrink:0}
.rule-icon svg{width:18px;height:18px}
.rule-body{flex:1;min-width:0;display:grid;gap:8px}
.rule-body>b{font-size:13px}
.rule-fields{display:flex;gap:8px;flex-wrap:wrap}
.rule-fields select,.rule-fields input{width:auto!important;min-height:36px!important;padding:7px 10px!important;font-size:12px}
.rule-fields input[type=number]{width:72px!important}
.rule-fields .rule-topic{flex:1;min-width:180px}
.rule-check{display:flex;align-items:center;gap:8px;font-weight:500;font-size:11px;margin:0;color:#6f819b}
.rule-note{font-size:10px;color:#7a8ba5}
.rule-body .error{margin:0;font-size:11px}
.rule-approve{display:flex;gap:16px;align-items:center;justify-content:space-between;padding:16px 22px;border-top:1px solid var(--line);background:#fafbfd}
.rule-approve small{font-size:10px;color:#7a8ba5;line-height:1.7}
.rule-outcomes{list-style:none;margin:0;padding:12px 22px 18px;display:grid;gap:8px;font-size:12px}
.rule-outcomes li{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.rule-outcomes .status{margin-left:0}
.rule-outcomes .error{margin:0;font-size:11px}
.schedule-row{border-bottom:1px solid var(--line);padding-bottom:14px}
.schedule-row:last-child{border-bottom:0}
.schedule-row .result-row{border-bottom:0;padding-bottom:10px}
.schedule-row .error{display:block;margin-top:6px;font-size:11px}
.schedule-actions{display:flex;gap:7px;flex-wrap:wrap;padding-left:61px}
.topic-edit{display:flex;gap:7px;margin-top:9px;max-width:520px}
.topic-edit input{min-height:34px!important;padding:6px 10px!important;font-size:12px}
.run-history{list-style:none;margin:12px 0 0 61px;padding:10px 14px;background:#fafbfd;border:1px solid var(--line);border-radius:8px;display:grid;gap:7px;font-size:11px;color:#6f819b}
.run-history li{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.run-history .status{margin-left:0}
.run-history .error{margin:0;font-size:11px}
@media(max-width:700px){.schedule-actions,.run-history{padding-left:0;margin-left:0}.rule-approve{flex-direction:column;align-items:stretch}}
.lang-switch{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:white}
.lang-switch button{border:0;background:transparent;padding:5px 9px;font-size:10px;font-weight:650;color:#8b97a9}
.lang-switch button.active{background:#edf4ff;color:var(--blue)}
.login-top{display:flex;align-items:center;justify-content:space-between;gap:12px;position:relative;z-index:1}
/* hidden still allows programmatic scroll (e.g. focusing the language switch); clip does not. */
.login-story{overflow:clip}
/* Results */
.status.failed{color:#b64747;background:#fdeeee}
.content-cover.kind-quiz,.content-cover.kind-flashcards{background:#eef7f3;color:#5f9a86}
.content-cover.kind-roleplay,.content-cover.kind-simulator{background:#f2effa;color:#8f78b8}
.content-cover.kind-slide_deck{background:#fff6e8;color:#b48a45}
.content-cover>small{max-width:calc(100% - 24px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.content-title:disabled{cursor:default;color:#8b97a9}
.content-card-foot>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.research-row .status{margin-left:0}
.research-row h3 a{color:var(--ink)}
.research-row h3 a:hover{color:var(--blue)}
.research-row p a{color:var(--blue)}
.research-row .error{display:block;margin-top:6px;font-size:11px}
.source-preview{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;color:#7a879b;line-height:1.7}
.research-actions{display:flex;gap:7px;flex-shrink:0}
.quiz-list{padding-left:20px;display:grid;gap:16px}
.quiz-list ul{margin-top:7px;padding-left:18px}
.quiz-list li.correct{color:var(--green);font-weight:650}
.flashcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.flashcard{border:1px solid var(--line);border-radius:10px;padding:14px;display:grid;gap:8px;font-size:12px;line-height:1.6}
.flashcard span{color:#6f819b}
.slide-preview{border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:12px}
.slide-preview small{font-size:9px;letter-spacing:.8px;text-transform:uppercase;color:#a0adbd}
.slide-preview h2{margin-top:4px}
.modal-foot .error{margin-right:auto;font-size:11px}
@media(max-width:700px){.research-row{flex-wrap:wrap}.research-actions{width:100%}.research-actions .btn{flex:1}}
.produce-panel{margin:12px 0 0 61px;padding:12px 14px;background:#f5f9ff;border:1px solid #dfeaff;border-radius:8px;display:grid;gap:10px;font-size:12px;color:#5f7290;max-width:620px}
.produce-panel label{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.produce-panel input{width:90px!important;flex:none;min-height:32px!important;padding:5px 9px!important}
.produce-panel p{line-height:1.7}
.produce-panel .error{font-size:11px}
.produce-actions{display:flex;gap:7px}
@media(max-width:700px){.produce-panel{margin-left:0}}
.production-progress{margin:6px 0 4px 61px;padding:13px 15px;background:#f5f9ff;border:1px solid #dfeaff;border-radius:9px;display:grid;gap:10px;font-size:12px;color:#5f7290;max-width:620px}
.production-progress.finished{background:#f7faf9;border-color:#e1efe9}
.production-head{display:flex;align-items:center;gap:9px;color:var(--ink)}
.production-count{margin-left:auto;font-size:11px;color:#6f819b}
.production-steps{display:flex;flex-wrap:wrap;gap:6px 16px;list-style:none;padding:0;margin:0;font-size:11px}
.production-steps li{display:flex;align-items:center;gap:6px;color:#a0adbd}
.production-steps li:before{content:"";width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor}
.production-steps li.done{color:var(--green)}
.production-steps li.done:before{background:currentColor}
.production-steps li.current{color:var(--blue);font-weight:650}
.production-steps li.current:before{background:currentColor;box-shadow:0 0 0 3px #dbe7ff}
.production-bar{height:5px;border-radius:5px;background:#e5edf9;overflow:hidden}
.production-bar i{display:block;height:100%;background:var(--blue);transition:width .4s}
.production-progress.finished .production-bar i{background:var(--green)}
.production-progress .text-btn{justify-self:start;font-size:11px}
.production-progress .text-btn svg{width:12px;height:12px}
.spinner{width:13px;height:13px;border-radius:50%;border:2px solid #cddcf5;border-top-color:var(--blue);animation:spin .8s linear infinite;flex-shrink:0}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:700px){.production-progress{margin-left:0}}
.download-menu{position:relative}
.download-menu>summary{list-style:none;cursor:pointer}
.download-menu>summary::-webkit-details-marker{display:none}
.download-menu>div{position:absolute;right:0;bottom:calc(100% + 6px);z-index:5;min-width:250px;background:white;border:1px solid var(--line);border-radius:10px;box-shadow:0 14px 40px #142d5026;padding:5px;display:grid}
.download-menu>div button{display:grid;gap:2px;text-align:left;background:none;border:0;border-radius:7px;padding:9px 11px;font-size:12px;color:var(--ink);cursor:pointer}
.download-menu>div button:hover:not(:disabled){background:#f2f6fd}
.download-menu>div small{font-size:10px;color:#8b97a9}
.download-menu>.error{position:absolute;right:0;bottom:calc(100% + 6px);white-space:nowrap;font-size:11px}
@media(max-width:700px){.download-menu{flex:1}.download-menu>summary{width:100%;justify-content:center}.download-menu>div{left:0;right:auto}}
/* Audio lesson player (Phase 4) */
.audio-preview .audio-rate{width:auto;min-height:38px;padding:7px 10px;font-size:12px}
.audio-progress{height:4px;max-width:320px;margin:18px auto 10px;background:#dcece6;border-radius:4px;overflow:hidden}
.audio-progress i{display:block;height:100%;background:#619d89;transition:width .3s}
.audio-now{margin:16px auto 0;max-width:520px;font-size:13px;line-height:1.7;color:#43695c;font-style:italic}
.waveform.playing i{animation:wave 1s ease-in-out infinite alternate}
@keyframes wave{to{transform:scaleY(.35)}}
@media (prefers-reduced-motion:reduce){.waveform.playing i{animation:none}}
.content-cover.kind-video{background:#ecf3ff;color:#4f7bc4}
.content-cover.kind-audio{background:#edf7f4;color:#679b8d}
.content-cover.kind-scorm{background:#f2effa;color:#9a82bb}
.scorm-preview .scorm-preview-head b{font-size:12px}

36
src/styles/mockup.css Normal file

File diff suppressed because one or more lines are too long

260
src/styles/theme.css Normal file
View File

@@ -0,0 +1,260 @@
/* Modern theme from the "Learning Lab Express Redesign" canvas: light sidebar, bright blue
accent, Plus Jakarta Sans, softer radii and layered shadows. Loaded last, so it only overrides. */
:root{
--blue:#1570ef;--blue-dark:#175cd3;--navy:#101828;--ink:#101828;--muted:#667085;--line:#e4e7ec;--bg:#f5f8ff;--light:#eff8ff;--green:#067647;--radius:18px;
--glow:#2e90fa;--soft:#d1e9ff;--deep:#194185;
--shadow-card:0 1px 2px rgba(16,24,40,.04),0 10px 30px -14px rgba(21,94,239,.16);
--shadow-btn:inset 0 1px 0 rgba(255,255,255,.22),0 8px 18px -8px rgba(21,112,239,.7);
font-family:'Plus Jakarta Sans',system-ui,-apple-system,"Segoe UI",sans-serif;font-size:14px;
-webkit-font-smoothing:antialiased;
}
button:focus-visible,a:focus-visible{outline:2px solid var(--glow);outline-offset:2px}
p{color:var(--muted)}
h1,h2,h3,h4{letter-spacing:-.03em}
small{color:var(--muted)}
/* Brand */
.brand-mark{background:linear-gradient(140deg,#53b1fd,#2e90fa 45%,#1570ef);border-radius:12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 8px 20px -6px rgba(46,144,250,.6)}
.brand{font-weight:800;letter-spacing:-.02em}
/* Buttons and controls */
.btn{min-height:40px;padding:9px 16px;border-radius:12px;border-color:var(--line);font-size:13.5px;font-weight:600;transition:background .15s,transform .15s,box-shadow .15s}
.btn:hover{background:#f5f8ff;border-color:#d0d5dd}
.btn.primary{background:var(--blue);border-color:var(--blue);box-shadow:var(--shadow-btn)}
.btn.primary:hover{background:var(--blue-dark);transform:translateY(-1px)}
.btn.primary:disabled{transform:none;box-shadow:none}
.btn.light{background:var(--light);border-color:var(--soft);color:var(--blue-dark)}
.btn.small{min-height:34px;padding:7px 12px;font-size:12.5px;border-radius:10px}
.btn.danger{color:#b91c1c}
.icon-btn{border-radius:10px;color:var(--muted)}
.icon-btn:hover{background:var(--light);color:var(--blue)}
.text-btn{color:var(--blue-dark);font-size:13px}
.badge,.status,.type-badge{border-radius:999px;padding:5px 10px;font-size:11.5px;font-weight:600}
.badge.blue{color:var(--blue-dark);background:var(--light)}
.badge.neutral{background:#f2f4f7;color:#475467}
.status.ready{color:#047857;background:#ecfdf5}
.status.draft{color:#b45309;background:#fffbeb}
.status.failed{color:#b91c1c;background:#fef2f2}
.type-badge{background:var(--light);color:var(--blue-dark)}
label{font-size:13px;font-weight:600}
input:not([type=checkbox]):not([type=radio]):not([type=file]),select,textarea{border-color:#d0d5dd;border-radius:12px;font-size:14px;outline-color:var(--blue);transition:border-color .15s,box-shadow .15s}
input:focus,select:focus,textarea:focus{border-color:var(--blue);box-shadow:0 0 0 4px rgba(21,112,239,.14)}
input::placeholder,textarea::placeholder{color:#98a2b3}
.inline-note,.preview-notice{background:#f5faff;border:1px solid var(--soft);border-radius:14px;color:#475467;font-size:12.5px}
.inline-note svg,.preview-notice svg{color:var(--blue)}
.error{color:#b91c1c}
/* Sidebar: light, bright blue active state */
.sidebar{width:264px;background:#fff;border-right:1px solid var(--line);padding:24px 16px 16px;color:var(--ink);overflow:hidden}
.sidebar::before{content:"";position:absolute;left:-120px;top:-140px;width:360px;height:360px;border-radius:50%;background:radial-gradient(closest-side,rgba(46,144,250,.16),rgba(46,144,250,0));pointer-events:none}
.sidebar>*{position:relative}
.sidebar>a:first-child{display:block}
.sidebar>a>.brand{margin:0 4px 24px;font-size:16px}
.sidebar .brand-mark{width:38px;height:38px;border-radius:12px}
.workspace{border:1px solid var(--line);background:#f8faff;border-radius:14px;padding:10px;margin-bottom:26px;font-size:13.5px}
.workspace small{font-size:12px}
.workspace-icon{background:var(--light);color:var(--blue-dark);border-radius:9px;width:30px;height:30px;font-weight:800}
.nav-label{color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin:0 12px 10px}
.sidebar nav{gap:4px}
.sidebar nav :is(button,a){position:relative;padding:11px 12px;border-radius:12px;color:#475467;font-size:14px;font-weight:550;transition:background .15s,color .15s}
.sidebar nav :is(button,a) svg{color:#98a2b3}
.sidebar nav :is(button,a):hover{background:#f2f7ff;color:var(--ink)}
.sidebar nav :is(button,a).active{background:linear-gradient(90deg,#1570ef,#2e90fa);color:#fff;font-weight:650;box-shadow:0 8px 18px -10px rgba(21,112,239,.8)}
.sidebar nav :is(button,a).active svg{color:#fff}
.sidebar-help{border:1px solid var(--soft);background:linear-gradient(160deg,#eff8ff,#fff);border-radius:16px;padding:16px 14px}
.sidebar-help>svg{color:var(--blue)}
.sidebar-help b{font-size:13px}
.sidebar-help p{font-size:12px}
.sidebar-help button{color:var(--blue-dark);font-size:12.5px}
.profile{border-top:1px solid var(--line);padding:14px 4px 0}
.profile b{font-size:13px}
.profile small{font-size:11.5px}
.avatar{background:linear-gradient(140deg,#2e90fa,#1570ef);color:#fff;width:34px;height:34px;font-weight:700;box-shadow:0 0 0 2px #fff,0 0 0 3.5px rgba(46,144,250,.5);flex-shrink:0}
/* Main column: glass top bar and a soft blue glow at the top */
.workspace-main{margin-left:264px;width:calc(100% - 264px);background:radial-gradient(900px 420px at 85% -120px,rgba(46,144,250,.18),rgba(124,92,255,0) 70%),radial-gradient(700px 360px at 10% -160px,rgba(125,215,255,.22),rgba(190,242,100,0) 70%) no-repeat}
.topbar{position:sticky;top:0;z-index:4;height:64px;padding:0 36px;background:rgba(255,255,255,.7);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1px solid #eaecf0}
.breadcrumb{font-size:13.5px;color:var(--muted)}
.breadcrumb span{color:#d0d5dd}
.breadcrumb b{color:var(--ink);font-weight:600}
.topbar-right{font-size:13px;color:var(--muted);gap:16px}
.demo-indicator{height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
.demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5}
.lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px}
.lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467}
.lang-switch button.active{background:#fff;color:var(--ink);box-shadow:0 1px 3px rgba(16,24,40,.12)}
.demo-strip{margin:24px 36px 0;padding:8px 10px 8px 16px;border:1px solid rgba(46,144,250,.25);border-radius:999px;background:rgba(255,255,255,.75);box-shadow:0 6px 20px -12px rgba(21,112,239,.35);color:var(--deep);font-size:13px}
.demo-strip svg{color:var(--blue)}
.demo-strip-tag{font-size:11px;letter-spacing:.06em;font-weight:700;color:#fff;background:var(--blue);border-radius:999px;padding:4px 10px}
.app main{padding:28px 36px 32px}
.page-head{margin-bottom:28px}
.page-head h1{font-size:38px;line-height:1.08;font-weight:800;letter-spacing:-.04em}
.page-head p{font-size:15px;margin-top:8px;max-width:720px}
.app-footer{margin:auto 36px 0;padding:20px 0;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}
.app-footer>span:first-child{color:var(--blue-dark)}
.app-footer>span:last-child{font-size:12.5px}
/* Cards and sections */
.card{border-color:var(--line);border-radius:var(--radius);box-shadow:var(--shadow-card)}
.card-head{padding:18px 22px}
.card-head h3{font-size:16px;font-weight:700}
.card-head p{font-size:13px}
.card-body{padding:22px}
.section-heading h3,.toolbar h3{font-size:16px;font-weight:700}
.section-heading p{font-size:13px}
/* Metrics: bento tiles, the first one dark */
.metrics{gap:16px;margin-bottom:24px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.metric{border-color:var(--line);border-radius:18px;padding:20px 22px;box-shadow:var(--shadow-card);overflow:hidden}
.metric>span{font-size:13px;font-weight:600;color:#475467}
.metric strong{font-size:38px;font-weight:800;letter-spacing:-.045em;line-height:1.05;margin:10px 0 6px;font-variant-numeric:tabular-nums}
.metric small{font-size:12.5px}
.metric:first-child{background:linear-gradient(140deg,#175cd3,#1570ef);border-color:#1570ef;color:#fff;box-shadow:0 18px 40px -18px rgba(21,112,239,.7)}
.metric:first-child::after{content:"";position:absolute;right:-60px;top:-80px;width:220px;height:220px;border-radius:50%;background:radial-gradient(closest-side,rgba(255,255,255,.35),rgba(255,255,255,0));pointer-events:none}
.metric:first-child>span{color:#eff8ff}
.metric:first-child small{color:#eff8ff}
.metric i{border-radius:12px;background:var(--light);color:var(--blue)}
/* Upload */
.upload-box{border:1.5px dashed #84caff;border-radius:20px;background:linear-gradient(135deg,#f5faff,#fff 60%);padding:32px 24px}
.upload-box.drag{background:var(--soft);border-color:var(--blue);box-shadow:0 0 0 4px rgba(21,112,239,.12)}
.upload-symbol{background:#fff;border:1px solid #b2ddff;color:var(--blue);border-radius:14px}
.upload-box h3{font-size:17px;font-weight:700}
.upload-box p{font-size:14px;color:#475467}
.upload-box .upload-limit{font-size:12px;color:var(--muted)}
.drop-overlay{background:rgba(16,24,40,.4);backdrop-filter:blur(4px)}
.drop-overlay>div{border-radius:20px;border-color:var(--blue);color:var(--blue-dark)}
/* Tables and lists */
.toolbar{padding:16px 22px}
.search>svg{color:var(--muted);top:12px}
.search input{font-size:13px;min-height:38px!important;border-radius:12px}
th{font-size:12px;font-weight:600;color:var(--muted);background:#f9fafb;padding:12px 22px}
td{font-size:13px;color:#475467;padding:16px 22px;border-bottom-color:#eaecf0}
tbody tr{transition:background .12s}
tbody tr:hover{background:#f9fafb}
.file-name b{font-size:14px;color:var(--ink)}
.file-name small{font-size:12px}
.file-icon{border-radius:10px}
.table-note{font-size:12px;color:var(--muted)}
.notice-row{background:var(--light);border-color:var(--soft);border-radius:14px;color:var(--deep);font-size:13px}
.result-row{padding:18px 0}
.result-title{font-size:14.5px;color:var(--ink);font-weight:700}
.result-meta{font-size:12px;color:var(--muted)}
.result-cover,.rule-icon{background:var(--light);border-color:var(--soft);color:var(--blue);border-radius:12px}
.rule-body>b{font-size:14.5px}
.rule-note,.rule-check,.rule-approve small{font-size:12.5px;color:var(--muted)}
.rule-approve,.run-history{background:#f9fafb}
.run-history,.produce-panel,.production-progress{border-radius:14px}
.produce-panel,.production-progress{background:#f5faff;border-color:var(--soft);color:#475467}
.production-steps li.current:before{box-shadow:0 0 0 3px var(--soft)}
.production-bar,.progress-track{background:#ecebf3}
.spinner{border-color:var(--soft);border-top-color:var(--blue)}
/* Results */
.content-grid{gap:18px}
.content-card{border-radius:18px;box-shadow:var(--shadow-card);transition:transform .15s,box-shadow .15s}
.content-card:hover{transform:translateY(-2px);box-shadow:0 1px 2px rgba(16,24,40,.04),0 18px 36px -16px rgba(21,94,239,.25)}
.content-cover{background:linear-gradient(140deg,#eff8ff,#d1e9ff);color:var(--blue)}
.content-cover>span{font-size:13px}
.content-title{font-size:14.5px;font-weight:700}
.content-title:hover{color:var(--blue)}
.content-card-body>p{font-size:12px}
.content-card-foot>span{font-size:11.5px;color:var(--muted)}
.content-card-foot .text-btn{font-size:12.5px}
.preview-tabs button{font-size:13px}
.preview-tabs button.active{color:var(--blue-dark);border-color:var(--blue)}
.preview-side{background:#f9fafb;border-color:var(--line);border-radius:14px}
.prose p,.prose li{font-size:14px;color:#344054}
.quiz-item,.flashcard,.slide-preview,.chapter{border-radius:14px}
/* Empty states */
.empty{color:var(--muted)}
.empty-icon{background:var(--light);color:var(--blue);border-radius:14px}
.empty h3{font-size:16px;color:var(--ink)}
.empty p{font-size:13.5px}
/* Dialogs and toast */
dialog{border:1px solid var(--line);border-radius:22px;box-shadow:0 30px 80px -20px rgba(16,24,40,.45)}
dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.modal-head h2{font-size:22px;font-weight:800}
.modal-head p{font-size:13px}
.help-step>span{background:var(--light);color:var(--blue-dark);border-radius:10px;font-weight:700}
.help-step h3{font-size:14.5px}
.help-step p{font-size:13px}
.toast{background:var(--ink);border-radius:14px;font-size:13.5px;box-shadow:0 18px 40px -16px rgba(16,24,40,.6)}
.toast.error{background:#991b1b}
.progress-orb{background:var(--light);color:var(--blue);border-radius:20px}
/* Sign in: bright blue story panel with dot grid and light glow */
.login-story{background:linear-gradient(150deg,#175cd3,#1570ef 55%,#2e90fa);color:#fff;padding:48px 64px}
.login-story::before{content:"";position:absolute;inset:0;background-image:radial-gradient(rgba(255,255,255,.16) 1px,transparent 1.2px);background-size:22px 22px;pointer-events:none}
.login-story::after{right:-260px;top:-240px;bottom:auto;width:720px;height:720px;border:0;background:radial-gradient(closest-side,rgba(255,255,255,.28),rgba(255,255,255,0))}
.login-story .brand,.login-story .brand b{color:#fff}
.login-story .brand small{color:#d1e9ff}
.login-story .brand-mark{background:#fff;color:#1570ef}
.login-story .lang-switch{background:rgba(255,255,255,.16)}
.login-story .lang-switch button{color:#fff}
.login-story .lang-switch button.active{background:#fff;color:var(--blue-dark)}
.eyebrow{display:inline-flex;align-items:center;gap:8px;padding:6px 14px;border-radius:999px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.25);color:#fff;font-size:12px;letter-spacing:.04em}
.eyebrow::before{content:"NEW";padding:2px 8px;border-radius:999px;background:#fff;color:var(--blue-dark);font-size:10.5px;font-weight:800}
.login-copy h1{font-size:60px;line-height:1.03;font-weight:800;letter-spacing:-.04em;color:#fff}
.login-copy>p{font-size:17px;line-height:1.6;color:#eff8ff;max-width:520px}
.login-benefits{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:32px;color:#fff;font-size:14px;font-weight:600}
.login-benefits>span{flex-direction:column;align-items:flex-start;gap:14px;padding:16px;border-radius:18px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.22);line-height:1.35;backdrop-filter:blur(6px)}
.login-benefits i{display:grid;place-items:center;width:38px;height:38px;border-radius:12px;background:#fff}
.login-benefits svg{color:#1570ef}
.login-footer{font-size:12px;letter-spacing:.06em;color:#d1e9ff}
.login-panel{background:#fff}
.login-form-wrap h2{font-size:28px;font-weight:800;letter-spacing:-.035em}
.login-form-wrap>div>p{font-size:14px;margin:8px 0 20px}
@media(max-width:1120px){
.sidebar{width:230px}
.workspace-main{margin-left:230px;width:calc(100% - 230px)}
.topbar{padding:0 24px}.demo-strip{margin-left:24px;margin-right:24px}.app main{padding-left:24px;padding-right:24px}.app-footer{margin-left:24px;margin-right:24px}
.login-copy h1{font-size:46px}.login-story{padding:40px}
}
@media(max-width:900px){
.login-benefits{grid-template-columns:1fr}
.login-copy h1{font-size:36px}
}
@media(max-width:700px){
.sidebar{width:100%;padding:16px 16px 4px}
.sidebar>a>.brand{margin:0 0 14px}
.sidebar nav :is(button,a){font-size:12.5px;padding:10px}
.sidebar nav :is(button,a).active::before{display:none}
.workspace-main{margin:0;width:100%}
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
.topbar-right{gap:10px}
.topbar-right>span:not(.demo-indicator){display:none}
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
.app main{padding:22px 16px}
.app-footer{margin-left:16px;margin-right:16px}
.page-head h1{font-size:30px}
.page-head p{font-size:14px}
.metric strong{font-size:32px}
.login-story{padding:28px 20px}
}
@media (prefers-reduced-motion:reduce){
.btn,.content-card,.sidebar nav :is(button,a){transition:none}
.btn.primary:hover,.content-card:hover{transform:none}
}
/* Skeletons: a soft blue shimmer in the shape of the content that is loading */
.skel{display:block;border-radius:8px;background:linear-gradient(90deg,#eaf2ff 0%,#f5f9ff 40%,#eaf2ff 80%) 0 0/300% 100%;animation:skel-shimmer 1.4s ease-in-out infinite}
.skel-stack{display:grid;gap:10px}
.skel-row{align-items:center}
.skel-row .result-main{display:grid;gap:9px}
.skel-cover{width:46px;height:52px;border-radius:12px;flex-shrink:0}
.skel-pill{width:78px;height:24px;border-radius:999px;flex-shrink:0}
.skel-block{height:122px;border-radius:0}
.skel-num{display:block;width:56px;height:38px;margin:10px 0 6px;border-radius:10px}
.metric:first-child .skel{background:linear-gradient(90deg,rgba(255,255,255,.18) 0%,rgba(255,255,255,.38) 40%,rgba(255,255,255,.18) 80%) 0 0/300% 100%}
@keyframes skel-shimmer{from{background-position:100% 0}to{background-position:-50% 0}}
/* Each route fades in instead of snapping, which hides the swap between pages */
.app main{animation:page-in .28s ease-out}
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}

24
tsconfig.app.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

4
tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

19
tsconfig.node.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["vite.config.ts"]
}

34
vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// Clerk publishable keys are public. Default to the engine's own key so Express
// and learning-lab share one Clerk instance (same users, organizations, credits).
function engineClerkKey(): string {
try {
const env = readFileSync(fileURLToPath(new URL('../learning-lab/backend/.env', import.meta.url)), 'utf8')
return env.match(/^VITE_CLERK_PUBLISHABLE_KEY=["']?([^"'\s]+)/m)?.[1] ?? ''
} catch {
return ''
}
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const engineUrl = env.ENGINE_URL || 'http://localhost:8000'
const clerkKey = env.VITE_CLERK_PUBLISHABLE_KEY || engineClerkKey()
// Learning Studio (learning-lab/frontend), where a result opens in Compose for full editing.
const studioUrl = env.VITE_STUDIO_URL || 'http://localhost:5173'
return {
plugins: [react()],
define: {
'import.meta.env.VITE_CLERK_PUBLISHABLE_KEY': JSON.stringify(clerkKey),
'import.meta.env.VITE_STUDIO_URL': JSON.stringify(studioUrl),
},
// Same-origin /api keeps the engine free of CORS setup, as in production behind one domain.
server: { port: 5174, proxy: { '/api': { target: engineUrl, changeOrigin: true } } },
preview: { port: 5174, proxy: { '/api': { target: engineUrl, changeOrigin: true } } },
}
})