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

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 }
}