production-dashboard #1

Merged
iqbal merged 13 commits from production-dashboard into main 2026-10-27 14:58:53 +00:00
24 changed files with 1534 additions and 146 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

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 |

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: /brain, /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,6 +12,7 @@ export default function App() {
<AuthGate>
<Routes>
<Route element={<AppLayout />}>
<Route path="/production" element={<ProductionPage />} />
<Route path="/brain" element={<BrainPage />} />
<Route path="/instruction" element={<InstructionPage />} />
<Route path="/schedule" element={<SchedulePage />} />
@@ -21,7 +23,7 @@ export default function App() {
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
<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')
@@ -97,6 +98,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

@@ -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 }[] = [
// 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: '/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' },
],
},
]
export const NAV = NAV_GROUPS.flatMap(g => g.items)
export function LanguageSwitch() {
const lang = useLang()
@@ -57,32 +70,87 @@ 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))
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 => (
{NAV_GROUPS.map(g => [
<div key={g.label} className="nav-label">{t(g.label)}</div>,
...g.items.map(n => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
<Icon name={n.icon} />{t(n.label)}
</NavLink>
))}
)),
])}
</nav>
<div className="sidebar-help">
<Icon name="info" />
@@ -101,7 +169,8 @@ 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>

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,20 @@ 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"/>',
'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"/>',
}

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

@@ -0,0 +1,86 @@
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 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('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()
})
})

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

@@ -0,0 +1,186 @@
// 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, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf, weeklyTarget,
type ContentRule, 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
}
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null }
export type Question =
| { key: string; type: 'unknown'; text: string }
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
| { key: string; type: 'topic'; 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] }
for (const { id, rule } of s.items) {
if (rule.type === 'content' && rule.kind === 'video' && videoCadence(rule) === null) return { key: `video:${id}`, type: 'video', id }
if (rule.type === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id }
if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id }
}
const ids = s.items.filter(i => !i.rule.topic).map(i => i.id)
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 bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(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
/** 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 'video':
return { text: t('agent.askVideo', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
case 'tooMany':
return { text: t('agent.askTooMany', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
case 'weekday':
return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) }
case 'topic':
return {
text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }),
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 } : {}),
}
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').split(' '))
const EN_CHAT = new Set(('i me my want would like make create please the and with for from to in this that what how day week month every '
+ 'each about research summary summaries quiz quizzes skip remove whole brain monday tuesday wednesday thursday friday saturday '
+ 'sunday yes no okay sure start use language also just now').split(' '))
/** 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/
let seq = 0
const withIds = (rules: Rule[]) => rules.map(rule => ({ id: ++seq, rule }))
// A new instruction: its rules join the plan and its unclear parts are queued as questions.
function instruct(s: AgentState, text: string): Turn | null {
const { rules, unknown, language } = parseInstruction(text)
const languageNote = language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : ''
if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
return {
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language },
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }) + languageNote,
}
}
// 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: describeRule(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 (REMOVE.test(lower) || is('agent.optRemove')) {
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) }
}
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) {
// Not an answer, so maybe a new instruction; otherwise the same question is asked again.
return answer(q, s, text, lower, is) ?? instruct(s, text) ?? { state: s, reply: t('agent.unclear') }
}
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

@@ -45,6 +45,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 +73,8 @@ 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
}
export interface ResearchRule {
@@ -61,6 +83,8 @@ 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
@@ -69,9 +93,12 @@ 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
}
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/
// "dari file LLM" / "from the document onboarding" names a Brain file, which works as the topic.
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic|(?:dari|from)(?: the)? (?:file|berkas|dokumen|documents?|materi|material))\s+(?!latihan\b)(.+)$/
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
function topicOf(clause: string): string {
@@ -87,13 +114,13 @@ function countOf(clause: string, kindAt: number): number {
return (word && NUMBER_WORDS[word]) || 1
}
function perOf(clause: string): Per | 'month' {
export function perOf(clause: string): Per | 'month' {
if (/\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/.test(clause)) return 'day'
if (/\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/.test(clause)) return 'month'
return 'week'
}
function weekdayOf(clause: string): number | null {
export function weekdayOf(clause: string): number | null {
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
return null
}
@@ -108,7 +135,9 @@ function parseClause(clause: string): Rule | null {
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)
@@ -124,8 +153,10 @@ function parseClause(clause: string): Rule | null {
}
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())
@@ -146,7 +177,15 @@ 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)
}
}
return language ? { rules, unknown, language } : { rules, unknown }
}
export function weeklyTarget(rule: ContentRule): number {

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

@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, 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,15 @@ 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('ruleOf', () => {
it.each([
rule('summary', 3),
@@ -62,6 +71,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)
})

View File

@@ -4,7 +4,10 @@
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 {
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,24 @@ 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 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 +85,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 }
@@ -135,7 +151,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') {
@@ -145,7 +162,7 @@ function goalText(rule: Rule): string {
}
function instructionsFor(rule: Rule): string {
const language = getLang() === 'id' ? 'Write all content in Bahasa Indonesia.' : 'Write all content in English.'
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(' ')
}
@@ -178,7 +195,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,
}

View File

@@ -11,11 +11,16 @@ const en = {
'nav.instruction': 'Instruction',
'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.creditsTitle': 'Workspace credit balance',
'layout.toStudio': 'Switch to Learning Lab',
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation',
@@ -42,6 +47,39 @@ const en = {
'common.retry': 'Try again',
'common.credits': '{n} credits',
// Production
'prod.title': 'Production',
'prod.description': 'Everything Express is producing from the Brain: material, content made, what is live, and what is being made right now.',
'prod.metricWorking': 'Content in progress',
'prod.metricWorkingSub': 'being researched or generated now',
'prod.metricMaterial': 'Material',
'prod.metricMaterialSub': '{n} in the Brain',
'prod.metricMade': 'Content made',
'prod.metricMadeSub': 'results the AI has produced',
'prod.metricLive': 'In production',
'prod.metricLiveSub': 'approved for use',
'prod.metricLms': 'On the LMS',
'prod.metricLmsSub': 'approved SCORM modules',
'prod.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.',
@@ -79,20 +117,18 @@ const en = {
'brain.needsCreditsNeed': ' (needs {credits})',
'brain.failed': 'Could not add this to the Brain.',
'brain.retryWith': 'Try again ({credits})',
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is removed: delete it and upload it again.',
'brain.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.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 +136,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…',
@@ -235,7 +268,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 +306,50 @@ 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. Try something like “2 summaries per day about leadership”.',
'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.',
@@ -351,15 +427,51 @@ const en = {
export type Key = keyof typeof en
const id: Record<Key, string> = {
'prod.title': 'Produksi',
'prod.description': 'Semua yang diproduksi Express dari Otak: materi, konten yang dibuat, yang sudah tayang, dan yang sedang dibuat saat ini.',
'prod.metricWorking': 'Konten sedang dibuat',
'prod.metricWorkingSub': 'sedang diriset atau dibuat sekarang',
'prod.metricMaterial': 'Materi',
'prod.metricMaterialSub': '{n} sudah di Otak',
'prod.metricMade': 'Konten dibuat',
'prod.metricMadeSub': 'hasil yang sudah dibuat AI',
'prod.metricLive': 'Sudah produksi',
'prod.metricLiveSub': 'disetujui untuk dipakai',
'prod.metricLms': 'Sudah di LMS',
'prod.metricLmsSub': 'modul SCORM yang disetujui',
'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Klik sebuah node',
'prod.loadingGraph': 'Menggambar knowledge graph…',
'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
'prod.hub.sources': 'Sumber',
'prod.hub.made': 'Dibuat',
'prod.hub.working': 'Sedang dibuat',
'prod.hub.research': 'Riset',
'prod.hub.schedules': 'Berulang',
'prod.nodes': 'Node',
'prod.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': 'Otak',
'nav.instruction': 'Perintah',
'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.creditsTitle': 'Saldo kredit workspace',
'layout.toStudio': 'Beralih ke Learning Lab',
'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.',
'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama',
@@ -420,19 +532,17 @@ const id: Record<Key, string> = {
'brain.needsCreditsNeed': ' (butuh {credits})',
'brain.failed': 'Gagal memasukkan ke Otak.',
'brain.retryWith': 'Coba lagi ({credits})',
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai file ini dihapus: hapus lalu unggah ulang.',
'brain.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.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 +550,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…',
@@ -572,7 +679,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 +717,50 @@ 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. Coba misalnya “2 ringkasan per hari soal leadership”.',
'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.',
@@ -696,6 +846,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 +862,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,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
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, supportedExtensions, uploadDocument,
type Source,
} from '../api/engine'
import { Icon } from '../components/Icon'
@@ -12,6 +12,7 @@ import { locale, t } from '../i18n'
type Phase = 'ready' | 'working' | 'answer' | 'failed'
function phaseOf(s: Source): Phase {
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base.
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
if (s.knowledge === 'ready') return 'ready'
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
@@ -154,7 +155,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,6 +166,36 @@ function DocumentRow({ doc }: { doc: Source }) {
)
}
// 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 StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
const label = {
ready: t('brain.statusReady'),
@@ -216,6 +247,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: building again is refused, only removing it unblocks schedules.
text = t('brain.failedAfterBuild')
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,124 +1,267 @@
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 {
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 { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
import { inLang, t, useLang, type Key, type Lang } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
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)
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)
const question = nextQuestion(agent)
const ready = agent.items.filter(i => canSchedule(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, agent)
: 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(agent, 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) 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)
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
done.push({ 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('') }
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')}
<div className="ai-workspace">
<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.metaKey || e.ctrlKey) && text.trim()) compose() }} />
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<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>
</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>)}
<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>
{rules && (
<div className="card" style={{ marginTop: 22 }}>
<div className="card">
<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>
{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">
{rules.map((rule, i) => <RuleCard key={i} rule={rule} onChange={p => update(i, p)} onRemove={() => remove(i)} />)}
{agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))}
</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}>
<button className="btn primary" disabled={saving || !idle} 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) => (
</div>
</div>
</>
)
}
// 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>
))}
{outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></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>
)
}

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

@@ -0,0 +1,244 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { listDocuments } from '../api/engine'
import { Icon } from '../components/Icon'
import { PageHead, Skeleton } from '../components/Page'
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
import { describeRule } from '../express/parse'
import { isWorking, listResults, titleOf } from '../express/results'
import { listSchedules } from '../express/schedules'
import { locale, t, type Key } from '../i18n'
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 docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
const results = useQuery({
queryKey: ['results'],
queryFn: listResults,
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
})
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')
const approved = materials.filter(m => m.status === 'accepted')
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
const lms = approved.filter(m => m.kind === 'scorm')
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
const 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) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
return (
<>
<PageHead title={t('prod.title')} description={t('prod.description')} />
<div className="metrics prod-metrics">
<div className="metric">
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small>
</div>
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data)}<small>{t('prod.metricMaterialSub', { n: inBrain })}</small></div>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data)}<small>{t('prod.metricMadeSub')}</small></div>
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
</div>
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
{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="/instruction">{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>
</>
)
}
// 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

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

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}
@@ -62,6 +65,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 +135,48 @@ 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}}

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}
@@ -184,7 +198,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 +241,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 +284,99 @@ 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 */
.sidebar nav .nav-label{margin:0 12px 6px}
.sidebar nav .nav-label~.nav-label{margin-top:18px}
@media(max-width:700px){.sidebar nav .nav-label{display:none}}
/* Production: metric row with the in-progress count as the hero, then the knowledge graph */
.prod-metrics{grid-template-columns:repeat(5,minmax(0,1fr))}
.prod-metrics .metric>span{display:flex;align-items:center;gap:8px}
.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:1120px){.prod-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}.prod-metrics .metric:first-child{grid-column:1/-1}}
@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 1fr}.prod-metrics .metric:first-child{grid-column:1/-1}}
@media (prefers-reduced-motion:reduce){.live-dot,.graph-stars circle,.graph-glow,.graph-orbit,.graph-links path,.graph-node *{animation:none!important}}