Initial commit: Learning Lab Express

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

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

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

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

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

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