// 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('/upload/list')).map(asSource) export const documentStatus = (id: number) => request(`/upload/${id}`) export const deleteDocument = (id: number) => request(`/upload/${id}`, { method: 'DELETE' }) export const documentFile = (id: number) => request(`/upload/${id}/file`, { as: 'blob' }) export async function supportedExtensions(): Promise> { const f = await request<{ extensions: string[] }>('/upload/supported-formats') return new Set(f.extensions) } export async function uploadDocument(file: File): Promise { 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(`/upload/${id}/knowledge`) // Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short). // Reads a failed document's stored file again, for free. A knowledge base it already has is kept. export const reprocessDocument = (id: number) => request<{ id: number; status: string }>(`/upload/${id}/reprocess`, { method: 'POST' }) 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) } } // ── Workspace wallet ── // The organization's credit balance: Express spends workspace credits, never personal ones. export const workspaceCredits = async () => (await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null // ── Compose (text content) ── export interface ComposeSet { id: number; name: string } export async function generateText(docIds: number[], name: string) { const set = await request('/compose/sets', { method: 'POST', json: { name, mode: 'knowledge_documents', source_document_ids: docIds } }) const summary = await request(`/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(`/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 { if (agentCache) return agentCache let agent = (await request(AGENTS)).find(a => a.name === AGENT_NAME && a.status !== 'archived') agent ??= await request(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(`${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(`${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(`${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(`${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(`${AGENTS}/${agent.id}/source-drafts`)).filter(d => d.run_id === run.id) return { run: detail, drafts } }