Compare commits
46 Commits
production
...
66237bd217
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66237bd217 | ||
|
|
bdc65529bd | ||
|
|
6d78476b2b | ||
|
|
a3be635648 | ||
|
|
eb194df8e0 | ||
|
|
46730279cb | ||
|
|
8a282b6d0d | ||
|
|
822c5fcac9 | ||
|
|
93c665fdbf | ||
|
|
7ce5a0fa12 | ||
|
|
012159aae2 | ||
|
|
e1c88b291c | ||
|
|
833eb00919 | ||
|
|
677937eb98 | ||
|
|
7a7f67d3e2 | ||
|
|
74d7e01e4f | ||
|
|
7a51f35ec4 | ||
|
|
4e1bee4503 | ||
|
|
977a8fafc4 | ||
|
|
138be5ac96 | ||
|
|
dfb3275afe | ||
|
|
a47fe0f845 | ||
|
|
ebfd3802c6 | ||
|
|
6ac8692d0d | ||
|
|
3478d28bb2 | ||
|
|
4658d66503 | ||
|
|
c72807dc26 | ||
|
|
5bc0915edf | ||
|
|
d00561f75d | ||
|
|
8d745ad96e | ||
|
|
bcd747721d | ||
|
|
bdae539e9e | ||
|
|
643c3c39f9 | ||
|
|
adbcfcc7ba | ||
|
|
278c66897a | ||
|
|
bcd7964e0b | ||
| 43a977017b | |||
|
|
a73a5a3fdf | ||
|
|
d1492b9c76 | ||
|
|
b63e9c2ca8 | ||
|
|
d018c6826f | ||
|
|
844c88912a | ||
|
|
6f3a5234b4 | ||
|
|
3b56c27210 | ||
|
|
ef4fb7d971 | ||
| c1063bfa7e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.netlify
|
||||
|
||||
38
deploy.sh
Executable file
38
deploy.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploys Learning Lab Express to Netlify production (https://express.bawana.com).
|
||||
# The site has no Git integration: it is built here, with .env.production, and the dist folder is uploaded.
|
||||
#
|
||||
# ./deploy.sh test, build and deploy to production
|
||||
# ./deploy.sh --draft same, but to a draft URL for checking first
|
||||
set -euo pipefail
|
||||
|
||||
SITE_ID="574369ce-8199-498e-bb7c-0a19b8684ab2" # Netlify project "lab-express"
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v netlify >/dev/null; then
|
||||
echo "Netlify CLI not found. Install it with: npm install -g netlify-cli" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f .env.production ]; then
|
||||
echo ".env.production is missing: the build would use the dev Clerk key." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$(git status --porcelain -- src index.html package.json package-lock.json netlify.toml)" ]; then
|
||||
echo "Warning: there are uncommitted changes; they will be deployed too." >&2
|
||||
fi
|
||||
|
||||
echo "› Running tests"
|
||||
npm test
|
||||
|
||||
echo "› Building"
|
||||
npm run build
|
||||
|
||||
MESSAGE="$(git log -1 --pretty='%h %s')"
|
||||
if [ "${1:-}" = "--draft" ]; then
|
||||
echo "› Deploying a draft"
|
||||
netlify deploy --no-build --dir dist --site "$SITE_ID" --message "Draft: $MESSAGE"
|
||||
else
|
||||
echo "› Deploying to production"
|
||||
netlify deploy --prod --no-build --dir dist --site "$SITE_ID" --message "$MESSAGE"
|
||||
fi
|
||||
145
docs/engine-api.md
Normal file
145
docs/engine-api.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Alur API Express → Engine learning-lab
|
||||
|
||||
Express tidak punya backend sendiri. Semua logika AI (perencanaan, pembuatan materi, video, riset) ada di engine **learning-lab** (FastAPI, `/api/v1`). Express hanya membuat *agent* (jadwal) beserta konfigurasinya, lalu membaca dan meninjau hasilnya.
|
||||
|
||||
## Jalur request
|
||||
|
||||
| Lingkungan | Jalur |
|
||||
|---|---|
|
||||
| Production | `express.bawana.com/api/*` → redirect Netlify ([netlify.toml](../netlify.toml)) → `https://learningstudio.bawana.com/api/*` |
|
||||
| Dev | Proxy Vite `/api` → `ENGINE_URL` (default `http://localhost:8000`, [vite.config.ts](../vite.config.ts)) |
|
||||
|
||||
- Prefix `/api/v1` ditambahkan di [src/api/client.ts](../src/api/client.ts). Setiap request membawa `Authorization: Bearer <Clerk session token>`.
|
||||
- Request baca dan aksi diatur lajunya oleh `pace` di `client.ts`, supaya tidak terkena rate limit proxy (503/429).
|
||||
- Endpoint agent ada di `learning-lab/backend/app/api/v1/endpoints/agents.py` (semua di bawah `/organizations/current/agents`). Endpoint produksi konten ada di `agent_content.py` (`/organizations/current/agents/{id}/content`).
|
||||
|
||||
Di bawah ini, `AGENTS` = `/organizations/current/agents`.
|
||||
|
||||
## Jadwal = agent
|
||||
|
||||
Setiap jadwal Express adalah satu agent engine. Namanya diawali prefix Express, lalu dibaca lewat `expressAgents()` di [schedules.ts](../src/express/schedules.ts).
|
||||
|
||||
### Membuat jadwal (`createSchedule`)
|
||||
|
||||
| Langkah | Endpoint |
|
||||
|---|---|
|
||||
| (khusus video) Ambil harga satu video sebagai budget run | `GET AGENTS/video-quote?scenes=…&duration_seconds=…` |
|
||||
| Buat agent dengan konfigurasi dari `configFor()` | `POST AGENTS` |
|
||||
| Pasang dokumen Brain (semua dokumen yang siap, atau hanya yang dipilih) | `PUT AGENTS/{id}/knowledge-sources` |
|
||||
| Aktifkan (menyetujui aturan produksi; sebelum aktif tidak ada yang berjalan) | `POST AGENTS/{id}/activate` |
|
||||
|
||||
### Mengelola jadwal
|
||||
|
||||
| Aksi | Endpoint |
|
||||
|---|---|
|
||||
| Ubah brief (engine mengembalikan agent ke draft, jadi langsung diaktifkan ulang atau dipause) | `PATCH AGENTS/{id}`, lalu `POST AGENTS/{id}/activate` atau `/pause` |
|
||||
| Pause / resume | `POST AGENTS/{id}/pause`, `POST AGENTS/{id}/activate` |
|
||||
| Arsipkan | `POST AGENTS/{id}/archive` |
|
||||
| Riwayat run | `GET AGENTS/{id}/runs` |
|
||||
| Kesiapan knowledge | `GET AGENTS/{id}/knowledge-readiness` |
|
||||
|
||||
## 1. Konten materi (summary, quiz, flashcard, slide, audio, SCORM, …)
|
||||
|
||||
**Konfigurasi agent** (`configFor`):
|
||||
|
||||
```ts
|
||||
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'compose.create_material']
|
||||
material_outputs: outputsFor(kind)
|
||||
content_goal: { enabled: true, weekly_target, execution_mode: 'automatic' }
|
||||
```
|
||||
|
||||
Audio dan SCORM adalah jenis buatan Express sendiri, disusun dari output Compose. Audio adalah summary narasi; SCORM adalah pasangan summary dan quiz dari satu item rencana.
|
||||
|
||||
**Produksi** (`AGENTS/{id}/content`):
|
||||
|
||||
| Langkah | Endpoint |
|
||||
|---|---|
|
||||
| "Produce now", atau tick otomatis jam 09:00 WIB | `POST …/content/planning-quotes` `{ target }` |
|
||||
| Konfirmasi rencana | `POST …/content/plans/{plan}/confirm-planning` `{ revision }` |
|
||||
| Approve dan start | Dijalankan otomatis oleh tick engine (mode `automatic`) |
|
||||
| Retry satu item (maksimal 3 percobaan) | `POST …/plans/{plan}/items/{item}/retry-quote`, lalu `POST …/plans/{plan}/start` |
|
||||
| Status rencana dan periode minggu ini | `GET …/content` (di-cache 30 detik oleh `productionStatus`) |
|
||||
|
||||
**Di sisi engine** (`backend/app/services/`):
|
||||
|
||||
- `agent_content_planning.py`: model mengusulkan topik beserta bukti dari isi Brain.
|
||||
- `agent_content_execution.py`: membuat quote dan memesan kredit untuk setiap item.
|
||||
- `agent_materials.py`: menjalankan Compose (`compose_creation`) untuk membuat materinya.
|
||||
|
||||
**Hasil**: `material-drafts`.
|
||||
|
||||
## 2. Video
|
||||
|
||||
**Konfigurasi agent**:
|
||||
|
||||
```ts
|
||||
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'studio.create_video']
|
||||
video_options: { max_scenes, max_duration_seconds, art_style: 'realistic', language }
|
||||
run_schedule: { cadence: 'weekly' | 'daily', hour: 9, minute: 0, weekday: 0 }
|
||||
```
|
||||
|
||||
**Memicu run**: tick jadwal di engine, atau `runNow`, yaitu `POST AGENTS/{id}/runs` dengan header `Idempotency-Key`.
|
||||
|
||||
**Di sisi engine**, `agent_worker.py` menjalankan:
|
||||
|
||||
`agent_video_drafts.py` (membuat project Video Studio dan memesan kredit) → `agent_video_script.py` → `agent_video_storyboard.py` → `agent_video_clips.py` → `agent_video_render.py`
|
||||
|
||||
**Hasil**: `video-drafts`. Satu draft tidak bisa di-retry sendiri; yang dilakukan adalah run baru dari jadwalnya, dan tombol retry hanya muncul di draft terbaru.
|
||||
|
||||
## 3. Riset sumber web
|
||||
|
||||
**Konfigurasi agent**:
|
||||
|
||||
```ts
|
||||
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url']
|
||||
research_options: { max_results: 5 }
|
||||
auto_approve_sources
|
||||
```
|
||||
|
||||
**Memicu run**: jam 07:00 WIB, atau `POST AGENTS/{id}/runs`.
|
||||
|
||||
**Hasil**: `source-drafts`.
|
||||
|
||||
## Membaca dan meninjau hasil ([results.ts](../src/express/results.ts))
|
||||
|
||||
| Jenis | Daftar | Detail | Aksi |
|
||||
|---|---|---|---|
|
||||
| Materi | `GET AGENTS/{id}/material-drafts` | `GET AGENTS/{id}/material-drafts/{draft}` | `POST …/material-drafts/{draft}/decision` `{ expected_revision, decision }` |
|
||||
| Video | `GET AGENTS/{id}/video-drafts` | (judul diambil dari daftar) | `POST …/video-drafts/{draft}/review` `{ decision }`, `POST …/video-drafts/{draft}/cancel` |
|
||||
| Sumber | `GET AGENTS/{id}/source-drafts` | — | `POST …/source-drafts/{draft}/review` `{ expected_revision, decision }` |
|
||||
|
||||
Ekspor slide deck ke PPTX memakai `POST /slide-deck/{deckId}/export`.
|
||||
|
||||
### Pola polling
|
||||
|
||||
`listResults()` dipakai oleh halaman Results dan Production (query key `['results']`, `RESULTS_POLL` = setiap 30 detik):
|
||||
|
||||
- Setiap poll membaca `GET AGENTS/status?archived=true` satu kali, dengan `If-None-Match` (opsi `revalidate` di `client.ts`). Kalau tidak ada perubahan, engine menjawab 304.
|
||||
- Dari respons itu dibuat tanda per agent. Endpoint per jadwal (`material-drafts`, `video-drafts`, `source-drafts`) hanya dibaca ulang untuk agent yang tandanya berubah.
|
||||
- Pembacaan ulang tetap dipaksa untuk jadwal konten dengan plan aktif (topik plan belum punya draft), setelah 30 menit (URL video bertanda tangan berlaku 1 jam), dan setelah setiap mutation (`forgetReads`, [main.tsx](../src/main.tsx)).
|
||||
- Kalau `/status` sibuk, tanda terakhir dipakai. Kalau endpoint tidak tersedia, semua jadwal dibaca seperti dulu; jadwal arsip tetap cukup dibaca sekali.
|
||||
- Paling banyak 4 request per jadwal berjalan bersamaan (`PER_SCHEDULE_LIMIT`). Konten dibaca dulu, baru riset.
|
||||
- Kalau engine membalas 503/429, request dicoba sekali lagi setelah 3 detik. Kalau tetap gagal, hasil poll sebelumnya yang dipakai.
|
||||
- Di engine, `_video_draft_out` menjalankan sekitar 5 query untuk setiap draft (project, run step, dokumen, scene, kredit), dengan batas 100 draft per agent.
|
||||
|
||||
## Brain dan kredit ([src/api/engine.ts](../src/api/engine.ts))
|
||||
|
||||
| Aksi | Endpoint |
|
||||
|---|---|
|
||||
| Daftar / status / hapus / unduh dokumen | `GET /upload/list`, `GET /upload/{id}`, `DELETE /upload/{id}`, `GET /upload/{id}/file` |
|
||||
| Upload | `POST /upload/` |
|
||||
| Format yang didukung | `GET /upload/supported-formats` |
|
||||
| Knowledge graph dokumen | `GET /upload/{id}/knowledge`, `POST /upload/{id}/knowledge/build` |
|
||||
| Proses ulang | `POST /upload/{id}/reprocess` |
|
||||
| Versi baru atau bukan | `POST /upload/{id}/versions/link` atau `/decline` |
|
||||
| Saldo kredit | `GET /organizations/me` |
|
||||
| Pemilik jadwal | `GET /auth/me`, `GET /organizations/current/dashboard/members` |
|
||||
|
||||
## Kode lama yang tidak dipakai
|
||||
|
||||
Fungsi berikut di `src/api/engine.ts` tidak dipanggil di mana pun di `src/`. Ini sisa alur versi awal, yang memakai satu agent tunggal bernama `Learning Lab Express` dan memanggil Compose langsung:
|
||||
|
||||
- `generateText`, `generateQuiz`: `POST /compose/sets`, `/generate/summary`, `/generate/quiz`
|
||||
- `ensureAgent`, `attachSources`, `setSchedule`, `research`
|
||||
|
||||
`resetAgentCache` masih dipakai oleh `AuthGate.tsx`.
|
||||
@@ -3,7 +3,7 @@
|
||||
<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." />
|
||||
<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 />
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
status = 200
|
||||
force = true
|
||||
|
||||
# BrowserRouter: /brain, /results, ... all load index.html.
|
||||
# BrowserRouter: /upload, /results, ... all load index.html.
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -1,25 +1,36 @@
|
||||
import { useAuth } from '@clerk/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthGate } from './auth/AuthGate'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import { AppLayout, ORG_ADMIN } from './components/AppLayout'
|
||||
import { BillingPage } from './pages/Billing'
|
||||
import { BrainPage } from './pages/Brain'
|
||||
import { ProductionPage } from './pages/Production'
|
||||
import { InstructionPage } from './pages/Instruction'
|
||||
import { SchedulePage } from './pages/Schedule'
|
||||
import { ResultsPage } from './pages/Results'
|
||||
|
||||
// For a member who opens the link directly; the engine refuses them the data either way.
|
||||
function AdminOnly({ children }: { children: ReactNode }) {
|
||||
const { orgRole } = useAuth()
|
||||
return orgRole === ORG_ADMIN ? children : <Navigate replace to="/production" />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/production" element={<ProductionPage />} />
|
||||
<Route path="/brain" element={<BrainPage />} />
|
||||
<Route path="/upload" element={<BrainPage />} />
|
||||
<Route path="/instruction" element={<InstructionPage />} />
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
<Route path="/results" element={<ResultsPage />} />
|
||||
<Route path="/admin/billing" element={<AdminOnly><BillingPage /></AdminOnly>} />
|
||||
</Route>
|
||||
{/* Indonesian paths from before the routes were renamed, so old links keep working. */}
|
||||
<Route path="/otak" element={<Navigate replace to="/brain" />} />
|
||||
{/* Paths from before the routes were renamed, so old links keep working. */}
|
||||
<Route path="/brain" element={<Navigate replace to="/instruction" />} />
|
||||
<Route path="/otak" element={<Navigate replace to="/upload" />} />
|
||||
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
|
||||
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
|
||||
<Route path="/hasil" element={<Navigate replace to="/results" />} />
|
||||
|
||||
57
src/api/client.test.ts
Normal file
57
src/api/client.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { limiter, pacer, request } from './client'
|
||||
|
||||
describe('limiter', () => {
|
||||
it('runs at most n calls at once, all of them, failures included', async () => {
|
||||
const limit = limiter(2)
|
||||
let running = 0, peak = 0
|
||||
const call = (n: number) => limit(async () => {
|
||||
peak = Math.max(peak, ++running)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
running--
|
||||
if (n === 2) throw new Error('503')
|
||||
return n
|
||||
})
|
||||
const out = await Promise.allSettled([1, 2, 3, 4, 5].map(call))
|
||||
expect(peak).toBe(2)
|
||||
expect(out.map(r => (r.status === 'fulfilled' ? r.value : 'x'))).toEqual([1, 'x', 3, 4, 5])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pacer', () => {
|
||||
it('lets a burst through, then spaces the rest out in order', async () => {
|
||||
const pace = pacer(100, 2)
|
||||
const t0 = Date.now()
|
||||
const order: number[] = []
|
||||
await Promise.all([1, 2, 3, 4, 5, 6].map(n => pace.wait().then(() => order.push(n))))
|
||||
// Two at once, then four more at 10 ms each.
|
||||
expect(Date.now() - t0).toBeGreaterThanOrEqual(35)
|
||||
expect(order).toEqual([1, 2, 3, 4, 5, 6])
|
||||
})
|
||||
|
||||
it('pauses everything after the proxy turns one away', async () => {
|
||||
const pace = pacer(100, 5)
|
||||
pace.pause(50)
|
||||
const t0 = Date.now()
|
||||
await pace.wait()
|
||||
expect(Date.now() - t0).toBeGreaterThanOrEqual(45)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request with revalidate', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('sends the last ETag back and answers a 304 with the last body', async () => {
|
||||
const sent: (string | null)[] = []
|
||||
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const tag = new Headers(init.headers).get('If-None-Match')
|
||||
sent.push(tag)
|
||||
return tag
|
||||
? new Response(null, { status: 304, headers: { ETag: 'W/"1"' } })
|
||||
: new Response(JSON.stringify({ agents: [1] }), { status: 200, headers: { ETag: 'W/"1"' } })
|
||||
}))
|
||||
expect(await request('/etag-test', { revalidate: true })).toEqual({ agents: [1] })
|
||||
expect(await request('/etag-test', { revalidate: true })).toEqual({ agents: [1] })
|
||||
expect(sent).toEqual([null, 'W/"1"'])
|
||||
})
|
||||
})
|
||||
@@ -42,18 +42,30 @@ export interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: BodyInit
|
||||
/** 'blob' for file downloads; errors still come back as JSON. */
|
||||
as?: 'json' | 'blob'
|
||||
/** Waits its turn like a read: for actions sent many at a time, which would otherwise outrun the rate limit. */
|
||||
paced?: boolean
|
||||
/** For a read the engine answers with an ETag: asks again with If-None-Match, and a 304 returns the last answer. */
|
||||
revalidate?: boolean
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, { json, headers, as = 'json', ...opts }: RequestOptions = {}): Promise<T> {
|
||||
// The last answer to each `revalidate` read, by path, with the ETag that identifies it.
|
||||
const validated = new Map<string, { etag: string; data: unknown }>()
|
||||
|
||||
export async function request<T>(path: string, { json, headers, as = 'json', paced, revalidate, ...opts }: RequestOptions = {}): Promise<T> {
|
||||
const h = new Headers(headers)
|
||||
const token = await getToken()
|
||||
if (token) h.set('Authorization', `Bearer ${token}`)
|
||||
const kept = revalidate ? validated.get(path) : undefined
|
||||
if (kept) h.set('If-None-Match', kept.etag)
|
||||
let body = opts.body
|
||||
if (json !== undefined) {
|
||||
h.set('Content-Type', 'application/json')
|
||||
body = JSON.stringify(json)
|
||||
}
|
||||
|
||||
if (paced || !opts.method || opts.method === 'GET') await pace.wait()
|
||||
else pace.take()
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(BASE + path, { ...opts, headers: h, body })
|
||||
@@ -61,14 +73,22 @@ export async function request<T>(path: string, { json, headers, as = 'json', ...
|
||||
throw new EngineError(t('err.unreachable'), 0)
|
||||
}
|
||||
if (res.status === 204) return null as T
|
||||
if (res.status === 304 && kept) return kept.data 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)
|
||||
// The proxy's rate limit answers 503 (or 429) with an HTML page, not the engine's JSON.
|
||||
if ((res.status === 503 || res.status === 429) && !data) {
|
||||
pace.pause(BUSY_PAUSE)
|
||||
throw new EngineError(t('err.busy'), 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
|
||||
}
|
||||
const etag = revalidate ? res.headers.get('ETag') : null
|
||||
if (etag) validated.set(path, { etag, data })
|
||||
return data as T
|
||||
}
|
||||
|
||||
@@ -76,3 +96,62 @@ export async function request<T>(path: string, { json, headers, as = 'json', ...
|
||||
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))
|
||||
|
||||
/** Spaces requests out to at most `perSecond` after a first `burst`. A limit on how many run at once
|
||||
* does not do this: the engine answers in about 100 ms, so four at a time is still 40 a second. */
|
||||
export function pacer(perSecond: number, burst: number) {
|
||||
let tokens = burst
|
||||
let last = Date.now()
|
||||
let queue = Promise.resolve()
|
||||
const refill = () => {
|
||||
const now = Date.now()
|
||||
tokens = Math.min(burst, tokens + ((now - last) * perSecond) / 1000)
|
||||
last = now
|
||||
}
|
||||
return {
|
||||
/** Waits for a turn; callers are served in the order they asked. */
|
||||
wait(): Promise<void> {
|
||||
const turn = queue.then(async () => {
|
||||
for (refill(); tokens < 1; refill()) await sleep(((1 - tokens) * 1000) / perSecond)
|
||||
tokens -= 1
|
||||
})
|
||||
queue = turn
|
||||
return turn
|
||||
},
|
||||
/** Takes a turn at once, ahead of the waiting ones, and the queue pays it back. */
|
||||
take() {
|
||||
refill()
|
||||
tokens -= 1
|
||||
},
|
||||
/** Sends nothing more for `ms`. */
|
||||
pause(ms: number) {
|
||||
refill()
|
||||
tokens = Math.min(tokens, (-ms * perSecond) / 1000)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's proxy allows 10 requests a second per address after a burst of 20, and answers the
|
||||
// rest 503. Everything the app reads shares one pace below that; an action the user takes (any
|
||||
// non-GET) is sent at once rather than queued behind a page's polling. When the proxy turns one away
|
||||
// anyway (e.g. another tab sharing the address), every request waits for it to recover.
|
||||
const pace = pacer(8, 10)
|
||||
const BUSY_PAUSE = 2000
|
||||
|
||||
/** Runs the calls it is given at most `n` at a time, for requests a page makes one per row. */
|
||||
export function limiter(n: number) {
|
||||
let running = 0
|
||||
const waiting: (() => void)[] = []
|
||||
return async <T,>(fn: () => Promise<T>): Promise<T> => {
|
||||
// A finished call hands its slot straight to the next waiting one, so none can slip in between.
|
||||
if (running >= n) await new Promise<void>(r => waiting.push(r))
|
||||
else running++
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
const next = waiting.shift()
|
||||
if (next) next()
|
||||
else running--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Typed engine calls used by Express Mode. Ported from prototype/engine.js.
|
||||
import { EngineError, opKey, request, sleep } from './client'
|
||||
import { EngineError, limiter, opKey, request, sleep } from './client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
// ── Documents (Brain) ──
|
||||
@@ -76,9 +76,14 @@ export interface KnowledgeDetail {
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
|
||||
// The Upload page asks once per document waiting on an answer; two at a time keeps a long list
|
||||
// from tripping the proxy's rate limit.
|
||||
const twoAtATime = limiter(2)
|
||||
export const knowledgeDetail = (id: number) => twoAtATime(() => 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).
|
||||
// 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.
|
||||
|
||||
@@ -33,7 +33,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
<div>
|
||||
<h2>{t('auth.pickOrg')}</h2>
|
||||
<p>{t('auth.pickOrgCopy')}</p>
|
||||
<OrganizationList hidePersonal afterSelectOrganizationUrl="/brain" afterCreateOrganizationUrl="/brain" />
|
||||
<OrganizationList hidePersonal afterSelectOrganizationUrl="/upload" afterCreateOrganizationUrl="/upload" />
|
||||
</div>
|
||||
</AuthScreen>
|
||||
)
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useAuth, useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
|
||||
import { workspaceCredits } from '../api/engine'
|
||||
import { Brand, Icon, type IconName } from './Icon'
|
||||
import { Toaster } from './Toast'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
|
||||
// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in.
|
||||
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||
|
||||
/** The Clerk role the engine treats as the organization's owner (`require_org_owner`). */
|
||||
export const ORG_ADMIN = 'org:admin'
|
||||
|
||||
// The Production dashboard shows what Express is making; the four settings doors run it:
|
||||
// feed the Brain, give an instruction, watch the schedule, collect results.
|
||||
export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [
|
||||
export const NAV_GROUPS: { label: Key; adminOnly?: boolean; items: { to: string; label: Key; icon: IconName }[] }[] = [
|
||||
{ label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] },
|
||||
{
|
||||
label: 'nav.settings',
|
||||
items: [
|
||||
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
|
||||
{ to: '/upload', 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' },
|
||||
],
|
||||
},
|
||||
{ label: 'nav.admin', adminOnly: true, items: [{ to: '/admin/billing', label: 'nav.billing', icon: 'coin' }] },
|
||||
]
|
||||
export const NAV = NAV_GROUPS.flatMap(g => g.items)
|
||||
|
||||
@@ -128,44 +133,96 @@ function OrgSwitcher() {
|
||||
)
|
||||
}
|
||||
|
||||
// Remembered per browser; storage can throw in private windows, so it falls back to expanded.
|
||||
// Between the phone layout and a full desktop, the sidebar would leave too little room for the
|
||||
// page and the topbar, so it starts as the icon rail there; the toggle still opens it for this visit.
|
||||
const NARROW = '(min-width: 701px) and (max-width: 1024px)'
|
||||
function useNarrow() {
|
||||
return useSyncExternalStore(
|
||||
notify => { const m = matchMedia(NARROW); m.addEventListener('change', notify); return () => m.removeEventListener('change', notify) },
|
||||
() => matchMedia(NARROW).matches,
|
||||
)
|
||||
}
|
||||
|
||||
const COLLAPSED_KEY = 'sidebar-collapsed'
|
||||
function readCollapsed() {
|
||||
try { return localStorage.getItem(COLLAPSED_KEY) === '1' } catch { return false }
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
useLang()
|
||||
const [help, setHelp] = useState(false)
|
||||
const { user } = useUser()
|
||||
const { signOut } = useClerk()
|
||||
const { orgRole } = useAuth()
|
||||
const { pathname } = useLocation()
|
||||
const current = NAV.find(n => pathname.startsWith(n.to))
|
||||
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
|
||||
const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({})
|
||||
useEffect(() => { setToggled({}) }, [pathname])
|
||||
// The window scrolls, not <main>, so a new page would open at the last page's scroll position.
|
||||
// In braces: Chrome's scrollTo now returns a Promise, which React would keep as the effect's
|
||||
// cleanup and call on the next route change ("is not a function"), unmounting the whole app.
|
||||
useLayoutEffect(() => { window.scrollTo(0, 0) }, [pathname])
|
||||
const [saved, setSaved] = useState(readCollapsed)
|
||||
const narrow = useNarrow()
|
||||
const [openNarrow, setOpenNarrow] = useState(false)
|
||||
const collapsed = narrow ? !openNarrow : saved
|
||||
const toggleCollapsed = () => {
|
||||
if (narrow) return setOpenNarrow(o => !o)
|
||||
setSaved(c => {
|
||||
try { localStorage.setItem(COLLAPSED_KEY, c ? '0' : '1') } catch { /* keep it for this visit only */ }
|
||||
return !c
|
||||
})
|
||||
}
|
||||
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className={collapsed ? 'app sidebar-collapsed' : 'app'}>
|
||||
<aside className="sidebar">
|
||||
<NavLink to="/production"><Brand /></NavLink>
|
||||
<OrgSwitcher />
|
||||
<nav aria-label={t('layout.mainNav')}>
|
||||
{NAV_GROUPS.map(g => [
|
||||
<div key={g.label} className="nav-label">{t(g.label)}</div>,
|
||||
...g.items.map(n => (
|
||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
||||
<Icon name={n.icon} />{t(n.label)}
|
||||
</NavLink>
|
||||
)),
|
||||
])}
|
||||
{NAV_GROUPS.filter(g => !g.adminOnly || orgRole === ORG_ADMIN).map(g => {
|
||||
// The icon rail has no group labels, so every item shows.
|
||||
const open = collapsed || (toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)))
|
||||
return [
|
||||
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
|
||||
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
|
||||
{t(g.label)}<Icon name="chevron-down" />
|
||||
</button>,
|
||||
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
|
||||
{g.items.map(n => (
|
||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||
title={collapsed ? t(n.label) : undefined}>
|
||||
<Icon name={n.icon} /><span className="nav-text">{t(n.label)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>,
|
||||
]
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-help">
|
||||
{collapsed ? (
|
||||
<button type="button" className="icon-btn sidebar-help-icon" title={t('help.open')} aria-label={t('help.open')}
|
||||
onClick={() => setHelp(true)}><Icon name="info" /></button>
|
||||
) : <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>}
|
||||
<div className="profile">
|
||||
<span className="avatar">{name.charAt(0).toUpperCase()}</span>
|
||||
<div><b>{name}</b><small>{user?.primaryEmailAddress?.emailAddress}</small></div>
|
||||
<span className="avatar" title={collapsed ? name : undefined}>{name.charAt(0).toUpperCase()}</span>
|
||||
<div><b title={name}>{name}</b><small title={user?.primaryEmailAddress?.emailAddress}>{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">
|
||||
<button type="button" className="icon-btn sidebar-toggle" aria-pressed={collapsed}
|
||||
aria-label={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
|
||||
title={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
|
||||
onClick={toggleCollapsed}><Icon name="sidebar" /></button>
|
||||
<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>
|
||||
@@ -174,14 +231,9 @@ export function AppLayout() {
|
||||
<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>
|
||||
<main tabIndex={-1} key={pathname}><ErrorBoundary><Outlet /></ErrorBoundary></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 />
|
||||
|
||||
40
src/components/ErrorBoundary.tsx
Normal file
40
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { t } from '../i18n'
|
||||
import { Empty } from './Page'
|
||||
|
||||
// Without this, one component throwing while rendering unmounts the whole app and leaves a white
|
||||
// page. The layout keys it by route, so moving to another page clears the error; main.tsx also puts
|
||||
// one around the whole app (the layout included), cleared by a change of `resetKey`.
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode; resetKey?: string; className?: string }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
componentDidUpdate(prev: { resetKey?: string }) {
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null })
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
console.error('Page crashed', error)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state
|
||||
if (!error) return this.props.children
|
||||
return (
|
||||
<div className={this.props.className ?? 'card'}>
|
||||
<Empty icon="info" title={t('err.crashTitle')}>
|
||||
{t('err.crashCopy')}
|
||||
<br />
|
||||
<small>{error.name}: {error.message}</small>
|
||||
</Empty>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, paddingBottom: 24 }}>
|
||||
<button className="btn small" onClick={() => this.setState({ error: null })}>{t('common.retry')}</button>
|
||||
<button className="btn small primary" onClick={() => window.location.reload()}>{t('err.reload')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,13 @@ const paths: Record<string, string> = {
|
||||
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"/>',
|
||||
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
||||
shrink: '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',
|
||||
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>',
|
||||
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
|
||||
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
|
||||
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c1-4 4-6 8-6s7 2 8 6"/>',
|
||||
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"/>',
|
||||
@@ -49,7 +54,7 @@ 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>Learning Lab <b>Express</b><small>by BAWANA</small></span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Icon, type IconName } from './Icon'
|
||||
|
||||
export function PageHead({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
|
||||
export function PageHead({ title, description, actions, eyebrow }: { title: ReactNode; description: string; actions?: ReactNode; eyebrow?: string }) {
|
||||
return (
|
||||
<div className="page-head">
|
||||
<div><h1>{title}</h1><p>{description}</p></div>
|
||||
<div>{eyebrow && <small className="page-eyebrow">{eyebrow}</small>}<h1>{title}</h1><p>{description}</p></div>
|
||||
{actions && <div className="head-actions">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,16 @@ describe('instruction agent', () => {
|
||||
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
|
||||
})
|
||||
|
||||
it('asks no topic for content when material is chosen, and names that material in the plan', () => {
|
||||
const s = { ...say('make 1 scorm per week'), sources: '“LLM”' }
|
||||
expect(nextQuestion(s)?.type).toBe('language')
|
||||
expect(respond({ ...EMPTY, sources: '“LLM”' }, 'make 1 scorm per week').reply).toMatch(/from “LLM”/)
|
||||
})
|
||||
|
||||
it('still asks what to research on the internet when material is chosen', () => {
|
||||
expect(nextQuestion({ ...say('research every day'), sources: '“LLM”' })?.type).toBe('topic')
|
||||
})
|
||||
|
||||
it('asks about a video count that has no cadence', () => {
|
||||
const s = say('3 videos per week about onboarding')
|
||||
expect(nextQuestion(s)?.type).toBe('video')
|
||||
@@ -77,6 +87,53 @@ describe('instruction agent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('instruction agent with vague or long instructions', () => {
|
||||
const LONG = `Buat 5 konten pembelajaran baru bertema Management Skills setiap hari secara otomatis pada pukul 09.00 WIB.
|
||||
|
||||
Gunakan seluruh materi pembelajaran yang tersedia di dalam sistem sebagai referensi. Pastikan setiap konten memiliki topik yang berbeda, relevan, informatif, dan tidak menduplikasi konten yang sudah pernah dibuat.
|
||||
|
||||
Simpan seluruh konten yang berhasil dibuat ke dalam sistem dengan status Draft untuk ditinjau oleh admin sebelum dipublikasikan.
|
||||
|
||||
Jalankan proses ini menggunakan scheduler atau background job yang tersedia di sistem.`
|
||||
|
||||
it('reads a long brief as one rule and answers the rest truthfully instead of asking about it', () => {
|
||||
const turn = respond(EMPTY, LONG)
|
||||
expect(rules(turn.state)).toEqual([expect.objectContaining({ count: 5, per: 'day', topic: 'Management Skills', kindGuessed: true })])
|
||||
expect(turn.state.unknown).toEqual([])
|
||||
for (const key of ['note.draft', 'note.novelty', 'note.automatic', 'note.wholeBrain'] as const) expect(turn.reply).toContain(t(key))
|
||||
expect(turn.reply).toContain(t('note.hourMatches', { at: '09.00' }))
|
||||
expect(nextQuestion(turn.state)?.type).toBe('kind')
|
||||
})
|
||||
|
||||
it('asks which kind "content" means, and splits the count over the kinds named', () => {
|
||||
const s = say(LONG, 'ringkasan dan kuis')
|
||||
expect(rules(s)).toMatchObject([{ kind: 'summary', count: 3 }, { kind: 'quiz', count: 2 }])
|
||||
expect(rules(say(LONG, t('agent.optMix'))).map(r => r.type === 'content' && r.kind)).toEqual(['summary', 'quiz', 'flashcards'])
|
||||
})
|
||||
|
||||
it('asks how often instead of assuming per week', () => {
|
||||
const s = say('bikin 10 konten baru dalam 20 menit', 'kuis')
|
||||
expect(nextQuestion(s)?.type).toBe('per')
|
||||
expect(rules(say('bikin 10 konten baru dalam 20 menit', 'kuis', t('agent.optEveryWeek')))[0]).toMatchObject({ kind: 'quiz', count: 10, per: 'week', perGuessed: undefined })
|
||||
})
|
||||
|
||||
it('says plainly that it cannot work in minutes', () => {
|
||||
expect(respond(EMPTY, 'bikin 10 konten baru dalam 20 menit').reply).toContain(t('note.subDay'))
|
||||
})
|
||||
|
||||
it('replaces the placeholder when the user restates the request, rather than adding another', () => {
|
||||
const s = say('10 pdf per day', '10 konten')
|
||||
expect(rules(s)).toHaveLength(1)
|
||||
expect(rules(s)[0]).toMatchObject({ count: 10, per: 'day', kindGuessed: true })
|
||||
})
|
||||
|
||||
it('does not repeat the same question when a rephrasing still makes no sense', () => {
|
||||
const s = say('2 summaries per day about sales. hello there', 'blabla')
|
||||
expect(s.unknown).toEqual([])
|
||||
expect(nextQuestion(s)?.type).toBe('language')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatLangOf', () => {
|
||||
it('tells Indonesian from English, and stays undecided on short replies', () => {
|
||||
expect(chatLangOf('Saya mau buat video dari file LLM')).toBe('id')
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// user to spot alone.
|
||||
import { t, type Key, type Lang } from '../i18n'
|
||||
import {
|
||||
dayName, describeRule, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf, weeklyTarget,
|
||||
type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
|
||||
dayName, describeRule, KINDS, kindLabel, kindsIn, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf,
|
||||
weeklyTarget, type ContentKind, type ContentRule, type Note, type OutputLanguage, type ResearchRule, type Rule,
|
||||
} from './parse'
|
||||
|
||||
export interface Item { id: number; rule: Rule }
|
||||
@@ -18,25 +18,35 @@ export interface AgentState {
|
||||
settled: string[]
|
||||
/** The language the content is written in, confirmed by the user; null until then. */
|
||||
language: OutputLanguage | null
|
||||
/** The material chosen in the Brain panel, as the plan names it; null for the whole Brain. */
|
||||
sources: string | null
|
||||
}
|
||||
|
||||
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null }
|
||||
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null, sources: null }
|
||||
|
||||
export type Question =
|
||||
| { key: string; type: 'unknown'; text: string }
|
||||
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
|
||||
| { key: string; type: 'kind' | 'video' | 'tooMany' | 'weekday'; id: number }
|
||||
| { key: string; type: 'topic'; ids: number[] }
|
||||
| { key: string; type: 'per'; ids: number[] }
|
||||
| { key: string; type: 'language' }
|
||||
|
||||
/** What the plan still needs, in the order the agent asks about it. */
|
||||
export function nextQuestion(s: AgentState): Question | null {
|
||||
if (s.unknown.length) return { key: `unknown:${s.unknown[0]}`, type: 'unknown', text: s.unknown[0] }
|
||||
// The kind and the frequency come first: the other checks depend on them.
|
||||
const generic = s.items.find(i => i.rule.type === 'content' && i.rule.kindGuessed)
|
||||
if (generic) return { key: `kind:${generic.id}`, type: 'kind', id: generic.id }
|
||||
// One question for every rule with no frequency, since they usually come from the same request.
|
||||
const unset = s.items.filter(i => i.rule.type === 'content' && i.rule.perGuessed).map(i => i.id)
|
||||
if (unset.length) return { key: `per:${unset.join(',')}`, type: 'per', ids: unset }
|
||||
for (const { id, rule } of s.items) {
|
||||
if (rule.type === 'content' && rule.kind === 'video' && videoCadence(rule) === null) return { key: `video:${id}`, type: 'video', id }
|
||||
if (rule.type === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id }
|
||||
if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id }
|
||||
}
|
||||
const ids = s.items.filter(i => !i.rule.topic).map(i => i.id)
|
||||
// Chosen material already says what content is about; research still needs a topic to search the internet for.
|
||||
const ids = s.items.filter(i => !i.rule.topic && !(s.sources && i.rule.type === 'content')).map(i => i.id)
|
||||
const key = `topic:${ids.join(',')}`
|
||||
if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids }
|
||||
// Research only adds sources to the Brain, so the language matters once there is content to write.
|
||||
@@ -44,24 +54,40 @@ export function nextQuestion(s: AgentState): Question | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n')
|
||||
const describe = (s: AgentState, rule: Rule) => describeRule(rule, s.sources ?? undefined)
|
||||
const bullets = (s: AgentState, rules: Rule[]) => rules.map(r => `• ${describe(s, r)}`).join('\n')
|
||||
const about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '')
|
||||
const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule
|
||||
const contentById = (s: AgentState, id: number) => ruleById(s, id) as ContentRule
|
||||
// "Mix" splits a generic request over the three kinds most teams start with.
|
||||
const MIX: ContentKind[] = ['summary', 'quiz', 'flashcards']
|
||||
|
||||
/** The agent's question, with replies the user can pick instead of typing. */
|
||||
export function ask(q: Question, s: AgentState): { text: string; options: string[] } {
|
||||
switch (q.type) {
|
||||
case 'unknown':
|
||||
return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] }
|
||||
case 'kind': {
|
||||
const rule = contentById(s, q.id)
|
||||
return {
|
||||
text: t('agent.askKind', { count: rule.count, about: about(rule) }),
|
||||
options: [...KINDS.map(kindLabel), t('agent.optMix')],
|
||||
}
|
||||
}
|
||||
case 'per':
|
||||
return {
|
||||
text: t('agent.askPer', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
|
||||
options: [t('agent.optEveryDay'), t('agent.optEveryWeek')],
|
||||
}
|
||||
case 'video':
|
||||
return { text: t('agent.askVideo', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
|
||||
return { text: t('agent.askVideo', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
|
||||
case 'tooMany':
|
||||
return { text: t('agent.askTooMany', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
|
||||
return { text: t('agent.askTooMany', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
|
||||
case 'weekday':
|
||||
return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) }
|
||||
case 'topic':
|
||||
return {
|
||||
text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }),
|
||||
text: t('agent.askTopic', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
|
||||
options: [t('agent.optWholeBrain')],
|
||||
}
|
||||
case 'language':
|
||||
@@ -75,6 +101,8 @@ export function editRule(s: AgentState, id: number, patch: Partial<ContentRule>
|
||||
// A count or frequency typed in by hand replaces the monthly request it was derived from.
|
||||
...('count' in patch || 'per' in patch ? { monthly: undefined } : {}),
|
||||
...('weekday' in patch || 'cadence' in patch ? { weekdayGuessed: undefined } : {}),
|
||||
...('kind' in patch ? { kindGuessed: undefined } : {}),
|
||||
...('per' in patch ? { perGuessed: undefined } : {}),
|
||||
}
|
||||
return { ...s, items: s.items.map(i => (i.id === id ? { ...i, rule: { ...i.rule, ...patch, ...guessed } as Rule } : i)) }
|
||||
}
|
||||
@@ -84,10 +112,11 @@ export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, it
|
||||
// Words that tell which language a message is written in, so the agent can answer in it.
|
||||
const ID_CHAT = new Set(('saya aku mau ingin bikin bikinin buat buatkan tolong dong ya iya tidak gak nggak enggak yang dan dengan untuk dari '
|
||||
+ 'ke di ini itu apa bagaimana gimana hari minggu bulan setiap tiap soal tentang mengenai riset ringkasan kuis lewati hapus seluruh '
|
||||
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai').split(' '))
|
||||
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai konten baru materi '
|
||||
+ 'pembelajaran menit jam dalam simpan gunakan pastikan').split(' '))
|
||||
const EN_CHAT = new Set(('i me my want would like make create please the and with for from to in this that what how day week month every '
|
||||
+ 'each about research summary summaries quiz quizzes skip remove whole brain monday tuesday wednesday thursday friday saturday '
|
||||
+ 'sunday yes no okay sure start use language also just now').split(' '))
|
||||
+ 'sunday yes no okay sure start use language also just now content new minutes hours save make sure').split(' '))
|
||||
|
||||
/** The language a message is written in, or null when it is too short or mixed to tell. */
|
||||
export function chatLangOf(text: string): Lang | null {
|
||||
@@ -105,19 +134,72 @@ const REMOVE = /^(remove|hapus|buang|delete)\b/
|
||||
const WHOLE = /^(whole brain|the whole brain|seluruh otak|semua|semuanya|all|everything|apa saja|bebas)$/
|
||||
const DAILY = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/
|
||||
const WEEKLY = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/
|
||||
const MONTHLY = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/
|
||||
const MIX_REPLY = /^(campur|campuran|mix|mixed|bebas|terserah|apa saja|any)\b/
|
||||
|
||||
let seq = 0
|
||||
const withIds = (rules: Rule[]) => rules.map(rule => ({ id: ++seq, rule }))
|
||||
|
||||
// A new instruction: its rules join the plan and its unclear parts are queued as questions.
|
||||
const noteText = (notes: Note[]) => notes.map(n => `\n${t(n.key, n.vars)}`).join('')
|
||||
|
||||
// A new instruction: its rules join the plan and its unclear parts are queued as questions. Requests about
|
||||
// how schedules run (drafts, the hour, "automatically") get a truthful note instead of being ignored.
|
||||
function instruct(s: AgentState, text: string): Turn | null {
|
||||
const { rules, unknown, language } = parseInstruction(text)
|
||||
const languageNote = language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : ''
|
||||
if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
|
||||
return {
|
||||
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language },
|
||||
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }) + languageNote,
|
||||
const { rules, unknown, language, notes } = parseInstruction(text)
|
||||
const extra = (language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : '') + noteText(notes)
|
||||
if (!rules.length) return extra ? { state: { ...s, language: language ?? s.language }, reply: extra.trim() } : null
|
||||
let state: AgentState = { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language }
|
||||
// "Gunakan seluruh materi" already answers the topic question with the whole Brain.
|
||||
const q = nextQuestion(state)
|
||||
if (q?.type === 'topic' && notes.some(n => n.key === 'note.wholeBrain')) state = { ...state, settled: [...state.settled, q.key] }
|
||||
return { state, reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + extra }
|
||||
}
|
||||
|
||||
// A placeholder rule becomes the rules the reply asks for, in its place in the plan.
|
||||
function replaceRule(s: AgentState, id: number, rules: Rule[], notes: Note[] = []): Turn {
|
||||
const at = s.items.findIndex(i => i.id === id)
|
||||
const items = [...s.items.slice(0, at), ...withIds(rules), ...s.items.slice(at + 1)]
|
||||
return { state: { ...s, items }, reply: t('agent.kindSet', { list: bullets(s, rules) }) + noteText(notes) }
|
||||
}
|
||||
|
||||
// "Ringkasan dan kuis" for "10 konten" splits the count: 5 summaries and 5 quizzes.
|
||||
function answerKind(s: AgentState, id: number, text: string, lower: string, mix: boolean): Turn | null {
|
||||
const rule = contentById(s, id)
|
||||
// A full instruction ("2 ringkasan per hari", or "10 konten" again) restates the request, so it
|
||||
// replaces the placeholder instead of adding a second one; what it leaves out is kept from before.
|
||||
if (/\d/.test(lower)) {
|
||||
const { rules: parsed, notes } = parseInstruction(text)
|
||||
if (!parsed.length) return null
|
||||
return replaceRule(s, id, parsed.map(r => {
|
||||
if (r.type === 'research') return r.topic ? r : { ...r, topic: rule.topic }
|
||||
const keepPer = r.perGuessed && !rule.perGuessed ? { per: rule.per, perGuessed: undefined } : {}
|
||||
return { ...r, topic: r.topic || rule.topic, ...keepPer }
|
||||
}), notes)
|
||||
}
|
||||
const kinds = mix ? MIX : [...new Set([...kindsIn(lower), ...KINDS.filter(k => lower === kindLabel(k).toLowerCase())])]
|
||||
if (!kinds.length) return null
|
||||
const { kindGuessed: _, ...base } = rule
|
||||
return replaceRule(s, id, kinds.map((kind, i) => ({
|
||||
...base,
|
||||
kind,
|
||||
count: Math.max(1, Math.floor(rule.count / kinds.length) + (i < rule.count % kinds.length ? 1 : 0)),
|
||||
})))
|
||||
}
|
||||
|
||||
// "Per hari" sets the frequency of every rule asked about; a number ("5 per hari") only when there is one.
|
||||
function answerPer(s: AgentState, ids: number[], lower: string): Turn | null {
|
||||
// A reply naming a kind or research is a new request, not a frequency for these.
|
||||
if (kindsIn(lower).length || /\b(riset|research)\b/.test(lower)) return null
|
||||
const n = ids.length === 1 ? Number(lower.match(/\d+/)?.[0]) || null : null
|
||||
const monthly = MONTHLY.test(lower) || /\b(bulan|month)\b/.test(lower)
|
||||
const per = monthly || WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week'
|
||||
: DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : null
|
||||
if (!per) return null
|
||||
const state = ids.reduce((st, id) => {
|
||||
const count = n ?? contentById(st, id).count
|
||||
return editRule(st, id, { per, count: monthly ? Math.max(1, Math.round(count / 4)) : count })
|
||||
}, s)
|
||||
return { state, reply: t('agent.kindSet', { list: bullets(state, ids.map(id => ruleById(state, id))) }) }
|
||||
}
|
||||
|
||||
// A reply to the language question may be short ("indo", "inggris", "zh"), so it is read more loosely.
|
||||
@@ -131,7 +213,7 @@ function languageAnswer(lower: string): OutputLanguage | null {
|
||||
|
||||
function updated(s: AgentState, id: number, patch: Partial<ContentRule> | Partial<ResearchRule>): Turn {
|
||||
const state = editRule(s, id, patch)
|
||||
return { state, reply: t('agent.updated', { rule: describeRule(ruleById(state, id)) }) }
|
||||
return { state, reply: t('agent.updated', { rule: describe(state, ruleById(state, id)) }) }
|
||||
}
|
||||
|
||||
// Reads a reply to the agent's question; null when it does not answer it.
|
||||
@@ -153,9 +235,11 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
|
||||
return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) }
|
||||
}
|
||||
|
||||
if (q.type === 'per') return answerPer(s, q.ids, is('agent.optEveryDay') ? 'per hari' : is('agent.optEveryWeek') ? 'per minggu' : lower)
|
||||
if (REMOVE.test(lower) || is('agent.optRemove')) {
|
||||
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) }
|
||||
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describe(s, ruleById(s, q.id)) }) }
|
||||
}
|
||||
if (q.type === 'kind') return answerKind(s, q.id, text, lower, MIX_REPLY.test(lower) || is('agent.optMix'))
|
||||
const per = DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week' : null
|
||||
if (q.type === 'video') return per ? updated(s, q.id, { count: 1, per }) : null
|
||||
if (q.type === 'tooMany') {
|
||||
@@ -178,8 +262,13 @@ export function respond(s: AgentState, input: string): Turn {
|
||||
|
||||
const q = nextQuestion(s)
|
||||
if (q) {
|
||||
// Not an answer, so maybe a new instruction; otherwise the same question is asked again.
|
||||
return answer(q, s, text, lower, is) ?? instruct(s, text) ?? { state: s, reply: t('agent.unclear') }
|
||||
const turn = answer(q, s, text, lower, is) ?? instruct(s, text)
|
||||
if (turn) return turn
|
||||
// A rephrasing that still made no sense: asking about the old wording again would loop, so it is
|
||||
// dropped and the agent says what it can do instead.
|
||||
if (q.type === 'unknown') return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.notUnderstood') }
|
||||
// Otherwise the same question is asked again.
|
||||
return { state: s, reply: t('agent.unclear') }
|
||||
}
|
||||
if (s.items.length && (YES.test(lower) || is('agent.optApprove'))) return { state: s, reply: '', approve: true }
|
||||
return instruct(s, text) ?? { state: s, reply: t('agent.notUnderstood') }
|
||||
|
||||
93
src/express/billing.test.ts
Normal file
93
src/express/billing.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dayKey, entryTime, lastDays, ledgerSince, moduleOf, usageCsv, usageInPeriod, type LedgerEntry } from './billing'
|
||||
import { request } from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ request: vi.fn() }))
|
||||
|
||||
const at = (y: number, m: number, d: number, h = 12) => new Date(y, m - 1, d, h).toISOString()
|
||||
const entry = (id: number, kind: string, amount: number, created_at: string): LedgerEntry =>
|
||||
({ id, kind, amount, activity: 'agent.browse.search', actor_name: 'Ana', created_at })
|
||||
|
||||
afterEach(() => vi.mocked(request).mockReset())
|
||||
|
||||
describe('entryTime', () => {
|
||||
it('reads a time without an offset as UTC', () => {
|
||||
expect(entryTime('2026-11-04T01:00:00').toISOString()).toBe('2026-11-04T01:00:00.000Z')
|
||||
expect(entryTime('2026-11-04T01:00:00.250').toISOString()).toBe('2026-11-04T01:00:00.250Z')
|
||||
})
|
||||
|
||||
it('keeps an offset that is there', () => {
|
||||
expect(entryTime('2026-11-04T08:00:00+07:00').toISOString()).toBe('2026-11-04T01:00:00.000Z')
|
||||
expect(entryTime('2026-11-04T01:00:00Z').toISOString()).toBe('2026-11-04T01:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageInPeriod', () => {
|
||||
it('includes both whole end days and nothing outside them', () => {
|
||||
const { rows } = usageInPeriod([
|
||||
entry(1, 'charge', -1, new Date(2026, 10, 1, 0, 0).toISOString()),
|
||||
entry(2, 'charge', -2, new Date(2026, 10, 3, 23, 59).toISOString()),
|
||||
entry(3, 'charge', -4, new Date(2026, 10, 4, 0, 0).toISOString()),
|
||||
entry(4, 'charge', -8, new Date(2026, 9, 31, 23, 59).toISOString()),
|
||||
], '2026-11-01', '2026-11-03')
|
||||
expect(rows.map(r => r.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('subtracts refunds and ignores grants and adjustments', () => {
|
||||
const { rows, total } = usageInPeriod([
|
||||
entry(1, 'charge', -10, at(2026, 11, 2)),
|
||||
entry(2, 'refund', 3, at(2026, 11, 2)),
|
||||
entry(3, 'grant', 500, at(2026, 11, 2)),
|
||||
entry(4, 'adjustment', -20, at(2026, 11, 2)),
|
||||
], '2026-11-01', '2026-11-03')
|
||||
expect(total).toBe(7)
|
||||
expect(rows.map(r => r.id)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ledgerSince', () => {
|
||||
it('follows pages until the ledger ends', async () => {
|
||||
vi.mocked(request)
|
||||
.mockResolvedValueOnce({ entries: [entry(3, 'charge', -1, at(2026, 11, 3))], next_before_id: 3 })
|
||||
.mockResolvedValueOnce({ entries: [entry(2, 'charge', -1, at(2026, 11, 2))], next_before_id: null })
|
||||
expect((await ledgerSince('2026-11-01')).map(e => e.id)).toEqual([3, 2])
|
||||
expect(vi.mocked(request).mock.calls[1][0]).toContain('before_id=3')
|
||||
})
|
||||
|
||||
it('stops once a page reaches past the start of the period', async () => {
|
||||
vi.mocked(request).mockResolvedValueOnce({ entries: [entry(9, 'charge', -1, at(2026, 10, 1))], next_before_id: 9 })
|
||||
await ledgerSince('2026-11-01')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageCsv', () => {
|
||||
const when = new Date(2026, 10, 3, 9, 5).toISOString()
|
||||
|
||||
it('writes a header and one row per entry, with refunds negative', () => {
|
||||
const lines = usageCsv([entry(1, 'charge', -5, when), entry(2, 'refund', 3, when)]).split('\r\n')
|
||||
expect(lines[0]).toBe('Date,User,Module,Activity,Type,Credits')
|
||||
expect(lines[1]).toBe('2026-11-03 09:05,Ana,Agent,browse search,Charge,5')
|
||||
expect(lines[2]).toBe('2026-11-03 09:05,Ana,Agent,browse search,Refund,-3')
|
||||
})
|
||||
|
||||
it('quotes what would break a column and defuses formulas', () => {
|
||||
const row = (actor_name: string | null) => usageCsv([{ ...entry(1, 'charge', -5, when), actor_name }]).split('\r\n')[1].split(',Agent')[0]
|
||||
expect(row('Doe, "Ana"')).toBe('2026-11-03 09:05,"Doe, ""Ana"""')
|
||||
expect(row('=HYPERLINK("x")')).toBe(`2026-11-03 09:05,"'=HYPERLINK(""x"")"`)
|
||||
expect(row(null)).toBe('2026-11-03 09:05,-')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('names the module and detail of an activity', () => {
|
||||
expect(moduleOf('agent.browse.search')).toEqual({ module: 'Agent', detail: 'browse search' })
|
||||
expect(moduleOf('slide_deck.generate')).toEqual({ module: 'Slide deck', detail: 'generate' })
|
||||
expect(moduleOf(null)).toEqual({ module: '-', detail: '' })
|
||||
})
|
||||
|
||||
it('counts the last days including today, in local dates', () => {
|
||||
expect(dayKey(new Date(2026, 0, 5))).toBe('2026-01-05')
|
||||
expect(lastDays(30, new Date(2026, 10, 4))).toEqual({ from: '2026-10-06', to: '2026-11-04' })
|
||||
})
|
||||
})
|
||||
93
src/express/billing.ts
Normal file
93
src/express/billing.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Billing reads the organization's credit ledger. The engine records credits, not LLM tokens, and its
|
||||
// ledger has no date filter, so a period is loaded page by page and cut to size here.
|
||||
import { request } from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
export interface LedgerEntry {
|
||||
id: number
|
||||
kind: string
|
||||
/** Signed: negative for a charge, positive for a refund or grant. */
|
||||
amount: number
|
||||
activity: string | null
|
||||
actor_name: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface LedgerPage { entries: LedgerEntry[]; next_before_id: number | null }
|
||||
|
||||
const LEDGER = '/organizations/current/dashboard/ledger'
|
||||
|
||||
// The ledger's times are UTC without saying so; new Date() alone would read them as local time.
|
||||
export const entryTime = (iso: string) => new Date(/(Z|[+-]\d\d:?\d\d)$/.test(iso) ? iso : `${iso}Z`)
|
||||
|
||||
const startOf = (day: string) => new Date(`${day}T00:00:00`)
|
||||
const endOf = (day: string) => {
|
||||
const end = startOf(day)
|
||||
end.setDate(end.getDate() + 1)
|
||||
return end
|
||||
}
|
||||
|
||||
/** A local date as the yyyy-mm-dd a date input uses. */
|
||||
export function dayKey(date: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function lastDays(days: number, today = new Date()) {
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - (days - 1))
|
||||
return { from: dayKey(from), to: dayKey(today) }
|
||||
}
|
||||
|
||||
/** Every ledger entry back to the start of a day. Pages come newest first, so older pages can stop early. */
|
||||
export async function ledgerSince(from: string): Promise<LedgerEntry[]> {
|
||||
const start = startOf(from)
|
||||
const entries: LedgerEntry[] = []
|
||||
let before: number | null = null
|
||||
do {
|
||||
const query: string = before === null ? '' : `&before_id=${before}`
|
||||
const page: LedgerPage = await request<LedgerPage>(`${LEDGER}?limit=200${query}`)
|
||||
entries.push(...page.entries)
|
||||
const oldest = page.entries.at(-1)
|
||||
before = oldest && entryTime(oldest.created_at) >= start ? page.next_before_id : null
|
||||
} while (before !== null)
|
||||
return entries
|
||||
}
|
||||
|
||||
/** What a period consumed: charges, less what was refunded. Top-ups and adjustments are not consumption. */
|
||||
export function usageInPeriod(entries: LedgerEntry[], from: string, to: string) {
|
||||
const start = startOf(from)
|
||||
const end = endOf(to)
|
||||
const rows = entries.filter(e => {
|
||||
if (e.kind !== 'charge' && e.kind !== 'refund') return false
|
||||
const at = entryTime(e.created_at)
|
||||
return at >= start && at < end
|
||||
})
|
||||
return { rows, total: rows.reduce((sum, e) => sum - e.amount, 0) }
|
||||
}
|
||||
|
||||
/** "agent.browse.search" → Agent, browse search. */
|
||||
export function moduleOf(activity: string | null) {
|
||||
if (!activity) return { module: '-', detail: '' }
|
||||
const [first, ...rest] = activity.split('.').map(part => part.replace(/_/g, ' '))
|
||||
return { module: first.charAt(0).toUpperCase() + first.slice(1), detail: rest.join(' ') }
|
||||
}
|
||||
|
||||
// A name is free text: quoted when it needs it, and kept from running as a formula when opened in Excel.
|
||||
function cell(value: string | number) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
const safe = /^([=+@\t\r]|-.)/.test(value) ? `'${value}` : value
|
||||
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe
|
||||
}
|
||||
|
||||
/** The rows as CSV, in local time and plain numbers. The BOM makes Excel read names as UTF-8. */
|
||||
export function usageCsv(rows: LedgerEntry[]) {
|
||||
const header = [t('billing.colDate'), t('billing.colUser'), t('billing.colModule'), t('billing.colActivity'), t('billing.colType'), t('billing.colCredits')]
|
||||
const lines = rows.map(e => {
|
||||
const at = entryTime(e.created_at)
|
||||
const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
|
||||
const { module, detail } = moduleOf(e.activity)
|
||||
return [`${dayKey(at)} ${time}`, e.actor_name || '-', module, detail, t(e.kind === 'refund' ? 'billing.refund' : 'billing.charge'), -e.amount]
|
||||
})
|
||||
return `${[header, ...lines].map(line => line.map(cell).join(',')).join('\r\n')}\r\n`
|
||||
}
|
||||
@@ -1,31 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Source } from '../api/engine'
|
||||
import { buildGraph, HEIGHT, WIDTH } from './graph'
|
||||
import type { FoundSource, Material } from './results'
|
||||
import type { Schedule } from './schedules'
|
||||
import type { Material } from './results'
|
||||
|
||||
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source
|
||||
const doc = (docId: number, title: string, status = 'completed') => ({ docId, title, originalName: `${title}.pdf`, status }) as Source
|
||||
const material = (id: number, status: Material['status'], sources: string[]) =>
|
||||
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
|
||||
const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule
|
||||
|
||||
describe('buildGraph', () => {
|
||||
const data = {
|
||||
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')],
|
||||
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])],
|
||||
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource],
|
||||
schedules: [schedule],
|
||||
docs: [doc(1, 'Onboarding'), doc(2, 'Safety'), doc(3, 'Broken', 'failed')],
|
||||
materials: [
|
||||
material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', []),
|
||||
material(12, 'failed', ['Safety']), material(13, 'ready', ['Safety']),
|
||||
],
|
||||
}
|
||||
const graph = buildGraph(data, c => c)
|
||||
|
||||
it('puts results in progress in their own cluster', () => {
|
||||
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made')
|
||||
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working')
|
||||
it('holds only the knowledge base and the content made from it', () => {
|
||||
expect(graph.nodes.filter(n => !n.hub).map(n => `${n.cluster}:${n.id}`).sort())
|
||||
.toEqual(['made:mat-7-10', 'made:mat-7-13', 'sources:doc-1', 'sources:doc-2'])
|
||||
})
|
||||
|
||||
it('links results to their sources and schedule, and found sources to their document', () => {
|
||||
it('links content to its sources', () => {
|
||||
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
|
||||
expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2']))
|
||||
expect(cross.sort()).toEqual(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-13>doc-2'])
|
||||
})
|
||||
|
||||
it('lays out the same data the same way, inside the canvas', () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// The Production knowledge graph: every Brain document, result, found source and schedule as a node,
|
||||
// grouped around one hub per cluster and linked where the engine says they are related (a result's
|
||||
// sources, the schedule that made it, the document a found source became). Laid out once with a
|
||||
// small seeded force simulation, so the same data always draws the same picture.
|
||||
// The Production knowledge graph: what the agent's Brain holds, its knowledge base (the documents)
|
||||
// and the content made from it, grouped around one hub per cluster and linked where a piece of
|
||||
// content names a document as its source. Work in progress, failed runs and schedules are status,
|
||||
// not knowledge, so they stay out. Laid out once with a small seeded force simulation, so the same
|
||||
// data always draws the same picture.
|
||||
import type { Source } from '../api/engine'
|
||||
import { isWorking, type FoundSource, type Material } from './results'
|
||||
import type { Schedule } from './schedules'
|
||||
import { isWorking, type Material } from './results'
|
||||
|
||||
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules'
|
||||
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules']
|
||||
export type Cluster = 'sources' | 'made'
|
||||
export const CLUSTERS: Cluster[] = ['sources', 'made']
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
@@ -27,13 +27,16 @@ export const HEIGHT = 640
|
||||
|
||||
// Where each cluster settles, around the middle of the canvas.
|
||||
const ANCHOR: Record<Cluster, [number, number]> = {
|
||||
sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230],
|
||||
sources: [580, 320], made: [420, 320],
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.trim().toLowerCase()
|
||||
|
||||
// Content still being made, failed or turned down is not part of the Brain.
|
||||
const inBrain = (m: Material) => !isWorking(m) && m.status !== 'failed' && m.status !== 'rejected'
|
||||
|
||||
export function buildGraph(
|
||||
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] },
|
||||
{ docs, materials }: { docs: Source[]; materials: Material[] },
|
||||
hubLabel: (c: Cluster) => string,
|
||||
): Graph {
|
||||
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
|
||||
@@ -46,30 +49,19 @@ export function buildGraph(
|
||||
|
||||
// Results name their sources by title, so documents are found by title or file name.
|
||||
const docByName = new Map<string, string>()
|
||||
for (const d of docs) {
|
||||
for (const d of docs.filter(d => d.status !== 'failed' && d.knowledge !== 'failed')) {
|
||||
add(`doc-${d.docId}`, 'sources', d.title)
|
||||
docByName.set(norm(d.title), `doc-${d.docId}`)
|
||||
docByName.set(norm(d.originalName), `doc-${d.docId}`)
|
||||
}
|
||||
for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name)
|
||||
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
|
||||
|
||||
for (const m of materials) {
|
||||
for (const m of materials.filter(inBrain)) {
|
||||
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
|
||||
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind)
|
||||
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
|
||||
add(id, 'made', m.planTitle ?? m.kind)
|
||||
for (const title of new Set(m.sources)) {
|
||||
const doc = docByName.get(norm(title))
|
||||
if (doc) link(id, doc)
|
||||
}
|
||||
}
|
||||
for (const s of sources) {
|
||||
const id = `found-${s.agentId}-${s.id}`
|
||||
add(id, 'research', s.title)
|
||||
if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`)
|
||||
if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`)
|
||||
}
|
||||
|
||||
layout(nodes, links)
|
||||
return { nodes, links, width: WIDTH, height: HEIGHT }
|
||||
}
|
||||
|
||||
@@ -47,6 +47,23 @@ describe('parseInstruction', () => {
|
||||
expect(content('1 modul scorm dengan kuis per minggu')[0].kind).toBe('scorm')
|
||||
})
|
||||
|
||||
it('reads INTERACTIVE, the name the interface shows, as SCORM', () => {
|
||||
const rules = content('1 video per week, 2 podcasts per week and 1 INTERACTIVE 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'],
|
||||
])
|
||||
expect(content('2 interactive per week')[0]).toMatchObject({ kind: 'scorm', count: 2 })
|
||||
expect(content('1 modul interaktif dengan kuis per minggu')[0].kind).toBe('scorm')
|
||||
})
|
||||
|
||||
it('keeps the named kind when interactive only describes it', () => {
|
||||
expect(content('2 kuis interaktif per minggu')[0].kind).toBe('quiz')
|
||||
expect(content('1 interactive video per week')[0].kind).toBe('video')
|
||||
expect(content('2 summaries per week about interactive learning')[0].kind).toBe('summary')
|
||||
})
|
||||
|
||||
it('reports clauses it did not understand', () => {
|
||||
const { rules, unknown } = parseInstruction('2 summaries per day. hello there')
|
||||
expect(rules).toHaveLength(1)
|
||||
@@ -78,3 +95,26 @@ describe('counts', () => {
|
||||
expect(parseInstruction('buat 0 ringkasan per minggu').rules[0]).toMatchObject({ kind: 'summary', count: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('generic content and sentences', () => {
|
||||
it('reads "konten" and "pdf" with a count as content of a kind still to choose', () => {
|
||||
expect(content('10 pdf per day')[0]).toMatchObject({ kind: 'summary', count: 10, per: 'day', kindGuessed: true })
|
||||
expect(content('bikin konten tiap minggu soal sales')[0]).toMatchObject({ kindGuessed: true, topic: 'sales' })
|
||||
})
|
||||
|
||||
it('does not read "gunakan seluruh materi" as a request', () => {
|
||||
expect(parseInstruction('gunakan seluruh materi pembelajaran sebagai referensi').rules).toEqual([])
|
||||
})
|
||||
|
||||
it('marks a rule with no frequency instead of silently making it weekly', () => {
|
||||
expect(content('3 kuis soal sales')[0]).toMatchObject({ per: 'week', perGuessed: true })
|
||||
expect(content('3 kuis per minggu soal sales')[0].perGuessed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not end a sentence inside a time such as 09.00', () => {
|
||||
const { rules, unknown } = parseInstruction('buat 2 kuis tiap hari soal sales pada pukul 09.00 WIB. simpan sebagai draft')
|
||||
expect(rules).toHaveLength(1)
|
||||
expect(rules[0]).toMatchObject({ topic: 'sales' })
|
||||
expect(unknown).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ 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/],
|
||||
['scorm', /\b(scorm|skorm|skong|e-?learning|interactive modules?|modul(e|es)? interaktif)\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/],
|
||||
@@ -30,6 +30,25 @@ const KIND_WORDS: [ContentKind, RegExp][] = [
|
||||
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
|
||||
]
|
||||
|
||||
// The interface calls SCORM "INTERACTIVE", so the bare word means it too, but only when nothing else
|
||||
// is named: "kuis interaktif" stays a quiz, and "summaries about interactive learning" stay summaries.
|
||||
const INTERACTIVE = /\b(interactive|interaktif)\b/
|
||||
|
||||
/** Every content kind a text names, in the order it names them: "ringkasan dan kuis" → summary, quiz. */
|
||||
export function kindsIn(text: string): ContentKind[] {
|
||||
const kinds = KIND_WORDS
|
||||
.map(([kind, re]) => [kind, text.search(re)] as const)
|
||||
.filter(([, at]) => at >= 0)
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.map(([kind]) => kind)
|
||||
return kinds.length || !INTERACTIVE.test(text) ? kinds : ['scorm']
|
||||
}
|
||||
|
||||
// "10 konten", "5 materi pembelajaran", "10 pdf": content of no particular kind, so the agent asks which.
|
||||
// Only with a count or a create verb before it, so "gunakan seluruh materi" is not a request.
|
||||
const GENERIC = /\b(konten|content|contents|materi|materials?|bahan ajar|pdf|pdfs|dokumen|documents?|modul|modules?|items?)\b/
|
||||
const CREATE = /\b(bikin|bikinin|buat|buatkan|membuat|hasilkan|produksi|make|create|generate|produce|write|tulis)\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,
|
||||
@@ -75,6 +94,12 @@ export interface ContentRule {
|
||||
monthly?: number
|
||||
/** The language the content is written in; unset means the UI language. */
|
||||
language?: OutputLanguage
|
||||
/** Made from material chosen in the Brain panel rather than the whole Brain. */
|
||||
chosen?: boolean
|
||||
/** The instruction said "content" without a kind, so the kind is only a placeholder the agent asks about. */
|
||||
kindGuessed?: boolean
|
||||
/** The instruction named no frequency, so per week is only a placeholder the agent asks about. */
|
||||
perGuessed?: boolean
|
||||
}
|
||||
|
||||
export interface ResearchRule {
|
||||
@@ -89,37 +114,81 @@ export interface ResearchRule {
|
||||
|
||||
export type Rule = ContentRule | ResearchRule
|
||||
|
||||
/** Something the instruction asked for that is not a rule: the agent answers it truthfully instead of ignoring it. */
|
||||
export interface Note { key: Key; vars?: Record<string, string | number> }
|
||||
|
||||
export interface ParseResult {
|
||||
rules: Rule[]
|
||||
/** Clauses that said something but matched no rule, so the user can rephrase them. */
|
||||
unknown: string[]
|
||||
/** The output language the instruction named. */
|
||||
language?: OutputLanguage
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
// Requests about how schedules work rather than what they make. Each is answered with what Express
|
||||
// actually does, so the agent neither ignores them nor promises something it cannot do.
|
||||
const META: [RegExp, Key | null][] = [
|
||||
[/\b(?:seluruh|semua)\s+(?:materi|bahan|dokumen|otak|brain|isi)|\bwhole brain\b|\ball (?:the )?(?:materials?|documents?|brain)\b/, 'note.wholeBrain'],
|
||||
[/duplika|duplicat|\b(?:tidak|jangan|tanpa|bukan)\s+(?:meng)?ulang|\b(?:without|not|don'?t|no)\s+repeat|\b(?:topik|topics?)\s+(?:yang\s+)?(?:berbeda|beda|different|unik|unique)|\bdifferent topics?\b/, 'note.novelty'],
|
||||
[/\b(?:draf|draft|drafts|ditinjau|tinjau|peninjauan|reviews?|reviewed|approval|persetujuan|disetujui|dipublikasikan|publikasi|published?)\b/, 'note.draft'],
|
||||
[/\b(?:scheduler|penjadwal|background|latar belakang|cron|otomatis|automatic(?:ally)?|terjadwal)\b/, 'note.automatic'],
|
||||
// Wishes every schedule already aims for; nothing to add, but not a part the agent failed to understand.
|
||||
[/\b(?:relevan|relevant|informatif|informative|berkualitas|bermanfaat|menarik|useful|engaging|quality|referensi|reference)\b/, null],
|
||||
]
|
||||
const HOUR = /\b(?:pukul|pkl|jam|at)\s*(\d{1,2})(?:[.:](\d{2}))?\s*(am|pm)?/
|
||||
const SUB_DAY = /\b\d+\s*(?:menit|jam|detik|minutes?|mins?|hours?|seconds?)\b|\b(?:tiap|setiap|per|every|each)\s+(?:menit|jam|minute|hour)\b|\bhourly\b/
|
||||
|
||||
function notesOf(text: string): Note[] {
|
||||
const notes: Note[] = META.flatMap(([re, key]) => (key && re.test(text) ? [{ key }] : []))
|
||||
const hour = text.match(HOUR)
|
||||
if (hour) {
|
||||
let h = Number(hour[1])
|
||||
if (hour[3] === 'pm' && h < 12) h += 12
|
||||
if (hour[3] === 'am' && h === 12) h = 0
|
||||
const at = `${String(h).padStart(2, '0')}.${hour[2] ?? '00'}`
|
||||
notes.push({ key: at === '09.00' ? 'note.hourMatches' : 'note.hourFixed', vars: { at } })
|
||||
}
|
||||
if (SUB_DAY.test(text)) notes.push({ key: 'note.subDay' })
|
||||
return notes
|
||||
}
|
||||
|
||||
const isMeta = (clause: string) => META.some(([re]) => re.test(clause)) || HOUR.test(clause) || SUB_DAY.test(clause)
|
||||
|
||||
// "dari file LLM" / "from the document onboarding" names a Brain file, which works as the topic.
|
||||
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic|(?:dari|from)(?: the)? (?:file|berkas|dokumen|documents?|materi|material))\s+(?!latihan\b)(.+)$/
|
||||
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
|
||||
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic|(?:dari|from)(?: the)? (?:file|berkas|dokumen|documents?|materi|material))\s+(?!latihan\b|yang\b)(.+)$/
|
||||
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month|pada pukul|pada jam|pukul|pkl|secara|otomatis|automatically|dalam \d|in \d|at \d)\b.*$/
|
||||
|
||||
function topicOf(clause: string): string {
|
||||
return clause.match(TOPIC)?.[1].replace(FREQUENCY_TAIL, '').replace(/[\s,]+$/, '').trim() ?? ''
|
||||
}
|
||||
|
||||
function countOf(clause: string, kindAt: number): number {
|
||||
/** The count right before a kind word, or null when the instruction named none. */
|
||||
function countBefore(clause: string, kindAt: number): number | null {
|
||||
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
|
||||
return (word && NUMBER_WORDS[word]) || null
|
||||
}
|
||||
|
||||
const countOf = (clause: string, kindAt: number) => countBefore(clause, kindAt) ?? 1
|
||||
|
||||
const DAY_WORDS = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/
|
||||
const WEEK_WORDS = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/
|
||||
const MONTH_WORDS = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/
|
||||
|
||||
export 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'
|
||||
if (DAY_WORDS.test(clause)) return 'day'
|
||||
if (MONTH_WORDS.test(clause)) return 'month'
|
||||
return 'week'
|
||||
}
|
||||
|
||||
/** Whether a clause says how often, rather than leaving perOf to fall back to per week. */
|
||||
export const namesFrequency = (clause: string) =>
|
||||
DAY_WORDS.test(clause) || WEEK_WORDS.test(clause) || MONTH_WORDS.test(clause) || weekdayOf(clause) !== null
|
||||
|
||||
export function weekdayOf(clause: string): number | null {
|
||||
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
|
||||
return null
|
||||
@@ -128,7 +197,8 @@ export function weekdayOf(clause: string): number | 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}`)
|
||||
// A full stop ends a sentence, but not inside a time or number such as "09.00".
|
||||
const SPLIT = new RegExp(String.raw`(?:[\n;]|\.(?!\d))+|\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)) {
|
||||
@@ -141,17 +211,27 @@ function parseClause(clause: string): Rule | null {
|
||||
}
|
||||
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) }
|
||||
if (m) return contentRule(clause, kind, countOf(clause, m.index))
|
||||
}
|
||||
const interactive = INTERACTIVE.exec(clause)
|
||||
if (interactive) return contentRule(clause, 'scorm', countOf(clause, interactive.index))
|
||||
const g = GENERIC.exec(clause)
|
||||
if (g) {
|
||||
const count = countBefore(clause, g.index)
|
||||
if (count !== null || CREATE.test(clause.slice(0, g.index))) return { ...contentRule(clause, 'summary', count ?? 1), kindGuessed: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function contentRule(clause: string, kind: ContentKind, count: number): ContentRule {
|
||||
const per = perOf(clause)
|
||||
const guessed = namesFrequency(clause) ? {} : { perGuessed: true }
|
||||
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), ...guessed }
|
||||
}
|
||||
|
||||
export function parseInstruction(text: string): ParseResult {
|
||||
const language = languageOf(text)
|
||||
const clauses = text
|
||||
@@ -168,6 +248,8 @@ export function parseInstruction(text: string): ParseResult {
|
||||
for (const clause of clauses) {
|
||||
const rule = parseClause(clause)
|
||||
if (rule) rules.push(rule)
|
||||
// "simpan sebagai draft", "jalankan otomatis": answered as notes, not asked about.
|
||||
else if (isMeta(clause)) continue
|
||||
// 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)
|
||||
@@ -185,7 +267,8 @@ export function parseInstruction(text: string): ParseResult {
|
||||
if (at >= 0) r.topic = text.slice(at, at + r.topic.length)
|
||||
}
|
||||
}
|
||||
return language ? { rules, unknown, language } : { rules, unknown }
|
||||
const notes = notesOf(text.toLowerCase())
|
||||
return language ? { rules, unknown, language, notes } : { rules, unknown, notes }
|
||||
}
|
||||
|
||||
export function weeklyTarget(rule: ContentRule): number {
|
||||
@@ -200,17 +283,21 @@ export function videoCadence(rule: ContentRule): 'daily' | 'weekly' | null {
|
||||
|
||||
export const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key)
|
||||
|
||||
export function describeRule(rule: Rule): string {
|
||||
/** `from` names the chosen material the rule is made from; without it, content comes from the whole Brain. */
|
||||
export function describeRule(rule: Rule, from?: string): 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 })
|
||||
}
|
||||
// A placeholder kind or frequency is shown as still open, not as if the user had chosen it.
|
||||
return t('rule.content', {
|
||||
kind: kindLabel(rule.kind),
|
||||
kind: rule.kindGuessed ? t('rule.anyKind') : kindLabel(rule.kind),
|
||||
count: rule.count,
|
||||
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
|
||||
about: about || t('rule.wholeBrain'),
|
||||
per: rule.perGuessed ? t('rule.perUnset') : t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
|
||||
about: about || (from ?? (rule.chosen ? t('rule.chosenMaterial') : null)
|
||||
? t('rule.from', { source: from ?? t('rule.chosenMaterial') })
|
||||
: t('rule.wholeBrain')),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { correctIndex, questionsOf, toMarkdown, withPlanItem, type Material } from './results'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EngineError } from '../api/client'
|
||||
import {
|
||||
correctIndex, decideMaterial, forgetReads, isStuckVideo, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, videoStatus, withDecisions, withPlanItem, type Material,
|
||||
} from './results'
|
||||
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
|
||||
import type { Schedule } from './schedules'
|
||||
|
||||
describe('correctIndex', () => {
|
||||
const options = ['Listen first', 'Interrupt', 'Leave']
|
||||
@@ -65,3 +69,144 @@ describe('withPlanItem', () => {
|
||||
expect(withPlanItem('preparing', undefined)).toBe('preparing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settleLimited', () => {
|
||||
it('runs at most the limit at once and keeps order and failures', async () => {
|
||||
let running = 0, peak = 0
|
||||
const out = await settleLimited([1, 2, 3, 4, 5, 6], 2, async n => {
|
||||
peak = Math.max(peak, ++running)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
running--
|
||||
if (n === 3) throw new Error('503')
|
||||
return n * 10
|
||||
})
|
||||
expect(peak).toBe(2)
|
||||
expect(out.map(r => (r.status === 'fulfilled' ? r.value : 'x'))).toEqual([10, 20, 'x', 40, 50, 60])
|
||||
})
|
||||
})
|
||||
|
||||
describe('remembering', () => {
|
||||
const schedule = (id: number) => ({ agent: { id } }) as Schedule
|
||||
const answers = (...seq: (number[] | number)[]) => vi.fn(async () => {
|
||||
const next = seq.shift()!
|
||||
if (typeof next === 'number') throw new EngineError('err', next)
|
||||
return next
|
||||
})
|
||||
|
||||
it('asks a busy engine once more', async () => {
|
||||
const fn = answers(503, [1])
|
||||
expect(await remembering(fn, 0)(schedule(1))).toEqual([1])
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the last results while the engine stays busy', async () => {
|
||||
const read = remembering(answers([1, 2], 503, 503, 502), 0)
|
||||
const s = schedule(1)
|
||||
expect(await read(s)).toEqual([1, 2])
|
||||
expect(await read(s)).toEqual([1, 2])
|
||||
expect(await read(s)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('drops a schedule the engine refuses, and one never read', async () => {
|
||||
const read = remembering(answers([1], 404), 0)
|
||||
await read(schedule(1))
|
||||
await expect(read(schedule(1))).rejects.toThrow()
|
||||
await expect(remembering(answers(503, 503), 0)(schedule(2))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('keepSettled', () => {
|
||||
const ok = (v: number[]): PromiseSettledResult<number[]> => ({ status: 'fulfilled', value: v })
|
||||
const bad = (message: string): PromiseSettledResult<number[]> => ({ status: 'rejected', reason: new Error(message) })
|
||||
|
||||
it('leaves out a schedule that failed', () => {
|
||||
expect(keepSettled([ok([1, 2]), bad('404'), ok([3])])).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('fails when every schedule failed', () => {
|
||||
expect(() => keepSettled([bad('500'), bad('502')])).toThrow('500')
|
||||
})
|
||||
|
||||
it('is empty without schedules', () => {
|
||||
expect(keepSettled([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('remembering an archived schedule', () => {
|
||||
const archived = { agent: { id: 7, status: 'archived' } } as Schedule
|
||||
|
||||
it('reads it once until an action', async () => {
|
||||
const fn = vi.fn(async () => [1])
|
||||
const read = remembering(fn, 0)
|
||||
await read(archived)
|
||||
expect(await read(archived)).toEqual([1])
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
forgetReads()
|
||||
await read(archived)
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('remembering with status marks', () => {
|
||||
const active = { agent: { id: 9, status: 'active' } } as Schedule
|
||||
|
||||
it('reads a schedule again only when its mark changes', async () => {
|
||||
const fn = vi.fn(async () => [1])
|
||||
const read = remembering(fn, 0)
|
||||
await read(active, 'a')
|
||||
await read(active, 'a')
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
await read(active, 'b')
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reads every time without a mark, or while the last read was not quiet', async () => {
|
||||
const fn = vi.fn(async () => [1])
|
||||
await remembering(fn, 0)(active)
|
||||
await remembering(fn, 0)(active)
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
let quiet = false
|
||||
const read = remembering(fn, 0, () => quiet)
|
||||
await read(active, 'a')
|
||||
await read(active, 'a')
|
||||
expect(fn).toHaveBeenCalledTimes(4)
|
||||
quiet = true
|
||||
await read(active, 'a')
|
||||
await read(active, 'a')
|
||||
expect(fn).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('videoStatus', () => {
|
||||
it('fails a video the engine stopped without a confirmed result', () => {
|
||||
expect(videoStatus({ status: 'clips', latest_error: 'clip_outcome_unknown' })).toBe('failed')
|
||||
expect(isStuckVideo({ status: 'clips', latest_error: 'clip_outcome_unknown' })).toBe(true)
|
||||
})
|
||||
it('keeps a video at work while its stages run', () => {
|
||||
expect(videoStatus({ status: 'clips', latest_error: null })).toBe('generating')
|
||||
})
|
||||
it('does not call a cancelled video stuck', () => {
|
||||
expect(isStuckVideo({ status: 'cancelled', latest_error: 'user_cancelled' })).toBe(false)
|
||||
expect(videoStatus({ status: 'cancelled', latest_error: 'clip_outcome_unknown' })).toBe('failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withDecisions', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
const scorm = (status: Material['status']) => ({
|
||||
id: 7, agentId: 3, kind: 'scorm', status, revision: 1, createdAt: '2026-10-29T00:00:00Z', sources: [],
|
||||
parts: [{ id: 7, revision: 1, kind: 'summary', status }, { id: 8, revision: 1, kind: 'quiz', status }],
|
||||
}) as unknown as Material
|
||||
|
||||
it('shows a decision before any read says so, and drops it once one does', async () => {
|
||||
vi.stubGlobal('fetch', async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
await decideMaterial(scorm('ready'), 'accept')
|
||||
// A remembered read from before the decision still says ready.
|
||||
const shown = withDecisions(scorm('ready'))
|
||||
expect(shown.status).toBe('accepted')
|
||||
expect(shown.parts.map(p => p.status)).toEqual(['accepted', 'accepted'])
|
||||
// The engine's own answer wins from then on, even if the material is later edited back to ready.
|
||||
expect(withDecisions(scorm('accepted')).status).toBe('accepted')
|
||||
expect(withDecisions(scorm('ready')).status).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// 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 { EngineError, request, sleep } from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
import { kindLabel, type ContentKind, type Rule } from './parse'
|
||||
import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, runNow, type PlanItem, type Schedule } from './schedules'
|
||||
import { expressAgents, forgetProductionReads, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, runNow, type PlanItem, type Schedule } from './schedules'
|
||||
|
||||
const AGENTS = '/organizations/current/agents'
|
||||
|
||||
@@ -41,7 +41,8 @@ export interface Material {
|
||||
rule: Rule
|
||||
/** How to produce it again, when the engine allows another attempt: the plan item, or (video) a new run. */
|
||||
retry?: { planId: number; itemId: number } | { run: true }
|
||||
video?: { url: string | null; progress: number; credits: number | null }
|
||||
/** stuck: the engine stopped it without knowing whether a paid step finished; only cancelling settles it. */
|
||||
video?: { url: string | null; progress: number; credits: number | null; stuck: boolean }
|
||||
}
|
||||
|
||||
// Labels are resolved at render time so they follow the UI language.
|
||||
@@ -87,14 +88,22 @@ const STOPPED = new Set<PlanItem['status']>(['failed', 'blocked', 'cancelled', '
|
||||
export const withPlanItem = (status: MaterialStatus, item: PlanItem['status'] | undefined): MaterialStatus =>
|
||||
item && STOPPED.has(item) && (status === 'preparing' || status === 'generating') ? 'failed' : status
|
||||
|
||||
// Schedules whose last read had a production plan at work. A plan's topics show before they have
|
||||
// drafts, and the status read only sees drafts, so these are read on every poll.
|
||||
const planning = new Set<number>()
|
||||
|
||||
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),
|
||||
// Without its plan a schedule still has drafts; a busy engine fails the whole read instead, so
|
||||
// the last complete one is shown rather than drafts missing their titles and planned pieces.
|
||||
productionStatus(agent.id).catch(e => { if (passing(e)) throw e; return null }),
|
||||
])
|
||||
if (status?.plans.some(p => PLAN_ACTIVE.has(p.status))) planning.add(agent.id)
|
||||
else planning.delete(agent.id)
|
||||
const items = new Map<number, PlanItem & { planId: number }>()
|
||||
for (const plan of status?.plans ?? []) {
|
||||
for (const item of plan.items ?? []) if (item.run_id) items.set(item.run_id, { ...item, planId: plan.id })
|
||||
@@ -134,6 +143,7 @@ interface RawVideo {
|
||||
status: string
|
||||
title: string | null
|
||||
progress_percentage: number
|
||||
latest_error: string | null
|
||||
video_url: string | null
|
||||
sources: { document_id: number; title: string }[]
|
||||
actual_credits: number | null
|
||||
@@ -141,9 +151,15 @@ interface RawVideo {
|
||||
}
|
||||
|
||||
// Video drafts move through script, storyboard, clips and render before they are ready to review.
|
||||
function videoStatus(s: string): MaterialStatus {
|
||||
// A stage whose outcome the engine could not confirm (e.g. clip_outcome_unknown) stops the run for
|
||||
// good, though the draft still says it is at work: that is a failure until it is cancelled.
|
||||
export const isStuckVideo = (d: Pick<RawVideo, 'status' | 'latest_error'>) =>
|
||||
!['ready', 'accepted', 'rejected', 'failed', 'cancelled'].includes(d.status) && !!d.latest_error?.endsWith('_outcome_unknown')
|
||||
|
||||
export function videoStatus(d: Pick<RawVideo, 'status' | 'latest_error'>): MaterialStatus {
|
||||
const s = d.status
|
||||
if (s === 'ready' || s === 'accepted' || s === 'rejected') return s
|
||||
if (s === 'failed' || s === 'cancelled') return 'failed'
|
||||
if (s === 'failed' || s === 'cancelled' || isStuckVideo(d)) return 'failed'
|
||||
return 'generating'
|
||||
}
|
||||
|
||||
@@ -153,13 +169,15 @@ async function videosOf({ agent, rule, active }: Schedule): Promise<Material[]>
|
||||
// newest draft only, so older failures don't each start another (paid) run.
|
||||
const newest = Math.max(...drafts.map(d => d.id))
|
||||
return drafts.map(d => {
|
||||
const status = videoStatus(d.status)
|
||||
const status = videoStatus(d)
|
||||
const stuck = isStuckVideo(d)
|
||||
return {
|
||||
retry: status === 'failed' && active && d.id === newest ? { run: true } as const : undefined,
|
||||
// A stuck video is cancelled first; a new run beside it would leave it unsettled.
|
||||
retry: status === 'failed' && !stuck && active && d.id === newest ? { run: true } as const : undefined,
|
||||
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 },
|
||||
video: { url: d.video_url, progress: d.progress_percentage, credits: d.actual_credits, stuck },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -172,14 +190,122 @@ async function sourcesOf({ agent, rule }: Schedule): Promise<FoundSource[]> {
|
||||
}))
|
||||
}
|
||||
|
||||
// One schedule the engine cannot answer for (e.g. an archived agent) leaves its results out
|
||||
// instead of failing them all; only when every schedule fails is that an error.
|
||||
export function keepSettled<T>(results: PromiseSettledResult<T[]>[]): T[] {
|
||||
const kept = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
|
||||
const failed = results.filter(r => r.status === 'rejected')
|
||||
if (failed.length) console.warn(`${failed.length} of ${results.length} schedules could not be loaded`, failed.map(r => r.reason))
|
||||
if (results.length && failed.length === results.length) throw failed[0].reason
|
||||
return kept
|
||||
}
|
||||
|
||||
// Promise.allSettled over a function, running at most `limit` at a time. Results are loaded one
|
||||
// request per schedule and polled; firing them all at once floods the engine, which then answers
|
||||
// some with 503 and drops those schedules' results until the next poll.
|
||||
export async function settleLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
|
||||
const out: PromiseSettledResult<R>[] = new Array(items.length)
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++
|
||||
try { out[i] = { status: 'fulfilled', value: await fn(items[i]) } }
|
||||
catch (reason) { out[i] = { status: 'rejected', reason } }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
return out
|
||||
}
|
||||
|
||||
const PER_SCHEDULE_LIMIT = 4
|
||||
|
||||
// The proxy's rate limit (503/429), a 5xx or no answer at all passes; a 4xx (e.g. a deleted agent) does not.
|
||||
const passing = (e: unknown) => e instanceof EngineError && (e.status === 0 || e.status === 429 || e.status >= 500)
|
||||
|
||||
const settledReads: Map<number, unknown>[] = []
|
||||
|
||||
/** After any action: a schedule's results (a decision on one) and production plans (a started or
|
||||
* retried piece) may have changed, so the next poll reads them again. */
|
||||
export function forgetReads() {
|
||||
for (const s of settledReads) s.clear()
|
||||
forgetProductionReads()
|
||||
}
|
||||
|
||||
// A settled read is still read again after this long: a ready video's signed URL lasts an hour.
|
||||
const SETTLED_FOR = 30 * 60_000
|
||||
|
||||
/** Wraps a per-schedule read so a passing failure does not change what the page shows: a busy
|
||||
* engine is asked once more after a pause, and if it still fails, the schedule's results from the
|
||||
* last poll that read them are used. Dropping them instead made every count on the page dip for
|
||||
* one poll and come back on the next.
|
||||
*
|
||||
* `mark` is what the engine's status read says about the schedule now; while it is the same as at
|
||||
* the last read, nothing the read shows has changed and that read is used again. A schedule whose
|
||||
* last read was not `quiet` (e.g. a plan whose topics have no drafts yet) is read every time. */
|
||||
export function remembering<T>(fn: (s: Schedule) => Promise<T[]>, pause = 3000, quiet: (s: Schedule) => boolean = () => true) {
|
||||
const last = new Map<number, T[]>()
|
||||
const settled = new Map<number, { mark: string; at: number }>()
|
||||
settledReads.push(settled)
|
||||
return async (s: Schedule, mark?: string): Promise<T[]> => {
|
||||
const id = s.agent.id
|
||||
// Without a status read, an archived schedule is the one known to make nothing new.
|
||||
const key = mark ?? (s.agent.status === 'archived' ? 'archived' : undefined)
|
||||
const kept = settled.get(id)
|
||||
if (key && kept?.mark === key && Date.now() - kept.at < SETTLED_FOR) return last.get(id)!
|
||||
try {
|
||||
const read = await fn(s).catch(async e => {
|
||||
if (!(e instanceof EngineError && (e.status === 503 || e.status === 429))) throw e
|
||||
await sleep(pause)
|
||||
return fn(s)
|
||||
})
|
||||
last.set(id, read)
|
||||
if (key && quiet(s)) settled.set(id, { mark: key, at: Date.now() })
|
||||
else settled.delete(id)
|
||||
return read
|
||||
} catch (e) {
|
||||
const kept = last.get(id)
|
||||
if (kept && passing(e)) return kept
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readMaterials = remembering(materialsOf, undefined, s => !planning.has(s.agent.id))
|
||||
const readSources = remembering(sourcesOf)
|
||||
|
||||
interface AgentStatus { id: number }
|
||||
|
||||
/** One read of every schedule's runs and drafts, as a mark per schedule that changes whenever
|
||||
* anything in its results does (a draft's status or progress, a new run). The engine answers an
|
||||
* unchanged one with 304. A busy engine keeps the last marks, rather than being sent a read of
|
||||
* every schedule; without any (an engine that has no such read), every schedule is read. */
|
||||
let lastMarks: Map<number, string> | null = null
|
||||
async function changeMarks(): Promise<Map<number, string> | null> {
|
||||
try {
|
||||
const { agents } = await request<{ agents: AgentStatus[] }>(`${AGENTS}/status?archived=true`, { revalidate: true })
|
||||
lastMarks = new Map(agents.map(a => [a.id, JSON.stringify(a)]))
|
||||
} catch (e) {
|
||||
if (!passing(e)) lastMarks = null
|
||||
console.warn('Agent status could not be read', e)
|
||||
}
|
||||
return lastMarks
|
||||
}
|
||||
|
||||
// One status read per poll, plus a read of each schedule whose results changed since the last one.
|
||||
// A page opened or refocused within the interval reuses what was read; an action on a result or
|
||||
// schedule still refreshes them at once.
|
||||
export const RESULTS_POLL = { refetchInterval: 30_000, staleTime: 30_000 }
|
||||
|
||||
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 [schedules, marks] = await Promise.all([expressAgents({ archived: true }), changeMarks()])
|
||||
const content = schedules.filter(s => s.rule.type === 'content')
|
||||
const research = schedules.filter(s => s.rule.type === 'research')
|
||||
const marked = <T,>(read: (s: Schedule, mark?: string) => Promise<T[]>) => (s: Schedule) => read(s, marks?.get(s.agent.id))
|
||||
// One shared budget: research runs after content rather than alongside it.
|
||||
const materials = await settleLimited(content, PER_SCHEDULE_LIMIT, marked(readMaterials))
|
||||
const sources = await settleLimited(research, PER_SCHEDULE_LIMIT, marked(readSources))
|
||||
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) }
|
||||
return { materials: keepSettled(materials).map(withDecisions).sort(newest), sources: keepSettled(sources).sort(newest) }
|
||||
}
|
||||
|
||||
const draftContent = (m: Material, id: number) =>
|
||||
@@ -199,14 +325,36 @@ export async function materialContent(m: Material): Promise<{ content: MaterialC
|
||||
// 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' })
|
||||
|
||||
/** Stops a video still being made, or settles a stuck one; the engine keeps every charge it knows of. */
|
||||
export const canCancelVideo = (m: Material) => m.kind === 'video' && (isWorking(m) || !!m.video?.stuck)
|
||||
export const cancelVideo = (m: Material) => request(`${AGENTS}/${m.agentId}/video-drafts/${m.id}/cancel`, { method: 'POST' })
|
||||
|
||||
export const retryMaterial = ({ agentId, retry }: Material) =>
|
||||
retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId)
|
||||
|
||||
// A decision the engine took, kept until a read of its part says so too: the read right after it
|
||||
// may be the last poll's (a busy engine) and still say ready, which left Approve looking undone.
|
||||
const decided = new Map<string, MaterialStatus>()
|
||||
const partKey = (m: Material, p: Part) => `${materialKey(m)}-${p.id}`
|
||||
|
||||
export function withDecisions(m: Material): Material {
|
||||
let changed = false
|
||||
const parts = m.parts.map(p => {
|
||||
const status = decided.get(partKey(m, p))
|
||||
if (!status) return p
|
||||
if (p.status !== 'ready') { decided.delete(partKey(m, p)); return p }
|
||||
changed = true
|
||||
return { ...p, status }
|
||||
})
|
||||
return changed ? { ...m, parts, status: combined(parts) } : m
|
||||
}
|
||||
|
||||
// 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 } })
|
||||
decided.set(partKey(m, p), decision === 'accept' ? 'accepted' : 'rejected')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ContentRule, ResearchRule } from './parse'
|
||||
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
|
||||
import {
|
||||
budgetFor, canSchedule, configFor, createSchedule, isMine, missingDocuments, needsTwoSources, productionError, resumeSchedule, ruleOf, updateTopic, VIDEO,
|
||||
type ExpressAgent, type Schedule,
|
||||
} from './schedules'
|
||||
|
||||
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule =>
|
||||
({ type: 'content', kind, count, per, topic, language })
|
||||
@@ -9,7 +12,7 @@ const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'w
|
||||
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,
|
||||
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', created_by_user_id: 7, auto_approve_sources: false,
|
||||
name: c.name as string, goal: c.goal as string, instructions: c.instructions as string,
|
||||
enabled_tools: c.enabled_tools as string[],
|
||||
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
|
||||
@@ -63,6 +66,17 @@ describe('content language', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('chosen material', () => {
|
||||
it('marks a schedule made from chosen material in its brief, and reads it back as chosen', () => {
|
||||
const r = rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia')
|
||||
const config = configFor(r, 0, true)
|
||||
expect(config.instructions).toMatch(/Use only the Brain material chosen for this schedule\./)
|
||||
expect(configFor(r).instructions).not.toMatch(/chosen/)
|
||||
expect(ruleOf(agentFrom(config))).toEqual({ ...r, chosen: true })
|
||||
expect(ruleOf(agentFrom(configFor(r)))).toEqual(r)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ruleOf', () => {
|
||||
it.each([
|
||||
rule('summary', 3),
|
||||
@@ -97,3 +111,108 @@ describe('canSchedule', () => {
|
||||
expect(canSchedule(rule('summary', 0))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('production sources', () => {
|
||||
it('needs two sources for planned content, not for video or research', () => {
|
||||
expect(needsTwoSources(rule('quiz'))).toBe(true)
|
||||
expect(needsTwoSources(rule('video', 1))).toBe(false)
|
||||
expect(needsTwoSources({ type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: false })).toBe(false)
|
||||
})
|
||||
|
||||
it("explains the engine's refusal for too few sources and passes others through", () => {
|
||||
expect(productionError('At least two ready sources with complementary evidence are required')).toMatch(/2/)
|
||||
expect(productionError('At least two ready sources with complementary evidence are required')).not.toMatch(/complementary/)
|
||||
expect(productionError('Something else')).toBe('Something else')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMine', () => {
|
||||
const owners = { me: 7, names: new Map<number, string>() }
|
||||
const schedule = (by: number | null) => {
|
||||
const agent = { ...agentFrom(configFor(rule('summary'))), created_by_user_id: by }
|
||||
return { agent, rule: ruleOf(agent), active: true }
|
||||
}
|
||||
|
||||
it('keeps only the viewer’s own schedules', () => {
|
||||
expect(isMine(schedule(7), owners)).toBe(true)
|
||||
expect(isMine(schedule(8), owners)).toBe(false)
|
||||
})
|
||||
|
||||
it('counts a schedule whose creator was removed as someone else’s', () => {
|
||||
expect(isMine(schedule(null), owners)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missingDocuments', () => {
|
||||
it('leaves out the documents a schedule already has', () => {
|
||||
expect(missingDocuments([1, 2, 3], [{ kind: 'document', id: 2 }, { kind: 'compose_set', id: 3 }])).toEqual([1, 3])
|
||||
})
|
||||
})
|
||||
|
||||
// Answers the engine's calls by method and path, and records them in order.
|
||||
function fakeEngine(answer: (method: string, path: string) => { status?: number; body?: unknown }) {
|
||||
const calls: string[] = []
|
||||
vi.stubGlobal('fetch', async (url: string, init: RequestInit = {}) => {
|
||||
const method = init.method ?? 'GET'
|
||||
const path = url.replace('/api/v1', '')
|
||||
calls.push(`${method} ${path}${init.body ? ` ${init.body}` : ''}`)
|
||||
const { status = 200, body = {} } = answer(method, path)
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
const doc = (id: number) => ({ id, filename: `d${id}.pdf`, status: 'completed', knowledge_status: 'ready', created_at: '2026-10-01T00:00:00Z' })
|
||||
const put = (id: number) => `PUT /organizations/current/agents/1/knowledge-sources {"kind":"document","source_id":${id}}`
|
||||
|
||||
describe('updateTopic and resumeSchedule', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
const schedule = (active: boolean): Schedule => {
|
||||
const agent = { ...agentFrom(configFor(rule('summary'))), status: active ? 'active' : 'paused' }
|
||||
return { agent, rule: ruleOf(agent), active }
|
||||
}
|
||||
const engine = () => fakeEngine((method, path) => {
|
||||
if (path === '/upload/list') return { body: [doc(1), doc(2), doc(3)] }
|
||||
if (path.endsWith('/knowledge-sources') && method === 'GET') return { body: [{ kind: 'document', id: 1 }, { kind: 'document', id: 2 }] }
|
||||
return { body: { id: 1, status: 'active' } }
|
||||
})
|
||||
|
||||
it('re-activates right after the topic changes, then attaches only new material', async () => {
|
||||
const calls = engine()
|
||||
await updateTopic(schedule(true), 'leadership')
|
||||
expect(calls[0]).toMatch(/^PATCH \/organizations\/current\/agents\/1 /)
|
||||
expect(calls[1]).toBe('POST /organizations/current/agents/1/activate')
|
||||
expect(calls.filter(c => c.startsWith('PUT'))).toEqual([put(3)])
|
||||
})
|
||||
|
||||
it('keeps a paused schedule paused', async () => {
|
||||
const calls = engine()
|
||||
await updateTopic(schedule(false), 'leadership')
|
||||
expect(calls[1]).toBe('POST /organizations/current/agents/1/pause')
|
||||
})
|
||||
|
||||
it('resumes with one activate and attaches only new material', async () => {
|
||||
const calls = engine()
|
||||
await resumeSchedule(schedule(false))
|
||||
expect(calls[0]).toBe('POST /organizations/current/agents/1/activate')
|
||||
expect(calls.filter(c => c.includes('/activate'))).toHaveLength(1)
|
||||
expect(calls.filter(c => c.startsWith('PUT'))).toEqual([put(3)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSchedule', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('attaches every ready document, skips one it cannot share, reports progress, then activates', async () => {
|
||||
const calls = fakeEngine((method, path) => {
|
||||
if (path === '/upload/list') return { body: [doc(1), doc(2), doc(3)] }
|
||||
if (method === 'PUT') return calls.at(-1)!.includes('"source_id":2') ? { status: 404, body: { detail: 'Ready workspace source not found' } } : { body: {} }
|
||||
return { body: { ...agentFrom(configFor(rule('summary'))), status: 'active' } }
|
||||
})
|
||||
const progress: string[] = []
|
||||
await createSchedule(rule('summary'), undefined, (done, total) => progress.push(`${done}/${total}`))
|
||||
expect(calls.filter(c => c.startsWith('PUT')).sort()).toEqual([put(1), put(2), put(3)])
|
||||
expect(calls.at(-1)).toBe('POST /organizations/current/agents/1/activate')
|
||||
expect(progress).toEqual(['0/3', '1/3', '2/3', '3/3'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 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 { EngineError, limiter, opKey, request } from '../api/client'
|
||||
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
|
||||
import { getLang } from '../i18n'
|
||||
import { getLang, t } from '../i18n'
|
||||
import {
|
||||
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
|
||||
@@ -41,6 +41,7 @@ export interface ExpressAgent extends Agent {
|
||||
run_budget: number
|
||||
monthly_budget: number
|
||||
created_at: string
|
||||
created_by_user_id: number | null
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
@@ -73,6 +74,11 @@ function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
|
||||
}
|
||||
|
||||
export function ruleOf(agent: ExpressAgent): Rule {
|
||||
const rule = baseRuleOf(agent)
|
||||
return rule.type === 'content' && usesChosen(agent) ? { ...rule, chosen: true } : rule
|
||||
}
|
||||
|
||||
function baseRuleOf(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'
|
||||
@@ -102,9 +108,35 @@ export async function expressAgents({ archived = false } = {}): Promise<Schedule
|
||||
|
||||
export const listSchedules = () => expressAgents()
|
||||
|
||||
// An organization admin sees every schedule in the organization, a member only their own (and any
|
||||
// shared with them), so the Schedule page says who made each one and starts with the viewer's own.
|
||||
export interface Owners { me: number; names: Map<number, string> }
|
||||
|
||||
/** The viewer's engine user id and the organization roster's names. The roster is admin-only;
|
||||
* a member gets no names, which is fine: a member's list holds their own schedules. */
|
||||
export async function scheduleOwners(): Promise<Owners> {
|
||||
const [me, roster] = await Promise.all([
|
||||
request<{ id: number }>('/auth/me'),
|
||||
request<{ members: { user_id: number; name: string | null; email: string | null }[] }>('/organizations/current/dashboard/members')
|
||||
.catch(() => ({ members: [] })),
|
||||
])
|
||||
return { me: me.id, names: new Map(roster.members.map(m => [m.user_id, m.name || m.email || `#${m.user_id}`])) }
|
||||
}
|
||||
|
||||
export const isMine = (s: Schedule, owners: Owners) => s.agent.created_by_user_id === owners.me
|
||||
|
||||
/** 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'
|
||||
|
||||
// The engine plans each production piece from a pair of documents, so a content schedule
|
||||
// with fewer ready sources than this cannot plan anything. Video and research runs need one.
|
||||
export const MIN_PRODUCTION_SOURCES = 2
|
||||
export const needsTwoSources = (rule: Rule) => !isRunSchedule(rule)
|
||||
const TOO_FEW_SOURCES = 'At least two ready sources with complementary evidence are required'
|
||||
/** The engine's planner refusal, said in the UI language; any other message as it came. */
|
||||
export const productionError = (message: string) =>
|
||||
(message.includes(TOO_FEW_SOURCES) ? t('sch.tooFewSources', { n: MIN_PRODUCTION_SOURCES }) : message)
|
||||
|
||||
// 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 }
|
||||
|
||||
@@ -161,18 +193,23 @@ function goalText(rule: Rule): string {
|
||||
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 {
|
||||
// A schedule made from chosen material says so in its brief. That keeps it to those documents:
|
||||
// editing or resuming it attaches nothing else, while a whole-Brain schedule picks up new material.
|
||||
const CHOSEN_SENTENCE = 'Use only the Brain material chosen for this schedule.'
|
||||
const usesChosen = (agent: ExpressAgent) => !!agent.instructions?.includes(CHOSEN_SENTENCE)
|
||||
|
||||
function instructionsFor(rule: Rule, chosen = false): string {
|
||||
const language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage())
|
||||
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
|
||||
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
|
||||
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, chosen && CHOSEN_SENTENCE, language].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export function configFor(rule: Rule, videoCredits = 0) {
|
||||
export function configFor(rule: Rule, videoCredits = 0, chosen = false) {
|
||||
const budget = budgetFor(rule, videoCredits)
|
||||
const common = {
|
||||
name: scheduleName(rule),
|
||||
goal: goalText(rule),
|
||||
instructions: instructionsFor(rule),
|
||||
instructions: instructionsFor(rule, chosen),
|
||||
timezone: TIMEZONE,
|
||||
run_budget: budget.run,
|
||||
monthly_budget: budget.monthly,
|
||||
@@ -227,38 +264,85 @@ export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule {
|
||||
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
|
||||
// The production corpus is the agent's own sources: the chosen documents, or every ready Brain document.
|
||||
// The engine adds them one request at a time and checks the whole workspace on each, so a Brain of
|
||||
// 150 documents is 150 slow requests: they go a few at a time, paced under the proxy's rate limit.
|
||||
const ATTACH_AT_ONCE = 3
|
||||
const ATTACH_TRIES = 3
|
||||
|
||||
export type AttachProgress = (done: number, total: number) => void
|
||||
|
||||
async function attachOne(agentId: number, id: number, tries = ATTACH_TRIES): Promise<void> {
|
||||
try {
|
||||
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', paced: true, json: { kind: 'document', source_id: id } })
|
||||
} catch (e) {
|
||||
// A busy proxy is asked again once the pace has waited for it; a document someone else cannot
|
||||
// share (404) is skipped, not fatal.
|
||||
const busy = e instanceof EngineError && (e.status === 503 || e.status === 429)
|
||||
if (busy && tries > 1) return attachOne(agentId, id, tries - 1)
|
||||
}
|
||||
return docs.length
|
||||
}
|
||||
|
||||
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
|
||||
async function attachDocuments(agentId: number, ids: number[], onProgress?: AttachProgress) {
|
||||
const one = limiter(ATTACH_AT_ONCE)
|
||||
let done = 0
|
||||
onProgress?.(0, ids.length)
|
||||
await Promise.all(ids.map(id => one(async () => {
|
||||
await attachOne(agentId, id)
|
||||
onProgress?.(++done, ids.length)
|
||||
})))
|
||||
}
|
||||
|
||||
/** The ready documents a whole-Brain schedule does not have yet. */
|
||||
export const missingDocuments = (ready: number[], attached: { kind: string; id: number }[]) => {
|
||||
const have = new Set(attached.filter(s => s.kind === 'document').map(s => s.id))
|
||||
return ready.filter(id => !have.has(id))
|
||||
}
|
||||
|
||||
const readyDocuments = async () => (await listDocuments()).filter(d => d.knowledge === 'ready').map(d => d.docId)
|
||||
|
||||
// A whole-Brain schedule picks up material added since it was made; a schedule of chosen material keeps to it.
|
||||
// The schedule's own change has gone through by then, so a failure here does not fail it: the next
|
||||
// edit or resume attaches what is still missing.
|
||||
async function attachNewMaterial(schedule: Schedule) {
|
||||
if (usesChosen(schedule.agent)) return
|
||||
const id = schedule.agent.id
|
||||
try {
|
||||
const [ready, attached] = await Promise.all([readyDocuments(), request<{ kind: string; id: number }[]>(`${AGENTS}/${id}/knowledge-sources`)])
|
||||
await attachDocuments(id, missingDocuments(ready, attached))
|
||||
} catch (e) {
|
||||
console.warn(`New Brain material was not attached to schedule ${id}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** With `chosen` document ids the schedule uses only those; without, the whole Brain. */
|
||||
export async function createSchedule(rule: Rule, chosen?: number[], onProgress?: AttachProgress): Promise<ExpressAgent> {
|
||||
const only = chosen?.length ? chosen : undefined
|
||||
// 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)
|
||||
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits, !!only) })
|
||||
await attachDocuments(agent.id, only ?? (await readyDocuments()), onProgress)
|
||||
// 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. */
|
||||
/** Changing the brief changes what was approved, so the engine sets the agent back to draft; it is
|
||||
* re-activated (or kept paused) at once. Sources are not part of the approval, so new material
|
||||
* is attached after that, without leaving the schedule paused meanwhile. */
|
||||
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
|
||||
const rule = { ...schedule.rule, topic: topic.trim() }
|
||||
const { name, goal, instructions } = configFor(rule)
|
||||
const { name, goal, instructions } = configFor(rule, 0, usesChosen(schedule.agent))
|
||||
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' })
|
||||
const agent = await request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
|
||||
await attachNewMaterial(schedule)
|
||||
return agent
|
||||
}
|
||||
|
||||
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 async function resumeSchedule(schedule: Schedule) {
|
||||
const agent = await request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/activate`, { method: 'POST' })
|
||||
await attachNewMaterial(schedule)
|
||||
return agent
|
||||
}
|
||||
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })
|
||||
|
||||
@@ -309,7 +393,19 @@ export function planProgress(plan: ContentPlan) {
|
||||
}
|
||||
}
|
||||
|
||||
export const productionStatus = (id: number) => request<ProductionStatus>(`${AGENTS}/${id}/content`)
|
||||
// The Schedule page (one query per row) and Results (one read per schedule) both poll this; a read
|
||||
// from the last half minute, or one still on its way, answers both. forgetReads drops them after an action.
|
||||
const PRODUCTION_FRESH = 30_000
|
||||
const productionReads = new Map<number, { at: number; read: Promise<ProductionStatus> }>()
|
||||
export function productionStatus(id: number, maxAge = PRODUCTION_FRESH): Promise<ProductionStatus> {
|
||||
const kept = productionReads.get(id)
|
||||
if (kept && Date.now() - kept.at < maxAge) return kept.read
|
||||
const read = request<ProductionStatus>(`${AGENTS}/${id}/content`)
|
||||
productionReads.set(id, { at: Date.now(), read })
|
||||
read.catch(() => { if (productionReads.get(id)?.read === read) productionReads.delete(id) })
|
||||
return read
|
||||
}
|
||||
export const forgetProductionReads = () => productionReads.clear()
|
||||
|
||||
// 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.
|
||||
@@ -341,8 +437,24 @@ export interface RunSummary {
|
||||
trigger_kind: string
|
||||
safe_error_code: string | null
|
||||
budget_spent: number | null
|
||||
has_readiness_issues: boolean
|
||||
/** Research runs: what came of the sources found, e.g. no_new_sources when nothing was kept. */
|
||||
source_outcome?: string | null
|
||||
created_at: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
|
||||
|
||||
// What stops a run with knowledge_not_ready: the schedule's own material that is not ready (the whole
|
||||
// Library only for an agent with no material). This is the current state, not the run's.
|
||||
export interface ReadinessIssue {
|
||||
kind: 'document' | 'compose_set'
|
||||
resource_id: number
|
||||
title: string
|
||||
reason: string
|
||||
action_url: string
|
||||
}
|
||||
|
||||
export const knowledgeReadiness = (id: number) =>
|
||||
request<{ ready: boolean; issues: ReadinessIssue[] }>(`${AGENTS}/${id}/knowledge-readiness`)
|
||||
|
||||
472
src/i18n.ts
472
src/i18n.ts
@@ -7,13 +7,41 @@ export const LANGS: { code: Lang; label: string }[] = [{ code: 'en', label: 'EN'
|
||||
|
||||
const en = {
|
||||
// Layout
|
||||
'nav.brain': 'Brain',
|
||||
'nav.brain': 'Upload material',
|
||||
'nav.instruction': 'Instruction',
|
||||
'nav.schedule': 'Schedule',
|
||||
'nav.results': 'Results',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.production': 'Production',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.billing': 'Billing',
|
||||
'billing.title': 'Billing',
|
||||
'billing.description': 'The credits your organization used, by date, person and module. Only organization admins can see this page.',
|
||||
'billing.total': 'Total used',
|
||||
'billing.period': '{from} to {to}',
|
||||
'billing.balance': 'Current balance',
|
||||
'billing.balanceSub': 'Organization credits',
|
||||
'billing.usage': 'Credit usage',
|
||||
'billing.from': 'Start date',
|
||||
'billing.to': 'End date',
|
||||
'billing.colDate': 'Date',
|
||||
'billing.colUser': 'User',
|
||||
'billing.colModule': 'Module',
|
||||
'billing.colCredits': 'Credits',
|
||||
'billing.refund': 'Refund',
|
||||
'billing.loading': 'Loading credit usage',
|
||||
'billing.loadFailed': 'Credit usage could not be loaded',
|
||||
'billing.empty': 'No credits used in this period',
|
||||
'billing.emptyCopy': 'Try a wider date range.',
|
||||
'billing.more': 'Show more',
|
||||
'billing.count': '{n} transactions',
|
||||
'billing.countOne': '{n} transaction',
|
||||
'billing.export': 'Export CSV',
|
||||
'billing.colActivity': 'Activity',
|
||||
'billing.colType': 'Type',
|
||||
'billing.charge': 'Charge',
|
||||
'billing.note': 'Usage is counted in credits, the unit your organization is charged in. Refunds are taken off the total.',
|
||||
'layout.learningSpace': 'Learning space',
|
||||
'layout.switchOrg': 'Switch organization',
|
||||
'layout.breadcrumb': 'Workspace',
|
||||
@@ -21,9 +49,10 @@ const en = {
|
||||
'layout.engine': 'learning-lab engine',
|
||||
'layout.creditsTitle': 'Workspace credit balance',
|
||||
'layout.toStudio': 'Switch to Learning Lab',
|
||||
'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.collapseSidebar': 'Collapse sidebar',
|
||||
'layout.expandSidebar': 'Expand sidebar',
|
||||
'layout.language': 'Language',
|
||||
|
||||
// Auth
|
||||
@@ -32,7 +61,7 @@ const en = {
|
||||
'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.copy': 'Upload material to the Brain, give one instruction, and the Agent 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',
|
||||
@@ -49,26 +78,35 @@ const en = {
|
||||
|
||||
// Production
|
||||
'prod.title': 'Production',
|
||||
'prod.description': 'Everything Express is producing from the Brain: material, content made, what is live, and what is being made right now.',
|
||||
'prod.metricWorking': 'Content in progress',
|
||||
'prod.metricWorkingSub': 'being researched or generated now',
|
||||
'prod.metricMaterial': 'Material',
|
||||
'prod.metricMaterialSub': '{n} in the Brain',
|
||||
'prod.metricMade': 'Content made',
|
||||
'prod.metricMadeSub': 'results the AI has produced',
|
||||
'prod.metricLive': 'In production',
|
||||
'prod.metricLiveSub': 'approved for use',
|
||||
'prod.metricLms': 'On the LMS',
|
||||
'prod.metricLmsSub': 'approved SCORM modules',
|
||||
'prod.description': 'Connected knowledge. Learning that keeps growing.',
|
||||
'prod.unitMaterial': 'materials',
|
||||
'prod.unitMaterialOne': 'material',
|
||||
'prod.unitContent': 'content',
|
||||
'prod.metricMaterial': 'Total material in the Brain',
|
||||
'prod.metricMaterialSub': 'Your team’s knowledge sources',
|
||||
'prod.metricMade': 'All content made',
|
||||
'prod.metricMadeSub': 'Every piece of content created',
|
||||
'prod.metricLms': 'Uploaded to the LMS',
|
||||
'prod.metricLmsSub': 'Upload status in this demo',
|
||||
'prod.activityTitle': 'Content activity',
|
||||
'prod.activityDescription': 'From knowledge in the Brain to new learning.',
|
||||
'prod.activityAll': 'See all content',
|
||||
'prod.activityLatest': 'Latest content',
|
||||
'prod.activityWorking': 'Content in progress',
|
||||
'prod.activityNoneLatest': 'No content has been made yet.',
|
||||
'prod.activityNoneWorking': 'Nothing is being made right now.',
|
||||
'prod.eyebrow': 'Workspace overview',
|
||||
'prod.welcome': 'Welcome',
|
||||
'prod.welcomeName': 'Welcome, {name}',
|
||||
'prod.newContent': 'Create new content',
|
||||
'prod.graphTitle': 'Knowledge graph',
|
||||
'prod.clickNode': 'Click a node',
|
||||
'prod.fullscreen': 'Full screen',
|
||||
'prod.exitFullscreen': 'Exit full screen',
|
||||
'prod.loadingGraph': 'Drawing the knowledge graph…',
|
||||
'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
|
||||
'prod.hub.sources': 'Sources',
|
||||
'prod.hub.made': 'Made',
|
||||
'prod.hub.working': 'In progress',
|
||||
'prod.hub.research': 'Research',
|
||||
'prod.hub.schedules': 'Repeats',
|
||||
'prod.hub.sources': 'Knowledge base',
|
||||
'prod.hub.made': 'Content',
|
||||
'prod.nodes': 'Nodes',
|
||||
'prod.nodesSub': 'in this Brain',
|
||||
'prod.links': 'Links',
|
||||
@@ -81,8 +119,8 @@ const en = {
|
||||
'prod.more': '+{n} more in Results',
|
||||
|
||||
// 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.title': 'Upload material',
|
||||
'brain.description': 'Upload your team’s training material here. Every file is processed into the Brain the Agent creates content from.',
|
||||
'brain.metricMaterial': 'Material',
|
||||
'brain.metricMaterialSub': 'documents in the workspace',
|
||||
'brain.metricReady': 'In the Brain',
|
||||
@@ -90,6 +128,10 @@ const en = {
|
||||
'brain.metricAction': 'Needs action',
|
||||
'brain.metricActionSub': 'waiting for your approval',
|
||||
'brain.listTitle': 'Material',
|
||||
'brain.allStatuses': 'All statuses',
|
||||
'brain.duplicateConfirm': 'Already in the Brain: {names}. Upload another copy anyway? Cancel skips these files.',
|
||||
'brain.duplicateSkipped': 'Skipped: already in the Brain',
|
||||
'brain.downloaded': '{name} is downloading: this format opens in its own app.',
|
||||
'brain.loading': 'Loading material…',
|
||||
'brain.engineDown': 'Cannot reach the engine',
|
||||
'brain.empty': 'No material yet',
|
||||
@@ -117,7 +159,11 @@ const en = {
|
||||
'brain.needsCreditsNeed': ' (needs {credits})',
|
||||
'brain.failed': 'Could not add this to the Brain.',
|
||||
'brain.retryWith': 'Try again ({credits})',
|
||||
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is removed: delete it and upload it again.',
|
||||
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use it stop until it is fixed. Process it again: its knowledge is kept.',
|
||||
'brain.failedProcessing': 'This file could not be processed. Schedules that use it stop until it is fixed. Process it again, or delete it.',
|
||||
'brain.failedVideoAfterBuild': 'Processing this video failed after its knowledge was built. Schedules that use it stop until it is fixed: upload it again.',
|
||||
'brain.reprocess': 'Process again',
|
||||
'brain.reprocessing': 'Starting…',
|
||||
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
|
||||
'brain.confirmCreditsFallback': 'credits',
|
||||
'brain.addToBrain': 'Add to Brain',
|
||||
@@ -127,13 +173,13 @@ const en = {
|
||||
|
||||
// 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.description': 'Tell the Agent what to make from the Brain. It turns your instruction into research and content schedules that keep running until you stop them.',
|
||||
'ins.label': 'Instruction',
|
||||
'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.ex5': '1 video per week, 2 podcasts per week and 1 INTERACTIVE module per week about onboarding',
|
||||
'ins.previewTitle': 'Schedules to create',
|
||||
'ins.readyCount': '{n} ready',
|
||||
'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.',
|
||||
@@ -167,6 +213,10 @@ const en = {
|
||||
'rule.research': '{when}: internet research{about}',
|
||||
'rule.about': ' about {topic}',
|
||||
'rule.wholeBrain': ' from the whole Brain',
|
||||
'rule.from': ' from {source}',
|
||||
'rule.chosenMaterial': 'chosen material',
|
||||
'rule.anyKind': 'Content (kind not chosen yet)',
|
||||
'rule.perUnset': '(how often not set yet)',
|
||||
'rule.content': '{kind}: {count} {per}{about}',
|
||||
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
|
||||
'kind.summary': 'Summary',
|
||||
@@ -177,7 +227,7 @@ const en = {
|
||||
'kind.slide_deck': 'Slides',
|
||||
'kind.video': 'Video',
|
||||
'kind.audio': 'Audio',
|
||||
'kind.scorm': 'SCORM',
|
||||
'kind.scorm': 'INTERACTIVE',
|
||||
'day.0': 'Monday', 'day.1': 'Tuesday', 'day.2': 'Wednesday', 'day.3': 'Thursday', 'day.4': 'Friday', 'day.5': 'Saturday', 'day.6': 'Sunday',
|
||||
|
||||
// Schedule
|
||||
@@ -188,6 +238,13 @@ const en = {
|
||||
'sch.loading': 'Loading schedules…',
|
||||
'sch.loadFailed': 'Could not load schedules',
|
||||
'sch.empty': 'No schedules yet',
|
||||
'sch.mine': 'Mine',
|
||||
'sch.all': 'Everyone in the organization',
|
||||
'sch.emptyMine': 'You have no schedules yet',
|
||||
'sch.emptyMineCopy': 'Other people in this organization have schedules; see them under “Everyone in the organization”.',
|
||||
'sch.byYou': 'Created by you',
|
||||
'sch.by': 'Created by {name}',
|
||||
'sch.byUnknown': 'Created by someone else',
|
||||
'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.',
|
||||
'sch.nextResearch': 'Next research: {when}',
|
||||
'sch.paused': 'Paused',
|
||||
@@ -207,7 +264,7 @@ const en = {
|
||||
'prod.runningTitle': 'Producing {n} new pieces',
|
||||
'prod.lastTitle': 'Last production',
|
||||
'prod.count': '{ready} of {total} ready',
|
||||
'prod.stepTopics': 'AI chooses new topics',
|
||||
'prod.stepTopics': 'Agent chooses new topics',
|
||||
'prod.stepStart': 'Starting',
|
||||
'prod.stepCreate': 'Creating content ({ready}/{total})',
|
||||
'prod.stepDone': 'Done',
|
||||
@@ -231,10 +288,43 @@ const en = {
|
||||
'sch.scheduled': 'Scheduled',
|
||||
'sch.manual': 'Manual',
|
||||
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
|
||||
'run.paused_configuration': 'Needs attention', 'run.paused_budget': 'Paused: credits',
|
||||
'runErr.knowledge_not_ready': 'Some material this schedule uses is not ready, so the engine will not start.',
|
||||
'runErr.video_sources_unavailable': 'No ready material matched this schedule for a video.',
|
||||
'runErr.material_sources_unavailable': 'No ready material matched this schedule.',
|
||||
'runErr.knowledge_search_failed': 'Searching the material failed. Try again.',
|
||||
'runErr.organization_wallet': 'The organization does not have enough credits.',
|
||||
'runErr.run_cap': 'This run would exceed the per-run credit limit.',
|
||||
'runErr.monthly_cap': 'The monthly credit limit for this schedule is reached.',
|
||||
'runErr.reservation_cap': 'Too many credits are already reserved by other runs.',
|
||||
'runErr.price_changed': 'The price changed. Try again.',
|
||||
'runErr.video_price_changed': 'The video price changed. Try again.',
|
||||
'runErr.agent_not_active': 'The schedule is paused or deleted.',
|
||||
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
|
||||
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
|
||||
'runErr.user_cancelled': 'Cancelled.',
|
||||
'runErr.video_script_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
|
||||
'runErr.video_storyboard_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
|
||||
'runErr.video_style_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
|
||||
'runErr.video_clip_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
|
||||
'sch.noNewSources': 'No new sources found. A more specific topic may help.',
|
||||
'sch.blockedBy': 'Blocking material:',
|
||||
'sch.blockedNone': 'All material is ready now. Try again.',
|
||||
'sch.fixInStudio': 'Open in Learning Studio',
|
||||
'issue.document_processing': 'still processing',
|
||||
'issue.document_failed': 'failed to process; reprocess or delete it',
|
||||
'issue.graph_building': 'knowledge graph still building',
|
||||
'issue.graph_not_ready': 'knowledge graph not prepared yet',
|
||||
'issue.legacy_set_needs_conversion': 'old Compose set; convert it',
|
||||
'issue.no_knowledge_sources': 'Compose set has no sources',
|
||||
'issue.source_not_accessible': 'Compose set uses a document you cannot access',
|
||||
'issue.source_graph_not_ready': 'a source document is not ready',
|
||||
'issue.source_version_changed': 'a source document changed; refresh the set',
|
||||
'issue.snapshot_not_ready': 'Compose graph not prepared yet',
|
||||
|
||||
// Results
|
||||
'res.title': 'Results',
|
||||
'res.description': 'Everything Express creates: text, quizzes, roleplays, video, audio, and SCORM.',
|
||||
'res.description': 'Everything Express creates: text, quizzes, roleplays, video, audio, and INTERACTIVE.',
|
||||
'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',
|
||||
@@ -247,6 +337,7 @@ const en = {
|
||||
'res.filterStatus': 'Filter by status',
|
||||
'res.allKinds': 'All types',
|
||||
'res.status.all': 'All statuses',
|
||||
'res.status.done': 'Finished',
|
||||
'res.status.review': 'Needs review',
|
||||
'res.status.approved': 'Approved',
|
||||
'res.status.working': 'In progress',
|
||||
@@ -263,13 +354,13 @@ const en = {
|
||||
'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.noteContent': 'Download as PDF, Markdown or INTERACTIVE; 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.close': 'Close',
|
||||
'res.reviewNotice': 'AI-generated draft. Check it, then approve it or reject it.',
|
||||
'res.reviewNotice': 'Agent-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',
|
||||
@@ -295,14 +386,14 @@ const en = {
|
||||
'res.field.objective': 'Objective',
|
||||
'res.field.learning_objectives': 'Learning objectives',
|
||||
'res.field.user_role': 'Learner role',
|
||||
'res.field.ai_character': 'AI character',
|
||||
'res.field.ai_character': 'Agent 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.noteScorm': 'Each module is a lesson with a 10-question quiz, downloaded from Results as an INTERACTIVE 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',
|
||||
@@ -316,7 +407,20 @@ const en = {
|
||||
'agent.greeting': 'Hi! Tell me what to create from the Brain and how often, or what to research on the internet. If anything is unclear, I will ask you here.',
|
||||
'agent.understood': 'Got it. Here is the plan so far:\n{list}',
|
||||
'agent.added': 'Added to the plan:\n{list}',
|
||||
'agent.notUnderstood': 'I could not find a content type or research request in that. Try something like “2 summaries per day about leadership”.',
|
||||
'agent.notUnderstood': 'I could not find a content type or research request in that. I can make summaries, quizzes, flashcards, roleplays, simulations, slides, videos, audio and INTERACTIVE modules from the Brain, or research the internet. Try something like “2 summaries per day about leadership”.',
|
||||
'agent.askKind': '{count} pieces of content{about}: which kind? Pick one, or name several (for example “summaries and quizzes”) to split them.',
|
||||
'agent.askPer': 'How often should this be made? You did not say, and I would rather not guess.\n{list}',
|
||||
'agent.optMix': 'Mix (summaries, quizzes, flashcards)',
|
||||
'agent.optEveryDay': 'Every day',
|
||||
'agent.optEveryWeek': 'Every week',
|
||||
'agent.kindSet': 'Updated the plan:\n{list}',
|
||||
'note.wholeBrain': 'Sources: the whole Brain (every ready document), unless you tick specific material in the Brain panel.',
|
||||
'note.novelty': 'Each production plan is checked against what was already made, so topics are not repeated.',
|
||||
'note.draft': 'Everything made lands in Results as a draft marked “Needs review”: an admin approves or rejects it there before it is used.',
|
||||
'note.automatic': 'Once you approve, the schedules run on their own in the background; nothing needs starting by hand.',
|
||||
'note.hourMatches': 'Content is produced at {at} WIB, as you asked.',
|
||||
'note.hourFixed': 'Content is produced at 09.00 WIB and research runs at 07.00 WIB; a different hour such as {at} cannot be set here yet.',
|
||||
'note.subDay': 'Schedules run per day or per week, so I cannot promise results within minutes or hours. Once a schedule is active, “Produce now” on the Schedule page starts it early.',
|
||||
'agent.unclear': 'Sorry, I did not catch that. Pick one of the options below or answer in your own words.',
|
||||
'agent.askUnknown': 'I did not understand “{part}”. What did you mean? Rephrase it, or skip it.',
|
||||
'agent.askVideo': '{rule}: a video schedule makes one video per run, so it can be 1 per week or 1 per day. Which one?',
|
||||
@@ -329,6 +433,9 @@ const en = {
|
||||
'agent.languageSet': 'Content will be written in {language}.',
|
||||
'ins.language': 'Content language',
|
||||
'ins.languagePending': 'Not chosen yet',
|
||||
'ins.kindPending': 'Choose a kind',
|
||||
'ins.perPending': 'Choose how often',
|
||||
'ins.incomplete': 'Choose the kind, the frequency and the content language first.',
|
||||
'agent.skipped': 'OK, I skipped “{part}”.',
|
||||
'agent.removed': 'Removed {rule}.',
|
||||
'agent.updated': 'Updated: {rule}.',
|
||||
@@ -336,6 +443,7 @@ const en = {
|
||||
'agent.wholeBrain': 'OK, the whole Brain it is.',
|
||||
'agent.reset': 'Starting over. What should I create?',
|
||||
'agent.approving': 'Creating the schedules…',
|
||||
'agent.attaching': 'Connecting the Brain to schedule {n} of {count}: {done} of {total} documents',
|
||||
'agent.done': 'Done: {n} schedules are active and start right away.',
|
||||
'agent.partial': '{ok} of {n} schedules were created. The ones that failed are still in the plan: fix them, then approve again.',
|
||||
'agent.optSkip': 'Skip',
|
||||
@@ -359,6 +467,13 @@ const en = {
|
||||
'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.videoStuck': 'The video stopped',
|
||||
'res.videoStuckCopy': 'The engine could not confirm whether a step finished, so it will not continue. Cancel it to settle its credits; the schedule can then make it again.',
|
||||
'res.videoStuckShort': 'Stopped: cancel to retry',
|
||||
'res.cancelVideo': 'Cancel video',
|
||||
'res.cancellingVideo': 'Cancelling…',
|
||||
'res.cancelVideoConfirm': 'Cancel this video? Credits already spent are not refunded.',
|
||||
'res.videoCancelled': 'Video cancelled',
|
||||
'res.downloadVideo': 'Download video (MP4)',
|
||||
'res.creditsUsed': 'Credits used',
|
||||
'res.accepted': 'Approved.',
|
||||
@@ -372,11 +487,11 @@ const en = {
|
||||
'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.scormModule': 'INTERACTIVE 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.',
|
||||
'res.scormReady': 'INTERACTIVE package downloaded. Upload the .zip to your LMS.',
|
||||
'scorm.submit': 'Check answers',
|
||||
'scorm.retry': 'Try again',
|
||||
'scorm.complete': 'Mark as complete',
|
||||
@@ -396,27 +511,51 @@ const en = {
|
||||
'audio.ready': '{n} sentences · press Listen to start',
|
||||
'audio.sentence': 'Sentence {n} of {total}',
|
||||
'brain.deleted': '“{name}” deleted.',
|
||||
'brainPanel.title': 'Brain',
|
||||
'brainPanel.count': '{n} materials',
|
||||
'brainPanel.copy': 'Your team’s knowledge base.',
|
||||
'brainPanel.search': 'Search material…',
|
||||
'brainPanel.noMatch': 'No material matches',
|
||||
'brainPanel.noMatchCopy': 'Try another keyword or upload more material.',
|
||||
'brainPanel.note': 'Chosen material is what the Agent uses. Leave everything unticked to use the whole Brain.',
|
||||
'brainPanel.selectAll': 'Select all',
|
||||
'brainPanel.nChosen': '{n} chosen materials',
|
||||
'ins.topicPlaceholderChosen': 'Topic (empty = all chosen material)',
|
||||
'brainPanel.selected': '{n} selected',
|
||||
'brainPanel.wholeBrain': 'Whole Brain in use',
|
||||
'brainPanel.select': 'Select {name}',
|
||||
'brainPanel.notReady': 'Can be chosen once it is in the Brain',
|
||||
'ins.sourcesChosen': 'Source: chosen material ({n})',
|
||||
'ins.sourcesAll': 'Source: the whole Brain',
|
||||
'ins.tooFewChosen': ' Content schedules (summary, quiz and the like) need at least {n} materials. Choose one more in the Brain panel.',
|
||||
'ins.tooFewInBrain': ' Content schedules (summary, quiz and the like) need at least {n} ready materials in the Brain. Upload another one first.',
|
||||
'sch.tooFewSources': 'This schedule has fewer than {n} ready materials, and content is planned from pairs of materials. Delete it and make it again with {n} or more.',
|
||||
'brainPanel.upload': 'Upload material',
|
||||
|
||||
// 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.subtitle': 'Upload material → Instruction → Schedule → Results',
|
||||
'help.brainTitle': 'Upload material',
|
||||
'help.brainBody': 'Upload your training material. Once its knowledge is built, the Agent 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.resultsBody': 'Review what was made, approve or reject it, and download it: PDF, Markdown, PowerPoint, MP4, audio script, or INTERACTIVE for your LMS.',
|
||||
'help.note': 'Content is Agent-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.busy': 'The engine is busy right now. Try again in a moment.',
|
||||
'err.crashTitle': 'This page could not be shown',
|
||||
'err.crashCopy': 'Something went wrong while showing this page. Try again, or reload.',
|
||||
'err.reload': 'Reload',
|
||||
'err.credits': 'Not enough credits: needs {required}, balance {balance}.',
|
||||
'err.creditsShort': 'Not enough credits.',
|
||||
'err.docFailed': 'The document could not be processed.',
|
||||
@@ -428,53 +567,91 @@ export type Key = keyof typeof en
|
||||
|
||||
const id: Record<Key, string> = {
|
||||
'prod.title': 'Produksi',
|
||||
'prod.description': 'Semua yang diproduksi Express dari Otak: materi, konten yang dibuat, yang sudah tayang, dan yang sedang dibuat saat ini.',
|
||||
'prod.metricWorking': 'Konten sedang dibuat',
|
||||
'prod.metricWorkingSub': 'sedang diriset atau dibuat sekarang',
|
||||
'prod.metricMaterial': 'Materi',
|
||||
'prod.metricMaterialSub': '{n} sudah di Otak',
|
||||
'prod.metricMade': 'Konten dibuat',
|
||||
'prod.metricMadeSub': 'hasil yang sudah dibuat AI',
|
||||
'prod.metricLive': 'Sudah produksi',
|
||||
'prod.metricLiveSub': 'disetujui untuk dipakai',
|
||||
'prod.metricLms': 'Sudah di LMS',
|
||||
'prod.metricLmsSub': 'modul SCORM yang disetujui',
|
||||
'prod.graphTitle': 'Knowledge graph',
|
||||
'prod.clickNode': 'Klik sebuah node',
|
||||
'prod.loadingGraph': 'Menggambar knowledge graph…',
|
||||
'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
|
||||
'prod.hub.sources': 'Sumber',
|
||||
'prod.hub.made': 'Dibuat',
|
||||
'prod.hub.working': 'Sedang dibuat',
|
||||
'prod.hub.research': 'Riset',
|
||||
'prod.hub.schedules': 'Berulang',
|
||||
'prod.nodes': 'Node',
|
||||
'prod.description': 'Pengetahuan yang terhubung. Pembelajaran yang terus tumbuh.',
|
||||
'prod.unitMaterial': 'materi',
|
||||
'prod.unitMaterialOne': 'materi',
|
||||
'prod.unitContent': 'konten',
|
||||
'prod.metricMaterial': 'Total materi di Otak',
|
||||
'prod.metricMaterialSub': 'Sumber pengetahuan tim Anda',
|
||||
'prod.metricMade': 'Semua konten dibuat',
|
||||
'prod.metricMadeSub': 'Seluruh hasil pembuatan konten',
|
||||
'prod.metricLms': 'Sudah diunggah ke LMS',
|
||||
'prod.metricLmsSub': 'Status unggahan pada demo ini',
|
||||
'prod.activityTitle': 'Aktivitas konten',
|
||||
'prod.activityDescription': 'Dari pengetahuan di Otak menjadi pembelajaran baru.',
|
||||
'prod.activityAll': 'Lihat semua hasil konten',
|
||||
'prod.activityLatest': 'Konten terbaru',
|
||||
'prod.activityWorking': 'Konten sedang dibuat',
|
||||
'prod.activityNoneLatest': 'Belum ada konten yang dibuat.',
|
||||
'prod.activityNoneWorking': 'Tidak ada konten yang sedang dibuat.',
|
||||
'prod.eyebrow': 'Ringkasan ruang kerja',
|
||||
'prod.welcome': 'Selamat datang',
|
||||
'prod.welcomeName': 'Selamat datang, {name}',
|
||||
'prod.newContent': 'Buat konten baru',
|
||||
'prod.graphTitle': 'Peta pengetahuan',
|
||||
'prod.clickNode': 'Klik titik untuk melihat detailnya',
|
||||
'prod.fullscreen': 'Layar penuh',
|
||||
'prod.exitFullscreen': 'Keluar layar penuh',
|
||||
'prod.loadingGraph': 'Menggambar peta pengetahuan…',
|
||||
'prod.graphLabel': 'Peta pengetahuan berisi {nodes} titik',
|
||||
'prod.hub.sources': 'Basis pengetahuan',
|
||||
'prod.hub.made': 'Konten',
|
||||
'prod.nodes': 'Titik',
|
||||
'prod.nodesSub': 'di Otak ini',
|
||||
'prod.links': 'Tautan',
|
||||
'prod.linksSub': 'koneksi antar node',
|
||||
'prod.linksSub': 'koneksi antartitik',
|
||||
'prod.scheduleTitle': 'Jadwal',
|
||||
'prod.noSchedules': 'Belum ada jadwal.',
|
||||
'prod.giveInstruction': 'Beri perintah',
|
||||
'prod.nowTitle': 'Sedang berjalan',
|
||||
'prod.nothingNow': 'Tidak ada yang sedang dibuat.',
|
||||
'prod.more': '+{n} lagi di Hasil',
|
||||
'nav.brain': 'Otak',
|
||||
'nav.instruction': 'Perintah',
|
||||
'nav.brain': 'Upload materi',
|
||||
'nav.instruction': 'Instruksi',
|
||||
'nav.schedule': 'Jadwal',
|
||||
'nav.results': 'Hasil',
|
||||
'nav.dashboard': 'Dasbor',
|
||||
'nav.settings': 'Pengaturan',
|
||||
'nav.production': 'Produksi',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.billing': 'Billing',
|
||||
'billing.title': 'Billing',
|
||||
'billing.description': 'Kredit yang dipakai organisasi Anda, per tanggal, pengguna, dan modul. Halaman ini hanya bisa dilihat admin organisasi.',
|
||||
'billing.total': 'Total terpakai',
|
||||
'billing.period': '{from} sampai {to}',
|
||||
'billing.balance': 'Saldo saat ini',
|
||||
'billing.balanceSub': 'Kredit organisasi',
|
||||
'billing.usage': 'Konsumsi kredit',
|
||||
'billing.from': 'Tanggal mulai',
|
||||
'billing.to': 'Tanggal akhir',
|
||||
'billing.colDate': 'Tanggal',
|
||||
'billing.colUser': 'Pengguna',
|
||||
'billing.colModule': 'Modul',
|
||||
'billing.colCredits': 'Kredit',
|
||||
'billing.refund': 'Dikembalikan',
|
||||
'billing.loading': 'Memuat konsumsi kredit',
|
||||
'billing.loadFailed': 'Konsumsi kredit tidak bisa dimuat',
|
||||
'billing.empty': 'Tidak ada kredit terpakai di periode ini',
|
||||
'billing.emptyCopy': 'Coba rentang tanggal yang lebih lebar.',
|
||||
'billing.more': 'Tampilkan lagi',
|
||||
'billing.count': '{n} transaksi',
|
||||
'billing.countOne': '{n} transaksi',
|
||||
'billing.export': 'Ekspor CSV',
|
||||
'billing.colActivity': 'Aktivitas',
|
||||
'billing.colType': 'Jenis',
|
||||
'billing.charge': 'Terpakai',
|
||||
'billing.note': 'Pemakaian dihitung dalam kredit, satuan yang ditagihkan ke organisasi Anda. Kredit yang dikembalikan mengurangi total.',
|
||||
'layout.learningSpace': 'Ruang belajar',
|
||||
'layout.switchOrg': 'Ganti organisasi',
|
||||
'layout.breadcrumb': 'Ruang kerja',
|
||||
'layout.signOut': 'Keluar',
|
||||
'layout.engine': 'Engine learning-lab',
|
||||
'layout.creditsTitle': 'Saldo kredit workspace',
|
||||
'layout.creditsTitle': 'Saldo kredit ruang kerja',
|
||||
'layout.toStudio': 'Beralih ke 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.collapseSidebar': 'Ciutkan sidebar',
|
||||
'layout.expandSidebar': 'Lebarkan sidebar',
|
||||
'layout.language': 'Bahasa',
|
||||
|
||||
'auth.loading': 'Memuat…',
|
||||
@@ -482,7 +659,7 @@ const id: Record<Key, string> = {
|
||||
'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.copy': 'Unggah materi ke Otak, beri satu perintah, dan Agent 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',
|
||||
@@ -496,15 +673,19 @@ const id: Record<Key, string> = {
|
||||
'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.title': 'Upload materi',
|
||||
'brain.description': 'Unggah materi pelatihan tim di sini. Setiap file diproses ke dalam Otak yang menjadi sumber Agent membuat konten.',
|
||||
'brain.metricMaterial': 'Materi',
|
||||
'brain.metricMaterialSub': 'dokumen di ruang kerja',
|
||||
'brain.metricReady': 'Masuk Otak',
|
||||
'brain.metricReadySub': 'knowledge base siap',
|
||||
'brain.metricReadySub': 'basis pengetahuan siap',
|
||||
'brain.metricAction': 'Perlu tindakan',
|
||||
'brain.metricActionSub': 'menunggu persetujuan Anda',
|
||||
'brain.listTitle': 'Materi',
|
||||
'brain.allStatuses': 'Semua status',
|
||||
'brain.duplicateConfirm': 'Sudah ada di Otak: {names}. Tetap unggah salinan lain? Batal akan melewati file ini.',
|
||||
'brain.duplicateSkipped': 'Dilewati: sudah ada di Otak',
|
||||
'brain.downloaded': '{name} sedang diunduh: format ini dibuka di aplikasinya sendiri.',
|
||||
'brain.loading': 'Memuat materi…',
|
||||
'brain.engineDown': 'Engine tidak dapat dihubungi',
|
||||
'brain.empty': 'Belum ada materi',
|
||||
@@ -532,7 +713,11 @@ const id: Record<Key, string> = {
|
||||
'brain.needsCreditsNeed': ' (butuh {credits})',
|
||||
'brain.failed': 'Gagal memasukkan ke Otak.',
|
||||
'brain.retryWith': 'Coba lagi ({credits})',
|
||||
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai file ini dihapus: hapus lalu unggah ulang.',
|
||||
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang: pengetahuannya tetap disimpan.',
|
||||
'brain.failedProcessing': 'File ini gagal diproses. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang, atau hapus.',
|
||||
'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki: unggah ulang.',
|
||||
'brain.reprocess': 'Proses ulang',
|
||||
'brain.reprocessing': 'Memulai…',
|
||||
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
|
||||
'brain.confirmCreditsFallback': 'kredit',
|
||||
'brain.addToBrain': 'Masukkan ke Otak',
|
||||
@@ -540,14 +725,14 @@ const id: Record<Key, string> = {
|
||||
'brain.open': 'Buka {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.title': 'Instruksi',
|
||||
'ins.description': 'Beri tahu Agent apa yang perlu dibuat dari Otak. Agent menyusun instruksi Anda menjadi jadwal riset dan pembuatan konten yang terus berjalan sampai Anda menghentikannya.',
|
||||
'ins.label': 'Instruksi',
|
||||
'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.ex5': '1 video per minggu, 2 podcast per minggu dan 1 modul INTERACTIVE per minggu soal onboarding',
|
||||
'ins.previewTitle': 'Jadwal yang akan dibuat',
|
||||
'ins.readyCount': '{n} siap',
|
||||
'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.',
|
||||
@@ -580,6 +765,10 @@ const id: Record<Key, string> = {
|
||||
'rule.research': '{when}: riset internet{about}',
|
||||
'rule.about': ' tentang {topic}',
|
||||
'rule.wholeBrain': ' dari seluruh Otak',
|
||||
'rule.from': ' dari {source}',
|
||||
'rule.chosenMaterial': 'materi pilihan',
|
||||
'rule.anyKind': 'Konten (jenis belum dipilih)',
|
||||
'rule.perUnset': '(frekuensi belum ditentukan)',
|
||||
'rule.content': '{kind}: {count} {per}{about}',
|
||||
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
|
||||
'kind.summary': 'Ringkasan',
|
||||
@@ -590,7 +779,7 @@ const id: Record<Key, string> = {
|
||||
'kind.slide_deck': 'Slide',
|
||||
'kind.video': 'Video',
|
||||
'kind.audio': 'Audio',
|
||||
'kind.scorm': 'SCORM',
|
||||
'kind.scorm': 'INTERACTIVE',
|
||||
'day.0': 'Senin', 'day.1': 'Selasa', 'day.2': 'Rabu', 'day.3': 'Kamis', 'day.4': 'Jumat', 'day.5': 'Sabtu', 'day.6': 'Minggu',
|
||||
|
||||
'sch.title': 'Jadwal',
|
||||
@@ -600,6 +789,13 @@ const id: Record<Key, string> = {
|
||||
'sch.loading': 'Memuat jadwal…',
|
||||
'sch.loadFailed': 'Jadwal tidak dapat dimuat',
|
||||
'sch.empty': 'Belum ada jadwal',
|
||||
'sch.mine': 'Punya saya',
|
||||
'sch.all': 'Semua di organisasi',
|
||||
'sch.emptyMine': 'Anda belum punya jadwal',
|
||||
'sch.emptyMineCopy': 'Orang lain di organisasi ini punya jadwal; lihat di “Semua di organisasi”.',
|
||||
'sch.byYou': 'Dibuat oleh Anda',
|
||||
'sch.by': 'Dibuat oleh {name}',
|
||||
'sch.byUnknown': 'Dibuat oleh orang lain',
|
||||
'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.',
|
||||
'sch.nextResearch': 'Riset berikutnya: {when}',
|
||||
'sch.paused': 'Dijeda',
|
||||
@@ -619,14 +815,14 @@ const id: Record<Key, string> = {
|
||||
'prod.runningTitle': 'Memproduksi {n} konten baru',
|
||||
'prod.lastTitle': 'Produksi terakhir',
|
||||
'prod.count': '{ready} dari {total} siap',
|
||||
'prod.stepTopics': 'AI memilih topik baru',
|
||||
'prod.stepTopics': 'Agent 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',
|
||||
'prod.shortfall': 'Otak hanya punya materi baru untuk lebih sedikit konten dari yang diminta (kurang {n}); tambah dokumen untuk topik lain.',
|
||||
'prod.viewResults': 'Lihat di Hasil',
|
||||
'sch.produceHowMany': 'Berapa konten sekarang? (sisa {max} untuk minggu ini)',
|
||||
'sch.checkPrice': 'Cek harga',
|
||||
'sch.checkingPrice': 'Mengecek…',
|
||||
@@ -643,9 +839,42 @@ const id: Record<Key, string> = {
|
||||
'sch.scheduled': 'Terjadwal',
|
||||
'sch.manual': 'Manual',
|
||||
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
|
||||
'run.paused_configuration': 'Perlu tindakan', 'run.paused_budget': 'Tertahan: kredit',
|
||||
'runErr.knowledge_not_ready': 'Sebagian materi yang dipakai jadwal ini belum siap, jadi engine tidak mau mulai.',
|
||||
'runErr.video_sources_unavailable': 'Tidak ada materi siap yang cocok untuk video jadwal ini.',
|
||||
'runErr.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.',
|
||||
'runErr.knowledge_search_failed': 'Pencarian materi gagal. Coba lagi.',
|
||||
'runErr.organization_wallet': 'Kredit organisasi tidak cukup.',
|
||||
'runErr.run_cap': 'Proses ini melebihi batas kredit per proses.',
|
||||
'runErr.monthly_cap': 'Batas kredit bulanan jadwal ini sudah tercapai.',
|
||||
'runErr.reservation_cap': 'Terlalu banyak kredit sudah dipesan oleh proses lain.',
|
||||
'runErr.price_changed': 'Harga berubah. Coba lagi.',
|
||||
'runErr.video_price_changed': 'Harga video berubah. Coba lagi.',
|
||||
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
|
||||
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
|
||||
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
|
||||
'runErr.user_cancelled': 'Dibatalkan.',
|
||||
'runErr.video_script_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
|
||||
'runErr.video_storyboard_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
|
||||
'runErr.video_style_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
|
||||
'runErr.video_clip_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
|
||||
'sch.noNewSources': 'Tidak ada sumber baru yang ditemukan. Topik yang lebih spesifik bisa membantu.',
|
||||
'sch.blockedBy': 'Materi yang menahan:',
|
||||
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
|
||||
'sch.fixInStudio': 'Buka di Learning Studio',
|
||||
'issue.document_processing': 'masih diproses',
|
||||
'issue.document_failed': 'gagal diproses; proses ulang atau hapus',
|
||||
'issue.graph_building': 'peta pengetahuan masih dibangun',
|
||||
'issue.graph_not_ready': 'peta pengetahuan belum disiapkan',
|
||||
'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi',
|
||||
'issue.no_knowledge_sources': 'set Compose tidak punya sumber',
|
||||
'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses',
|
||||
'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap',
|
||||
'issue.source_version_changed': 'dokumen sumber berubah; perbarui set-nya',
|
||||
'issue.snapshot_not_ready': 'peta pengetahuan Compose belum disiapkan',
|
||||
|
||||
'res.title': 'Hasil',
|
||||
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
|
||||
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan INTERACTIVE.',
|
||||
'res.empty': 'Belum ada hasil',
|
||||
'res.emptyCopy': 'Konten dan sumber dari jadwal Anda tampil di sini. Beri perintah untuk memulai jadwal.',
|
||||
'res.tabContent': 'Konten',
|
||||
@@ -658,6 +887,7 @@ const id: Record<Key, string> = {
|
||||
'res.filterStatus': 'Filter status',
|
||||
'res.allKinds': 'Semua jenis',
|
||||
'res.status.all': 'Semua status',
|
||||
'res.status.done': 'Selesai dibuat',
|
||||
'res.status.review': 'Perlu ditinjau',
|
||||
'res.status.approved': 'Disetujui',
|
||||
'res.status.working': 'Sedang dibuat',
|
||||
@@ -669,18 +899,18 @@ const id: Record<Key, string> = {
|
||||
'res.state.failed': 'Gagal',
|
||||
'res.state.rejected': 'Ditolak',
|
||||
'res.source.pending': 'Perlu ditinjau',
|
||||
'res.source.accepted': 'Masuk Library',
|
||||
'res.source.accepted': 'Masuk Pustaka',
|
||||
'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.noteContent': 'Unduh sebagai PDF, Markdown, atau INTERACTIVE; 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 Pustaka, lalu bangun pengetahuannya di halaman Upload materi.',
|
||||
'res.sourceCount': '{n} sumber',
|
||||
'res.sourceOne': '1 sumber',
|
||||
'res.working': 'Sedang dibuat…',
|
||||
'res.close': 'Tutup',
|
||||
'res.reviewNotice': 'Draft buatan AI. Periksa dulu, lalu setujui atau tolak.',
|
||||
'res.reviewNotice': 'Draf buatan Agent. 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',
|
||||
@@ -688,17 +918,17 @@ const id: Record<Key, string> = {
|
||||
'res.schedule': 'Jadwal',
|
||||
'res.created': 'Dibuat',
|
||||
'res.sources': 'Sumber',
|
||||
'res.footNote': 'Dibuat oleh Express dari Brain Anda.',
|
||||
'res.footNote': 'Dibuat oleh Express dari Otak 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.pdfHint': 'Membuka dialog cetak: pilih “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.addToBrain': 'Masukkan ke Pustaka',
|
||||
'res.inBrain': 'lihat di Otak',
|
||||
'res.answer': 'Jawaban',
|
||||
'res.slide': 'Slide {n}',
|
||||
'res.field.description': 'Deskripsi',
|
||||
@@ -706,14 +936,14 @@ const id: Record<Key, string> = {
|
||||
'res.field.objective': 'Tujuan',
|
||||
'res.field.learning_objectives': 'Tujuan pembelajaran',
|
||||
'res.field.user_role': 'Peran peserta',
|
||||
'res.field.ai_character': 'Karakter AI',
|
||||
'res.field.ai_character': 'Karakter Agent',
|
||||
'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.noteScorm': 'Setiap modul berisi materi dan kuis 10 soal, diunduh dari Hasil sebagai paket INTERACTIVE 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',
|
||||
@@ -727,7 +957,20 @@ const id: Record<Key, string> = {
|
||||
'agent.greeting': 'Halo! Ceritakan konten apa yang perlu dibuat dari Otak dan seberapa sering, atau apa yang perlu diriset di internet. Kalau ada yang belum jelas, saya tanyakan di sini.',
|
||||
'agent.understood': 'Siap. Ini rencananya sejauh ini:\n{list}',
|
||||
'agent.added': 'Ditambahkan ke rencana:\n{list}',
|
||||
'agent.notUnderstood': 'Saya belum menemukan jenis konten atau permintaan riset di situ. Coba misalnya “2 ringkasan per hari soal leadership”.',
|
||||
'agent.notUnderstood': 'Saya belum menemukan jenis konten atau permintaan riset di situ. Saya bisa membuat ringkasan, kuis, flashcard, roleplay, simulasi, slide, video, audio, dan modul INTERACTIVE dari Otak, atau riset internet. Coba misalnya “2 ringkasan per hari soal leadership”.',
|
||||
'agent.askKind': '{count} konten{about}: jenisnya apa? Pilih satu, atau sebut beberapa (misalnya “ringkasan dan kuis”) untuk membaginya.',
|
||||
'agent.askPer': 'Seberapa sering ini dibuat? Anda belum menyebutkannya, dan saya tidak mau menebak.\n{list}',
|
||||
'agent.optMix': 'Campuran (ringkasan, kuis, flashcard)',
|
||||
'agent.optEveryDay': 'Setiap hari',
|
||||
'agent.optEveryWeek': 'Setiap minggu',
|
||||
'agent.kindSet': 'Rencana diperbarui:\n{list}',
|
||||
'note.wholeBrain': 'Sumbernya seluruh Otak (semua dokumen yang siap), kecuali Anda mencentang materi tertentu di panel Otak.',
|
||||
'note.novelty': 'Setiap rencana produksi dicek terhadap konten yang sudah ada, jadi topiknya tidak berulang.',
|
||||
'note.draft': 'Semua hasil masuk ke Hasil sebagai draf berstatus “Perlu ditinjau”: admin menyetujui atau menolaknya di sana sebelum dipakai.',
|
||||
'note.automatic': 'Setelah Anda setujui, jadwal berjalan sendiri di latar belakang; tidak perlu dijalankan manual.',
|
||||
'note.hourMatches': 'Konten diproduksi pukul {at} WIB, sesuai permintaan.',
|
||||
'note.hourFixed': 'Konten diproduksi pukul 09.00 WIB dan riset pukul 07.00 WIB; jam lain seperti {at} belum bisa diatur dari sini.',
|
||||
'note.subDay': 'Jadwal berjalan per hari atau per minggu, jadi saya tidak bisa menjanjikan hasil dalam hitungan menit atau jam. Setelah jadwal aktif, tombol “Produksi sekarang” di halaman Jadwal memulainya lebih awal.',
|
||||
'agent.unclear': 'Maaf, saya belum menangkap maksudnya. Pilih salah satu opsi di bawah atau jawab dengan kata-kata sendiri.',
|
||||
'agent.askUnknown': 'Saya belum paham “{part}”. Maksudnya apa? Tulis ulang, atau lewati saja.',
|
||||
'agent.askVideo': '{rule}: jadwal video membuat satu video per proses, jadi pilihannya 1 per minggu atau 1 per hari. Mau yang mana?',
|
||||
@@ -740,6 +983,9 @@ const id: Record<Key, string> = {
|
||||
'agent.languageSet': 'Konten akan ditulis dalam {language}.',
|
||||
'ins.language': 'Bahasa konten',
|
||||
'ins.languagePending': 'Belum dipilih',
|
||||
'ins.kindPending': 'Pilih jenis',
|
||||
'ins.perPending': 'Pilih frekuensi',
|
||||
'ins.incomplete': 'Pilih jenis, frekuensi, dan bahasa konten dulu.',
|
||||
'agent.skipped': 'Oke, “{part}” saya lewati.',
|
||||
'agent.removed': '{rule} dihapus.',
|
||||
'agent.updated': 'Diperbarui: {rule}.',
|
||||
@@ -747,6 +993,7 @@ const id: Record<Key, string> = {
|
||||
'agent.wholeBrain': 'Oke, pakai seluruh Otak.',
|
||||
'agent.reset': 'Mulai dari awal. Apa yang perlu dibuat?',
|
||||
'agent.approving': 'Membuat jadwal…',
|
||||
'agent.attaching': 'Menghubungkan Otak ke jadwal {n} dari {count}: {done} dari {total} dokumen',
|
||||
'agent.done': 'Selesai: {n} jadwal aktif dan langsung berjalan.',
|
||||
'agent.partial': '{ok} dari {n} jadwal berhasil dibuat. Yang gagal masih ada di rencana: perbaiki, lalu setujui lagi.',
|
||||
'agent.optSkip': 'Lewati',
|
||||
@@ -770,6 +1017,13 @@ const id: Record<Key, string> = {
|
||||
'res.videoNotReady': 'Video belum siap',
|
||||
'res.videoFailed': 'Video gagal dibuat',
|
||||
'res.videoFailedCopy': 'Proses render berhenti sebelum video selesai.',
|
||||
'res.videoStuck': 'Video berhenti',
|
||||
'res.videoStuckCopy': 'Engine tidak bisa memastikan apakah satu tahap selesai, jadi proses tidak dilanjutkan. Batalkan video ini untuk menyelesaikan kreditnya; setelah itu jadwal bisa membuatnya lagi.',
|
||||
'res.videoStuckShort': 'Berhenti: batalkan untuk coba lagi',
|
||||
'res.cancelVideo': 'Batalkan video',
|
||||
'res.cancellingVideo': 'Membatalkan…',
|
||||
'res.cancelVideoConfirm': 'Batalkan video ini? Kredit yang sudah terpakai tidak dikembalikan.',
|
||||
'res.videoCancelled': 'Video dibatalkan',
|
||||
'res.downloadVideo': 'Unduh video (MP4)',
|
||||
'res.creditsUsed': 'Kredit terpakai',
|
||||
'res.accepted': 'Disetujui.',
|
||||
@@ -783,11 +1037,11 @@ const id: Record<Key, string> = {
|
||||
'res.scriptHint': 'Teks narasi, untuk direkam atau alat TTS',
|
||||
'res.quiz': 'Kuis',
|
||||
'res.quizPending': 'Kuisnya belum siap.',
|
||||
'res.scormModule': 'Modul SCORM 1.2',
|
||||
'res.scormModule': 'Modul INTERACTIVE',
|
||||
'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.',
|
||||
'res.scormReady': 'Paket INTERACTIVE terunduh. Unggah file .zip ke LMS Anda.',
|
||||
'scorm.submit': 'Periksa jawaban',
|
||||
'scorm.retry': 'Coba lagi',
|
||||
'scorm.complete': 'Tandai selesai',
|
||||
@@ -807,26 +1061,50 @@ const id: Record<Key, string> = {
|
||||
'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai',
|
||||
'audio.sentence': 'Kalimat {n} dari {total}',
|
||||
'brain.deleted': '“{name}” dihapus.',
|
||||
'brainPanel.title': 'Otak',
|
||||
'brainPanel.count': '{n} materi',
|
||||
'brainPanel.copy': 'Basis pengetahuan tim Anda.',
|
||||
'brainPanel.search': 'Cari materi…',
|
||||
'brainPanel.noMatch': 'Belum ada materi yang cocok',
|
||||
'brainPanel.noMatchCopy': 'Coba kata kunci lain atau upload materi baru.',
|
||||
'brainPanel.note': 'Materi yang dipilih menjadi acuan Agent. Kosongkan pilihan untuk memakai seluruh Otak.',
|
||||
'brainPanel.selectAll': 'Pilih semua',
|
||||
'brainPanel.nChosen': '{n} materi pilihan',
|
||||
'ins.topicPlaceholderChosen': 'Topik (kosong = semua materi pilihan)',
|
||||
'brainPanel.selected': '{n} dipilih',
|
||||
'brainPanel.wholeBrain': 'Seluruh Otak digunakan',
|
||||
'brainPanel.select': 'Pilih {name}',
|
||||
'brainPanel.notReady': 'Bisa dipilih setelah masuk Otak',
|
||||
'ins.sourcesChosen': 'Sumber: {n} materi dipilih',
|
||||
'ins.sourcesAll': 'Sumber: seluruh Otak',
|
||||
'ins.tooFewChosen': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi. Pilih satu lagi di panel Otak.',
|
||||
'ins.tooFewInBrain': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi siap di Otak. Unggah satu lagi dulu.',
|
||||
'sch.tooFewSources': 'Jadwal ini punya kurang dari {n} materi siap, padahal konten direncanakan dari pasangan materi. Hapus lalu buat ulang dengan {n} materi atau lebih.',
|
||||
'brainPanel.upload': 'Unggah materi',
|
||||
|
||||
// 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.subtitle': 'Upload materi → Instruksi → Jadwal → Hasil',
|
||||
'help.brainTitle': 'Upload materi',
|
||||
'help.brainBody': 'Unggah materi pelatihan. Setelah pengetahuannya dibangun, Agent membuat semua konten dari situ.',
|
||||
'help.instructionTitle': 'Instruksi',
|
||||
'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.resultsBody': 'Tinjau hasilnya, setujui atau tolak, lalu unduh: PDF, Markdown, PowerPoint, MP4, naskah audio, atau INTERACTIVE untuk LMS.',
|
||||
'help.note': 'Konten dibuat Agent: 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.busy': 'Engine sedang sibuk. Coba lagi sebentar lagi.',
|
||||
'err.crashTitle': 'Halaman ini tidak dapat ditampilkan',
|
||||
'err.crashCopy': 'Terjadi kesalahan saat menampilkan halaman ini. Coba lagi, atau muat ulang.',
|
||||
'err.reload': 'Muat ulang',
|
||||
'err.credits': 'Kredit tidak cukup: butuh {required}, saldo {balance}.',
|
||||
'err.creditsShort': 'Kredit tidak cukup.',
|
||||
'err.docFailed': 'Dokumen gagal diproses.',
|
||||
|
||||
28
src/main.tsx
28
src/main.tsx
@@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client'
|
||||
import { ClerkProvider } from '@clerk/react'
|
||||
import { enUS, idID } from '@clerk/localizations'
|
||||
import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { BrowserRouter, useLocation } from 'react-router-dom'
|
||||
import { EngineError } from './api/client'
|
||||
import App from './App'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { forgetReads } from './express/results'
|
||||
import { useLang } from './i18n'
|
||||
import './styles/mockup.css'
|
||||
import './styles/app.css'
|
||||
@@ -17,11 +19,20 @@ if (!clerkPubKey) {
|
||||
}
|
||||
|
||||
const queryClient: QueryClient = new QueryClient({
|
||||
// Any action may have spent workspace credits, so the topbar balance is read again after each one.
|
||||
mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }),
|
||||
mutationCache: new MutationCache({
|
||||
// Runs before the action's own onSuccess, whose invalidations must not get a remembered read.
|
||||
onSuccess: forgetReads,
|
||||
// Any action may have spent workspace credits, so the topbar balance is read again after each one.
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }),
|
||||
}),
|
||||
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) },
|
||||
queries: {
|
||||
retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500 && e.status !== 429),
|
||||
// A busy engine (rate limited) is asked again only after it has had time to recover; an
|
||||
// immediate retry from every query at once is what keeps it over the limit.
|
||||
retryDelay: (n, e) => (e instanceof EngineError && (e.status === 503 || e.status === 429) ? 5000 : 1000) * 2 ** n,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -30,6 +41,13 @@ const clerkAppearance = {
|
||||
variables: { colorPrimary: '#1570ef', colorText: '#101828', fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", borderRadius: '12px' },
|
||||
}
|
||||
|
||||
// A crash outside a page (the layout, the sign-in gate) would otherwise unmount everything and leave
|
||||
// a white page until a reload; moving to another page tries again.
|
||||
function AppBoundary() {
|
||||
const { pathname } = useLocation()
|
||||
return <ErrorBoundary resetKey={pathname} className="card app-crash"><App /></ErrorBoundary>
|
||||
}
|
||||
|
||||
// Clerk's own sign-in and organization screens follow the app language.
|
||||
function Root() {
|
||||
const lang = useLang()
|
||||
@@ -37,7 +55,7 @@ function Root() {
|
||||
<ClerkProvider publishableKey={clerkPubKey} afterSignOutUrl="/" localization={lang === 'id' ? idID : enUS} appearance={clerkAppearance}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AppBoundary />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ClerkProvider>
|
||||
|
||||
100
src/pages/Billing.tsx
Normal file
100
src/pages/Billing.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { locale, t, useLang } from '../i18n'
|
||||
import { workspaceCredits } from '../api/engine'
|
||||
import { entryTime, lastDays, ledgerSince, moduleOf, usageCsv, usageInPeriod } from '../express/billing'
|
||||
import { download } from '../express/results'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { Empty, PageHead, Skeleton, SkeletonRows } from '../components/Page'
|
||||
|
||||
const PAGE = 100
|
||||
|
||||
// The engine limits the ledger to organization admins, whatever this page shows.
|
||||
export function BillingPage() {
|
||||
useLang()
|
||||
const [{ from, to }, setPeriod] = useState(() => lastDays(30))
|
||||
const [shown, setShown] = useState(PAGE)
|
||||
// A date input can be cleared, and a start after the end would be an empty period: keep the pair valid.
|
||||
const pick = (from: string, to: string) => { setPeriod({ from, to }); setShown(PAGE) }
|
||||
|
||||
const ledger = useQuery({ queryKey: ['billing', from], queryFn: () => ledgerSince(from) })
|
||||
const balance = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits })
|
||||
const usage = ledger.data && usageInPeriod(ledger.data, from, to)
|
||||
|
||||
const number = (n: number) => n.toLocaleString(locale())
|
||||
const date = (iso: string) => new Intl.DateTimeFormat(locale(), { dateStyle: 'medium', timeStyle: 'short' }).format(entryTime(iso))
|
||||
const day = (key: string) => new Intl.DateTimeFormat(locale(), { dateStyle: 'medium' }).format(new Date(`${key}T00:00:00`))
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('billing.title')} description={t('billing.description')} />
|
||||
<div className="metrics">
|
||||
<div className="metric">
|
||||
<span>{t('billing.total')}</span>
|
||||
<strong>{usage ? number(usage.total) : <Skeleton w={90} h={34} />}</strong>
|
||||
<small>{t('billing.period', { from: day(from), to: day(to) })}</small>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span>{t('billing.balance')}</span>
|
||||
<strong>{balance.data != null ? number(balance.data) : <Skeleton w={90} h={34} />}</strong>
|
||||
<small>{t('billing.balanceSub')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<section className="card">
|
||||
<div className="toolbar billing-toolbar">
|
||||
<h3>{t('billing.usage')} {usage && <span className="badge neutral">{t(usage.rows.length === 1 ? 'billing.countOne' : 'billing.count', { n: number(usage.rows.length) })}</span>}</h3>
|
||||
<div className="toolbar-controls billing-controls">
|
||||
<label className="billing-date">{t('billing.from')}
|
||||
<input type="date" value={from} max={to} onChange={e => e.target.value && pick(e.target.value, e.target.value > to ? e.target.value : to)} />
|
||||
</label>
|
||||
<label className="billing-date">{t('billing.to')}
|
||||
<input type="date" value={to} min={from} onChange={e => e.target.value && pick(e.target.value < from ? e.target.value : from, e.target.value)} />
|
||||
</label>
|
||||
<button className="btn" disabled={!usage?.rows.length}
|
||||
onClick={() => usage && download(`credit-usage-${from}-to-${to}.csv`, new Blob([usageCsv(usage.rows)], { type: 'text/csv;charset=utf-8' }))}>
|
||||
<Icon name="download" />{t('billing.export')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ledger.isPending && <SkeletonRows label={t('billing.loading')} />}
|
||||
{ledger.isError && <Empty icon="info" title={t('billing.loadFailed')}>{ledger.error.message}</Empty>}
|
||||
{usage && usage.rows.length === 0 && <Empty icon="coin" title={t('billing.empty')}>{t('billing.emptyCopy')}</Empty>}
|
||||
{usage && usage.rows.length > 0 && (
|
||||
<div className="billing-scroll">
|
||||
<table className="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('billing.colDate')}</th>
|
||||
<th scope="col">{t('billing.colUser')}</th>
|
||||
<th scope="col">{t('billing.colModule')}</th>
|
||||
<th scope="col" className="num">{t('billing.colCredits')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.rows.slice(0, shown).map(e => {
|
||||
const { module, detail } = moduleOf(e.activity)
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td>{date(e.created_at)}</td>
|
||||
<td>{e.actor_name || '-'}</td>
|
||||
<td><b>{module}</b>{detail && <small>{detail}</small>}</td>
|
||||
<td className="num">
|
||||
{number(-e.amount)}
|
||||
{e.kind === 'refund' && <small>{t('billing.refund')}</small>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{usage && usage.rows.length > shown && (
|
||||
<div className="billing-more"><button className="btn" onClick={() => setShown(s => s + PAGE)}>{t('billing.more')}</button></div>
|
||||
)}
|
||||
<div className="spacer" />
|
||||
<div className="inline-note"><Icon name="info" />{t('billing.note')}</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/pages/Brain.test.ts
Normal file
9
src/pages/Brain.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { duplicateNames } from './Brain'
|
||||
|
||||
describe('duplicateNames', () => {
|
||||
it('finds files already in the Brain, ignoring case, and repeats within one pick', () => {
|
||||
const dupes = duplicateNames(['Katalog.pdf', 'new.docx', 'new.docx', 'other.pdf'], ['katalog.PDF'])
|
||||
expect([...dupes]).toEqual(['Katalog.pdf', 'new.docx'])
|
||||
})
|
||||
})
|
||||
@@ -1,56 +1,155 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, reprocessDocument, supportedExtensions, uploadDocument,
|
||||
type Source,
|
||||
} from '../api/engine'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { Empty, PageHead, Skeleton, SkeletonLines, SkeletonRows } from '../components/Page'
|
||||
import { Empty, PageHead, 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 {
|
||||
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base.
|
||||
// A failed document blocks every schedule that uses it, even with a built knowledge base.
|
||||
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
||||
// A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules.
|
||||
if (s.status !== 'completed') return 'working'
|
||||
if (s.knowledge === 'ready') return 'ready'
|
||||
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
|
||||
if (s.knowledge === 'building') return 'working'
|
||||
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
|
||||
}
|
||||
|
||||
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
|
||||
|
||||
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
|
||||
export const useDocuments = () => useQuery({
|
||||
queryKey: ['documents'],
|
||||
queryFn: listDocuments,
|
||||
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 5000 : 20_000),
|
||||
})
|
||||
|
||||
const PHASES: Phase[] = ['ready', 'working', 'answer', 'failed']
|
||||
|
||||
/** Files whose name is already in the Brain (or twice in the same pick), compared without case. */
|
||||
export function duplicateNames(names: string[], existing: string[]): Set<string> {
|
||||
const seen = new Set(existing.map(n => n.toLowerCase()))
|
||||
const dupes = new Set<string>()
|
||||
for (const name of names) {
|
||||
if (seen.has(name.toLowerCase())) dupes.add(name)
|
||||
seen.add(name.toLowerCase())
|
||||
}
|
||||
return dupes
|
||||
}
|
||||
|
||||
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
|
||||
const docs = useDocuments()
|
||||
const [query, setQuery] = useState('')
|
||||
const [phase, setPhase] = useState<Phase | 'all'>('all')
|
||||
const needle = query.trim().toLowerCase()
|
||||
const shown = docs.data?.filter(d => (phase === 'all' || phaseOf(d) === phase)
|
||||
&& (!needle || `${d.title} ${d.originalName}`.toLowerCase().includes(needle)))
|
||||
|
||||
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>
|
||||
<div className="toolbar">
|
||||
<h3>{t('brain.listTitle')} {docs.data && <span className="badge neutral">{shown?.length}</span>}</h3>
|
||||
<div className="toolbar-controls">
|
||||
<div className="search"><Icon name="search" />
|
||||
<input type="search" aria-label={t('brainPanel.search')} placeholder={t('brainPanel.search')} value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<select aria-label={t('res.filterStatus')} value={phase} onChange={e => setPhase(e.target.value as Phase | 'all')}>
|
||||
<option value="all">{t('brain.allStatuses')} ({docs.data?.length ?? 0})</option>
|
||||
{PHASES.map(p => <option key={p} value={p}>{statusName(p)} ({docs.data?.filter(d => phaseOf(d) === p).length ?? 0})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</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>}
|
||||
{!!docs.data?.length && shown?.length === 0 && <Empty icon="search" title={t('res.noMatch')}>{t('res.noMatchCopy')}</Empty>}
|
||||
{!!shown?.length && <div className="result-list">{shown.map(d => <DocumentRow key={d.docId} doc={d} />)}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Brain documents a schedule can be made from; nothing chosen means the whole Brain. */
|
||||
export const choosable = (docs: Source[] | undefined) => docs?.filter(d => phaseOf(d) === 'ready') ?? []
|
||||
|
||||
// The Brain beside the AI chat: what schedules will draw on, with the same open and delete as Upload material.
|
||||
// Failed documents are left out: they cannot be chosen, and Upload material is where they are fixed.
|
||||
export function BrainPanel({ selected, onSelect }: { selected: Set<number>; onSelect: (ids: Set<number>) => void }) {
|
||||
const docs = useDocuments()
|
||||
const [query, setQuery] = useState('')
|
||||
const q = query.trim().toLowerCase()
|
||||
const listed = docs.data?.filter(d => phaseOf(d) !== 'failed')
|
||||
const shown = listed?.filter(d => !q || `${d.title} ${d.originalName}`.toLowerCase().includes(q))
|
||||
const ready = choosable(docs.data)
|
||||
const allChosen = ready.length > 0 && ready.every(d => selected.has(d.docId))
|
||||
const toggle = (id: number) => {
|
||||
const next = new Set(selected)
|
||||
if (!next.delete(id)) next.add(id)
|
||||
onSelect(next)
|
||||
}
|
||||
return (
|
||||
<section className="card compact-brain">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>{t('brainPanel.title')}{listed && <span className="badge neutral">{t('brainPanel.count', { n: listed.length })}</span>}</h3>
|
||||
<p>{t('brainPanel.copy')}</p>
|
||||
</div>
|
||||
<Icon name="brain" />
|
||||
</div>
|
||||
<div className="compact-brain-search">
|
||||
<div className="search">
|
||||
<Icon name="search" />
|
||||
<input type="search" aria-label={t('brainPanel.search')} placeholder={t('brainPanel.search')} value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
{!!ready.length && (
|
||||
<div className="brain-selection">
|
||||
<label className="check-label">
|
||||
<input type="checkbox" checked={allChosen} onChange={() => onSelect(allChosen ? new Set() : new Set(ready.map(d => d.docId)))} />
|
||||
{t('brainPanel.selectAll')}
|
||||
</label>
|
||||
<span>{selected.size ? t('brainPanel.selected', { n: selected.size }) : t('brainPanel.wholeBrain')}</span>
|
||||
</div>
|
||||
)}
|
||||
{docs.isPending && <SkeletonRows label={t('brain.loading')} />}
|
||||
{docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>}
|
||||
{listed?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>}
|
||||
{!!listed?.length && shown?.length === 0 && <Empty icon="search" title={t('brainPanel.noMatch')}>{t('brainPanel.noMatchCopy')}</Empty>}
|
||||
{!!shown?.length && (
|
||||
<div className="compact-source-list">
|
||||
{shown.map(d => (
|
||||
<div key={d.docId} className="compact-source-row">
|
||||
<input type="checkbox" checked={selected.has(d.docId)} disabled={phaseOf(d) !== 'ready'}
|
||||
aria-label={t('brainPanel.select', { name: d.title })} title={phaseOf(d) === 'ready' ? undefined : t('brainPanel.notReady')}
|
||||
onChange={() => toggle(d.docId)} />
|
||||
<span className={`file-icon ${d.format.toLowerCase()}`}>{d.format.slice(0, 4)}</span>
|
||||
<div className="compact-source-name">
|
||||
<OpenButton doc={d} />
|
||||
<small>{d.format.toUpperCase()} · <StatusLabel doc={d} phase={phaseOf(d)} /></small>
|
||||
</div>
|
||||
<DeleteButton doc={d} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="table-note compact-brain-foot">
|
||||
<span>{t('brainPanel.note')}</span>
|
||||
<Link className="btn small" to="/upload"><Icon name="upload" />{t('brainPanel.upload')}</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
|
||||
|
||||
let nextUploadId = 1
|
||||
@@ -68,9 +167,15 @@ function UploadBox() {
|
||||
async function upload(files: File[]) {
|
||||
if (!files.length) return
|
||||
const allowed = exts.data
|
||||
// The same file uploaded again is a second copy in the Brain; it is asked about, not assumed.
|
||||
const existing = queryClient.getQueryData<Source[]>(['documents'])?.map(d => d.originalName) ?? []
|
||||
const dupes = duplicateNames(files.map(f => f.name), existing)
|
||||
const skipDupes = dupes.size > 0 && !confirm(t('brain.duplicateConfirm', { names: [...dupes].join(', ') }))
|
||||
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' }))
|
||||
: skipDupes && dupes.has(f.name)
|
||||
? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.duplicateSkipped') }
|
||||
: { 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.
|
||||
@@ -116,7 +221,7 @@ function UploadBox() {
|
||||
|
||||
const busy = items.some(i => i.state === 'uploading')
|
||||
return (
|
||||
<div className={`upload-box upload-compact${drag ? ' drag' : ''}`}>
|
||||
<div className={`upload-box upload-large${drag ? ' drag' : ''}`}>
|
||||
{drag && (
|
||||
<div className="drop-overlay" aria-hidden="true">
|
||||
<div><Icon name="upload" /><b>{t('brain.dropHere')}</b></div>
|
||||
@@ -179,8 +284,11 @@ function OpenButton({ doc }: { doc: Source }) {
|
||||
try {
|
||||
const url = URL.createObjectURL(await documentFile(doc.docId))
|
||||
if (tab) tab.location.href = url
|
||||
// Not viewable, or the tab was blocked: save it instead of doing nothing.
|
||||
else Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click()
|
||||
// Not viewable, or the tab was blocked: save it instead of doing nothing, and say so.
|
||||
else {
|
||||
Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click()
|
||||
toast(t('brain.downloaded', { name: doc.originalName }))
|
||||
}
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch (e) {
|
||||
tab?.close()
|
||||
@@ -196,13 +304,25 @@ function OpenButton({ doc }: { doc: Source }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||
const label = {
|
||||
const statusName = (phase: Phase) => ({
|
||||
ready: t('brain.statusReady'), working: t('brain.statusProcessing'), answer: t('brain.statusAnswer'), failed: t('brain.statusFailed'),
|
||||
}[phase])
|
||||
|
||||
function statusLabel(doc: Source, phase: Phase) {
|
||||
return {
|
||||
ready: t('brain.statusReady'),
|
||||
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
|
||||
answer: t('brain.statusAnswer'),
|
||||
failed: t('brain.statusFailed'),
|
||||
}[phase]
|
||||
}
|
||||
|
||||
function StatusLabel({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||
return <span className={phase === 'ready' ? 'brain-in' : undefined}>{statusLabel(doc, phase)}</span>
|
||||
}
|
||||
|
||||
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||
const label = statusLabel(doc, phase)
|
||||
return <span className={`status ${phase === 'ready' ? 'ready' : 'draft'}`}><i className="dot"></i>{label}</span>
|
||||
}
|
||||
|
||||
@@ -219,12 +339,31 @@ function Progress({ doc }: { doc: Source }) {
|
||||
// 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) })
|
||||
// Keyed by the knowledge status, so it is read again when that changes and reused otherwise.
|
||||
const detail = useQuery({ queryKey: ['knowledge', doc.docId, doc.knowledge], queryFn: () => knowledgeDetail(doc.docId), staleTime: 5 * 60_000 })
|
||||
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 reprocess = useMutation({ mutationFn: () => reprocessDocument(doc.docId), onSuccess: refresh })
|
||||
const error = build.error ?? version.error ?? reprocess.error
|
||||
const busy = build.isPending || version.isPending || reprocess.isPending
|
||||
|
||||
// A failed file is read again, free; a knowledge base it already has is kept, so Compose sets
|
||||
// that pin it (and block deleting it) keep working. Video goes back through upload instead.
|
||||
if (doc.status === 'failed' && (doc.knowledge === 'ready' || doc.knowledge === 'not_built' || doc.knowledge === null)
|
||||
&& doc.format.toLowerCase() !== 'mp4') {
|
||||
return (
|
||||
<div className="doc-action">
|
||||
<small>{t(doc.knowledge === 'ready' ? 'brain.failedAfterBuild' : 'brain.failedProcessing')}</small>
|
||||
<div className="doc-action-buttons">
|
||||
<button className="btn small primary" disabled={busy} onClick={() => reprocess.mutate()}>
|
||||
{reprocess.isPending ? t('brain.reprocessing') : t('brain.reprocess')}
|
||||
</button>
|
||||
</div>
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = detail.data
|
||||
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
|
||||
@@ -248,8 +387,8 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
||||
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
||||
break
|
||||
case 'ready':
|
||||
// Processing failed after the build: building again is refused, only removing it unblocks schedules.
|
||||
text = t('brain.failedAfterBuild')
|
||||
// Processing failed after the build. Documents are reprocessed above; this is a video.
|
||||
text = t('brain.failedVideoAfterBuild')
|
||||
break
|
||||
case 'failed':
|
||||
text = d.error || t('brain.failed')
|
||||
|
||||
@@ -3,13 +3,14 @@ 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 { choosable, useDocuments } from './Brain'
|
||||
import {
|
||||
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
|
||||
} from '../express/parse'
|
||||
import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
|
||||
import { inLang, t, useLang, type Key, type Lang } from '../i18n'
|
||||
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
|
||||
import { budgetFor, canSchedule, createSchedule, MIN_PRODUCTION_SOURCES, needsTwoSources, VIDEO, videoQuote } from '../express/schedules'
|
||||
|
||||
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
|
||||
const THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3']
|
||||
@@ -35,6 +36,9 @@ export function InstructionPage() {
|
||||
const [agent, setAgent] = useState<AgentState>(EMPTY)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// While schedules are created: which one, and how many Brain documents it has been given so far.
|
||||
const [progress, setProgress] = useState<{ n: number; count: number; done: number; total: number } | null>(null)
|
||||
const docs = useDocuments()
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
// The live question whose typing has finished, so its reply options can show.
|
||||
const [typedLive, setTypedLive] = useState('')
|
||||
@@ -48,7 +52,11 @@ export function InstructionPage() {
|
||||
|
||||
const question = nextQuestion(agent)
|
||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||
// The whole Brain is attached, so every ready document counts.
|
||||
const tooFewSources = choosable(docs.data).length < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
|
||||
const hasContent = agent.items.some(i => i.rule.type === 'content')
|
||||
// A kind or frequency the agent only guessed, or no content language yet: the plan is not finished.
|
||||
const incomplete = ready.some(i => i.rule.type === 'content' && (i.rule.kindGuessed || i.rule.perGuessed)) || (hasContent && !agent.language)
|
||||
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
|
||||
const live = inLang(lang, () => saving
|
||||
? { text: t('agent.approving'), options: [] as string[] }
|
||||
@@ -97,16 +105,18 @@ export function InstructionPage() {
|
||||
|
||||
async function approve(state = agent, replyLang = lang) {
|
||||
const items = state.items.filter(i => canSchedule(i.rule))
|
||||
if (!items.length) return
|
||||
const unfinished = items.some(i => i.rule.type === 'content' && (i.rule.kindGuessed || i.rule.perGuessed))
|
||||
if (!items.length || tooFewSources || unfinished) return
|
||||
setSaving(true)
|
||||
const done: Outcome[] = []
|
||||
const created = new Set<number>()
|
||||
// Approving from the plan before the agent asked about the language uses the conversation's language.
|
||||
const language: OutputLanguage = state.language ?? (replyLang === 'id' ? 'Bahasa Indonesia' : 'English')
|
||||
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
|
||||
for (const { id, rule } of items) {
|
||||
for (const [i, { id, rule }] of items.entries()) {
|
||||
try {
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, undefined,
|
||||
(d, total) => setProgress({ n: i + 1, count: items.length, done: d, total }))
|
||||
done.push({ rule, ok: true })
|
||||
created.add(id)
|
||||
} catch (e) {
|
||||
@@ -114,7 +124,9 @@ export function InstructionPage() {
|
||||
}
|
||||
}
|
||||
setSaving(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
|
||||
setProgress(null)
|
||||
// A new schedule may start producing right away; the dashboard shows it under content in progress.
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: ['schedules'] }), queryClient.invalidateQueries({ queryKey: ['results'] })])
|
||||
const ok = done.filter(o => o.ok).length
|
||||
const report = inLang(replyLang, () => (ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })))
|
||||
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done, typed: true }])
|
||||
@@ -143,6 +155,7 @@ export function InstructionPage() {
|
||||
{phase === 'pausing' && <Typing />}
|
||||
{idle && live.text && (
|
||||
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
|
||||
note={saving && progress?.total ? inLang(lang, () => t('agent.attaching', progress)) : undefined}
|
||||
onTyped={() => setTypedLive(live.text)} onType={scroll} />
|
||||
)}
|
||||
</div>
|
||||
@@ -184,8 +197,13 @@ export function InstructionPage() {
|
||||
</div>
|
||||
{ready.length > 0 && (
|
||||
<div className="rule-approve">
|
||||
<small>{t('ins.approveNote')}</small>
|
||||
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
|
||||
<small>
|
||||
<b className="rule-sources"><Icon name="brain" />{t('ins.sourcesAll')}</b>
|
||||
{t('ins.approveNote')}
|
||||
{tooFewSources && <span className="error">{t('ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
|
||||
{incomplete && <span className="error">{t('ins.incomplete')}</span>}
|
||||
</small>
|
||||
<button className="btn primary" disabled={saving || !idle || tooFewSources || incomplete} onClick={() => approve()}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
</button>
|
||||
</div>
|
||||
@@ -212,8 +230,8 @@ function useTyped(text: string, enabled: boolean, onDone?: () => void, onType?:
|
||||
return text.slice(0, shown)
|
||||
}
|
||||
|
||||
function Bubble({ message, lang, busy, onTyped, onType }: {
|
||||
message: Message; lang: Lang; busy?: boolean; onTyped?: () => void; onType?: () => void
|
||||
function Bubble({ message, lang, busy, note, onTyped, onType }: {
|
||||
message: Message; lang: Lang; busy?: boolean; note?: string; onTyped?: () => void; onType?: () => void
|
||||
}) {
|
||||
const text = useTyped(message.text, !!message.typed, onTyped, onType)
|
||||
const typing = text.length < message.text.length
|
||||
@@ -223,6 +241,7 @@ function Bubble({ message, lang, busy, onTyped, onType }: {
|
||||
<div className="ai-bubble">
|
||||
{message.thought && <small className="ai-thought">{inLang(lang, () => t('agent.thought', { s: message.thought! }))}</small>}
|
||||
<p className={typing ? 'typing' : undefined}>{text}</p>
|
||||
{note && <small className="ai-thought" role="status">{note}</small>}
|
||||
{message.outcomes && !typing && (
|
||||
<ul className="rule-outcomes appear">
|
||||
{message.outcomes.map((o, i) => (
|
||||
@@ -265,8 +284,9 @@ function Typing() {
|
||||
)
|
||||
}
|
||||
|
||||
function RuleCard({ rule, onChange, onRemove }: {
|
||||
function RuleCard({ rule, from, onChange, onRemove }: {
|
||||
rule: Rule
|
||||
from?: string
|
||||
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
@@ -281,16 +301,19 @@ function RuleCard({ rule, onChange, onRemove }: {
|
||||
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
|
||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||
<div className="rule-body">
|
||||
<b>{describeRule(rule)}</b>
|
||||
<b>{describeRule(rule, from)}</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 })}>
|
||||
{/* A guessed kind or frequency shows as not chosen, as the rule's title says. */}
|
||||
<select aria-label={t('ins.kind')} value={rule.kindGuessed ? '' : rule.kind} onChange={e => onChange({ kind: e.target.value as ContentKind })}>
|
||||
{rule.kindGuessed && <option value="" disabled>{t('ins.kindPending')}</option>}
|
||||
{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'] })}>
|
||||
<select aria-label={t('ins.frequency')} value={rule.perGuessed ? '' : rule.per} onChange={e => onChange({ per: e.target.value as ContentRule['per'] })}>
|
||||
{rule.perGuessed && <option value="" disabled>{t('ins.perPending')}</option>}
|
||||
<option value="day">{t('ins.perDay')}</option>
|
||||
<option value="week">{t('ins.perWeek')}</option>
|
||||
</select>
|
||||
@@ -308,7 +331,7 @@ function RuleCard({ rule, onChange, onRemove }: {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t('ins.topicPlaceholder')}
|
||||
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t(from && rule.type === 'content' ? 'ins.topicPlaceholderChosen' : 'ins.topicPlaceholder')}
|
||||
onChange={e => onChange({ topic: e.target.value })} />
|
||||
</div>
|
||||
{rule.type === 'research' && (
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useUser } from '@clerk/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listDocuments } from '../api/engine'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { PageHead, Skeleton } from '../components/Page'
|
||||
import { PageHead, Skeleton, SkeletonCards } from '../components/Page'
|
||||
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
|
||||
import { describeRule } from '../express/parse'
|
||||
import { isWorking, listResults, titleOf } from '../express/results'
|
||||
import { isWorking, listResults, materialKey, RESULTS_POLL, titleOf, type Material } from '../express/results'
|
||||
import { listSchedules } from '../express/schedules'
|
||||
import { locale, t, type Key } from '../i18n'
|
||||
import { ContentCard, Preview } from './Results'
|
||||
|
||||
const HUB_LABEL: Record<Cluster, Key> = {
|
||||
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
|
||||
sources: 'prod.hub.sources', made: 'prod.hub.made',
|
||||
}
|
||||
|
||||
// Uses the Fullscreen API on the whole stage (graph + panel); where it is missing (iPhone Safari)
|
||||
// the stage is pinned over the page instead, and Escape leaves it.
|
||||
function useFullscreen() {
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [native, setNative] = useState(false)
|
||||
useEffect(() => {
|
||||
const sync = () => setNative(document.fullscreenElement != null && document.fullscreenElement === ref.current)
|
||||
document.addEventListener('fullscreenchange', sync)
|
||||
return () => document.removeEventListener('fullscreenchange', sync)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!pinned) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPinned(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [pinned])
|
||||
const toggle = () => {
|
||||
if (native) void document.exitFullscreen()
|
||||
else if (pinned) setPinned(false)
|
||||
else if (ref.current?.requestFullscreen && document.fullscreenEnabled) ref.current.requestFullscreen().catch(() => setPinned(true))
|
||||
else setPinned(true)
|
||||
}
|
||||
return { ref, full: native || pinned, pinned, toggle }
|
||||
}
|
||||
|
||||
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||
@@ -19,47 +47,50 @@ const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'shor
|
||||
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
|
||||
// knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
|
||||
export function ProductionPage() {
|
||||
const { user } = useUser()
|
||||
// A short greeting: Clerk's first name can hold more than one word.
|
||||
const name = user?.firstName?.trim().split(/\s+/)[0]
|
||||
const docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
|
||||
const results = useQuery({
|
||||
queryKey: ['results'],
|
||||
queryFn: listResults,
|
||||
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
|
||||
...RESULTS_POLL,
|
||||
})
|
||||
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||
|
||||
const fullscreen = useFullscreen()
|
||||
|
||||
const materials = results.data?.materials ?? []
|
||||
const working = materials.filter(isWorking)
|
||||
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
|
||||
const approved = materials.filter(m => m.status === 'accepted')
|
||||
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
|
||||
const lms = approved.filter(m => m.kind === 'scorm')
|
||||
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
|
||||
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
|
||||
const graph = useMemo(
|
||||
() => (docs.data && results.data && schedules.data
|
||||
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
|
||||
: null),
|
||||
[docs.data, results.data, schedules.data],
|
||||
() => (docs.data && results.data ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null),
|
||||
[docs.data, results.data],
|
||||
)
|
||||
|
||||
const num = (n: number, ready: boolean) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
|
||||
const num = (n: number, ready: boolean, unit: Key) => (ready
|
||||
? <strong>{n.toLocaleString(locale())} <em>{t(unit, { n })}</em></strong>
|
||||
: <Skeleton className="skel-num" />)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('prod.title')} description={t('prod.description')} />
|
||||
<div className="metrics prod-metrics">
|
||||
<div className="metric">
|
||||
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small>
|
||||
</div>
|
||||
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data)}<small>{t('prod.metricMaterialSub', { n: inBrain })}</small></div>
|
||||
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data)}<small>{t('prod.metricMadeSub')}</small></div>
|
||||
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
|
||||
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
|
||||
</div>
|
||||
|
||||
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
|
||||
<PageHead eyebrow={t('prod.eyebrow')} description={t('prod.description')}
|
||||
title={<>{name ? t('prod.welcomeName', { name }) : t('prod.welcome')} <span className="welcome-spark">✦</span></>}
|
||||
actions={<>
|
||||
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
|
||||
<Link className="btn primary" to="/instruction"><Icon name="spark" />{t('prod.newContent')}</Link>
|
||||
</>} />
|
||||
<section ref={fullscreen.ref} className={`graph-stage${fullscreen.full ? ' full' : ''}${fullscreen.pinned ? ' pinned' : ''}`} aria-label={t('prod.graphTitle')}>
|
||||
<button type="button" className="graph-fullscreen" onClick={fullscreen.toggle} aria-pressed={fullscreen.full}
|
||||
aria-label={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')} title={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')}>
|
||||
<Icon name={fullscreen.full ? 'shrink' : 'expand'} />
|
||||
</button>
|
||||
{graph
|
||||
? <KnowledgeGraph graph={graph} />
|
||||
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}
|
||||
// The engine's own message: an unreachable engine says so, any other failure says what it was.
|
||||
: <div className="graph-loading" role="status">{(docs.error ?? results.error)?.message ?? t('prod.loadingGraph')}</div>}
|
||||
|
||||
<aside className="graph-panel">
|
||||
<div className="graph-stats">
|
||||
@@ -91,15 +122,61 @@ export function ProductionPage() {
|
||||
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<div className="metrics prod-metrics">
|
||||
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data, docs.data?.length === 1 ? 'prod.unitMaterialOne' : 'prod.unitMaterial')}<small>{t('prod.metricMaterialSub')}</small></div>
|
||||
{/* Counted as Results counts "Done" (?status=done), so the two pages show the same number. */}
|
||||
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricMadeSub')}</small></div>
|
||||
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricLmsSub')}</small></div>
|
||||
</div>
|
||||
|
||||
<ContentActivity made={made} working={working} loading={results.isPending} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ACTIVITY_LIMIT = 3
|
||||
const newest = (list: Material[]) => [...list].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, ACTIVITY_LIMIT)
|
||||
|
||||
// The newest finished results and everything still being made (planned pieces included), as the same
|
||||
// cards as the Results page; one opens in place.
|
||||
function ContentActivity({ made, working, loading }: { made: Material[]; working: Material[]; loading: boolean }) {
|
||||
const [tab, setTab] = useState<'latest' | 'working'>('latest')
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const shown = newest(tab === 'latest' ? made : working)
|
||||
const open = [...made, ...working].find(m => materialKey(m) === openId) ?? null
|
||||
return (
|
||||
<section className="prod-activity">
|
||||
<div className="section-heading">
|
||||
<div><h3>{t('prod.activityTitle')}</h3><p>{t('prod.activityDescription')}</p></div>
|
||||
<Link className="text-btn" to={`/results?status=${tab === 'latest' ? 'done' : 'working'}`}>{t('prod.activityAll')}<Icon name="arrow-right" /></Link>
|
||||
</div>
|
||||
<div className="result-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'latest'} className={tab === 'latest' ? 'active' : undefined} onClick={() => setTab('latest')}>
|
||||
<Icon name="book" />{t('prod.activityLatest')} <b>{made.length}</b>
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'working'} className={tab === 'working' ? 'active' : undefined} onClick={() => setTab('working')}>
|
||||
{working.length ? <span className="live-dot" /> : <Icon name="clock" />}{t('prod.activityWorking')} <b>{working.length}</b>
|
||||
</button>
|
||||
</div>
|
||||
{loading
|
||||
? <SkeletonCards count={3} label={t('common.loading')} />
|
||||
: shown.length
|
||||
? <div className="activity-grid">
|
||||
{shown.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}
|
||||
</div>
|
||||
: <p className="graph-empty activity-empty">{t(tab === 'latest' ? 'prod.activityNoneLatest' : 'prod.activityNoneWorking')}</p>}
|
||||
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Motion: nodes grow out of their hub on first draw, then drift gently around their place while
|
||||
// light travels along the links between clusters. Positions are written straight to the DOM each
|
||||
// frame, so hovering or selecting (React state) never fights the animation.
|
||||
const DRIFT = 5
|
||||
const MAX_PARTICLES = 80
|
||||
const NAMED_NEIGHBOURS = 12
|
||||
const ease = (x: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3)
|
||||
|
||||
function curve(ax: number, ay: number, bx: number, by: number, bend: number) {
|
||||
@@ -183,6 +260,9 @@ function KnowledgeGraph({ graph }: { graph: Graph }) {
|
||||
}
|
||||
return set
|
||||
}, [focus, graph])
|
||||
// Linked nodes are named only when there are few: a hub links to every document or result, and
|
||||
// naming them all at once piles hundreds of labels on top of each other.
|
||||
const nameNear = !!neighbours && !graph.nodes[index.get(focus!)!]?.hub && neighbours.size <= NAMED_NEIGHBOURS + 1
|
||||
const lit = (p: { a: number; b: number }) => neighbours?.has(graph.nodes[p.a].id) && neighbours.has(graph.nodes[p.b].id)
|
||||
|
||||
return (
|
||||
@@ -219,10 +299,10 @@ function KnowledgeGraph({ graph }: { graph: Graph }) {
|
||||
</g>
|
||||
<g>
|
||||
{graph.nodes.map((n, i) => {
|
||||
const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
||||
const r = n.hub ? 9 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
||||
return (
|
||||
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
|
||||
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
||||
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${nameNear && n.id !== focus && neighbours?.has(n.id) ? ' near' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
||||
onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)}
|
||||
onClick={e => { e.stopPropagation(); setSelected(n) }}>
|
||||
<circle className="hit" r={r + 8} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useSearchParams } 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'
|
||||
@@ -8,7 +8,7 @@ import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../
|
||||
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, retryMaterial, reviewSource, slidesOf, textOf,
|
||||
canCancelVideo, cancelVideo, decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf, withDecisions,
|
||||
titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus,
|
||||
} from '../express/results'
|
||||
import { MASTERY, scormPackage } from '../express/scorm'
|
||||
@@ -19,10 +19,15 @@ 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> = {
|
||||
// 'done' is everything no longer being made except failures, as the Production page counts it; the
|
||||
// Production page links here with ?status=done or ?status=working.
|
||||
const STATUS_FILTERS = ['all', 'done', 'review', 'approved', 'working', 'failed'] as const
|
||||
type StatusFilter = typeof STATUS_FILTERS[number]
|
||||
const STATUS_GROUP: Record<MaterialStatus, Exclude<StatusFilter, 'all' | 'done'>> = {
|
||||
preparing: 'working', generating: 'working', ready: 'review', accepted: 'approved', failed: 'failed', rejected: 'failed',
|
||||
}
|
||||
const matchesStatus = (m: Material, status: StatusFilter) =>
|
||||
status === 'all' || (status === 'done' ? !isWorking(m) && m.status !== 'failed' : STATUS_GROUP[m.status] === status)
|
||||
|
||||
const fmt = (iso: string) => new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||
|
||||
@@ -30,20 +35,23 @@ export function ResultsPage() {
|
||||
const results = useQuery({
|
||||
queryKey: ['results'],
|
||||
queryFn: listResults,
|
||||
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
|
||||
...RESULTS_POLL,
|
||||
})
|
||||
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 [params] = useSearchParams()
|
||||
const [status, setStatus] = useState<StatusFilter>(() => STATUS_FILTERS.find(s => s === params.get('status')) ?? '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 searched = materials.filter(m => (kind === 'all' || m.kind === kind) && (!needle || titleOf(m).toLowerCase().includes(needle)))
|
||||
const shownMaterials = searched.filter(m => matchesStatus(m, status))
|
||||
// Each status says how many it holds, so the tab's total and the dashboard's counts add up here.
|
||||
const statusCount = (s: StatusFilter) => searched.filter(m => matchesStatus(m, s)).length
|
||||
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
|
||||
@@ -73,7 +81,7 @@ export function ResultsPage() {
|
||||
{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>)}
|
||||
{STATUS_FILTERS.map(s => <option key={s} value={s}>{t(`res.status.${s}` as Key)} ({statusCount(s)})</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
@@ -116,7 +124,7 @@ function StatusBadge({ status }: { status: MaterialStatus }) {
|
||||
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 }) {
|
||||
export function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
|
||||
const working = isWorking(m)
|
||||
return (
|
||||
<article className="content-card">
|
||||
@@ -126,12 +134,14 @@ function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () =
|
||||
</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>
|
||||
{/* A video opens while it is made too: its progress and Cancel are there. */}
|
||||
<button className="content-title" disabled={working && !m.video} 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>
|
||||
: m.video?.stuck ? <span className="error">{t('res.videoStuckShort')}</span>
|
||||
: m.retry && <div className="content-card-actions"><RetryButton material={m} className="text-btn" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,6 +149,24 @@ function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () =
|
||||
)
|
||||
}
|
||||
|
||||
function CancelButton({ material: m, className }: { material: Material; className: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
const cancel = useMutation({
|
||||
mutationFn: () => cancelVideo(m),
|
||||
onSuccess: () => {
|
||||
toast(t('res.videoCancelled'))
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
},
|
||||
onError: e => toast(e.message, { error: true }),
|
||||
})
|
||||
return (
|
||||
<button className={className} disabled={cancel.isPending}
|
||||
onClick={() => confirm(t('res.cancelVideoConfirm')) && cancel.mutate()}>
|
||||
{cancel.isPending ? t('res.cancellingVideo') : t('res.cancelVideo')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// A failed plan item is produced again with a fresh quote; the result then shows as preparing.
|
||||
function RetryButton({ material: m, className }: { material: Material; className: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -157,7 +185,7 @@ function RetryButton({ material: m, className }: { material: Material; className
|
||||
)
|
||||
}
|
||||
|
||||
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
|
||||
export 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.
|
||||
@@ -166,6 +194,9 @@ function Preview({ material: m, onClose }: { material: Material; onClose: () =>
|
||||
mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision),
|
||||
onSuccess: (_, decision) => {
|
||||
toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected'))
|
||||
// Shown at once; reading every schedule again takes a while, longer on a busy engine.
|
||||
queryClient.setQueryData<Awaited<ReturnType<typeof listResults>>>(['results'], old =>
|
||||
old && { ...old, materials: old.materials.map(withDecisions) })
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
},
|
||||
})
|
||||
@@ -208,6 +239,7 @@ function Preview({ material: m, onClose }: { material: Material; onClose: () =>
|
||||
<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>}
|
||||
{canCancelVideo(m) && <CancelButton material={m} className="btn danger" />}
|
||||
{m.retry && <RetryButton material={m} className="btn primary" />}
|
||||
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
|
||||
{m.status === 'ready' && (
|
||||
@@ -263,7 +295,7 @@ function DownloadMenu({ material: m, content, outputId }: { material: Material;
|
||||
|
||||
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>
|
||||
<b>INTERACTIVE (.zip)</b><small>{t(m.kind === 'scorm' || m.kind === 'quiz' ? 'res.scormQuizHint' : 'res.scormHint')}</small>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -343,6 +375,7 @@ function Body({ material: m, content }: { material: Material; content: MaterialC
|
||||
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.video?.stuck) return <Empty icon="video" title={t('res.videoStuck')}>{t('res.videoStuckCopy')}</Empty>
|
||||
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>
|
||||
}
|
||||
@@ -427,7 +460,7 @@ function SourceRow({ source: s }: { source: FoundSource }) {
|
||||
<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>
|
||||
<p>{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · <Link to="/upload">{t('res.inBrain')}</Link></>}</p>
|
||||
{s.preview && <p className="source-preview">{s.preview}</p>}
|
||||
{review.error && <span className="error">{review.error.message}</span>}
|
||||
</div>
|
||||
|
||||
@@ -7,36 +7,88 @@ 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,
|
||||
archiveSchedule, confirmProduction, forgetProductionReads, isMine, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, scheduleOwners, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
||||
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type Owners, type ProductionStatus, type RunSummary, type Schedule,
|
||||
} from '../express/schedules'
|
||||
|
||||
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||
|
||||
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 RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled', 'paused_configuration', 'paused_budget'])
|
||||
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
|
||||
const RUN_ERRORS = new Set([
|
||||
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
|
||||
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
|
||||
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable', 'user_cancelled',
|
||||
'video_script_outcome_unknown', 'video_storyboard_outcome_unknown', 'video_style_outcome_unknown', 'video_clip_outcome_unknown',
|
||||
])
|
||||
const ISSUE_REASONS = new Set([
|
||||
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',
|
||||
'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready',
|
||||
])
|
||||
const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready'])
|
||||
const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code)
|
||||
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
|
||||
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
|
||||
|
||||
// Which schedules the page starts with; remembered per device.
|
||||
type Scope = 'mine' | 'all'
|
||||
const SCOPE_KEY = 'express.scheduleScope'
|
||||
const savedScope = (): Scope => {
|
||||
try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'mine' } catch { return 'mine' }
|
||||
}
|
||||
|
||||
export function SchedulePage() {
|
||||
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||
const owners = useQuery({ queryKey: ['scheduleOwners'], queryFn: scheduleOwners, staleTime: 5 * 60_000 })
|
||||
const [scope, setScope] = useState<Scope>(savedScope)
|
||||
const choose = (next: Scope) => {
|
||||
setScope(next)
|
||||
try { localStorage.setItem(SCOPE_KEY, next) } catch { /* keep it for this visit only */ }
|
||||
}
|
||||
|
||||
const all = schedules.data
|
||||
const mine = all && owners.data ? all.filter(s => isMine(s, owners.data)) : undefined
|
||||
// Members only ever see their own, so the switch appears only when someone else's schedules are listed.
|
||||
const mixed = !!all && !!mine && mine.length < all.length
|
||||
const shown = mixed && scope === 'mine' ? mine : all
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('sch.title')} description={t('sch.description')}
|
||||
actions={<Link className="btn primary" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
|
||||
{mixed && (
|
||||
<div className="result-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={scope === 'mine'} className={scope === 'mine' ? 'active' : undefined} onClick={() => choose('mine')}>
|
||||
{t('sch.mine')} <b>{mine.length}</b>
|
||||
</button>
|
||||
<button role="tab" aria-selected={scope === 'all'} className={scope === 'all' ? 'active' : undefined} onClick={() => choose('all')}>
|
||||
{t('sch.all')} <b>{all.length}</b>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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>}
|
||||
{all?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
|
||||
{mixed && shown?.length === 0 && <Empty icon="calendar" title={t('sch.emptyMine')}>{t('sch.emptyMineCopy')}</Empty>}
|
||||
{!!shown?.length && <div className="result-list">{shown.map(s => <ScheduleRow key={s.agent.id} schedule={s} owners={owners.data} />)}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
||||
function createdBy(schedule: Schedule, owners: Owners) {
|
||||
if (isMine(schedule, owners)) return t('sch.byYou')
|
||||
const name = schedule.agent.created_by_user_id != null ? owners.names.get(schedule.agent.created_by_user_id) : undefined
|
||||
return name ? t('sch.by', { name }) : t('sch.byUnknown')
|
||||
}
|
||||
|
||||
function ScheduleRow({ schedule, owners }: { schedule: Schedule; owners?: Owners }) {
|
||||
const { agent, rule, active } = schedule
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -48,7 +100,7 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
||||
// 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 toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(schedule)), 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({
|
||||
@@ -69,6 +121,7 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<b>{describeRule(rule)}</b>
|
||||
{owners && <div className="schedule-owner"><small><Icon name="user" />{createdBy(schedule, owners)}</small></div>}
|
||||
<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>
|
||||
@@ -99,12 +152,21 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
// Content schedules report this week's progress from the engine's production period. Each row
|
||||
// asks for its own, so it is read once a minute; while its latest production is under way it is
|
||||
// read every few seconds, so each step shows as it happens. Starting a production refreshes it at once.
|
||||
const LIVE_POLL = 5000
|
||||
const isLive = (s: ProductionStatus | undefined) => !!s?.plans[0] && PLAN_ACTIVE.has(s.plans[0].status)
|
||||
function useProduction(agentId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
const key = ['production', agentId]
|
||||
return useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => productionStatus(agentId, isLive(queryClient.getQueryData<ProductionStatus>(key)) ? LIVE_POLL : undefined),
|
||||
refetchInterval: q => (isLive(q.state.data) ? LIVE_POLL : 60_000),
|
||||
staleTime: q => (isLive(q.state.data) ? LIVE_POLL : 60_000),
|
||||
})
|
||||
}
|
||||
|
||||
function ProductionLine({ agentId, active }: { agentId: number; active: boolean }) {
|
||||
const status = useProduction(agentId)
|
||||
@@ -164,6 +226,8 @@ function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedu
|
||||
const start = useMutation({
|
||||
mutationFn: (plan: ContentPlan) => confirmProduction(agent.id, plan),
|
||||
onSuccess: () => {
|
||||
// The read kept from before the start still shows no production; it must not answer the refresh.
|
||||
forgetProductionReads()
|
||||
queryClient.invalidateQueries({ queryKey: ['production', agent.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
onClose()
|
||||
@@ -186,7 +250,7 @@ function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedu
|
||||
<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>}
|
||||
{error && <span className="error">{productionError(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>
|
||||
@@ -201,22 +265,52 @@ 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),
|
||||
refetchInterval: q => (q.state.data?.some(r => ['queued', 'planning', 'running'].includes(r.status)) ? 5000 : 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>
|
||||
const shown = runs.data.slice(0, 8)
|
||||
// Readiness is today's state, so it is shown once, under the newest run it stopped.
|
||||
const blockedId = shown.find(isNotReady)?.id
|
||||
return (
|
||||
<ul className="run-history">
|
||||
{runs.data.slice(0, 8).map(r => (
|
||||
{shown.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>}
|
||||
{r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
|
||||
{r.source_outcome === 'no_new_sources' && <span>{t('sch.noNewSources')}</span>}
|
||||
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadinessIssues({ agentId }: { agentId: number }) {
|
||||
const readiness = useQuery({
|
||||
queryKey: ['readiness', agentId],
|
||||
queryFn: () => knowledgeReadiness(agentId),
|
||||
// Processing and building finish on their own; keep the list current until they do.
|
||||
refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false),
|
||||
})
|
||||
if (readiness.isPending) return <div className="run-issues"><SkeletonLines count={1} label={t('sch.loadingHistory')} /></div>
|
||||
if (readiness.isError) return <div className="run-issues"><span className="error">{readiness.error.message}</span></div>
|
||||
if (!readiness.data.issues.length) return <div className="run-issues"><small>{t('sch.blockedNone')}</small></div>
|
||||
return (
|
||||
<div className="run-issues">
|
||||
<strong>{t('sch.blockedBy')}</strong>
|
||||
<ul>
|
||||
{readiness.data.issues.map(issue => (
|
||||
<li key={`${issue.kind}-${issue.resource_id}`}>
|
||||
<span><b>{issue.title}</b> · {issueReason(issue.reason)}</span>
|
||||
<a href={`${STUDIO_URL}${issue.action_url}`} target="_blank" rel="noreferrer">{t('sch.fixInStudio')}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ textarea{resize:vertical;line-height:1.6}
|
||||
.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-owner small{display:inline-flex;align-items:center;gap:5px;color:var(--muted)}
|
||||
.schedule-owner svg{width:12px;height:12px}
|
||||
.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}
|
||||
@@ -49,6 +51,10 @@ textarea{resize:vertical;line-height:1.6}
|
||||
.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}
|
||||
.run-issues{flex-basis:100%;padding:8px 10px;border-radius:8px;background:#fff7ed;color:#7a4b1f;display:grid;gap:4px}
|
||||
.run-issues ul{list-style:none;margin:0;padding:0;display:grid;gap:4px}
|
||||
.run-issues li{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.run-issues a{color:var(--blue);font-weight:600}
|
||||
@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}
|
||||
@@ -180,3 +186,43 @@ textarea{resize:vertical;line-height:1.6}
|
||||
@keyframes blink{50%{opacity:0}}
|
||||
@keyframes appear{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){.dots b,.agent-chat .ai-message,.appear,.ai-message .typing:after{animation:none}}
|
||||
.upload-box.upload-large{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:56px 32px;margin-bottom:24px}
|
||||
.upload-large .upload-symbol{width:64px;height:64px;justify-content:center;align-items:center;margin-bottom:10px}
|
||||
.upload-large .upload-symbol svg{width:28px;height:28px}
|
||||
.upload-large h3{font-size:20px}
|
||||
.upload-large p{font-size:14px;max-width:520px;margin:6px auto 4px}
|
||||
.upload-large small{font-size:12px;color:var(--muted)}
|
||||
.upload-large .upload-progress{text-align:left;max-width:520px;margin:12px auto 0}
|
||||
.upload-large .btn{margin-top:14px;padding-inline:22px}
|
||||
@media(max-width:600px){.upload-box.upload-large{padding:32px 20px}.upload-large .btn{width:100%}}
|
||||
.ai-column{display:grid;gap:22px;min-width:0}
|
||||
.compact-brain .card-head h3{display:flex;align-items:center;gap:4px}
|
||||
.compact-brain .card-head p{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.compact-source-name .doc-title{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;overflow-wrap:normal}
|
||||
.compact-source-name small{font-size:11px;color:var(--muted)}
|
||||
.compact-source-name .brain-in{color:#067647}
|
||||
.compact-brain .search input{width:100%;font-size:13px}
|
||||
.compact-brain>.compact-brain-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;line-height:1.5}
|
||||
.compact-brain-foot .btn{flex-shrink:0}
|
||||
.rule-sources{display:flex;align-items:center;gap:6px;color:var(--ink);font-weight:600;margin-bottom:4px}
|
||||
.rule-sources svg{width:14px;height:14px;color:var(--blue)}
|
||||
.compact-source-row>input:disabled{opacity:.4;cursor:not-allowed}
|
||||
.compact-brain .brain-selection label{font-size:13px}
|
||||
.compact-brain .brain-selection{font-size:12px}
|
||||
.app-crash{max-width:560px;margin:12vh auto;padding-top:8px}
|
||||
|
||||
/* Billing: the credit ledger as a table, which scrolls by itself on a phone. */
|
||||
/* One row wherever it fits: without the shared toolbar's 520px cap, it only wraps when the room runs out. */
|
||||
.billing-toolbar{flex-wrap:wrap}
|
||||
.toolbar .billing-controls{width:auto;max-width:none;margin-left:auto;flex-wrap:wrap;justify-content:flex-end;align-items:center}
|
||||
.billing-date{display:flex;align-items:center;gap:8px;margin:0;white-space:nowrap}
|
||||
.billing-date input:not([type=checkbox]):not([type=radio]):not([type=file]){width:auto;height:40px;min-height:40px;padding-block:0}
|
||||
.billing-controls .btn{height:40px;min-height:40px;margin:0}
|
||||
.billing-scroll{overflow-x:auto}
|
||||
.billing-table{width:100%;min-width:560px;border-collapse:collapse;font-size:13.5px}
|
||||
.billing-table th{text-align:left;font-size:12px;font-weight:650;color:var(--muted);padding:10px 12px;border-bottom:1px solid var(--line)}
|
||||
.billing-table td{padding:12px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
.billing-table tbody tr:last-child td{border-bottom:0}
|
||||
.billing-table small{display:block;color:var(--muted);font-size:12px;margin-top:2px}
|
||||
.billing-table .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.billing-more{display:flex;justify-content:center;padding-top:14px}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -77,8 +77,11 @@ button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--s
|
||||
.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>div{flex:1;min-width:0}
|
||||
.profile b,.profile small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.profile b{font-size:13px}
|
||||
.profile small{font-size:11.5px}
|
||||
.profile>.icon-btn{flex-shrink:0}
|
||||
.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 */
|
||||
@@ -105,6 +108,8 @@ button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--s
|
||||
.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}
|
||||
.page-eyebrow{display:block;margin-bottom:10px;font-size:12px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:#667085}
|
||||
.welcome-spark{font-size:.7em;vertical-align:.12em}
|
||||
.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}
|
||||
@@ -285,19 +290,25 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
||||
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
||||
|
||||
/* Sidebar groups: Dashboard, then Settings */
|
||||
.sidebar nav .nav-label{margin:0 12px 6px}
|
||||
.sidebar nav .nav-label~.nav-label{margin-top:18px}
|
||||
@media(max-width:700px){.sidebar nav .nav-label{display:none}}
|
||||
/* Sidebar groups: Dashboard, then Settings, each a dropdown that opens on its active page */
|
||||
.sidebar nav button.nav-label{display:flex;align-items:center;justify-content:space-between;width:auto;margin:0 0 2px;padding:6px 12px;border-radius:8px;color:var(--muted);font-size:11px;font-weight:600;box-shadow:none;background:none}
|
||||
.sidebar nav button.nav-label:hover{background:#f2f7ff;color:var(--ink)}
|
||||
.sidebar nav button.nav-label svg{width:14px;height:14px;color:inherit;transition:transform .15s}
|
||||
.sidebar nav button.nav-label[aria-expanded=false] svg{transform:rotate(-90deg)}
|
||||
.sidebar nav .nav-items~.nav-label{margin-top:12px}
|
||||
.nav-items{display:contents}
|
||||
@media(min-width:701px){.nav-items.closed{display:none}}
|
||||
@media(max-width:700px){.sidebar nav button.nav-label{display:none}}
|
||||
|
||||
/* Production: metric row with the in-progress count as the hero, then the knowledge graph */
|
||||
.prod-metrics{grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
/* Production: the knowledge graph, then the metric row and the latest content */
|
||||
.prod-metrics{grid-template-columns:repeat(3,minmax(0,1fr));margin-top:24px}
|
||||
.prod-metrics .metric>span{display:flex;align-items:center;gap:8px}
|
||||
.prod-metrics .metric strong em{font-style:normal;font-size:16px;font-weight:600;letter-spacing:0;opacity:.7}
|
||||
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0}
|
||||
@keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
|
||||
|
||||
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;--g-research:#84caff;--g-schedules:#fdb022;
|
||||
position:relative;min-height:640px;border-radius:22px;overflow:hidden;color:#e4e7ec;
|
||||
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;
|
||||
position:relative;height:640px;max-height:calc(100vh - 260px);max-height:calc(100dvh - 260px);min-height:440px;border-radius:22px;overflow:hidden;color:#e4e7ec;
|
||||
background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)}
|
||||
.graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block}
|
||||
.graph-loading{position:absolute;inset:0 300px 0 0;display:grid;place-items:center;color:#98a2b3;font-size:13px}
|
||||
@@ -333,9 +344,9 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
.graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
||||
.graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s}
|
||||
.graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none}
|
||||
.graph-node.c-working:not(.hub) .core{animation:node-pulse 1.6s ease-in-out infinite}
|
||||
@keyframes node-pulse{50%{opacity:.4}}
|
||||
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}.c-working{--c:var(--g-working)}.c-research{--c:var(--g-research)}.c-schedules{--c:var(--g-schedules)}
|
||||
/* What the focused node is linked to is named too, a little quieter than the node itself. */
|
||||
.graph-node.near:not(.hub) text{opacity:.85;transform:none;font-size:10.5px;font-weight:500}
|
||||
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}
|
||||
|
||||
.graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px));
|
||||
padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)}
|
||||
@@ -346,7 +357,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
.graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3}
|
||||
.graph-legend li{display:flex;align-items:center;gap:6px}
|
||||
.node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0}
|
||||
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}.node-dot.research{--c:var(--g-research)}.node-dot.schedules{--c:var(--g-schedules)}
|
||||
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}
|
||||
|
||||
.graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px;
|
||||
background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}
|
||||
@@ -369,14 +380,63 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
.graph-empty a,.graph-more{color:#84caff}
|
||||
.graph-more{display:block;font-size:12px;margin:8px 4px 0}
|
||||
|
||||
@media(max-width:1120px){.prod-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}.prod-metrics .metric:first-child{grid-column:1/-1}}
|
||||
.graph-fullscreen{position:absolute;top:16px;right:316px;z-index:3;display:grid;place-items:center;width:34px;height:34px;padding:0;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:rgba(10,22,40,.6);backdrop-filter:blur(8px);color:#d0d5dd;cursor:pointer;transition:background .15s,color .15s}
|
||||
.graph-fullscreen:hover{background:rgba(46,144,250,.25);color:#fff}
|
||||
.graph-fullscreen svg{width:16px;height:16px}
|
||||
.graph-stage.full{border-radius:0;min-height:0;max-height:none;height:100vh;height:100dvh;box-shadow:none}
|
||||
.graph-stage.pinned{position:fixed;inset:0;z-index:50}
|
||||
@media(max-width:900px){
|
||||
.graph-stage{min-height:0;display:flex;flex-direction:column}
|
||||
.graph-stage{height:auto;max-height:none;min-height:0;display:flex;flex-direction:column}
|
||||
.graph-stage.full{overflow:auto}
|
||||
.graph-fullscreen{right:16px}
|
||||
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640}
|
||||
.graph-loading{position:relative;inset:auto;min-height:280px}
|
||||
.graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start}
|
||||
.graph-legend{position:relative;left:auto;bottom:auto;margin:0 16px 12px}
|
||||
.graph-panel{position:relative;inset:auto;width:auto;margin:0 16px 16px}
|
||||
}
|
||||
@media(max-width:700px){.prod-metrics{grid-template-columns:1fr 1fr}.prod-metrics .metric:first-child{grid-column:1/-1}}
|
||||
@media(max-width:700px){.prod-metrics{grid-template-columns:1fr}}
|
||||
@media (prefers-reduced-motion:reduce){.live-dot,.graph-stars circle,.graph-glow,.graph-orbit,.graph-links path,.graph-node *{animation:none!important}}
|
||||
|
||||
/* Production: content activity */
|
||||
.prod-activity .section-heading{margin-top:8px}
|
||||
.prod-activity .result-tabs button{display:inline-flex;align-items:center;gap:8px}
|
||||
.prod-activity .result-tabs svg{width:16px;height:16px}
|
||||
.activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
|
||||
.activity-empty{padding:28px 0;text-align:center}
|
||||
@media(max-width:1000px){.activity-grid{grid-template-columns:1fr}}
|
||||
|
||||
/* Collapsible sidebar: the toggle in the topbar shrinks it to an icon rail (desktop only) */
|
||||
.sidebar,.workspace-main{transition:width .2s,margin-left .2s}
|
||||
.sidebar-toggle{flex-shrink:0;margin-right:12px}
|
||||
.topbar{justify-content:flex-start}.topbar-right{margin-left:auto}
|
||||
.sidebar-help-icon{align-self:center;margin:auto 0 12px}
|
||||
@media(min-width:701px){
|
||||
/* The rail lets the space menu spill past its edge, so the glow is clipped to the rail itself */
|
||||
.sidebar-collapsed .sidebar{width:76px;padding-left:12px;padding-right:12px;overflow:visible}
|
||||
.sidebar-collapsed .sidebar::before{clip-path:inset(140px 164px 0 120px)}
|
||||
.sidebar-collapsed .workspace-main{margin-left:76px;width:calc(100% - 76px)}
|
||||
.sidebar-collapsed .sidebar>a>.brand{margin:0 0 24px;justify-content:center}
|
||||
.sidebar-collapsed .sidebar .brand>span:last-child,
|
||||
.sidebar-collapsed .workspace>div,.sidebar-collapsed .workspace>svg,
|
||||
.sidebar-collapsed .sidebar nav button.nav-label,.sidebar-collapsed .nav-text,
|
||||
.sidebar-collapsed .profile>div{display:none}
|
||||
.sidebar-collapsed button.workspace{justify-content:center;padding:8px}
|
||||
.sidebar-collapsed .workspace-menu{right:auto;width:240px}
|
||||
.sidebar-collapsed .sidebar nav :is(button,a){justify-content:center;padding:11px 0}
|
||||
.sidebar-collapsed .sidebar nav .nav-items~.nav-items>:first-child{margin-top:12px}
|
||||
.sidebar-collapsed .profile{flex-direction:column;padding-left:0;padding-right:0}
|
||||
.sidebar-collapsed .profile>.icon-btn{margin-left:0}
|
||||
}
|
||||
@media(max-width:700px){.sidebar-toggle,.sidebar-help-icon{display:none}}
|
||||
@media(prefers-reduced-motion:reduce){.sidebar,.workspace-main{transition:none}}
|
||||
|
||||
/* Tablet widths: the topbar wraps instead of pushing its pills past the edge, and the page head's
|
||||
buttons go under the title when they no longer fit beside it */
|
||||
@media(min-width:701px) and (max-width:1100px){
|
||||
.topbar{height:auto;min-height:64px;padding-top:10px;padding-bottom:10px;flex-wrap:wrap;row-gap:8px}
|
||||
.topbar-right{flex-wrap:wrap;justify-content:flex-end;gap:8px 10px}
|
||||
.demo-indicator{display:none}
|
||||
.page-head{flex-wrap:wrap}
|
||||
.page-head .head-actions{flex-wrap:wrap}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user