Read from GET /organizations/me, refreshed after every action and every 30 seconds, since agents and schedules spend credits in the background. The date is dropped from the topbar, and its items wrap on phones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.2 KiB
TypeScript
201 lines
8.2 KiB
TypeScript
// 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 const documentFile = (id: number) => request<Blob>(`/upload/${id}/file`, { as: 'blob' })
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ── 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<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 }
|
|
}
|