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:
6
.claude/launch.json
Normal file
6
.claude/launch.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{ "name": "express", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 5174 }
|
||||
]
|
||||
}
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
38
README.md
Normal file
38
README.md
Normal 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
35
index.html
Normal 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
615
mockup.html
Normal file
File diff suppressed because one or more lines are too long
2328
package-lock.json
generated
Normal file
2328
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal 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
27
prototype/README.md
Normal 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
192
prototype/engine.js
vendored
Normal 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
333
prototype/index.html
Normal file
File diff suppressed because one or more lines are too long
52
prototype/server.mjs
Normal file
52
prototype/server.mjs
Normal 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
28
src/App.tsx
Normal 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
78
src/api/client.ts
Normal 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
193
src/api/engine.ts
Normal 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
77
src/auth/AuthGate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
src/components/AppLayout.tsx
Normal file
121
src/components/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
src/components/AudioPlayer.tsx
Normal file
114
src/components/AudioPlayer.tsx
Normal 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
51
src/components/Icon.tsx
Normal 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
71
src/components/Page.tsx
Normal 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
28
src/components/Toast.tsx
Normal 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
80
src/express/parse.test.ts
Normal 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
179
src/express/parse.ts
Normal 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
|
||||
54
src/express/results.test.ts
Normal file
54
src/express/results.test.ts
Normal 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
331
src/express/results.ts
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
|
||||
`
|
||||
88
src/express/schedules.test.ts
Normal file
88
src/express/schedules.test.ts
Normal 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
316
src/express/schedules.ts
Normal 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
78
src/express/scorm.test.ts
Normal 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 & 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
265
src/express/scorm.ts
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
717
src/i18n.ts
Normal 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
49
src/main.tsx
Normal 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
250
src/pages/Brain.tsx
Normal 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
194
src/pages/Instruction.tsx
Normal 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
423
src/pages/Results.tsx
Normal 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
222
src/pages/Schedule.tsx
Normal 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
128
src/styles/app.css
Normal 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
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
260
src/styles/theme.css
Normal 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
24
tsconfig.app.json
Normal 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
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal 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
34
vite.config.ts
Normal 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 } } },
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user