Compare commits

22 Commits

Author SHA1 Message Date
Iqbal
a73a5a3fdf Require two materials for content schedules and scope readiness copy
The engine plans each production piece from a pair of documents, so a
summary, quiz or similar schedule with one material can never plan. Approving
such a plan is now blocked with a note to choose or upload another material,
and the engine's refusal on an existing schedule is explained in plain
language. Video and research schedules still work with one material.

The engine now checks only a schedule's own material before a run, so the
run error and Upload texts no longer say the whole Library or Brain is
checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:37:56 +07:00
Iqbal
d1492b9c76 Explain blocked schedule runs and let a failed document be processed again
Schedule history now shows a readable reason instead of the engine's raw code,
and under the newest run stopped by knowledge_not_ready it lists the Library
material that blocks it, with a link to fix it in Learning Studio. The list
refreshes while that material is still processing or building.

Upload gets a "Process again" button for a failed document, using the engine's
new reprocess endpoint; a knowledge base the document already has is kept. A
document being processed again no longer shows as "In the Brain".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:13:03 +07:00
Iqbal
b63e9c2ca8 Make the Brain agent ask instead of guess, and answer how schedules run
Generic requests ("10 konten", "10 pdf") now become a rule whose kind the
agent asks about, and a rule with no frequency is asked about instead of
silently becoming weekly. Times like 09.00 no longer split sentences, and
requests about drafts, the hour, scheduling, sources and duplicates get a
truthful note rather than an "I did not understand" loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:09:27 +07:00
Iqbal
d018c6826f Collapse sidebar nav groups into dropdowns that open on the active page
Each group label (Dashboard, Settings) toggles its items; a group starts
closed unless it holds the current route, and a manual toggle resets on
the next navigation. Mobile keeps every item visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:55:27 +07:00
Iqbal
844c88912a Add deploy.sh for Netlify production deploys
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:22:57 +07:00
Iqbal
6f3a5234b4 Turn Production into a welcome dashboard with content activity
Greet the user by name, drop the development-phase strip, move three metric
cards (material in the Brain, content made, uploaded to the LMS) below the
knowledge graph, and add a content activity section with the latest and
in-progress results as Results cards that open their preview in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:22:57 +07:00
Iqbal
3b56c27210 Show the Brain beside the AI chat and make schedules from chosen material
The Brain and AI page lists Brain material (failed documents hidden) with
search, open, delete and per-material or select-all ticks. New schedules use
only the ticked material, kept through topic edits and resumes; nothing ticked
means the whole Brain. With material ticked, the agent skips the content topic
question and names the material in the chat and plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:53:38 +07:00
Iqbal
ef4fb7d971 Rename Brain to Upload material and Instruction to Brain and AI
Upload material (/upload) drops the metric cards for a larger upload box
above the material list; Brain and AI moves to /brain. Old paths redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:36:52 +07:00
c1063bfa7e Merge pull request 'production-dashboard' (#1) from production-dashboard into main
Reviewed-on: #1
2026-10-27 14:58:52 +00:00
Iqbal
68050d8d4b Pin production Clerk key and Studio URL for local builds
A build without them used learning-lab's dev Clerk key, so production
users signed in to the wrong instance and saw no data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:56:56 +07:00
Iqbal
9f9fc93dfe Retry failed videos with a new run and explain documents that fail after their build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:48:00 +07:00
Iqbal
8a02f68589 Link to Learning Lab from the topbar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:48:00 +07:00
Iqbal
ddbdbc7d44 Add a Production dashboard with an animated knowledge graph
The sidebar now groups Production under Dashboard and the four existing
pages under Settings, and Production is the landing page. It shows content
in progress, material, content made, approved and LMS-ready counts, plus a
knowledge graph linking Brain documents, results, found sources and
schedules, with the schedule and what is being made right now beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:57:10 +07:00
Iqbal
6d4850f4ce Switch organizations from the Learning space card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:22 +07:00
Iqbal
51700bc0f3 Deploy to Netlify with the engine behind a same-origin /api proxy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:56:37 +07:00
Iqbal
42464d88b3 Show the workspace credit balance in the topbar
Read from GET /organizations/me, refreshed after every action and every
30 seconds, since agents and schedules spend credits in the background.
The date is dropped from the topbar, and its items wrap on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:57:01 +07:00
Iqbal
7c17191b8b Open Brain documents from their title
Clicking a document's title fetches the original file from the engine:
viewable formats open in a new tab, the rest (or a blocked tab) download
under the original name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:48:07 +07:00
Iqbal
c294e91253 Open a result by clicking anywhere on its card
Drop the Open button: the title's hit area now covers the whole card,
with Retry kept on top so it doesn't open the preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:52 +07:00
Iqbal
a4189f0d2a Fit the sign-in page on phones with a full blue background
Shorten the story header so the form shows without scrolling, keep the
Clerk card inside the screen, and run one blue background behind the
whole page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:33:51 +07:00
Iqbal
a1d49b37b5 Make the Instruction agent reply in the user's language, at a human pace
The agent answers in the language each message is written in (Indonesian
or English), whatever the UI language; short replies such as "LLM" keep
the conversation's language. inLang() runs t() in that language.

Each turn now reads like a person: a thinking step with what the agent is
doing, the reply typed out with "Thought for Ns" above it, a short typing
pause, then one question, with its options shown once it is typed.
Reduced motion skips the animation.

"dari file LLM" / "from the document X" is read as the topic, and topics
keep the user's capitals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:08 +07:00
Iqbal
f55cccb6eb Ask which language the content is written in
The Instruction agent now confirms the output language before the plan is
ready: Bahasa Indonesia, English or 中文, the languages the engine writes
in. It can also be named in the instruction ("pakai bahasa Inggris"), is
read from short replies, and can be changed in the plan. Research alone
is not asked about.

The engine's content production has no language field, so the choice
goes into the agent's instructions; video schedules also set
video_options.language. ruleOf reads it back, so editing a topic keeps
it. The audio player recognises Chinese scripts and uses a zh-CN voice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:32:06 +07:00
Iqbal
2b37d80e26 Turn Instruction into a chat with an agent that asks
The Instruction page is now a conversation with the Express Agent. Each
message updates the draft plan, and when something is unclear (a part it
did not understand, a video count with no cadence, a schedule over 50 per
week, weekly research with no day, a missing topic) the agent asks right
in the chat, one question at a time, with replies to pick or type. Once
the plan is clear it offers to start the schedules and reports the result
in the chat. The plan stays editable beside the chat.

The chat text is also larger and darker than the mockup's 9-11px grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:23:47 +07:00
30 changed files with 2413 additions and 262 deletions

5
.env.production Normal file
View File

@@ -0,0 +1,5 @@
# Used by `vite build` (Netlify deploys are built locally). Both values are public.
# Without these, the build falls back to learning-lab's dev Clerk key and localhost Studio,
# so production users sign in to the wrong Clerk instance and see no data.
VITE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsubGVhcm5pbmdzdHVkaW8uYmF3YW5hLmNvbSQ
VITE_STUDIO_URL=https://learningstudio.bawana.com

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules
dist
.env
.env.local
.netlify

View File

@@ -16,6 +16,7 @@ npm test # tes parser, jadwal, SCORM, dan ekspor (Vitest)
- Clerk key dibaca dari `VITE_CLERK_PUBLISHABLE_KEY`, atau otomatis dari `../learning-lab/backend/.env`.
- Tombol "Open in Learning Studio" di Results membuka `VITE_STUDIO_URL` (default `http://localhost:5173`).
- Dua bahasa: English (default) dan Indonesia, lewat tombol EN/ID di topbar dan halaman login. Teks ada di `src/i18n.ts`; pilihan disimpan per perangkat.
- Instruction berbentuk chat dengan agen (`src/express/agent.ts`): bila ada yang belum jelas (bagian yang tidak dipahami, frekuensi video, hari riset, topik), agen langsung bertanya di chat dengan opsi jawaban, lalu menawarkan untuk memulai jadwal. Pemahamannya tetap memakai parser deterministik, bukan LLM.
- Login wajib memakai akun Clerk dengan organisasi aktif: agents, jadwal, dan kredit semuanya per organisasi.
| Folder | Isi |

38
deploy.sh Executable file
View 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

24
netlify.toml Normal file
View File

@@ -0,0 +1,24 @@
[build]
command = "npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "22"
# Same-origin /api, as the Vite proxy does in dev: the engine needs no CORS setup.
[[redirects]]
from = "/api/*"
to = "https://learningstudio.bawana.com/api/:splat"
status = 200
force = true
# BrowserRouter: /upload, /results, ... all load index.html.
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthGate } from './auth/AuthGate'
import { AppLayout } from './components/AppLayout'
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'
@@ -11,17 +12,19 @@ export default function App() {
<AuthGate>
<Routes>
<Route element={<AppLayout />}>
<Route path="/brain" element={<BrainPage />} />
<Route path="/instruction" element={<InstructionPage />} />
<Route path="/production" element={<ProductionPage />} />
<Route path="/upload" element={<BrainPage />} />
<Route path="/brain" element={<InstructionPage />} />
<Route path="/schedule" element={<SchedulePage />} />
<Route path="/results" element={<ResultsPage />} />
</Route>
{/* Indonesian paths from before the routes were renamed, so old links keep working. */}
<Route path="/otak" element={<Navigate replace to="/brain" />} />
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
{/* Paths from before the routes were renamed, so old links keep working. */}
<Route path="/instruction" element={<Navigate replace to="/brain" />} />
<Route path="/otak" element={<Navigate replace to="/upload" />} />
<Route path="/perintah" element={<Navigate replace to="/brain" />} />
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
<Route path="/hasil" element={<Navigate replace to="/results" />} />
<Route path="*" element={<Navigate replace to="/brain" />} />
<Route path="*" element={<Navigate replace to="/production" />} />
</Routes>
</AuthGate>
)

View File

@@ -49,6 +49,7 @@ function asSource(d: EngineDocument): Source {
export const listDocuments = async () => (await request<EngineDocument[]>('/upload/list')).map(asSource)
export const documentStatus = (id: number) => request<EngineDocument>(`/upload/${id}`)
export const deleteDocument = (id: number) => request<null>(`/upload/${id}`, { method: 'DELETE' })
export const documentFile = (id: number) => request<Blob>(`/upload/${id}/file`, { as: 'blob' })
export async function supportedExtensions(): Promise<Set<string>> {
const f = await request<{ extensions: string[] }>('/upload/supported-formats')
@@ -78,6 +79,8 @@ export interface KnowledgeDetail {
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
// 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.
@@ -97,6 +100,12 @@ export async function waitReady(id: number, onProgress?: (d: EngineDocument) =>
}
}
// ── Workspace wallet ──
// The organization's credit balance: Express spends workspace credits, never personal ones.
export const workspaceCredits = async () =>
(await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null
// ── Compose (text content) ──
export interface ComposeSet { id: number; name: string }

View File

@@ -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>
)

View File

@@ -1,17 +1,30 @@
import { useEffect, useRef, useState } from 'react'
import { useClerk, useOrganization, useUser } from '@clerk/react'
import { 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'
// Express Mode has four doors: feed the Brain, give an instruction, watch the schedule, collect results.
export const NAV: { to: string; label: Key; icon: IconName }[] = [
{ to: '/brain', 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' },
// 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 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 }[] }[] = [
{ label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] },
{
label: 'nav.settings',
items: [
{ to: '/upload', label: 'nav.brain', icon: 'brain' },
{ to: '/brain', label: 'nav.instruction', icon: 'spark' },
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
{ to: '/results', label: 'nav.results', icon: 'layers' },
],
},
]
export const NAV = NAV_GROUPS.flatMap(g => g.items)
export function LanguageSwitch() {
const lang = useLang()
@@ -57,32 +70,98 @@ function HelpDialog({ onClose }: { onClose: () => void }) {
)
}
// Polled because agents and schedules also spend credits outside this tab; main.tsx refreshes it after every mutation.
function CreditBalance() {
const credits = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits, refetchInterval: 30_000 })
if (credits.data == null) return null
return (
<span className="credit-pill" title={t('layout.creditsTitle')}>
<Icon name="coin" />{t('common.credits', { n: credits.data.toLocaleString(locale()) })}
</span>
)
}
// The Learning space card switches the active organization. AuthGate keys the pages by orgId and
// clears the query cache, so Brain, schedules, results and credits all reload for the new one.
function OrgSwitcher() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { organization } = useOrganization()
const { setActive, userMemberships } = useOrganizationList({ userMemberships: { infinite: true } })
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey) }
}, [open])
const choose = (id: string) => {
setOpen(false)
if (id !== organization?.id) void setActive?.({ organization: id })
}
return (
<div className="workspace-switch" ref={ref}>
<button type="button" className="workspace" aria-haspopup="listbox" aria-expanded={open}
title={t('layout.switchOrg')} onClick={() => setOpen(o => !o)}>
<span className="workspace-icon">{(organization?.name || 'L').charAt(0).toUpperCase()}</span>
<div>{t('layout.learningSpace')}<small>{organization?.name ?? '-'}</small></div>
<Icon name="chevron-updown" />
</button>
{open && (
<ul className="workspace-menu" role="listbox" aria-label={t('layout.switchOrg')}>
{userMemberships.data?.map(({ organization: org }) => (
<li key={org.id} role="option" aria-selected={org.id === organization?.id}>
<button type="button" onClick={() => choose(org.id)}>
<span className="workspace-icon">{org.name.charAt(0).toUpperCase()}</span>
<span>{org.name}</span>
{org.id === organization?.id && <Icon name="check" />}
</button>
</li>
))}
</ul>
)}
</div>
)
}
export function AppLayout() {
useLang()
const [help, setHelp] = useState(false)
const { user } = useUser()
const { organization } = useOrganization()
const { signOut } = useClerk()
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])
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
return (
<div className="app">
<aside className="sidebar">
<NavLink to="/brain"><Brand /></NavLink>
<div className="workspace">
<span className="workspace-icon">{(organization?.name || 'L').charAt(0).toUpperCase()}</span>
<div>{t('layout.learningSpace')}<small>{organization?.name ?? '-'}</small></div>
</div>
<div className="nav-label">{t('nav.workspace')}</div>
<NavLink to="/production"><Brand /></NavLink>
<OrgSwitcher />
<nav aria-label={t('layout.mainNav')}>
{NAV.map(n => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
<Icon name={n.icon} />{t(n.label)}
</NavLink>
))}
{NAV_GROUPS.map(g => {
const open = 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)}>
<Icon name={n.icon} />{t(n.label)}
</NavLink>
))}
</div>,
]
})}
</nav>
<div className="sidebar-help">
<Icon name="info" />
@@ -101,15 +180,11 @@ export function AppLayout() {
<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>
<span>{today.format(new Date())}</span>
<CreditBalance />
<a className="studio-link" href={STUDIO_URL}><Icon name="external" />{t('layout.toStudio')}</a>
<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>

View File

@@ -11,7 +11,8 @@ export function sentencesOf(text: string): string[] {
return text
.replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes
.replace(/[*_`]+/g, '')
.split(/(?<=[.!?…])\s+|\n+/)
// Chinese ends sentences with 。!? and no space after them.
.split(/(?<=[.!?…])\s+|(?<=[。!?])|\n+/)
.map(s => s.replace(/\s+/g, ' ').trim())
.filter(s => /\p{L}/u.test(s))
}
@@ -19,7 +20,12 @@ export function sentencesOf(text: string): string[] {
// The script's language decides the voice; the UI language may differ from the content's.
const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi
const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi
export const scriptLang = (text: string) => ((text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en')
// Chinese has no spaces, so it is told apart by its characters before the word counts.
export function scriptLang(text: string): 'id' | 'en' | 'zh' {
if ((text.match(/\p{Script=Han}/gu)?.length ?? 0) > (text.match(/[a-z]/gi)?.length ?? 0)) return 'zh'
return (text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en'
}
const SPEECH_LANG = { id: 'id-ID', en: 'en-US', zh: 'zh-CN' }
const supported = typeof window !== 'undefined' && 'speechSynthesis' in window
@@ -44,7 +50,7 @@ export function AudioPlayer({ title, text }: { title: string; text: string }) {
at.current = i
setIndex(i)
const u = new SpeechSynthesisUtterance(sentences[i])
u.lang = lang === 'id' ? 'id-ID' : 'en-US'
u.lang = SPEECH_LANG[lang]
u.voice = voice()
u.rate = rate
u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) }

View File

@@ -12,16 +12,21 @@ const paths: Record<string, string> = {
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
file: '<path d="M5 3h9l5 5v13H5zM14 3v6h5M8 13h8M8 17h6"/>',
'arrow-right': '<path d="M4 12h16m-6-6 6 6-6 6"/>',
external: '<path d="M14 4h6v6M20 4l-9 9M18 14v6H4V6h6"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
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"/>',
logout: '<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
'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"/>',
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"/>',
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
coin: '<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.5-1-1.6-1.5-3-1.5-1.7 0-3 .8-3 2s1.3 1.7 3 2 3 .8 3 2-1.3 2-3 2c-1.4 0-2.5-.5-3-1.5M12 6v2M12 16v2"/>',
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
}

View File

@@ -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>
)

143
src/express/agent.test.ts Normal file
View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest'
import { chatLangOf, EMPTY, nextQuestion, respond, type AgentState } from './agent'
import { t } from '../i18n'
const say = (...inputs: string[]) => inputs.reduce<AgentState>((s, input) => respond(s, input).state, EMPTY)
const rules = (s: AgentState) => s.items.map(i => i.rule)
describe('instruction agent', () => {
it('needs no question when the instruction is complete', () => {
const s = say('make 2 summaries per day about leadership in english')
expect(s.language).toBe('English')
expect(nextQuestion(s)).toBeNull()
expect(respond(s, 'yes').approve).toBe(true)
})
it('asks for a topic, and takes the reply as the topic', () => {
const s = say('make 3 quizzes per week')
expect(nextQuestion(s)?.type).toBe('topic')
expect(rules(say('make 3 quizzes per week', 'negotiation'))[0]).toMatchObject({ topic: 'negotiation' })
})
it('stops asking for a topic once the whole Brain is chosen', () => {
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')
expect(rules(say('3 videos per week about onboarding', t('agent.optPerDay')))[0]).toMatchObject({ count: 1, per: 'day' })
})
it('asks which day weekly research runs when none was named', () => {
const s = say('research every week about sales')
expect(nextQuestion(s)?.type).toBe('weekday')
const answered = say('research every week about sales', 'thursday')
expect(rules(answered)[0]).toMatchObject({ weekday: 3, weekdayGuessed: undefined })
expect(nextQuestion(answered)).toBeNull()
})
it('asks about parts it did not understand, and can skip them', () => {
const s = say('2 summaries per day about sales. hello there')
expect(nextQuestion(s)).toMatchObject({ type: 'unknown', text: 'hello there' })
expect(nextQuestion(say('2 summaries per day about sales. hello there', 'skip'))?.type).toBe('language')
})
it('asks which language the content is written in', () => {
expect(nextQuestion(say('3 quizzes per week about sales'))?.type).toBe('language')
expect(say('3 quizzes per week about sales', 'mandarin').language).toBe('中文')
expect(say('3 quizzes per week about sales', 'indo').language).toBe('Bahasa Indonesia')
expect(nextQuestion(say('3 quizzes per week about sales', '中文 (Mandarin)'))).toBeNull()
})
it('reads the language from the instruction without leaving it in the topic', () => {
const s = say('bikin 3 kuis per minggu soal negosiasi pakai bahasa inggris')
expect(s.language).toBe('English')
expect(rules(s)[0]).toMatchObject({ topic: 'negosiasi' })
expect(s.unknown).toEqual([])
})
it('does not ask about the language for research alone', () => {
expect(nextQuestion(say('research every day about sales in China'))).toBeNull()
})
it('treats a reply with a frequency as a new request', () => {
const s = say('make 3 quizzes per week', '1 video per week about onboarding')
expect(rules(s).map(r => r.type === 'content' && r.kind)).toEqual(['quiz', 'video'])
})
it('caps a schedule that is over the weekly limit', () => {
const s = say('10 summaries per day about sales')
expect(nextQuestion(s)?.type).toBe('tooMany')
expect(rules(say('10 summaries per day about sales', '30 per week'))[0]).toMatchObject({ count: 30, per: 'week' })
})
it('takes "dari file X" as the topic', () => {
expect(rules(say('Saya mau buat video dari file LLM'))[0]).toMatchObject({ kind: 'video', topic: 'LLM' })
})
})
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')
expect(chatLangOf('I want to make 2 quizzes per week')).toBe('en')
expect(chatLangOf('LLM')).toBeNull()
})
})

275
src/express/agent.ts Normal file
View File

@@ -0,0 +1,275 @@
// The Instruction agent: a conversation on top of the deterministic parser. Each message updates the
// draft plan, and the agent asks about one gap at a time (a part it did not understand, a video
// cadence, a weekday, a topic, the output language) right in the chat, so nothing is left for the
// user to spot alone.
import { t, type Key, type Lang } from '../i18n'
import {
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 }
export interface AgentState {
items: Item[]
/** Parts of the user's messages that matched no rule, asked about one by one. */
unknown: string[]
/** Questions settled without changing the plan, e.g. "use the whole Brain". */
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, sources: null }
export type Question =
| { key: string; type: 'unknown'; text: string }
| { 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 }
}
// 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.
if (!s.language && s.items.some(i => i.rule.type === 'content')) return { key: 'language', type: 'language' }
return null
}
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: describe(s, ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
case 'tooMany':
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(s, q.ids.map(id => ruleById(s, id))) }),
options: [t('agent.optWholeBrain')],
}
case 'language':
return { text: t('agent.askLanguage'), options: LANGUAGES.map(languageLabel) }
}
}
/** A change made by hand in the plan; it replaces what the agent had guessed. */
export function editRule(s: AgentState, id: number, patch: Partial<ContentRule> | Partial<ResearchRule>): AgentState {
const guessed = {
// 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)) }
}
export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, items: s.items.filter(i => i.id !== id) })
// 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 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 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 {
const words = text.toLowerCase().match(/\p{L}+/gu) ?? []
const id = words.filter(w => ID_CHAT.has(w)).length
const en = words.filter(w => EN_CHAT.has(w)).length
return id > en ? 'id' : en > id ? 'en' : null
}
export interface Turn { state: AgentState; reply: string; approve?: boolean }
const YES = /^(ya|iya|yes|y|ok|oke|okay|okey|setuju|approve|lanjut|gas|sip|go|boleh)\b/
const SKIP = /^(skip|lewati|lewat|abaikan|ignore|no|nope|tidak|nggak|gak|enggak|ga)$/
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 }))
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, 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.
function languageAnswer(lower: string): OutputLanguage | null {
return languageOf(lower)
?? LANGUAGES.find(l => lower === languageLabel(l).toLowerCase())
?? (/^(id|indo|indonesia)$/.test(lower) ? 'Bahasa Indonesia'
: /^(en|eng|inggris)$/.test(lower) ? 'English'
: /^(zh|cn|cina|china|tionghoa|mandarin|chinese)$/.test(lower) ? '中文' : 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: describe(state, ruleById(state, id)) }) }
}
// Reads a reply to the agent's question; null when it does not answer it.
function answer(q: Question, s: AgentState, text: string, lower: string, is: (k: Key) => boolean): Turn | null {
if (q.type === 'unknown') {
if (SKIP.test(lower) || is('agent.optSkip')) return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.skipped', { part: q.text }) }
return instruct({ ...s, unknown: s.unknown.slice(1) }, text)
}
if (q.type === 'language') {
const language = languageAnswer(lower)
return language ? { state: { ...s, language }, reply: t('agent.languageSet', { language: languageLabel(language) }) } : null
}
if (q.type === 'topic') {
if (WHOLE.test(lower) || SKIP.test(lower) || is('agent.optWholeBrain')) return { state: { ...s, settled: [...s.settled, q.key] }, reply: t('agent.wholeBrain') }
// A reply with a frequency is a new request, not a topic.
if (DAILY.test(lower) || WEEKLY.test(lower) || /\bper\b/.test(lower) || languageOf(lower)) return null
const topic = text.replace(/^(about|regarding|tentang|soal|mengenai|topik|topic)\s+/i, '').replace(/[.!]+$/, '').trim()
if (!topic) return null
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: 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') {
const n = Number(lower.match(/\d+/)?.[0])
if (!n) return null
return per === 'day' ? updated(s, q.id, { count: Math.min(n, 7), per }) : updated(s, q.id, { count: Math.min(n, 50), per: 'week' })
}
// weekday: "every day" turns it into daily research instead.
if (DAILY.test(lower)) return updated(s, q.id, { cadence: 'daily' })
const day = weekdayOf(lower) ?? [0, 1, 2, 3, 4, 5, 6].find(d => lower === dayName(d).toLowerCase())
return day === undefined || day === null ? null : updated(s, q.id, { weekday: day })
}
/** The agent's turn after the user says something, typed or picked from the options. */
export function respond(s: AgentState, input: string): Turn {
const text = input.trim()
const lower = text.toLowerCase()
const is = (key: Key) => lower === t(key).toLowerCase()
if (is('agent.optReset')) return { state: EMPTY, reply: t('agent.reset') }
const q = nextQuestion(s)
if (q) {
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') }
}

38
src/express/graph.test.ts Normal file
View File

@@ -0,0 +1,38 @@
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'
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) 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],
}
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('links results to their sources and schedule, and found sources to their document', () => {
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']))
})
it('lays out the same data the same way, inside the canvas', () => {
expect(buildGraph(data, c => c).nodes).toEqual(graph.nodes)
for (const n of graph.nodes) {
expect(n.x).toBeGreaterThanOrEqual(0); expect(n.x).toBeLessThanOrEqual(WIDTH)
expect(n.y).toBeGreaterThanOrEqual(0); expect(n.y).toBeLessThanOrEqual(HEIGHT)
}
})
})

133
src/express/graph.ts Normal file
View File

@@ -0,0 +1,133 @@
// 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.
import type { Source } from '../api/engine'
import { isWorking, type FoundSource, type Material } from './results'
import type { Schedule } from './schedules'
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules'
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules']
export interface GraphNode {
id: string
cluster: Cluster
label: string
hub: boolean
x: number
y: number
}
export interface GraphLink { source: string; target: string; hub: boolean }
export interface Graph { nodes: GraphNode[]; links: GraphLink[]; width: number; height: number }
export const WIDTH = 1000
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],
}
const norm = (s: string) => s.trim().toLowerCase()
export function buildGraph(
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] },
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 }))
const links: GraphLink[] = []
const add = (id: string, cluster: Cluster, label: string) => {
nodes.push({ id, cluster, label, hub: false, x: 0, y: 0 })
links.push({ source: id, target: `hub-${cluster}`, hub: true })
}
const link = (source: string, target: string) => links.push({ source, target, hub: false })
// 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) {
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) {
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}`)
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 }
}
// Deterministic per node, so a refetch with the same data does not reshuffle the picture.
function seeded(id: string) {
let h = 2166136261
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619)
return () => {
h = Math.imul(h ^ (h >>> 15), 2246822507)
h = Math.imul(h ^ (h >>> 13), 3266489909)
return ((h ^= h >>> 16) >>> 0) / 4294967296
}
}
function layout(nodes: GraphNode[], links: GraphLink[], iterations = 260) {
const index = new Map(nodes.map((n, i) => [n.id, i]))
const vx = new Float64Array(nodes.length)
const vy = new Float64Array(nodes.length)
for (const n of nodes) {
const rand = seeded(n.id)
const [ax, ay] = ANCHOR[n.cluster]
n.x = ax + (n.hub ? 0 : (rand() - 0.5) * 120)
n.y = ay + (n.hub ? 0 : (rand() - 0.5) * 120)
}
const pairs = links.map(l => [index.get(l.source)!, index.get(l.target)!, l.hub] as const).filter(([a, b]) => a != null && b != null)
for (let it = 0; it < iterations; it++) {
const alpha = 1 - it / iterations
// Repulsion keeps nodes from piling up; only near neighbours matter.
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x
const dy = nodes[j].y - nodes[i].y
const d2 = dx * dx + dy * dy + 0.01
if (d2 > 140 * 140) continue
const f = 260 / d2
vx[i] -= dx * f; vy[i] -= dy * f
vx[j] += dx * f; vy[j] += dy * f
}
}
// Springs: short to the cluster hub, longer across clusters.
for (const [a, b, hub] of pairs) {
const dx = nodes[b].x - nodes[a].x
const dy = nodes[b].y - nodes[a].y
const d = Math.sqrt(dx * dx + dy * dy) || 1
const f = ((d - (hub ? 46 : 110)) / d) * (hub ? 0.06 : 0.015)
vx[a] += dx * f; vy[a] += dy * f
vx[b] -= dx * f; vy[b] -= dy * f
}
nodes.forEach((n, i) => {
const [ax, ay] = ANCHOR[n.cluster]
const pull = n.hub ? 0.12 : 0.012
vx[i] += (ax - n.x) * pull
vy[i] += (ay - n.y) * pull
n.x = Math.min(WIDTH - 20, Math.max(20, n.x + vx[i] * alpha))
n.y = Math.min(HEIGHT - 20, Math.max(20, n.y + vy[i] * alpha))
vx[i] *= 0.6; vy[i] *= 0.6
})
}
}

View File

@@ -78,3 +78,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([])
})
})

View File

@@ -30,6 +30,20 @@ const KIND_WORDS: [ContentKind, RegExp][] = [
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
]
/** Every content kind a text names, in the order it names them: "ringkasan dan kuis" → summary, quiz. */
export function kindsIn(text: string): ContentKind[] {
return 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)
}
// "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,
@@ -45,6 +59,26 @@ const WEEKDAYS: [RegExp, number][] = [
export type Per = 'day' | 'week'
// The output languages the engine writes in (generation_brief.OutputLanguage, AgentVideoOptions.language).
export type OutputLanguage = 'English' | 'Bahasa Indonesia' | '中文'
export const LANGUAGES: OutputLanguage[] = ['Bahasa Indonesia', 'English', '中文']
export const languageLabel = (l: OutputLanguage) => (l === '中文' ? '中文 (Mandarin)' : l)
// "in English", "pakai bahasa Inggris", "dalam bahasa Mandarin". A bare country name needs "bahasa",
// so a topic such as "sales in China" is not read as a language.
const LANGUAGE_CORE = String.raw`bahasa\s+(?:indonesia|indo|inggris|english|mandarin|cina|china|tionghoa)|indonesian|english|inggris|mandarin|chinese|中文`
const LANGUAGE_PHRASE = new RegExp(
String.raw`(?<!\p{L})(?:(?:in|dalam|pakai|pake|memakai|menggunakan|gunakan|using|use|with|berbahasa)\s+)?(?:the\s+)?(?:${LANGUAGE_CORE})(?:\s+language)?(?!\p{L})`,
'gu',
)
/** The output language a text names, if any. */
export function languageOf(text: string): OutputLanguage | null {
const m = text.toLowerCase().match(LANGUAGE_PHRASE)?.[0]
if (!m) return null
return /indo/.test(m) ? 'Bahasa Indonesia' : /english|inggris/.test(m) ? 'English' : '中文'
}
export interface ContentRule {
type: 'content'
kind: ContentKind
@@ -53,6 +87,14 @@ export interface ContentRule {
topic: string
/** The monthly count the instruction asked for, when it had to be turned into a weekly one. */
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 {
@@ -61,39 +103,88 @@ export interface ResearchRule {
weekday: number
topic: string
autoApprove: boolean
/** Weekly research whose instruction named no day, so Monday is only a default the agent asks about. */
weekdayGuessed?: boolean
}
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[]
}
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
// 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|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
}
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'
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 (DAY_WORDS.test(clause)) return 'day'
if (MONTH_WORDS.test(clause)) return 'month'
return 'week'
}
function weekdayOf(clause: string): number | null {
/** 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
}
@@ -101,31 +192,44 @@ 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)) {
const per = perOf(clause)
const day = weekdayOf(clause)
const cadence = per === 'day' && day === null ? 'daily' : 'weekly'
return { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true }
const rule: ResearchRule = { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true }
if (cadence === 'weekly' && day === null) rule.weekdayGuessed = true
return rule
}
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 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
.toLowerCase()
.replace(LANGUAGE_PHRASE, ' ')
.replace(/[“”"]/g, '')
.split(SPLIT)
.map(s => s.trim())
@@ -137,6 +241,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)
@@ -146,7 +252,16 @@ export function parseInstruction(text: string): ParseResult {
const topics = new Set(rules.map(r => r.topic).filter(Boolean))
const fallback = shared || (topics.size === 1 ? [...topics][0] : '')
for (const r of rules) if (!r.topic) r.topic = fallback
return { rules, unknown }
// Topics are found in lower case; they keep the user's own capitals ("LLM", "HR").
const lowered = text.toLowerCase()
if (lowered.length === text.length) {
for (const r of rules) {
const at = r.topic ? lowered.indexOf(r.topic) : -1
if (at >= 0) r.topic = text.slice(at, at + r.topic.length)
}
}
const notes = notesOf(text.toLowerCase())
return language ? { rules, unknown, language, notes } : { rules, unknown, notes }
}
export function weeklyTarget(rule: ContentRule): number {
@@ -161,17 +276,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')),
})
}

View File

@@ -5,7 +5,7 @@
import { request } from '../api/client'
import { t } from '../i18n'
import { kindLabel, type ContentKind, type Rule } from './parse'
import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, type PlanItem, type Schedule } from './schedules'
import { expressAgents, MAX_ATTEMPTS, PLAN_ACTIVE, productionStatus, retryItem, runNow, type PlanItem, type Schedule } from './schedules'
const AGENTS = '/organizations/current/agents'
@@ -39,8 +39,8 @@ export interface Material {
sources: string[]
createdAt: string
rule: Rule
/** The plan item to produce again, when the engine allows another attempt. */
retry?: { planId: number; itemId: number }
/** 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 }
}
@@ -147,11 +147,15 @@ function videoStatus(s: string): MaterialStatus {
return 'generating'
}
async function videosOf({ agent, rule }: Schedule): Promise<Material[]> {
async function videosOf({ agent, rule, active }: Schedule): Promise<Material[]> {
const drafts = await request<RawVideo[]>(`${AGENTS}/${agent.id}/video-drafts`)
// A video draft cannot be retried itself; a new run of its schedule makes it again. Offered on the
// 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)
return {
retry: status === 'failed' && 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,
@@ -195,9 +199,10 @@ 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' })
// Every part waiting for review gets the same decision, so a SCORM module is approved as one.
export const retryMaterial = (m: Material) => retryItem(m.agentId, m.retry!.planId, m.retry!.itemId)
export const retryMaterial = ({ agentId, retry }: Material) =>
retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId)
// 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 } })

View File

@@ -1,16 +1,16 @@
import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
import { budgetFor, canSchedule, configFor, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules'
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding'): ContentRule =>
({ type: 'content', kind, count, per, topic })
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 })
// What the engine sends back for an agent created from configFor.
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,
name: c.name as string, goal: c.goal as string,
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']) ?? [],
content_goal: (c.content_goal as ExpressAgent['content_goal']) ?? null,
@@ -54,6 +54,26 @@ describe('configFor', () => {
})
})
describe('content language', () => {
it('writes the chosen language into the brief and the video options', () => {
expect((configFor(rule('summary', 2, 'week', 'sales', '中文')) as { instructions: string }).instructions)
.toMatch(/Write all content in Simplified Chinese \(中文\)\./)
expect((configFor(rule('video', 1, 'week', 'sales', 'Bahasa Indonesia'), 50) as { video_options: { language: string } }).video_options.language)
.toBe('Bahasa Indonesia')
})
})
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),
@@ -62,6 +82,8 @@ describe('ruleOf', () => {
rule('quiz', 1, 'day'),
rule('video', 1, 'week'),
rule('video', 1, 'day'),
rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia'),
rule('video', 1, 'week', 'sales', '中文'),
])('reads back the rule a schedule was created from: $count $kind per $per', r => {
expect(ruleOf(agentFrom(configFor(r, 50)))).toEqual(r)
})
@@ -86,3 +108,17 @@ 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')
})
})

View File

@@ -3,8 +3,11 @@
// use run_schedule (one run per day or week; a video run renders one video).
import { opKey, request } from '../api/client'
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
import { getLang } from '../i18n'
import { KINDS, SCHEDULABLE, videoCadence, weeklyTarget, type ContentKind, type ContentRule, type Per, type ResearchRule, type Rule } from './parse'
import { getLang, t } from '../i18n'
import {
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
} from './parse'
const AGENTS = '/organizations/current/agents'
export const PREFIX = 'Express · '
@@ -25,7 +28,7 @@ export interface VideoOptions {
max_scenes: number
max_duration_seconds: number
art_style: 'realistic' | 'cartoon' | 'anime' | 'watercolor' | '3d'
language: 'English' | 'Bahasa Indonesia'
language: OutputLanguage
}
export interface ExpressAgent extends Agent {
@@ -56,11 +59,29 @@ function scheduleName(rule: Rule): string {
const isKind = (k: string | undefined): k is ContentKind => KINDS.includes(k as ContentKind)
/** A rule with no language chosen is written in the UI language, as before languages could be chosen. */
export const defaultLanguage = (): OutputLanguage => (getLang() === 'id' ? 'Bahasa Indonesia' : 'English')
// How the agent's English brief names each language; read back by languageOfAgent.
const LANGUAGE_TEXT: Record<OutputLanguage, string> = {
English: 'English', 'Bahasa Indonesia': 'Bahasa Indonesia', 中文: 'Simplified Chinese (中文)',
}
const languageSentence = (l: OutputLanguage) => `Write all content in ${LANGUAGE_TEXT[l]}.`
function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
return agent.video_options?.language ?? LANGUAGES.find(l => agent.instructions?.includes(languageSentence(l)))
}
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'
return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic }
return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic, language: languageOfAgent(agent) }
}
if (agent.content_goal?.enabled && agent.material_outputs[0]) {
// " per hari" comes from schedules created before names were in English.
@@ -69,7 +90,7 @@ export function ruleOf(agent: ExpressAgent): Rule {
// Audio and SCORM are Express kinds made of Compose outputs, so the name carries the kind.
const named = agent.name.match(/^Express · \d+ (\w+) per /)?.[1]
const kind = isKind(named) ? named : agent.material_outputs[0].kind
return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic }
return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic, language: languageOfAgent(agent) }
}
const s = agent.run_schedule ?? { cadence: 'off' }
return { type: 'research', cadence: s.cadence === 'weekly' ? 'weekly' : 'daily', weekday: s.weekday ?? 0, topic, autoApprove: agent.auto_approve_sources }
@@ -89,6 +110,15 @@ export const listSchedules = () => expressAgents()
/** 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 }
@@ -135,7 +165,8 @@ function outputsFor(kind: ContentKind): ExpressAgent['material_outputs'] {
return [{ kind, options: {} }]
}
// The agent's brief is in English; the content language follows the UI language at creation time.
// The agent's brief is in English; the content language is the one chosen in the Instruction chat.
// The engine's content production has no language field, so it is part of the instructions.
function goalText(rule: Rule): string {
const about = rule.topic ? ` about ${rule.topic}` : ''
if (rule.type === 'research') {
@@ -144,18 +175,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 {
const language = getLang() === 'id' ? 'Write all content in Bahasa Indonesia.' : 'Write all content in English.'
// 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,
@@ -178,7 +214,7 @@ export function configFor(rule: Rule, videoCredits = 0) {
max_scenes: VIDEO.scenes,
max_duration_seconds: VIDEO.seconds,
art_style: 'realistic',
language: getLang() === 'id' ? 'Bahasa Indonesia' : 'English',
language: rule.language ?? defaultLanguage(),
} satisfies VideoOptions,
run_schedule: { cadence: videoCadence(rule) ?? 'weekly', hour: 9, minute: 0, weekday: 0 } satisfies RunSchedule,
}
@@ -210,21 +246,23 @@ 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 } })
// The production corpus is the agent's own sources: the chosen documents, or every ready Brain document.
async function attachBrain(agentId: number, chosen?: number[]) {
const ids = chosen ?? (await listDocuments()).filter(d => d.knowledge === 'ready').map(d => d.docId)
for (const id of ids) {
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } })
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
}
return docs.length
return ids.length
}
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
/** With `chosen` document ids the schedule uses only those; without, the whole Brain. */
export async function createSchedule(rule: Rule, chosen?: number[]): 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 attachBrain(agent.id, only)
// Activating approves the production rules as they stand; until then nothing runs.
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
}
@@ -232,15 +270,17 @@ export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
/** Changing a rule changes what was approved, so the agent is re-activated right after. */
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
const rule = { ...schedule.rule, topic: topic.trim() }
const { name, goal, instructions } = configFor(rule)
const chosen = usesChosen(schedule.agent)
const { name, goal, instructions } = configFor(rule, 0, chosen)
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
await attachBrain(schedule.agent.id)
if (!chosen) await attachBrain(schedule.agent.id)
return request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
}
export const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
export async function resumeSchedule(id: number) {
await attachBrain(id)
export async function resumeSchedule(schedule: Schedule) {
const id = schedule.agent.id
if (!usesChosen(schedule.agent)) await attachBrain(id)
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
}
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })
@@ -324,8 +364,22 @@ export interface RunSummary {
trigger_kind: string
safe_error_code: string | null
budget_spent: number | null
has_readiness_issues: boolean
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`)

View File

@@ -7,16 +7,20 @@ export const LANGS: { code: Lang; label: string }[] = [{ code: 'en', label: 'EN'
const en = {
// Layout
'nav.brain': 'Brain',
'nav.instruction': 'Instruction',
'nav.brain': 'Upload material',
'nav.instruction': 'Brain and AI',
'nav.schedule': 'Schedule',
'nav.results': 'Results',
'nav.workspace': 'WORKSPACE',
'nav.dashboard': 'Dashboard',
'nav.settings': 'Settings',
'nav.production': 'Production',
'layout.learningSpace': 'Learning space',
'layout.switchOrg': 'Switch organization',
'layout.breadcrumb': 'Workspace',
'layout.signOut': 'Sign out',
'layout.engine': 'learning-lab engine',
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
'layout.creditsTitle': 'Workspace credit balance',
'layout.toStudio': 'Switch to Learning Lab',
'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation',
'layout.language': 'Language',
@@ -42,9 +46,52 @@ const en = {
'common.retry': 'Try again',
'common.credits': '{n} credits',
// Production
'prod.title': 'Production',
'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.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.nodes': 'Nodes',
'prod.nodesSub': 'in this Brain',
'prod.links': 'Links',
'prod.linksSub': 'connections between nodes',
'prod.scheduleTitle': 'Schedule',
'prod.noSchedules': 'No schedules yet.',
'prod.giveInstruction': 'Give an instruction',
'prod.nowTitle': 'Happening now',
'prod.nothingNow': 'Nothing is being made right now.',
'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 AI creates content from.',
'brain.metricMaterial': 'Material',
'brain.metricMaterialSub': 'documents in the workspace',
'brain.metricReady': 'In the Brain',
@@ -79,20 +126,22 @@ 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 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',
'brain.delete': 'Delete {name}',
'brain.open': 'Open {name}',
'brain.deleteConfirm': 'Delete “{name}” from the Brain?',
// 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.cardTitle': 'What should be created?',
'ins.title': 'Brain and AI',
'ins.description': 'Tell the AI 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.placeholder': 'Example: make 3 quizzes per week and 2 summaries per day about leadership',
'ins.hint': 'Name the content type, how many, how often (per day / per week), and the topic. Internet research can be scheduled too.',
'ins.compose': 'Build schedule',
'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',
@@ -100,9 +149,6 @@ const en = {
'ins.ex5': '1 video per week, 2 podcasts per week and 1 SCORM module per week about onboarding',
'ins.previewTitle': 'Schedules to create',
'ins.readyCount': '{n} ready',
'ins.notUnderstood': 'Instruction not understood',
'ins.notUnderstoodCopy': 'Try naming the content type and how often, e.g. “2 summaries per day”.',
'ins.skipped': 'Not understood and skipped: {parts}',
'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.',
'ins.approve': 'Approve {n} schedules',
'ins.approving': 'Creating schedules…',
@@ -134,6 +180,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',
@@ -198,6 +248,33 @@ 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.',
'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',
@@ -235,7 +312,6 @@ const en = {
'res.sourceCount': '{n} sources',
'res.sourceOne': '1 source',
'res.working': 'Being created…',
'res.open': 'Open',
'res.close': 'Close',
'res.reviewNotice': 'AI-generated draft. Check it, then approve it or reject it.',
'res.noContent': 'No content to show',
@@ -274,6 +350,63 @@ const en = {
'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',
'agent.name': 'Express Agent',
'agent.status': 'Asks when something is unclear',
'agent.statusThinking': 'Thinking…',
'agent.think1': 'Reading your message',
'agent.think2': 'Checking the plan',
'agent.think3': 'Working out what is still unclear',
'agent.thought': 'Thought for {s}s',
'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. I can make summaries, quizzes, flashcards, roleplays, simulations, slides, videos, audio and SCORM 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?',
'agent.askTooMany': '{rule} is more than 50 per week, the limit for one schedule. How many per week should it be?',
'agent.askWeekday': 'Weekly internet research{about}: which day should it run? It is set to Monday for now.',
'agent.askTopic': 'What topic should this be about?\n{list}\nType a topic, or use the whole Brain.',
'agent.ready': 'Everything is clear: {n} schedules are ready. You can still change them in the plan. Shall I start them?',
'agent.readyLanguage': 'Everything is clear: {n} schedules are ready, with content written in {language}. You can still change them in the plan. Shall I start them?',
'agent.askLanguage': 'Which language should the content be written in?',
'agent.languageSet': 'Content will be written in {language}.',
'ins.language': 'Content language',
'ins.languagePending': 'Not chosen yet',
'agent.skipped': 'OK, I skipped “{part}”.',
'agent.removed': 'Removed {rule}.',
'agent.updated': 'Updated: {rule}.',
'agent.topicSet': 'Topic set to “{topic}”.',
'agent.wholeBrain': 'OK, the whole Brain it is.',
'agent.reset': 'Starting over. What should I create?',
'agent.approving': 'Creating the schedules…',
'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',
'agent.optRemove': 'Remove it',
'agent.optWholeBrain': 'Whole Brain',
'agent.optApprove': 'Yes, start them',
'agent.optReset': 'Start over',
'agent.optPerWeek': '1 per week',
'agent.optPerDay': '1 per day',
'agent.opt50': '50 per week',
'agent.placeholder': 'Reply to the agent or give a new instruction…',
'agent.send': 'Send',
'agent.hint': 'Enter to send, Shift+Enter for a new line.',
'agent.planEmpty': 'The plan fills in as you chat with the agent.',
'sch.nextVideo': 'Next video: {when}',
'sch.videoNow': 'Make a video now',
'sch.videoStarted': 'Video started. It shows in Results while it is being made.',
@@ -320,16 +453,36 @@ 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 AI 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.subtitle': 'Upload material → Brain and AI → Schedule → Results',
'help.brainTitle': 'Upload material',
'help.brainBody': 'Upload your training material. Once its knowledge is built, the AI creates everything from it.',
'help.instructionTitle': 'Instruction',
'help.instructionTitle': 'Brain and AI',
'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.',
@@ -351,16 +504,61 @@ const en = {
export type Key = keyof typeof en
const id: Record<Key, string> = {
'nav.brain': 'Otak',
'nav.instruction': 'Perintah',
'prod.title': 'Produksi',
'prod.description': 'Pengetahuan yang terhubung. Pembelajaran yang terus tumbuh.',
'prod.unitMaterial': 'materi',
'prod.unitMaterialOne': 'materi',
'prod.unitContent': 'konten',
'prod.metricMaterial': 'Total materi di Brain',
'prod.metricMaterialSub': 'Sumber pengetahuan tim Anda',
'prod.metricMade': 'Semua konten dibuat',
'prod.metricMadeSub': 'Seluruh hasil pembuatan konten',
'prod.metricLms': 'Sudah diupload ke LMS',
'prod.metricLmsSub': 'Status upload pada demo ini',
'prod.activityTitle': 'Aktivitas konten',
'prod.activityDescription': 'Dari pengetahuan di Brain menjadi pembelajaran baru.',
'prod.activityAll': 'Lihat semua hasil konten',
'prod.activityLatest': 'Konten terbaru',
'prod.activityWorking': 'Konten in progress',
'prod.activityNoneLatest': 'Belum ada konten yang dibuat.',
'prod.activityNoneWorking': 'Tidak ada konten yang sedang dibuat.',
'prod.eyebrow': 'Workspace overview',
'prod.welcome': 'Selamat datang',
'prod.welcomeName': 'Selamat datang, {name}',
'prod.newContent': 'Buat konten baru',
'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.nodesSub': 'di Otak ini',
'prod.links': 'Tautan',
'prod.linksSub': 'koneksi antar node',
'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': 'Upload materi',
'nav.instruction': 'Brain and AI',
'nav.schedule': 'Jadwal',
'nav.results': 'Hasil',
'nav.workspace': 'RUANG KERJA',
'nav.dashboard': 'Dasbor',
'nav.settings': 'Pengaturan',
'nav.production': 'Produksi',
'layout.learningSpace': 'Ruang belajar',
'layout.switchOrg': 'Ganti organisasi',
'layout.breadcrumb': 'Ruang kerja',
'layout.signOut': 'Keluar',
'layout.engine': 'Engine learning-lab',
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
'layout.creditsTitle': 'Saldo kredit workspace',
'layout.toStudio': 'Beralih ke Learning Lab',
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama',
'layout.language': 'Bahasa',
@@ -384,8 +582,8 @@ 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 AI membuat konten.',
'brain.metricMaterial': 'Materi',
'brain.metricMaterialSub': 'dokumen di ruang kerja',
'brain.metricReady': 'Masuk Otak',
@@ -420,19 +618,21 @@ 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 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',
'brain.delete': 'Hapus {name}',
'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.cardTitle': 'Apa yang perlu dibuat?',
'ins.title': 'Brain and AI',
'ins.description': 'Beri tahu AI apa yang perlu dibuat dari Otak. AI menyusun instruksi Anda menjadi jadwal riset dan pembuatan konten yang terus berjalan sampai Anda menghentikannya.',
'ins.label': 'Instruksi',
'ins.placeholder': 'Contoh: bikin 3 kuis per minggu dan 2 ringkasan per hari soal leadership',
'ins.hint': 'Sebut jenis konten, jumlah, frekuensi (per hari / per minggu), dan topiknya. Riset internet juga bisa dijadwalkan.',
'ins.compose': 'Susun jadwal',
'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',
@@ -440,9 +640,6 @@ const id: Record<Key, string> = {
'ins.ex5': '1 video per minggu, 2 podcast per minggu dan 1 modul SCORM per minggu soal onboarding',
'ins.previewTitle': 'Jadwal yang akan dibuat',
'ins.readyCount': '{n} siap',
'ins.notUnderstood': 'Instruksi belum dipahami',
'ins.notUnderstoodCopy': 'Coba sebut jenis konten dan frekuensinya, misalnya “2 ringkasan per hari”.',
'ins.skipped': 'Bagian ini belum dipahami dan dilewati: {parts}',
'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.',
'ins.approve': 'Setujui {n} jadwal',
'ins.approving': 'Membuat jadwal…',
@@ -473,6 +670,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',
@@ -536,6 +737,33 @@ 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': 'Run ini melebihi batas kredit per run.',
'runErr.monthly_cap': 'Batas kredit bulanan jadwal ini sudah tercapai.',
'runErr.reservation_cap': 'Terlalu banyak kredit sudah dipesan oleh run 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.',
'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': 'knowledge graph masih dibangun',
'issue.graph_not_ready': 'knowledge graph 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': 'graph Compose belum disiapkan',
'res.title': 'Hasil',
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
@@ -572,7 +800,6 @@ const id: Record<Key, string> = {
'res.sourceCount': '{n} sumber',
'res.sourceOne': '1 sumber',
'res.working': 'Sedang dibuat…',
'res.open': 'Buka',
'res.close': 'Tutup',
'res.reviewNotice': 'Draft buatan AI. Periksa dulu, lalu setujui atau tolak.',
'res.noContent': 'Tidak ada konten untuk ditampilkan',
@@ -611,6 +838,63 @@ const id: Record<Key, string> = {
'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',
'agent.name': 'Agen Express',
'agent.status': 'Bertanya bila ada yang belum jelas',
'agent.statusThinking': 'Sedang berpikir…',
'agent.think1': 'Membaca pesan Anda',
'agent.think2': 'Memeriksa rencana',
'agent.think3': 'Mencari yang masih belum jelas',
'agent.thought': 'Berpikir {s} detik',
'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. Saya bisa membuat ringkasan, kuis, flashcard, roleplay, simulasi, slide, video, audio, dan modul SCORM 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?',
'agent.askTooMany': '{rule} lebih dari 50 per minggu, batas untuk satu jadwal. Berapa per minggu?',
'agent.askWeekday': 'Riset internet mingguan{about}: dijalankan hari apa? Sementara diatur ke Senin.',
'agent.askTopic': 'Topiknya tentang apa?\n{list}\nTulis topiknya, atau pakai seluruh Otak.',
'agent.ready': 'Semua sudah jelas: {n} jadwal siap. Anda masih bisa mengubahnya di rencana. Mulai sekarang?',
'agent.readyLanguage': 'Semua sudah jelas: {n} jadwal siap, dengan konten berbahasa {language}. Anda masih bisa mengubahnya di rencana. Mulai sekarang?',
'agent.askLanguage': 'Kontennya mau ditulis dalam bahasa apa?',
'agent.languageSet': 'Konten akan ditulis dalam {language}.',
'ins.language': 'Bahasa konten',
'ins.languagePending': 'Belum dipilih',
'agent.skipped': 'Oke, “{part}” saya lewati.',
'agent.removed': '{rule} dihapus.',
'agent.updated': 'Diperbarui: {rule}.',
'agent.topicSet': 'Topik diatur ke “{topic}”.',
'agent.wholeBrain': 'Oke, pakai seluruh Otak.',
'agent.reset': 'Mulai dari awal. Apa yang perlu dibuat?',
'agent.approving': 'Membuat jadwal…',
'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',
'agent.optRemove': 'Hapus saja',
'agent.optWholeBrain': 'Seluruh Otak',
'agent.optApprove': 'Ya, mulai',
'agent.optReset': 'Mulai ulang',
'agent.optPerWeek': '1 per minggu',
'agent.optPerDay': '1 per hari',
'agent.opt50': '50 per minggu',
'agent.placeholder': 'Balas agen atau beri instruksi baru…',
'agent.send': 'Kirim',
'agent.hint': 'Enter untuk kirim, Shift+Enter untuk baris baru.',
'agent.planEmpty': 'Rencana terisi seiring Anda mengobrol dengan agen.',
'sch.nextVideo': 'Video berikutnya: {when}',
'sch.videoNow': 'Buat video sekarang',
'sch.videoStarted': 'Video mulai dibuat. Video tampil di Hasil selama proses berjalan.',
@@ -657,16 +941,36 @@ 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': 'Brain',
'brainPanel.count': '{n} materi',
'brainPanel.copy': 'Knowledge base 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 AI. Kosongkan pilihan untuk memakai seluruh Brain.',
'brainPanel.selectAll': 'Pilih semua',
'brainPanel.nChosen': '{n} materi pilihan',
'ins.topicPlaceholderChosen': 'Topik (kosong = semua materi pilihan)',
'brainPanel.selected': '{n} dipilih',
'brainPanel.wholeBrain': 'Seluruh Brain digunakan',
'brainPanel.select': 'Pilih {name}',
'brainPanel.notReady': 'Bisa dipilih setelah masuk Brain',
'ins.sourcesChosen': 'Sumber: {n} materi dipilih',
'ins.sourcesAll': 'Sumber: seluruh Brain',
'ins.tooFewChosen': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi. Pilih satu lagi di panel Brain.',
'ins.tooFewInBrain': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi siap di Brain. 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': 'Upload 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.subtitle': 'Upload materi → Brain and AI → Jadwal → Hasil',
'help.brainTitle': 'Upload materi',
'help.brainBody': 'Unggah materi pelatihan. Setelah pengetahuannya dibangun, AI membuat semua konten dari situ.',
'help.instructionTitle': 'Perintah',
'help.instructionTitle': 'Brain and AI',
'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.',
@@ -696,6 +1000,8 @@ function initial(): Lang {
}
let lang: Lang = initial()
// The language of the text being built right now, when it differs from the UI (see inLang).
let override: Lang | null = null
const listeners = new Set<() => void>()
if (typeof document !== 'undefined') document.documentElement.lang = lang
@@ -710,10 +1016,17 @@ export function setLang(next: Lang) {
}
export function t(key: Key, vars?: Record<string, string | number>): string {
const text = dictionaries[lang][key] ?? en[key]
const text = dictionaries[override ?? lang][key] ?? en[key]
return vars ? text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : text
}
/** Runs fn with t() in another language, e.g. the agent answering in the language the user writes in. */
export function inLang<T>(l: Lang, fn: () => T): T {
const previous = override
override = l
try { return fn() } finally { override = previous }
}
/** Re-renders the component when the language changes; returns the active language. */
export function useLang(): Lang {
return useSyncExternalStore(cb => { listeners.add(cb); return () => listeners.delete(cb) }, getLang)

View File

@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/react'
import { enUS, idID } from '@clerk/localizations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { EngineError } from './api/client'
import App from './App'
@@ -16,7 +16,9 @@ if (!clerkPubKey) {
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)')
}
const queryClient = new QueryClient({
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'] }) }),
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) },

View File

@@ -1,43 +1,42 @@
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, 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 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') ? 3000 : 20_000),
})
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()
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>
@@ -50,6 +49,77 @@ export function BrainPage() {
)
}
/** 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
@@ -115,7 +185,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>
@@ -154,7 +224,7 @@ function DocumentRow({ doc }: { doc: Source }) {
<div className="result-row">
<span className={`file-icon ${doc.format.toLowerCase()}`}>{doc.format.slice(0, 4)}</span>
<div style={{ minWidth: 0, flex: 1 }}>
<b>{doc.title}</b>
<OpenButton doc={doc} />
<div><small>{new Date(doc.createdAt).toLocaleDateString(locale())}</small></div>
{phase === 'working' && <Progress doc={doc} />}
{(phase === 'answer' || phase === 'failed') && <KnowledgeAction doc={doc} />}
@@ -165,13 +235,51 @@ function DocumentRow({ doc }: { doc: Source }) {
)
}
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
const label = {
// Formats a browser can show in a tab; anything else is saved under its original name.
const VIEWABLE = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'txt', 'md', 'csv', 'mp3', 'wav', 'm4a', 'ogg', 'mp4', 'webm', 'mov'])
function OpenButton({ doc }: { doc: Source }) {
const [busy, setBusy] = useState(false)
async function open() {
const inTab = VIEWABLE.has(doc.format.toLowerCase())
// Opened before the fetch: a tab opened after an await counts as a popup and is blocked.
const tab = inTab ? window.open('', '_blank') : null
setBusy(true)
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()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (e) {
tab?.close()
toast((e as Error).message, { error: true })
} finally {
setBusy(false)
}
}
return (
<button className="doc-title" disabled={busy} title={t('brain.open', { name: doc.originalName })} onClick={open}>
{doc.title}
</button>
)
}
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>
}
@@ -192,8 +300,26 @@ function KnowledgeAction({ doc }: { doc: Source }) {
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>
@@ -216,6 +342,10 @@ function KnowledgeAction({ doc }: { doc: Source }) {
text = t('brain.needsCredits', { need: credits ? t('brain.needsCreditsNeed', { credits }) : '' })
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
break
case 'ready':
// 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')
if (d.quote) actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('brain.retryWith', { credits: credits ?? '' })}</button>

View File

@@ -1,129 +1,295 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
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 { BrainPanel, choosable, useDocuments } from './Brain'
import {
dayName, describeRule, kindLabel, KINDS, monthNote, parseInstruction, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type ResearchRule, type Rule,
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
} from '../express/parse'
import { t, type Key } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
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, 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']
// How the kinds that are not a plain Compose output are made, shown under the rule.
const KIND_NOTE: Partial<Record<ContentKind, Key>> = { video: 'ins.noteVideo', audio: 'ins.noteAudio', scorm: 'ins.noteScorm' }
type Outcome = { rule: Rule; ok: boolean; message?: string }
type Message = { role: 'agent' | 'user'; text: string; outcomes?: Outcome[]; thought?: number; typed?: boolean }
// The agent's pace: it thinks before replying, types its reply out, and pauses before the next question.
// Users who ask for reduced motion get the replies at once.
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
const thinkFor = (input: string) => (reducedMotion() ? 250 : Math.min(2200, 900 + input.length * 12) + Math.random() * 400)
const PAUSE = 650
type Phase = 'idle' | 'thinking' | 'replying' | 'pausing'
export function InstructionPage() {
const queryClient = useQueryClient()
const uiLang = useLang()
const [text, setText] = useState('')
const [rules, setRules] = useState<Rule[] | null>(null)
const [unknown, setUnknown] = useState<string[]>([])
const [agent, setAgent] = useState<AgentState>(EMPTY)
const [messages, setMessages] = useState<Message[]>([])
const [saving, setSaving] = useState(false)
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null)
// Brain material the schedules are made from; none chosen means the whole Brain.
const [selected, setSelected] = useState<Set<number>>(new Set())
const docs = useDocuments()
// A chosen document that was deleted, or failed since, no longer counts.
const chosenDocs = choosable(docs.data).filter(d => selected.has(d.docId))
const chosen = chosenDocs.map(d => d.docId)
// How the chat and the plan name the chosen material: by title when there is one.
const sources = chosenDocs.length === 1 ? `“${chosenDocs[0].title}”` : chosenDocs.length ? t('brainPanel.nChosen', { n: chosenDocs.length }) : null
const [phase, setPhase] = useState<Phase>('idle')
// The live question whose typing has finished, so its reply options can show.
const [typedLive, setTypedLive] = useState('')
// The agent answers in the language the user writes in; until they write, in the UI language.
const [chatLang, setChatLang] = useState<Lang | null>(null)
const lang = chatLang ?? uiLang
const chat = useRef<HTMLDivElement>(null)
const timers = useRef<number[]>([])
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
useEffect(() => () => timers.current.forEach(clearTimeout), [])
function compose() {
const result = parseInstruction(text)
setRules(result.rules)
setUnknown(result.unknown)
setOutcomes(null)
// The agent sees the current choice, so ticking material in the Brain panel updates its question and plan.
const view: AgentState = { ...agent, sources }
const question = nextQuestion(view)
const ready = agent.items.filter(i => canSchedule(i.rule))
// Without a choice the whole Brain is attached, so every ready document counts.
const sourceCount = chosen.length || choosable(docs.data).length
const tooFewSources = sourceCount < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
const hasContent = agent.items.some(i => i.rule.type === 'content')
// 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[] }
: question
? ask(question, view)
: agent.items.length
? {
text: hasContent && agent.language
? t('agent.readyLanguage', { n: ready.length, language: languageLabel(agent.language) })
: t('agent.ready', { n: ready.length }),
options: [t('agent.optApprove'), t('agent.optReset')] }
// The greeting comes once; after that the examples alone invite the next request.
: { text: messages.length ? '' : t('agent.greeting'), options: EXAMPLES.map(k => t(k)) })
const idle = phase === 'idle'
const showOptions = idle && !saving && live.options.length > 0 && (!live.text || typedLive === live.text)
// Scrolls only the chat, so the page itself does not jump while the user is reading the plan.
// A block body: newer browsers return a promise from scrollTo, which an effect must not return.
const scroll = () => { chat.current?.scrollTo({ top: chat.current.scrollHeight, behavior: 'smooth' }) }
useEffect(scroll, [messages.length, live.text, phase, showOptions])
function send(input: string) {
if (!input.trim() || saving || !idle) return
// A picked option is in the conversation's language already; typed text may switch it.
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
setChatLang(replyLang)
const turn = inLang(replyLang, () => respond(view, input))
// The question being answered is kept in the history, with the reply after it.
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
setText('')
setPhase('thinking')
const ms = thinkFor(input)
later(() => {
setAgent(turn.state)
if (turn.approve) { setPhase('idle'); void approve(turn.state, replyLang); return }
setMessages(m => [...m, { role: 'agent', text: turn.reply, thought: Math.max(1, Math.round(ms / 1000)), typed: true }])
setPhase('replying')
}, ms)
}
// A count or frequency typed in by hand replaces the monthly request it was derived from.
const update = (i: number, patch: Partial<ContentRule> | Partial<ResearchRule>) =>
setRules(rs => rs!.map((r, j) => (j === i
? ({ ...r, ...patch, ...('count' in patch || 'per' in patch ? { monthly: undefined } : {}) } as Rule)
: r)))
const remove = (i: number) => setRules(rs => rs!.filter((_, j) => j !== i))
// After a reply is typed out, a short pause before the next question appears.
function replied() {
setPhase('pausing')
later(() => setPhase('idle'), reducedMotion() ? 0 : PAUSE)
}
const ready = rules?.filter(canSchedule) ?? []
async function approve() {
async function approve(state = agent, replyLang = lang) {
const items = state.items.filter(i => canSchedule(i.rule))
if (!items.length || tooFewSources) 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 rule of ready) {
for (const { id, rule } of items) {
try {
await createSchedule(rule)
done.push({ rule, ok: true })
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen)
done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : rule, ok: true })
created.add(id)
} catch (e) {
done.push({ rule, ok: false, message: (e as Error).message })
}
setOutcomes([...done])
}
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
if (done.every(o => o.ok)) { setRules(null); setText('') }
// 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 }])
setPhase('replying')
// Only the failed rules stay, so approving again does not create the others twice.
else setRules(rs => rs!.filter(r => !done.some(o => o.ok && o.rule === r)))
setAgent(s => (ok === done.length ? EMPTY : { ...s, items: s.items.filter(i => !created.has(i.id)) }))
}
return (
<>
<PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="card">
<div className="card-head"><h3>{t('ins.cardTitle')}</h3><Icon name="spark" /></div>
<div style={{ padding: '20px 22px 8px' }}>
<label htmlFor="instruction">{t('ins.label')}</label>
<textarea id="instruction" rows={3} value={text} placeholder={t('ins.placeholder')}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && text.trim()) compose() }} />
<div className="ai-compose-footer">
<small>{t('ins.hint')}</small>
<button className="btn primary" disabled={!text.trim()} onClick={compose}><Icon name="calendar" />{t('ins.compose')}</button>
<div className="unified-workspace">
<BrainPanel selected={selected} onSelect={setSelected} />
<div className="ai-column">
<div className="card agent-chat">
<div className="card-head">
<div className="assistant-title">
<span><Icon name="spark" /></span>
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
</div>
</div>
<div className="ai-messages" aria-live="polite" ref={chat}>
{messages.map((m, i) => (
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
))}
{phase === 'thinking' && <Thinking lang={lang} />}
{phase === 'pausing' && <Typing />}
{idle && live.text && (
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
onTyped={() => setTypedLive(live.text)} onType={scroll} />
)}
</div>
{showOptions && (
<div className="prompt-chips appear">
{live.options.map((o, i) => (
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
))}
</div>
)}
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<div className="ai-compose-footer">
<small>{t('agent.hint')}</small>
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
</div>
</form>
</div>
<div className="card">
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
{hasContent && (
<label className="plan-language">
<span>{t('ins.language')}</span>
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
</select>
</label>
)}
<div className="rule-list">
{agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))}
</div>
{ready.length > 0 && (
<div className="rule-approve">
<small>
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
{t('ins.approveNote')}
{tooFewSources && <span className="error">{t(chosen.length ? 'ins.tooFewChosen' : 'ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
</small>
<button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
)}
</div>
</div>
<div className="prompt-chips" style={{ paddingTop: 12 }}>
{EXAMPLES.map(key => <button key={key} onClick={() => { setText(t(key)); setRules(null); setOutcomes(null) }}>{t(key)}</button>)}
</div>
</div>
{rules && (
<div className="card" style={{ marginTop: 22 }}>
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{rules.length === 0 && (
<div className="empty"><h3>{t('ins.notUnderstood')}</h3><p>{t('ins.notUnderstoodCopy')}</p></div>
)}
<div className="rule-list">
{rules.map((rule, i) => <RuleCard key={i} rule={rule} onChange={p => update(i, p)} onRemove={() => remove(i)} />)}
</div>
{unknown.length > 0 && (
<div className="notice-row" style={{ margin: '0 22px 18px' }}>
<Icon name="info" /><span>{t('ins.skipped', { parts: unknown.map(u => `“${u}”`).join(', ') })}</span>
</div>
)}
{ready.length > 0 && (
<div className="rule-approve">
<small>{t('ins.approveNote')}</small>
<button className="btn primary" disabled={saving} onClick={approve}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
)}
{outcomes && (
<ul className="rule-outcomes">
{outcomes.map((o, i) => (
<li key={i}>
<span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span>
<span>{describeRule(o.rule)}</span>
{o.message && <span className="error">{o.message}</span>}
</li>
))}
{outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
</ul>
)}
</div>
)}
</>
)
}
function RuleCard({ rule, onChange, onRemove }: {
// Types the text out over at most ~1.2 s, then calls onDone once.
function useTyped(text: string, enabled: boolean, onDone?: () => void, onType?: () => void) {
const [shown, setShown] = useState(enabled && !reducedMotion() ? 0 : text.length)
const done = useRef(onDone)
done.current = onDone
const step = useRef(onType)
step.current = onType
useEffect(() => {
if (shown >= text.length) { done.current?.(); return }
const perTick = Math.max(1, Math.ceil(text.length / 60))
const id = window.setTimeout(() => { setShown(n => Math.min(text.length, n + perTick)); step.current?.() }, 20)
return () => clearTimeout(id)
}, [shown, text.length])
return text.slice(0, shown)
}
function Bubble({ message, lang, busy, onTyped, onType }: {
message: Message; lang: Lang; busy?: boolean; onTyped?: () => void; onType?: () => void
}) {
const text = useTyped(message.text, !!message.typed, onTyped, onType)
const typing = text.length < message.text.length
return (
<div className={`ai-message${message.role === 'user' ? ' user' : ''}`}>
{message.role === 'agent' && <span>{busy ? <i className="spinner" /> : <Icon name="spark" />}</span>}
<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>
{message.outcomes && !typing && (
<ul className="rule-outcomes appear">
{message.outcomes.map((o, i) => (
<li key={i}>
<span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span>
<span>{describeRule(o.rule)}</span>
{o.message && <span className="error">{o.message}</span>}
</li>
))}
{message.outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
</ul>
)}
</div>
</div>
)
}
// While the agent thinks: what it is doing, a step at a time.
function Thinking({ lang }: { lang: Lang }) {
const [step, setStep] = useState(0)
useEffect(() => {
const id = window.setInterval(() => setStep(n => Math.min(n + 1, THINKING.length - 1)), 700)
return () => clearInterval(id)
}, [])
return (
<div className="ai-message thinking" role="status">
<span><i className="spinner" /></span>
<div className="ai-bubble"><p>{inLang(lang, () => t(THINKING[step]))}<i className="dots"><b /><b /><b /></i></p></div>
</div>
)
}
// The short pause before the next question, like someone typing.
function Typing() {
return (
<div className="ai-message thinking" aria-hidden="true">
<span><Icon name="spark" /></span>
<div className="ai-bubble"><p><i className="dots"><b /><b /><b /></i></p></div>
</div>
)
}
function RuleCard({ rule, from, onChange, onRemove }: {
rule: Rule
from?: string
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
onRemove: () => void
}) {
@@ -138,7 +304,7 @@ 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' ? (
<>
@@ -165,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' && (

289
src/pages/Production.tsx Normal file
View File

@@ -0,0 +1,289 @@
import { 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, SkeletonCards } from '../components/Page'
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
import { describeRule } from '../express/parse'
import { isWorking, listResults, materialKey, 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',
}
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
// 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,
// Polled more often than on Results so a run that just started shows up under content in progress.
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 20_000),
})
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const materials = results.data?.materials ?? []
const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
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],
)
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 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="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
</>} />
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
{graph
? <KnowledgeGraph graph={graph} />
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}
<aside className="graph-panel">
<div className="graph-stats">
<div><small>{t('prod.nodes')}</small>{graph ? <b>{graph.nodes.filter(n => !n.hub).length}</b> : <b>-</b>}<span>{t('prod.nodesSub')}</span></div>
<div><small>{t('prod.links')}</small>{graph ? <b>{graph.links.filter(l => !l.hub).length}</b> : <b>-</b>}<span>{t('prod.linksSub')}</span></div>
</div>
<h3>{t('prod.scheduleTitle')}</h3>
{schedules.data?.length === 0 && <p className="graph-empty">{t('prod.noSchedules')} <Link to="/brain">{t('prod.giveInstruction')}</Link></p>}
<ul className="graph-list">
{schedules.data?.slice(0, 4).map(s => (
<li key={s.agent.id}>
<time>{s.active && s.agent.next_run_at ? when(s.agent.next_run_at) : '-'}</time>
<span>{describeRule(s.rule)}</span>
<em className={s.active ? 'on' : undefined}>{t(s.active ? 'sch.active' : 'sch.paused')}</em>
</li>
))}
</ul>
<h3>{t('prod.nowTitle')}</h3>
{results.data && working.length === 0 && <p className="graph-empty">{t('prod.nothingNow')}</p>}
<ul className="graph-list now">
{working.slice(0, 6).map(m => (
<li key={`${m.kind}-${m.agentId}-${m.id}`}>
<i className="node-dot working" />
<span>{titleOf(m)}</span>
<em>{t(`res.state.${m.status as 'preparing' | 'generating'}`)}</em>
</li>
))}
</ul>
{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>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(materials.filter(m => m.status !== 'failed').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">{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 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) {
const mx = (ax + bx) / 2, my = (ay + by) / 2
return { cx: mx - (by - ay) * bend, cy: my + (bx - ax) * bend }
}
function KnowledgeGraph({ graph }: { graph: Graph }) {
const [selected, setSelected] = useState<GraphNode | null>(null)
const [hover, setHover] = useState<string | null>(null)
const nodeEls = useRef<(SVGGElement | null)[]>([])
const linkEls = useRef<(SVGPathElement | null)[]>([])
const particleEls = useRef<(SVGCircleElement | null)[]>([])
const index = useMemo(() => new Map(graph.nodes.map((n, i) => [n.id, i])), [graph])
const pairs = useMemo(() => graph.links.map((l, i) => ({
a: index.get(l.source)!, b: index.get(l.target)!, hub: l.hub, bend: l.hub ? 0.08 * (i % 2 ? 1 : -1) : 0.18 * (i % 2 ? 1 : -1),
})), [graph, index])
const particles = useMemo(() => pairs.map((p, i) => ({ ...p, link: i })).filter(p => !p.hub).slice(0, MAX_PARTICLES), [pairs])
const degree = useMemo(() => {
const d = new Map<string, number>()
for (const l of graph.links) if (!l.hub) { d.set(l.source, (d.get(l.source) ?? 0) + 1); d.set(l.target, (d.get(l.target) ?? 0) + 1) }
return d
}, [graph])
const stars = useMemo(() => Array.from({ length: 70 }, (_, i) => {
const r = (k: number) => (Math.sin(i * 127.1 + k * 311.7) * 43758.5453) % 1
return { x: Math.abs(r(1)) * graph.width, y: Math.abs(r(2)) * graph.height, s: 0.4 + Math.abs(r(3)) * 1.1, d: Math.abs(r(4)) * 6 }
}), [graph])
useLayoutEffect(() => {
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const hubOf = graph.nodes.map(n => index.get(`hub-${n.cluster}`)!)
const phase = graph.nodes.map((_, i) => i * 2.399)
const x = new Float64Array(graph.nodes.length)
const y = new Float64Array(graph.nodes.length)
const t0 = performance.now()
let frame = 0
const draw = (now: number) => {
const t = still ? 1e9 : now - t0
graph.nodes.forEach((n, i) => {
const hub = graph.nodes[hubOf[i]]
const grow = n.hub ? ease(t / 700) : ease((t - 250 - (i % 24) * 25) / 1100)
const drift = still ? 0 : (n.hub ? 0.4 : 1) * DRIFT
const k = t / 1000
x[i] = hub.x + (n.x - hub.x) * grow + Math.sin(k * 0.6 + phase[i]) * drift
y[i] = hub.y + (n.y - hub.y) * grow + Math.cos(k * 0.5 + phase[i] * 1.3) * drift
const el = nodeEls.current[i]
if (el) {
el.setAttribute('transform', `translate(${x[i].toFixed(1)} ${y[i].toFixed(1)})`)
el.style.opacity = n.hub ? '' : String(Math.min(1, grow * 1.4))
}
})
pairs.forEach((p, i) => {
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
linkEls.current[i]?.setAttribute('d', `M${x[p.a].toFixed(1)} ${y[p.a].toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x[p.b].toFixed(1)} ${y[p.b].toFixed(1)}`)
})
particles.forEach((p, i) => {
const el = particleEls.current[i]
if (!el) return
if (still) { el.style.display = 'none'; return }
const u = ((t / 1000) * (0.18 + (i % 5) * 0.03) + i * 0.37) % 1
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
const v = 1 - u
el.setAttribute('cx', (v * v * x[p.a] + 2 * v * u * cx + u * u * x[p.b]).toFixed(1))
el.setAttribute('cy', (v * v * y[p.a] + 2 * v * u * cy + u * u * y[p.b]).toFixed(1))
el.style.opacity = String(Math.sin(u * Math.PI) * Math.min(1, t / 1600))
})
if (!still) frame = requestAnimationFrame(draw)
}
draw(t0)
if (!still) frame = requestAnimationFrame(draw)
return () => cancelAnimationFrame(frame)
}, [graph, index, pairs, particles])
const focus = hover ?? selected?.id ?? null
const neighbours = useMemo(() => {
if (!focus) return null
const set = new Set([focus])
for (const l of graph.links) {
if (l.source === focus) set.add(l.target)
if (l.target === focus) set.add(l.source)
}
return set
}, [focus, graph])
const lit = (p: { a: number; b: number }) => neighbours?.has(graph.nodes[p.a].id) && neighbours.has(graph.nodes[p.b].id)
return (
<>
<div className="graph-note">
<i className="live-dot" />
{selected
? <><b>{selected.label}</b>{!selected.hub && <span>{t(HUB_LABEL[selected.cluster])}</span>}<button type="button" aria-label={t('res.close')} onClick={() => setSelected(null)}><Icon name="close" /></button></>
: <><b>{t('prod.graphTitle')}</b><span>{t('prod.clickNode')}</span></>}
</div>
<svg className={`graph-svg${neighbours ? ' focused' : ''}`} viewBox={`0 0 ${graph.width} ${graph.height}`} role="img"
aria-label={t('prod.graphLabel', { nodes: graph.nodes.length - CLUSTERS.length })} onClick={() => setSelected(null)}>
<defs>
<radialGradient id="graph-glow"><stop offset="0" stopColor="#2e90fa" stopOpacity=".32" /><stop offset=".5" stopColor="#6941c6" stopOpacity=".08" /><stop offset="1" stopColor="#2e90fa" stopOpacity="0" /></radialGradient>
<radialGradient id="graph-core"><stop offset="0" stopColor="#fff" /><stop offset=".55" stopColor="#fff" stopOpacity=".9" /><stop offset="1" stopColor="#fff" stopOpacity=".2" /></radialGradient>
<filter id="graph-bloom" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
</defs>
<g className="graph-stars">
{stars.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.s} style={{ animationDelay: `${s.d}s` }} />)}
</g>
<circle className="graph-glow" cx={500} cy={320} r={320} fill="url(#graph-glow)" />
<ellipse className="graph-orbit slow" cx={500} cy={320} rx={430} ry={275} />
<ellipse className="graph-orbit" cx={500} cy={320} rx={300} ry={195} />
<g className="graph-links">
{pairs.map((p, i) => (
<path key={i} ref={el => { linkEls.current[i] = el }} style={{ ['--c' as string]: `var(--g-${graph.nodes[p.a].cluster})` }}
className={`${p.hub ? 'hub' : 'cross'}${lit(p) ? ' lit' : neighbours ? ' dim' : ''}`} />
))}
</g>
<g className="graph-particles">
{particles.map((p, i) => (
<circle key={i} r={1.8} ref={el => { particleEls.current[i] = el }} className={`c-${graph.nodes[p.a].cluster}${neighbours && !lit(p) ? ' dim' : ''}`} />
))}
</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)
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' : ''}`}
onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)}
onClick={e => { e.stopPropagation(); setSelected(n) }}>
<circle className="hit" r={r + 8} />
{n.hub && <><circle className="ring" r={r + 4} /><circle className="ring late" r={r + 4} /><circle className="halo" r={26} /></>}
<circle className="core" r={r} filter="url(#graph-bloom)" />
{n.hub && <circle className="dot" r={r * 0.45} />}
<text y={-r - 8}>{n.label.length > 40 ? n.label.slice(0, 38) + '…' : n.label}</text>
<title>{n.label}</title>
</g>
)
})}
</g>
</svg>
<ul className="graph-legend">
{CLUSTERS.map(c => <li key={c}><i className={`node-dot ${c}`} />{t(HUB_LABEL[c])}</li>)}
</ul>
</>
)
}

View File

@@ -105,7 +105,7 @@ function NoResults({ filtered, icon }: { filtered: boolean; icon: IconName }) {
<span className="empty-icon"><Icon name={icon} /></span>
<h3>{t('res.empty')}</h3>
<p>{t('res.emptyCopy')}</p>
<Link className="btn light" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>
<Link className="btn light" to="/brain"><Icon name="plus" />{t('sch.newInstruction')}</Link>
</div>
)
}
@@ -116,7 +116,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">
@@ -132,10 +132,7 @@ function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () =
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
{working
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
: <div className="content-card-actions">
{m.retry && <RetryButton material={m} className="text-btn" />}
<button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>
</div>}
: m.retry && <div className="content-card-actions"><RetryButton material={m} className="text-btn" /></div>}
</div>
</div>
</article>
@@ -160,7 +157,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.
@@ -430,7 +427,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>

View File

@@ -7,16 +7,31 @@ 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, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, 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',
])
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
export function SchedulePage() {
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
@@ -24,7 +39,7 @@ export function SchedulePage() {
return (
<>
<PageHead title={t('sch.title')} description={t('sch.description')}
actions={<Link className="btn primary" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
actions={<Link className="btn primary" to="/brain"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
<div className="card">
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />}
@@ -48,7 +63,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({
@@ -186,7 +201,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>
@@ -206,17 +221,46 @@ function RunHistory({ agentId }: { agentId: number }) {
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.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>
)
}

View File

@@ -10,6 +10,9 @@
.doc-action-buttons{display:flex;gap:7px;flex-wrap:wrap}
.doc-action .error{margin:0;font-size:11px}
.result-row b{overflow-wrap:anywhere}
.doc-title{display:block;max-width:100%;border:0;padding:0;background:none;text-align:left;font:inherit;font-weight:700;color:var(--ink);overflow-wrap:anywhere;cursor:pointer}
.doc-title:hover{color:var(--blue);text-decoration:underline}
.doc-title:disabled{cursor:progress;color:#8b97a9}
.drop-overlay{position:fixed;inset:0;z-index:50;display:grid;place-items:center;background:#142d5059;backdrop-filter:blur(2px);pointer-events:none}
.drop-overlay>div{display:flex;flex-direction:column;align-items:center;gap:12px;padding:34px 46px;border:2px dashed var(--blue);border-radius:16px;background:white;color:var(--blue);font-size:14px}
.drop-overlay svg{width:34px;height:34px}
@@ -46,6 +49,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}
@@ -62,6 +69,11 @@ textarea{resize:vertical;line-height:1.6}
.content-title:disabled{cursor:default;color:#8b97a9}
.content-card-foot>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.content-card-actions{display:flex;gap:12px;flex-shrink:0}
/* The whole card opens the result: the title button's hit area stretches over it, Retry stays on top */
.content-card{position:relative}
.content-title:not(:disabled)::after{content:"";position:absolute;inset:0;border-radius:inherit}
.content-card:has(.content-title:not(:disabled)):hover .content-title{color:var(--blue)}
.content-card-actions{position:relative;z-index:1}
.research-row .status{margin-left:0}
.research-row h3 a{color:var(--ink)}
.research-row h3 a:hover{color:var(--blue)}
@@ -127,3 +139,71 @@ textarea{resize:vertical;line-height:1.6}
.content-cover.kind-audio{background:#edf7f4;color:#679b8d}
.content-cover.kind-scorm{background:#f2effa;color:#9a82bb}
.scorm-preview .scorm-preview-head b{font-size:12px}
/* Instruction agent chat */
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.agent-chat .assistant-title small{display:block;font-size:10px;color:#7a8ba5;margin-top:3px}
.agent-chat .ai-messages{max-height:520px}
.ai-bubble{min-width:0;max-width:85%;display:grid;gap:8px}
.ai-message.user .ai-bubble{justify-items:end}
.ai-bubble .rule-outcomes{padding:4px 2px}
.ai-message .spinner{width:12px;height:12px}
.prompt-chips button.primary{background:var(--blue);border-color:var(--blue);color:white}
.prompt-chips button.primary:hover{background:var(--blue-dark);color:white}
.agent-chat #ai-request-form textarea{min-height:0}
/* The mockup's chat text is 9–11px in light grey; the agent chat is read closely, so it gets body-size, darker text. */
.agent-chat .ai-message p{font-size:13.5px;line-height:1.7;color:#344054;background:#f2f5fa}
.agent-chat .ai-message.user p{color:#fff;background:var(--blue)}
.agent-chat .ai-message>span{width:30px;height:30px}
.agent-chat .ai-message svg{width:16px;height:16px}
.agent-chat .assistant-title h3{font-size:15px}
.agent-chat .assistant-title small{font-size:12px;color:#667085}
.agent-chat .prompt-chips button{font-size:12.5px;padding:8px 13px;color:#344054;border-color:#d0d9e8}
.agent-chat .prompt-chips button.primary{color:#fff}
.agent-chat #ai-request-form textarea{font-size:14px}
.agent-chat .ai-compose-footer small{font-size:11.5px;color:#667085}
.agent-chat .ai-compose-footer .btn{font-size:13px}
.agent-chat .rule-outcomes{font-size:13px}
.rule-body>b{font-size:14px}
.rule-note,.rule-check{font-size:12px;color:#667085}
.rule-body .error{font-size:12px}
.rule-approve small{font-size:12px;color:#667085}
.rule-fields select,.rule-fields input{font-size:13px}
.plan-language{display:flex;align-items:center;gap:12px;margin:0;padding:14px 22px;border-bottom:1px solid var(--line);font-size:13px;font-weight:600;color:#344054}
.plan-language select{width:auto!important;min-height:36px!important;padding:7px 10px!important;font-size:13px}
/* The agent's pace: thinking, typing, then the next question */
.ai-thought{font-size:11px;color:#98a2b3;padding-left:2px}
.ai-message.thinking p{color:#667085;font-style:italic;display:flex;align-items:center;gap:8px}
.ai-message .typing:after{content:"";display:inline-block;width:2px;height:1em;margin-left:2px;vertical-align:-2px;background:#98a2b3;animation:blink .8s steps(1) infinite}
.dots{display:inline-flex;gap:4px;font-style:normal}
.dots b{width:6px;height:6px;border-radius:50%;background:#98a2b3;animation:dot 1.2s ease-in-out infinite}
.dots b:nth-child(2){animation-delay:.15s}
.dots b:nth-child(3){animation-delay:.3s}
.appear{animation:appear .3s ease-out}
.agent-chat .ai-message{animation:appear .25s ease-out}
@keyframes dot{0%,60%,100%{opacity:.3;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
@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}

View File

@@ -54,6 +54,15 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
.sidebar .brand-mark{width:38px;height:38px;border-radius:12px}
.workspace{border:1px solid var(--line);background:#f8faff;border-radius:14px;padding:10px;margin-bottom:26px;font-size:13.5px}
.workspace small{font-size:12px}
.workspace-switch{position:relative;margin-bottom:26px}
button.workspace{width:100%;margin:0;text-align:left;font:inherit;font-size:13.5px;color:inherit;cursor:pointer;transition:border-color .15s,background .15s}
button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--soft);background:#f2f7ff}
.workspace-menu{position:absolute;z-index:6;top:calc(100% + 6px);left:0;right:0;max-height:280px;overflow:auto;margin:0;padding:6px;list-style:none;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 16px 32px -12px rgba(16,24,40,.22)}
.workspace-menu button{display:flex;align-items:center;gap:10px;width:100%;padding:8px;border:0;border-radius:10px;background:none;font:inherit;font-size:13.5px;font-weight:550;color:var(--ink);text-align:left;cursor:pointer}
.workspace-menu button:hover{background:#f2f7ff}
.workspace-menu button>span:nth-child(2){flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.workspace-menu .workspace-icon{display:flex;align-items:center;justify-content:center;flex-shrink:0}
.workspace-menu svg{width:16px;height:16px;color:var(--blue)}
.workspace-icon{background:var(--light);color:var(--blue-dark);border-radius:9px;width:30px;height:30px;font-weight:800}
.nav-label{color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin:0 12px 10px}
.sidebar nav{gap:4px}
@@ -79,7 +88,12 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
.breadcrumb span{color:#d0d5dd}
.breadcrumb b{color:var(--ink);font-weight:600}
.topbar-right{font-size:13px;color:var(--muted);gap:16px}
.demo-indicator{height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
.demo-indicator{white-space:nowrap;height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054}
.credit-pill{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:999px;background:var(--light);color:var(--blue-dark);font-size:12.5px;font-weight:700;white-space:nowrap}
.credit-pill svg{width:15px;height:15px}
.studio-link{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;color:#344054;font-size:12.5px;font-weight:600;white-space:nowrap;text-decoration:none;transition:background .15s,border-color .15s}
.studio-link:hover{background:#f5f8ff;border-color:#d0d5dd;color:var(--blue-dark)}
.studio-link svg{width:14px;height:14px}
.demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5}
.lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px}
.lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467}
@@ -91,6 +105,8 @@ input::placeholder,textarea::placeholder{color:#98a2b3}
.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}
@@ -184,7 +200,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.help-step h3{font-size:14.5px}
.help-step p{font-size:13px}
.toast{background:var(--ink);border-radius:14px;font-size:13.5px;box-shadow:0 18px 40px -16px rgba(16,24,40,.6)}
.toast.error{background:#991b1b}
.toast.error{background:#991b1b;color:#fff}
.progress-orb{background:var(--light);color:var(--blue);border-radius:20px}
/* Sign in: bright blue story panel with dot grid and light glow */
@@ -227,15 +243,27 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.sidebar nav :is(button,a).active::before{display:none}
.workspace-main{margin:0;width:100%}
.topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap}
.topbar-right{gap:10px}
.topbar-right>span:not(.demo-indicator){display:none}
.topbar-right{gap:8px 10px;flex-wrap:wrap}
.demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px}
.app main{padding:22px 16px}
.app-footer{margin-left:16px;margin-right:16px}
.page-head h1{font-size:30px}
.page-head p{font-size:14px}
.metric strong{font-size:32px}
.login-story{padding:28px 20px}
/* Phone: a short story header so the sign-in form shows without scrolling past it */
/* Phone: one blue background behind both the story and the form, dot grid included */
.login-layout{background:radial-gradient(rgba(255,255,255,.16) 1px,transparent 1.2px) 0 0/22px 22px,radial-gradient(circle 360px at 100% 0,rgba(255,255,255,.28),rgba(255,255,255,0)),linear-gradient(170deg,#175cd3,#1570ef 45%,#2e90fa)}
.login-story,.login-panel{background:transparent}
.login-story::before,.login-story::after{display:none}
.login-story{padding:20px 16px 24px}
.login-copy{padding:20px 0 0}
.login-copy h1{font-size:28px;line-height:1.1;margin:14px 0 10px}
.login-copy>p{font-size:14px;line-height:1.5}
.eyebrow{font-size:10.5px;padding:5px 12px 5px 6px}
.login-benefits,.login-footer{display:none}
.login-panel{padding:24px 16px 32px}
.login-form-wrap{width:100%}
.login-form-wrap :is(.cl-rootBox,.cl-cardBox){width:100%;max-width:100%}
}
@media (prefers-reduced-motion:reduce){
.btn,.content-card,.sidebar nav :is(button,a){transition:none}
@@ -258,3 +286,112 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.app main{animation:page-in .28s ease-out}
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
@media (prefers-reduced-motion:reduce){.skel,.app main{animation: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: 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;
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}
.graph-stars circle{fill:#fff;opacity:.25;animation:twinkle 4s ease-in-out infinite}
@keyframes twinkle{50%{opacity:.04}}
.graph-glow{transform-origin:500px 320px;animation:glow-breathe 7s ease-in-out infinite}
@keyframes glow-breathe{50%{transform:scale(1.08);opacity:.75}}
.graph-orbit{fill:none;stroke:rgba(132,202,255,.12);stroke-dasharray:2 7;transform-origin:500px 320px;animation:orbit-spin 90s linear infinite}
.graph-orbit.slow{stroke:rgba(132,202,255,.07);animation-duration:160s;animation-direction:reverse}
@keyframes orbit-spin{to{transform:rotate(360deg)}}
.graph-links path{fill:none;transition:opacity .3s,stroke .3s,stroke-width .3s}
.graph-links path.hub{stroke:var(--c);stroke-opacity:.16;stroke-width:.8}
.graph-links path.cross{stroke:rgba(132,202,255,.22);stroke-width:.9;stroke-dasharray:4 5;animation:dash-flow 3s linear infinite}
@keyframes dash-flow{to{stroke-dashoffset:-18}}
.graph-links path.lit{stroke:var(--c);stroke-opacity:.9;stroke-width:1.6}
.graph-links path.dim{opacity:.12}
.graph-particles circle{fill:var(--c);filter:drop-shadow(0 0 3px var(--c));opacity:0;transition:opacity .3s}
.graph-particles circle.dim{visibility:hidden}
.graph-node{cursor:pointer;transition:opacity .3s}
.graph-node.dim{opacity:.2!important}
.graph-node .hit{fill:transparent}
.graph-node .core{fill:var(--c);transition:transform .25s cubic-bezier(.3,1.6,.5,1)}
.graph-node:is(.hover,.sel) .core{transform:scale(1.7)}
.graph-node.hub .core{fill:var(--c);opacity:.9}
.graph-node.hub:is(.hover,.sel) .core{transform:scale(1.25)}
.graph-node .dot{fill:url(#graph-core);pointer-events:none}
.graph-node .halo{fill:var(--c);opacity:.1;animation:halo-breathe 4s ease-in-out infinite}
@keyframes halo-breathe{50%{opacity:.2;transform:scale(1.15)}}
.graph-node .ring{fill:none;stroke:var(--c);stroke-width:1.2;opacity:0;animation:ring-out 3.2s ease-out infinite}
.graph-node .ring.late{animation-delay:1.6s}
@keyframes ring-out{0%{opacity:.7;transform:scale(1)}100%{opacity:0;transform:scale(3.2)}}
.graph-node :is(.core,.halo,.ring){transform-box:fill-box;transform-origin:center}
.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)}
.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)}
.graph-note b{color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.graph-note span{color:#98a2b3;white-space:nowrap}
.graph-note button{display:grid;place-items:center;border:0;background:none;color:#98a2b3;padding:0;cursor:pointer}
.graph-note svg{width:14px;height:14px}
.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)}
.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)}
.graph-panel h3{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#98a2b3;margin:18px 4px 8px;font-weight:650}
.graph-stats{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.graph-stats>div{padding:12px;border-radius:12px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08)}
.graph-stats small{display:block;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:#98a2b3}
.graph-stats b{display:block;font-size:28px;font-weight:650;color:#fff;margin:6px 0 2px;font-variant-numeric:tabular-nums}
.graph-stats span{font-size:11px;color:#98a2b3}
.graph-list{list-style:none;margin:0;padding:0;display:grid}
.graph-list li{display:grid;grid-template-columns:auto 1fr auto;gap:4px 10px;align-items:start;padding:9px 4px;border-bottom:1px solid rgba(255,255,255,.06);font-size:12.5px;line-height:1.45}
.graph-list li:last-child{border-bottom:0}
.graph-list time{font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:#84caff;white-space:nowrap}
.graph-list span{color:#e4e7ec;min-width:0;overflow-wrap:anywhere}
.graph-list em{font-style:normal;font-size:10.5px;color:#98a2b3;border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:1px 8px;white-space:nowrap}
.graph-list em.on{color:#6ce9a6;border-color:rgba(108,233,166,.4)}
.graph-list.now li{align-items:center}
.graph-list.now em{border:0;padding:0}
.graph-empty{font-size:12.5px;color:#98a2b3;margin:0 4px}
.graph-empty a,.graph-more{color:#84caff}
.graph-more{display:block;font-size:12px;margin:8px 4px 0}
@media(max-width:900px){
.graph-stage{min-height:0;display:flex;flex-direction:column}
.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}}
@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}}