5 Commits

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:36:52 +07:00
c1063bfa7e Merge pull request 'production-dashboard' (#1) from production-dashboard into main
Reviewed-on: #1
2026-10-27 14:58:52 +00:00
20 changed files with 505 additions and 198 deletions

1
.gitignore vendored
View File

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

38
deploy.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Deploys Learning Lab Express to Netlify production (https://express.bawana.com).
# The site has no Git integration: it is built here, with .env.production, and the dist folder is uploaded.
#
# ./deploy.sh test, build and deploy to production
# ./deploy.sh --draft same, but to a draft URL for checking first
set -euo pipefail
SITE_ID="574369ce-8199-498e-bb7c-0a19b8684ab2" # Netlify project "lab-express"
cd "$(dirname "$0")"
if ! command -v netlify >/dev/null; then
echo "Netlify CLI not found. Install it with: npm install -g netlify-cli" >&2
exit 1
fi
if [ ! -f .env.production ]; then
echo ".env.production is missing: the build would use the dev Clerk key." >&2
exit 1
fi
if [ -n "$(git status --porcelain -- src index.html package.json package-lock.json netlify.toml)" ]; then
echo "Warning: there are uncommitted changes; they will be deployed too." >&2
fi
echo "› Running tests"
npm test
echo "› Building"
npm run build
MESSAGE="$(git log -1 --pretty='%h %s')"
if [ "${1:-}" = "--draft" ]; then
echo "› Deploying a draft"
netlify deploy --no-build --dir dist --site "$SITE_ID" --message "Draft: $MESSAGE"
else
echo "› Deploying to production"
netlify deploy --prod --no-build --dir dist --site "$SITE_ID" --message "$MESSAGE"
fi

View File

@@ -12,7 +12,7 @@
status = 200
force = true
# BrowserRouter: /brain, /results, ... all load index.html.
# BrowserRouter: /upload, /results, ... all load index.html.
[[redirects]]
from = "/*"
to = "/index.html"

View File

@@ -13,14 +13,15 @@ export default function App() {
<Routes>
<Route element={<AppLayout />}>
<Route path="/production" element={<ProductionPage />} />
<Route path="/brain" element={<BrainPage />} />
<Route path="/instruction" element={<InstructionPage />} />
<Route path="/upload" element={<BrainPage />} />
<Route path="/brain" element={<InstructionPage />} />
<Route path="/schedule" element={<SchedulePage />} />
<Route path="/results" element={<ResultsPage />} />
</Route>
{/* Indonesian paths from before the routes were renamed, so old links keep working. */}
<Route path="/otak" element={<Navigate replace to="/brain" />} />
<Route path="/perintah" element={<Navigate replace to="/instruction" />} />
{/* Paths from before the routes were renamed, so old links keep working. */}
<Route path="/instruction" element={<Navigate replace to="/brain" />} />
<Route path="/otak" element={<Navigate replace to="/upload" />} />
<Route path="/perintah" element={<Navigate replace to="/brain" />} />
<Route path="/jadwal" element={<Navigate replace to="/schedule" />} />
<Route path="/hasil" element={<Navigate replace to="/results" />} />
<Route path="*" element={<Navigate replace to="/production" />} />

View File

@@ -33,7 +33,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
<div>
<h2>{t('auth.pickOrg')}</h2>
<p>{t('auth.pickOrgCopy')}</p>
<OrganizationList hidePersonal afterSelectOrganizationUrl="/brain" afterCreateOrganizationUrl="/brain" />
<OrganizationList hidePersonal afterSelectOrganizationUrl="/upload" afterCreateOrganizationUrl="/upload" />
</div>
</AuthScreen>
)

View File

@@ -17,8 +17,8 @@ export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: Ic
{
label: 'nav.settings',
items: [
{ to: '/brain', label: 'nav.brain', icon: 'brain' },
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' },
{ to: '/upload', label: 'nav.brain', icon: 'brain' },
{ to: '/brain', label: 'nav.instruction', icon: 'spark' },
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
{ to: '/results', label: 'nav.results', icon: 'layers' },
],
@@ -174,11 +174,6 @@ export function AppLayout() {
<LanguageSwitch />
</div>
</header>
<div className="demo-strip">
<Icon name="info" />
<span>{t('layout.devStrip')}</span>
<span className="demo-strip-tag">DEV</span>
</div>
{/* Keyed by route so each page plays the fade-in from theme.css. */}
<main tabIndex={-1} key={pathname}><Outlet /></main>
<footer className="app-footer">Learning Lab Express <span>by Bawana</span><span>{t('layout.footer')}</span></footer>

View File

@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import { Icon, type IconName } from './Icon'
export function PageHead({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
export function PageHead({ title, description, actions, eyebrow }: { title: ReactNode; description: string; actions?: ReactNode; eyebrow?: string }) {
return (
<div className="page-head">
<div><h1>{title}</h1><p>{description}</p></div>
<div>{eyebrow && <small className="page-eyebrow">{eyebrow}</small>}<h1>{title}</h1><p>{description}</p></div>
{actions && <div className="head-actions">{actions}</div>}
</div>
)

View File

@@ -23,6 +23,16 @@ describe('instruction agent', () => {
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
})
it('asks no topic for content when material is chosen, and names that material in the plan', () => {
const s = { ...say('make 1 scorm per week'), sources: '“LLM”' }
expect(nextQuestion(s)?.type).toBe('language')
expect(respond({ ...EMPTY, sources: '“LLM”' }, 'make 1 scorm per week').reply).toMatch(/from “LLM”/)
})
it('still asks what to research on the internet when material is chosen', () => {
expect(nextQuestion({ ...say('research every day'), sources: '“LLM”' })?.type).toBe('topic')
})
it('asks about a video count that has no cadence', () => {
const s = say('3 videos per week about onboarding')
expect(nextQuestion(s)?.type).toBe('video')

View File

@@ -18,9 +18,11 @@ export interface AgentState {
settled: string[]
/** The language the content is written in, confirmed by the user; null until then. */
language: OutputLanguage | null
/** The material chosen in the Brain panel, as the plan names it; null for the whole Brain. */
sources: string | null
}
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null }
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null, sources: null }
export type Question =
| { key: string; type: 'unknown'; text: string }
@@ -36,7 +38,8 @@ export function nextQuestion(s: AgentState): Question | null {
if (rule.type === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id }
if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id }
}
const ids = s.items.filter(i => !i.rule.topic).map(i => i.id)
// Chosen material already says what content is about; research still needs a topic to search the internet for.
const ids = s.items.filter(i => !i.rule.topic && !(s.sources && i.rule.type === 'content')).map(i => i.id)
const key = `topic:${ids.join(',')}`
if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids }
// Research only adds sources to the Brain, so the language matters once there is content to write.
@@ -44,7 +47,8 @@ export function nextQuestion(s: AgentState): Question | null {
return null
}
const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n')
const describe = (s: AgentState, rule: Rule) => describeRule(rule, s.sources ?? undefined)
const bullets = (s: AgentState, rules: Rule[]) => rules.map(r => `• ${describe(s, r)}`).join('\n')
const about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '')
const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule
@@ -54,14 +58,14 @@ export function ask(q: Question, s: AgentState): { text: string; options: string
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')] }
return { text: t('agent.askVideo', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
case 'tooMany':
return { text: t('agent.askTooMany', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
return { text: t('agent.askTooMany', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
case 'weekday':
return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) }
case 'topic':
return {
text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }),
text: t('agent.askTopic', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
options: [t('agent.optWholeBrain')],
}
case 'language':
@@ -116,7 +120,7 @@ function instruct(s: AgentState, text: string): Turn | null {
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,
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + languageNote,
}
}
@@ -131,7 +135,7 @@ function languageAnswer(lower: string): OutputLanguage | null {
function updated(s: AgentState, id: number, patch: Partial<ContentRule> | Partial<ResearchRule>): Turn {
const state = editRule(s, id, patch)
return { state, reply: t('agent.updated', { rule: describeRule(ruleById(state, id)) }) }
return { state, reply: t('agent.updated', { rule: describe(state, ruleById(state, id)) }) }
}
// Reads a reply to the agent's question; null when it does not answer it.
@@ -154,7 +158,7 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
}
if (REMOVE.test(lower) || is('agent.optRemove')) {
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) }
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describe(s, ruleById(s, q.id)) }) }
}
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

View File

@@ -75,6 +75,8 @@ export interface ContentRule {
monthly?: number
/** The language the content is written in; unset means the UI language. */
language?: OutputLanguage
/** Made from material chosen in the Brain panel rather than the whole Brain. */
chosen?: boolean
}
export interface ResearchRule {
@@ -200,7 +202,8 @@ export function videoCadence(rule: ContentRule): 'daily' | 'weekly' | null {
export const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key)
export function describeRule(rule: Rule): string {
/** `from` names the chosen material the rule is made from; without it, content comes from the whole Brain. */
export function describeRule(rule: Rule, from?: string): string {
const about = rule.topic ? t('rule.about', { topic: rule.topic }) : ''
if (rule.type === 'research') {
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) })
@@ -210,7 +213,9 @@ export function describeRule(rule: Rule): string {
kind: kindLabel(rule.kind),
count: rule.count,
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
about: about || t('rule.wholeBrain'),
about: about || (from ?? (rule.chosen ? t('rule.chosenMaterial') : null)
? t('rule.from', { source: from ?? t('rule.chosenMaterial') })
: t('rule.wholeBrain')),
})
}

View File

@@ -63,6 +63,17 @@ describe('content language', () => {
})
})
describe('chosen material', () => {
it('marks a schedule made from chosen material in its brief, and reads it back as chosen', () => {
const r = rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia')
const config = configFor(r, 0, true)
expect(config.instructions).toMatch(/Use only the Brain material chosen for this schedule\./)
expect(configFor(r).instructions).not.toMatch(/chosen/)
expect(ruleOf(agentFrom(config))).toEqual({ ...r, chosen: true })
expect(ruleOf(agentFrom(configFor(r)))).toEqual(r)
})
})
describe('ruleOf', () => {
it.each([
rule('summary', 3),

View File

@@ -73,6 +73,11 @@ function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
}
export function ruleOf(agent: ExpressAgent): Rule {
const rule = baseRuleOf(agent)
return rule.type === 'content' && usesChosen(agent) ? { ...rule, chosen: true } : rule
}
function baseRuleOf(agent: ExpressAgent): Rule {
const topic = agent.name.split(' · ').slice(2).join(' · ')
if (agent.enabled_tools.includes('studio.create_video')) {
const daily = agent.run_schedule?.cadence === 'daily'
@@ -161,18 +166,23 @@ function goalText(rule: Rule): string {
return `Create new learning ${KIND_TEXT[rule.kind]}${about} from the L&D team's Brain, without repeating content that already exists.`
}
function instructionsFor(rule: Rule): string {
// A schedule made from chosen material says so in its brief. That keeps it to those documents:
// editing or resuming it attaches nothing else, while a whole-Brain schedule picks up new material.
const CHOSEN_SENTENCE = 'Use only the Brain material chosen for this schedule.'
const usesChosen = (agent: ExpressAgent) => !!agent.instructions?.includes(CHOSEN_SENTENCE)
function instructionsFor(rule: Rule, chosen = false): string {
const language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage())
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, chosen && CHOSEN_SENTENCE, language].filter(Boolean).join(' ')
}
export function configFor(rule: Rule, videoCredits = 0) {
export function configFor(rule: Rule, videoCredits = 0, chosen = false) {
const budget = budgetFor(rule, videoCredits)
const common = {
name: scheduleName(rule),
goal: goalText(rule),
instructions: instructionsFor(rule),
instructions: instructionsFor(rule, chosen),
timezone: TIMEZONE,
run_budget: budget.run,
monthly_budget: budget.monthly,
@@ -227,21 +237,23 @@ export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule {
return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50
}
// The production corpus is the agent's own sources, so every ready Brain document is attached.
async function attachBrain(agentId: number) {
const docs = (await listDocuments()).filter(d => d.knowledge === 'ready')
for (const d of docs) {
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: d.docId } })
// The production corpus is the agent's own sources: the chosen documents, or every ready Brain document.
async function attachBrain(agentId: number, chosen?: number[]) {
const ids = chosen ?? (await listDocuments()).filter(d => d.knowledge === 'ready').map(d => d.docId)
for (const id of ids) {
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } })
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
}
return docs.length
return ids.length
}
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
/** With `chosen` document ids the schedule uses only those; without, the whole Brain. */
export async function createSchedule(rule: Rule, chosen?: number[]): Promise<ExpressAgent> {
const only = chosen?.length ? chosen : undefined
// A video schedule's run budget is today's price of one video, so it is fetched first.
const credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits) })
await attachBrain(agent.id)
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits, !!only) })
await attachBrain(agent.id, only)
// Activating approves the production rules as they stand; until then nothing runs.
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
}
@@ -249,15 +261,17 @@ export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
/** Changing a rule changes what was approved, so the agent is re-activated right after. */
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
const rule = { ...schedule.rule, topic: topic.trim() }
const { name, goal, instructions } = configFor(rule)
const chosen = usesChosen(schedule.agent)
const { name, goal, instructions } = configFor(rule, 0, chosen)
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
await attachBrain(schedule.agent.id)
if (!chosen) await attachBrain(schedule.agent.id)
return request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
}
export const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
export async function resumeSchedule(id: number) {
await attachBrain(id)
export async function resumeSchedule(schedule: Schedule) {
const id = schedule.agent.id
if (!usesChosen(schedule.agent)) await attachBrain(id)
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
}
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })

View File

@@ -7,8 +7,8 @@ export const LANGS: { code: Lang; label: string }[] = [{ code: 'en', label: 'EN'
const en = {
// Layout
'nav.brain': 'Brain',
'nav.instruction': 'Instruction',
'nav.brain': 'Upload material',
'nav.instruction': 'Brain and AI',
'nav.schedule': 'Schedule',
'nav.results': 'Results',
'nav.dashboard': 'Dashboard',
@@ -21,7 +21,6 @@ const en = {
'layout.engine': 'learning-lab engine',
'layout.creditsTitle': 'Workspace credit balance',
'layout.toStudio': 'Switch to Learning Lab',
'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.',
'layout.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation',
'layout.language': 'Language',
@@ -49,17 +48,27 @@ const en = {
// Production
'prod.title': 'Production',
'prod.description': 'Everything Express is producing from the Brain: material, content made, what is live, and what is being made right now.',
'prod.metricWorking': 'Content in progress',
'prod.metricWorkingSub': 'being researched or generated now',
'prod.metricMaterial': 'Material',
'prod.metricMaterialSub': '{n} in the Brain',
'prod.metricMade': 'Content made',
'prod.metricMadeSub': 'results the AI has produced',
'prod.metricLive': 'In production',
'prod.metricLiveSub': 'approved for use',
'prod.metricLms': 'On the LMS',
'prod.metricLmsSub': 'approved SCORM modules',
'prod.description': 'Connected knowledge. Learning that keeps growing.',
'prod.unitMaterial': 'materials',
'prod.unitMaterialOne': 'material',
'prod.unitContent': 'content',
'prod.metricMaterial': 'Total material in the Brain',
'prod.metricMaterialSub': 'Your team’s knowledge sources',
'prod.metricMade': 'All content made',
'prod.metricMadeSub': 'Every piece of content created',
'prod.metricLms': 'Uploaded to the LMS',
'prod.metricLmsSub': 'Upload status in this demo',
'prod.activityTitle': 'Content activity',
'prod.activityDescription': 'From knowledge in the Brain to new learning.',
'prod.activityAll': 'See all content',
'prod.activityLatest': 'Latest content',
'prod.activityWorking': 'Content in progress',
'prod.activityNoneLatest': 'No content has been made yet.',
'prod.activityNoneWorking': 'Nothing is being made right now.',
'prod.eyebrow': 'Workspace overview',
'prod.welcome': 'Welcome',
'prod.welcomeName': 'Welcome, {name}',
'prod.newContent': 'Create new content',
'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Click a node',
'prod.loadingGraph': 'Drawing the knowledge graph…',
@@ -81,8 +90,8 @@ const en = {
'prod.more': '+{n} more in Results',
// Brain
'brain.title': 'Brain',
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.',
'brain.title': 'Upload material',
'brain.description': 'Upload your team’s training material here. Every file is processed into the Brain the AI creates content from.',
'brain.metricMaterial': 'Material',
'brain.metricMaterialSub': 'documents in the workspace',
'brain.metricReady': 'In the Brain',
@@ -126,8 +135,8 @@ const en = {
'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.title': 'Brain and AI',
'ins.description': 'Tell the AI what to make from the Brain. It turns your instruction into research and content schedules that keep running until you stop them.',
'ins.label': 'Instruction',
'ins.ex1': 'Make 2 summaries per day and 1 quiz per week about leadership',
'ins.ex2': 'Research the internet every day about leadership',
@@ -167,6 +176,8 @@ const en = {
'rule.research': '{when}: internet research{about}',
'rule.about': ' about {topic}',
'rule.wholeBrain': ' from the whole Brain',
'rule.from': ' from {source}',
'rule.chosenMaterial': 'chosen material',
'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
'kind.summary': 'Summary',
@@ -396,16 +407,33 @@ const en = {
'audio.ready': '{n} sentences · press Listen to start',
'audio.sentence': 'Sentence {n} of {total}',
'brain.deleted': '“{name}” deleted.',
'brainPanel.title': 'Brain',
'brainPanel.count': '{n} materials',
'brainPanel.copy': 'Your team’s knowledge base.',
'brainPanel.search': 'Search material…',
'brainPanel.noMatch': 'No material matches',
'brainPanel.noMatchCopy': 'Try another keyword or upload more material.',
'brainPanel.note': 'Chosen material is what the AI uses. Leave everything unticked to use the whole Brain.',
'brainPanel.selectAll': 'Select all',
'brainPanel.nChosen': '{n} chosen materials',
'ins.topicPlaceholderChosen': 'Topic (empty = all chosen material)',
'brainPanel.selected': '{n} selected',
'brainPanel.wholeBrain': 'Whole Brain in use',
'brainPanel.select': 'Select {name}',
'brainPanel.notReady': 'Can be chosen once it is in the Brain',
'ins.sourcesChosen': 'Source: chosen material ({n})',
'ins.sourcesAll': 'Source: the whole Brain',
'brainPanel.upload': 'Upload material',
// Guide
'help.boxTitle': 'How Express works',
'help.boxBody': 'Four steps from your material to finished content.',
'help.open': 'View the guide',
'help.title': 'How Learning Lab Express works',
'help.subtitle': 'Brain → Instruction → Schedule → Results',
'help.brainTitle': 'Brain',
'help.subtitle': 'Upload material → Brain and AI → Schedule → Results',
'help.brainTitle': 'Upload material',
'help.brainBody': 'Upload your training material. Once its knowledge is built, the AI creates everything from it.',
'help.instructionTitle': 'Instruction',
'help.instructionTitle': 'Brain and AI',
'help.instructionBody': 'Say what you need in one sentence, e.g. “3 quizzes per week and 1 video per week about leadership”. Check the schedules, then approve them.',
'help.scheduleTitle': 'Schedule',
'help.scheduleBody': 'Schedules run on their own. Pause them, change the topic, or produce now; each stays within its credit limit.',
@@ -428,17 +456,27 @@ 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.description': 'Pengetahuan yang terhubung. Pembelajaran yang terus tumbuh.',
'prod.unitMaterial': 'materi',
'prod.unitMaterialOne': 'materi',
'prod.unitContent': 'konten',
'prod.metricMaterial': 'Total materi di Brain',
'prod.metricMaterialSub': 'Sumber pengetahuan tim Anda',
'prod.metricMade': 'Semua konten dibuat',
'prod.metricMadeSub': 'Seluruh hasil pembuatan konten',
'prod.metricLms': 'Sudah diupload ke LMS',
'prod.metricLmsSub': 'Status upload pada demo ini',
'prod.activityTitle': 'Aktivitas konten',
'prod.activityDescription': 'Dari pengetahuan di Brain menjadi pembelajaran baru.',
'prod.activityAll': 'Lihat semua hasil konten',
'prod.activityLatest': 'Konten terbaru',
'prod.activityWorking': 'Konten in progress',
'prod.activityNoneLatest': 'Belum ada konten yang dibuat.',
'prod.activityNoneWorking': 'Tidak ada konten yang sedang dibuat.',
'prod.eyebrow': 'Workspace overview',
'prod.welcome': 'Selamat datang',
'prod.welcomeName': 'Selamat datang, {name}',
'prod.newContent': 'Buat konten baru',
'prod.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Klik sebuah node',
'prod.loadingGraph': 'Menggambar knowledge graph…',
@@ -458,8 +496,8 @@ const id: Record<Key, string> = {
'prod.nowTitle': 'Sedang berjalan',
'prod.nothingNow': 'Tidak ada yang sedang dibuat.',
'prod.more': '+{n} lagi di Hasil',
'nav.brain': 'Otak',
'nav.instruction': 'Perintah',
'nav.brain': 'Upload materi',
'nav.instruction': 'Brain and AI',
'nav.schedule': 'Jadwal',
'nav.results': 'Hasil',
'nav.dashboard': 'Dasbor',
@@ -472,7 +510,6 @@ const id: Record<Key, string> = {
'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',
'layout.language': 'Bahasa',
@@ -496,8 +533,8 @@ const id: Record<Key, string> = {
'common.retry': 'Coba lagi',
'common.credits': '{n} kredit',
'brain.title': 'Otak',
'brain.description': 'Semua materi tim diproses menjadi satu Otak yang terus bertambah. Konten baru yang dihasilkan juga masuk ke sini.',
'brain.title': 'Upload materi',
'brain.description': 'Unggah materi pelatihan tim di sini. Setiap file diproses ke dalam Otak yang menjadi sumber AI membuat konten.',
'brain.metricMaterial': 'Materi',
'brain.metricMaterialSub': 'dokumen di ruang kerja',
'brain.metricReady': 'Masuk Otak',
@@ -540,8 +577,8 @@ const id: Record<Key, string> = {
'brain.open': 'Buka {name}',
'brain.deleteConfirm': 'Hapus “{name}” dari Otak?',
'ins.title': 'Perintah',
'ins.description': 'Tulis instruksi biasa. AI menyusunnya menjadi jadwal riset dan pembuatan konten, lalu terus berjalan sampai Anda menghentikannya.',
'ins.title': 'Brain and AI',
'ins.description': 'Beri tahu AI apa yang perlu dibuat dari Otak. AI menyusun instruksi Anda menjadi jadwal riset dan pembuatan konten yang terus berjalan sampai Anda menghentikannya.',
'ins.label': 'Instruksi',
'ins.ex1': 'Bikin 2 ringkasan per hari dan 1 kuis per minggu soal leadership',
'ins.ex2': 'Riset internet tiap hari soal leadership',
@@ -580,6 +617,8 @@ const id: Record<Key, string> = {
'rule.research': '{when}: riset internet{about}',
'rule.about': ' tentang {topic}',
'rule.wholeBrain': ' dari seluruh Otak',
'rule.from': ' dari {source}',
'rule.chosenMaterial': 'materi pilihan',
'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
'kind.summary': 'Ringkasan',
@@ -807,16 +846,33 @@ const id: Record<Key, string> = {
'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai',
'audio.sentence': 'Kalimat {n} dari {total}',
'brain.deleted': '“{name}” dihapus.',
'brainPanel.title': 'Brain',
'brainPanel.count': '{n} materi',
'brainPanel.copy': 'Knowledge base tim Anda.',
'brainPanel.search': 'Cari materi…',
'brainPanel.noMatch': 'Belum ada materi yang cocok',
'brainPanel.noMatchCopy': 'Coba kata kunci lain atau upload materi baru.',
'brainPanel.note': 'Materi yang dipilih menjadi acuan AI. Kosongkan pilihan untuk memakai seluruh Brain.',
'brainPanel.selectAll': 'Pilih semua',
'brainPanel.nChosen': '{n} materi pilihan',
'ins.topicPlaceholderChosen': 'Topik (kosong = semua materi pilihan)',
'brainPanel.selected': '{n} dipilih',
'brainPanel.wholeBrain': 'Seluruh Brain digunakan',
'brainPanel.select': 'Pilih {name}',
'brainPanel.notReady': 'Bisa dipilih setelah masuk Brain',
'ins.sourcesChosen': 'Sumber: {n} materi dipilih',
'ins.sourcesAll': 'Sumber: seluruh Brain',
'brainPanel.upload': 'Upload materi',
// Panduan
'help.boxTitle': 'Cara kerja Express',
'help.boxBody': 'Empat langkah dari materi sampai konten jadi.',
'help.open': 'Lihat panduan',
'help.title': 'Cara kerja Learning Lab Express',
'help.subtitle': 'Otak → Perintah → Jadwal → Hasil',
'help.brainTitle': 'Otak',
'help.subtitle': 'Upload materi → Brain and AI → Jadwal → Hasil',
'help.brainTitle': 'Upload materi',
'help.brainBody': 'Unggah materi pelatihan. Setelah pengetahuannya dibangun, AI membuat semua konten dari situ.',
'help.instructionTitle': 'Perintah',
'help.instructionTitle': 'Brain and AI',
'help.instructionBody': 'Tulis kebutuhan Anda dalam satu kalimat, misalnya “3 kuis per minggu dan 1 video per minggu soal leadership”. Periksa jadwalnya, lalu setujui.',
'help.scheduleTitle': 'Jadwal',
'help.scheduleBody': 'Jadwal berjalan sendiri. Jeda, ganti topik, atau produksi sekarang; masing-masing tetap dalam batas kreditnya.',

View File

@@ -1,11 +1,12 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
type Source,
} from '../api/engine'
import { Icon } from '../components/Icon'
import { Empty, PageHead, Skeleton, SkeletonLines, SkeletonRows } from '../components/Page'
import { Empty, PageHead, SkeletonLines, SkeletonRows } from '../components/Page'
import { toast } from '../components/Toast'
import { locale, t } from '../i18n'
@@ -21,24 +22,19 @@ function phaseOf(s: Source): Phase {
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
export function BrainPage() {
const docs = useQuery({
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
export const useDocuments = () => useQuery({
queryKey: ['documents'],
queryFn: listDocuments,
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
})
const ready = docs.data?.filter(d => phaseOf(d) === 'ready').length ?? 0
const waiting = docs.data?.filter(d => phaseOf(d) === 'answer').length ?? 0
export function BrainPage() {
const docs = useDocuments()
return (
<>
<PageHead title={t('brain.title')} description={t('brain.description')} />
<div className="metrics">
<div className="metric"><span>{t('brain.metricMaterial')}</span>{docs.data ? <strong>{docs.data.length}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricMaterialSub')}</small></div>
<div className="metric"><span>{t('brain.metricReady')}</span>{docs.data ? <strong>{ready}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricReadySub')}</small></div>
<div className="metric"><span>{t('brain.metricAction')}</span>{docs.data ? <strong>{waiting}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricActionSub')}</small></div>
</div>
<UploadBox />
<div className="card">
<div className="card-head"><h3>{t('brain.listTitle')}</h3><Icon name="brain" /></div>
@@ -51,6 +47,77 @@ export function BrainPage() {
)
}
/** Brain documents a schedule can be made from; nothing chosen means the whole Brain. */
export const choosable = (docs: Source[] | undefined) => docs?.filter(d => phaseOf(d) === 'ready') ?? []
// The Brain beside the AI chat: what schedules will draw on, with the same open and delete as Upload material.
// Failed documents are left out: they cannot be chosen, and Upload material is where they are fixed.
export function BrainPanel({ selected, onSelect }: { selected: Set<number>; onSelect: (ids: Set<number>) => void }) {
const docs = useDocuments()
const [query, setQuery] = useState('')
const q = query.trim().toLowerCase()
const listed = docs.data?.filter(d => phaseOf(d) !== 'failed')
const shown = listed?.filter(d => !q || `${d.title} ${d.originalName}`.toLowerCase().includes(q))
const ready = choosable(docs.data)
const allChosen = ready.length > 0 && ready.every(d => selected.has(d.docId))
const toggle = (id: number) => {
const next = new Set(selected)
if (!next.delete(id)) next.add(id)
onSelect(next)
}
return (
<section className="card compact-brain">
<div className="card-head">
<div>
<h3>{t('brainPanel.title')}{listed && <span className="badge neutral">{t('brainPanel.count', { n: listed.length })}</span>}</h3>
<p>{t('brainPanel.copy')}</p>
</div>
<Icon name="brain" />
</div>
<div className="compact-brain-search">
<div className="search">
<Icon name="search" />
<input type="search" aria-label={t('brainPanel.search')} placeholder={t('brainPanel.search')} value={query} onChange={e => setQuery(e.target.value)} />
</div>
</div>
{!!ready.length && (
<div className="brain-selection">
<label className="check-label">
<input type="checkbox" checked={allChosen} onChange={() => onSelect(allChosen ? new Set() : new Set(ready.map(d => d.docId)))} />
{t('brainPanel.selectAll')}
</label>
<span>{selected.size ? t('brainPanel.selected', { n: selected.size }) : t('brainPanel.wholeBrain')}</span>
</div>
)}
{docs.isPending && <SkeletonRows label={t('brain.loading')} />}
{docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>}
{listed?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>}
{!!listed?.length && shown?.length === 0 && <Empty icon="search" title={t('brainPanel.noMatch')}>{t('brainPanel.noMatchCopy')}</Empty>}
{!!shown?.length && (
<div className="compact-source-list">
{shown.map(d => (
<div key={d.docId} className="compact-source-row">
<input type="checkbox" checked={selected.has(d.docId)} disabled={phaseOf(d) !== 'ready'}
aria-label={t('brainPanel.select', { name: d.title })} title={phaseOf(d) === 'ready' ? undefined : t('brainPanel.notReady')}
onChange={() => toggle(d.docId)} />
<span className={`file-icon ${d.format.toLowerCase()}`}>{d.format.slice(0, 4)}</span>
<div className="compact-source-name">
<OpenButton doc={d} />
<small>{d.format.toUpperCase()} · <StatusLabel doc={d} phase={phaseOf(d)} /></small>
</div>
<DeleteButton doc={d} />
</div>
))}
</div>
)}
<div className="table-note compact-brain-foot">
<span>{t('brainPanel.note')}</span>
<Link className="btn small" to="/upload"><Icon name="upload" />{t('brainPanel.upload')}</Link>
</div>
</section>
)
}
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
let nextUploadId = 1
@@ -116,7 +183,7 @@ function UploadBox() {
const busy = items.some(i => i.state === 'uploading')
return (
<div className={`upload-box upload-compact${drag ? ' drag' : ''}`}>
<div className={`upload-box upload-large${drag ? ' drag' : ''}`}>
{drag && (
<div className="drop-overlay" aria-hidden="true">
<div><Icon name="upload" /><b>{t('brain.dropHere')}</b></div>
@@ -196,13 +263,21 @@ function OpenButton({ doc }: { doc: Source }) {
)
}
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
const label = {
function statusLabel(doc: Source, phase: Phase) {
return {
ready: t('brain.statusReady'),
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
answer: t('brain.statusAnswer'),
failed: t('brain.statusFailed'),
}[phase]
}
function StatusLabel({ doc, phase }: { doc: Source; phase: Phase }) {
return <span className={phase === 'ready' ? 'brain-in' : undefined}>{statusLabel(doc, phase)}</span>
}
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
const label = statusLabel(doc, phase)
return <span className={`status ${phase === 'ready' ? 'ready' : 'draft'}`}><i className="dot"></i>{label}</span>
}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon'
import { PageHead } from '../components/Page'
import { BrainPanel, choosable, useDocuments } from './Brain'
import {
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
@@ -35,6 +36,14 @@ export function InstructionPage() {
const [agent, setAgent] = useState<AgentState>(EMPTY)
const [messages, setMessages] = useState<Message[]>([])
const [saving, setSaving] = useState(false)
// Brain material the schedules are made from; none chosen means the whole Brain.
const [selected, setSelected] = useState<Set<number>>(new Set())
const docs = useDocuments()
// A chosen document that was deleted, or failed since, no longer counts.
const chosenDocs = choosable(docs.data).filter(d => selected.has(d.docId))
const chosen = chosenDocs.map(d => d.docId)
// How the chat and the plan name the chosen material: by title when there is one.
const sources = chosenDocs.length === 1 ? `“${chosenDocs[0].title}”` : chosenDocs.length ? t('brainPanel.nChosen', { n: chosenDocs.length }) : null
const [phase, setPhase] = useState<Phase>('idle')
// The live question whose typing has finished, so its reply options can show.
const [typedLive, setTypedLive] = useState('')
@@ -46,14 +55,16 @@ export function InstructionPage() {
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
useEffect(() => () => timers.current.forEach(clearTimeout), [])
const question = nextQuestion(agent)
// The agent sees the current choice, so ticking material in the Brain panel updates its question and plan.
const view: AgentState = { ...agent, sources }
const question = nextQuestion(view)
const ready = agent.items.filter(i => canSchedule(i.rule))
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)
? ask(question, view)
: agent.items.length
? {
text: hasContent && agent.language
@@ -75,7 +86,7 @@ export function InstructionPage() {
// 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))
const turn = inLang(replyLang, () => respond(view, input))
// The question being answered is kept in the history, with the reply after it.
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
setText('')
@@ -106,15 +117,16 @@ export function InstructionPage() {
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
for (const { id, rule } of items) {
try {
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
done.push({ rule, ok: true })
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen)
done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : rule, ok: true })
created.add(id)
} catch (e) {
done.push({ rule, ok: false, message: (e as Error).message })
}
}
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
// A new schedule may start producing right away; the dashboard shows it under content in progress.
await Promise.all([queryClient.invalidateQueries({ queryKey: ['schedules'] }), queryClient.invalidateQueries({ queryKey: ['results'] })])
const ok = done.filter(o => o.ok).length
const report = inLang(replyLang, () => (ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })))
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done, typed: true }])
@@ -127,7 +139,9 @@ export function InstructionPage() {
<>
<PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="ai-workspace">
<div className="unified-workspace">
<BrainPanel selected={selected} onSelect={setSelected} />
<div className="ai-column">
<div className="card agent-chat">
<div className="card-head">
<div className="assistant-title">
@@ -179,12 +193,15 @@ export function InstructionPage() {
)}
<div className="rule-list">
{agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))}
</div>
{ready.length > 0 && (
<div className="rule-approve">
<small>{t('ins.approveNote')}</small>
<small>
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
{t('ins.approveNote')}
</small>
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
@@ -192,6 +209,7 @@ export function InstructionPage() {
)}
</div>
</div>
</div>
</>
)
}
@@ -265,8 +283,9 @@ function Typing() {
)
}
function RuleCard({ rule, onChange, onRemove }: {
function RuleCard({ rule, from, onChange, onRemove }: {
rule: Rule
from?: string
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
onRemove: () => void
}) {
@@ -281,7 +300,7 @@ function RuleCard({ rule, onChange, onRemove }: {
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
<div className="rule-body">
<b>{describeRule(rule)}</b>
<b>{describeRule(rule, from)}</b>
<div className="rule-fields">
{rule.type === 'content' ? (
<>
@@ -308,7 +327,7 @@ function RuleCard({ rule, onChange, onRemove }: {
)}
</>
)}
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t('ins.topicPlaceholder')}
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t(from && rule.type === 'content' ? 'ins.topicPlaceholderChosen' : 'ins.topicPlaceholder')}
onChange={e => onChange({ topic: e.target.value })} />
</div>
{rule.type === 'research' && (

View File

@@ -1,14 +1,16 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useUser } from '@clerk/react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { listDocuments } from '../api/engine'
import { Icon } from '../components/Icon'
import { PageHead, Skeleton } from '../components/Page'
import { PageHead, Skeleton, SkeletonCards } from '../components/Page'
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
import { describeRule } from '../express/parse'
import { isWorking, listResults, titleOf } from '../express/results'
import { isWorking, listResults, materialKey, titleOf, type Material } from '../express/results'
import { listSchedules } from '../express/schedules'
import { locale, t, type Key } from '../i18n'
import { ContentCard, Preview } from './Results'
const HUB_LABEL: Record<Cluster, Key> = {
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
@@ -19,21 +21,23 @@ const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'shor
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
// knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
export function ProductionPage() {
const { user } = useUser()
// A short greeting: Clerk's first name can hold more than one word.
const name = user?.firstName?.trim().split(/\s+/)[0]
const docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
const results = useQuery({
queryKey: ['results'],
queryFn: listResults,
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
// Polled more often than on Results so a run that just started shows up under content in progress.
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 20_000),
})
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const materials = results.data?.materials ?? []
const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
const approved = materials.filter(m => m.status === 'accepted')
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
const lms = approved.filter(m => m.kind === 'scorm')
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
const graph = useMemo(
() => (docs.data && results.data && schedules.data
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
@@ -41,21 +45,18 @@ export function ProductionPage() {
[docs.data, results.data, schedules.data],
)
const num = (n: number, ready: boolean) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
const num = (n: number, ready: boolean, unit: Key) => (ready
? <strong>{n.toLocaleString(locale())} <em>{t(unit, { n })}</em></strong>
: <Skeleton className="skel-num" />)
return (
<>
<PageHead title={t('prod.title')} description={t('prod.description')} />
<div className="metrics prod-metrics">
<div className="metric">
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small>
</div>
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data)}<small>{t('prod.metricMaterialSub', { n: inBrain })}</small></div>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data)}<small>{t('prod.metricMadeSub')}</small></div>
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
</div>
<PageHead eyebrow={t('prod.eyebrow')} description={t('prod.description')}
title={<>{name ? t('prod.welcomeName', { name }) : t('prod.welcome')} <span className="welcome-spark">✦</span></>}
actions={<>
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
<Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
</>} />
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
{graph
? <KnowledgeGraph graph={graph} />
@@ -67,7 +68,7 @@ export function ProductionPage() {
<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>}
{schedules.data?.length === 0 && <p className="graph-empty">{t('prod.noSchedules')} <Link to="/brain">{t('prod.giveInstruction')}</Link></p>}
<ul className="graph-list">
{schedules.data?.slice(0, 4).map(s => (
<li key={s.agent.id}>
@@ -91,10 +92,54 @@ export function ProductionPage() {
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
</aside>
</section>
<div className="metrics prod-metrics">
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data, docs.data?.length === 1 ? 'prod.unitMaterialOne' : 'prod.unitMaterial')}<small>{t('prod.metricMaterialSub')}</small></div>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(materials.filter(m => m.status !== 'failed').length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricMadeSub')}</small></div>
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricLmsSub')}</small></div>
</div>
<ContentActivity made={made} working={working} loading={results.isPending} />
</>
)
}
const ACTIVITY_LIMIT = 3
const newest = (list: Material[]) => [...list].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, ACTIVITY_LIMIT)
// The newest finished results and everything still being made (planned pieces included), as the same
// cards as the Results page; one opens in place.
function ContentActivity({ made, working, loading }: { made: Material[]; working: Material[]; loading: boolean }) {
const [tab, setTab] = useState<'latest' | 'working'>('latest')
const [openId, setOpenId] = useState<string | null>(null)
const shown = newest(tab === 'latest' ? made : working)
const open = [...made, ...working].find(m => materialKey(m) === openId) ?? null
return (
<section className="prod-activity">
<div className="section-heading">
<div><h3>{t('prod.activityTitle')}</h3><p>{t('prod.activityDescription')}</p></div>
<Link className="text-btn" to="/results">{t('prod.activityAll')}<Icon name="arrow-right" /></Link>
</div>
<div className="result-tabs" role="tablist">
<button role="tab" aria-selected={tab === 'latest'} className={tab === 'latest' ? 'active' : undefined} onClick={() => setTab('latest')}>
<Icon name="book" />{t('prod.activityLatest')} <b>{made.length}</b>
</button>
<button role="tab" aria-selected={tab === 'working'} className={tab === 'working' ? 'active' : undefined} onClick={() => setTab('working')}>
{working.length ? <span className="live-dot" /> : <Icon name="clock" />}{t('prod.activityWorking')} <b>{working.length}</b>
</button>
</div>
{loading
? <SkeletonCards count={3} label={t('common.loading')} />
: shown.length
? <div className="activity-grid">
{shown.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}
</div>
: <p className="graph-empty activity-empty">{t(tab === 'latest' ? 'prod.activityNoneLatest' : 'prod.activityNoneWorking')}</p>}
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
</section>
)
}
// Motion: nodes grow out of their hub on first draw, then drift gently around their place while
// light travels along the links between clusters. Positions are written straight to the DOM each
// frame, so hovering or selecting (React state) never fights the animation.

View File

@@ -105,7 +105,7 @@ function NoResults({ filtered, icon }: { filtered: boolean; icon: IconName }) {
<span className="empty-icon"><Icon name={icon} /></span>
<h3>{t('res.empty')}</h3>
<p>{t('res.emptyCopy')}</p>
<Link className="btn light" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>
<Link className="btn light" to="/brain"><Icon name="plus" />{t('sch.newInstruction')}</Link>
</div>
)
}
@@ -116,7 +116,7 @@ function StatusBadge({ status }: { status: MaterialStatus }) {
return <span className={`status ${tone}`}><i className="dot"></i>{t(`res.state.${status}` as Key)}</span>
}
function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
export function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
const working = isWorking(m)
return (
<article className="content-card">
@@ -157,7 +157,7 @@ function RetryButton({ material: m, className }: { material: Material; className
)
}
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
export function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
const queryClient = useQueryClient()
// Every part's revision, so a SCORM module refetches when only its quiz changes.
@@ -427,7 +427,7 @@ function SourceRow({ source: s }: { source: FoundSource }) {
<div>
<span className={`status ${tone}`}><i className="dot"></i>{label}</span>
<h3>{s.url ? <a href={s.url} target="_blank" rel="noreferrer">{s.title}</a> : s.title}</h3>
<p>{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · <Link to="/brain">{t('res.inBrain')}</Link></>}</p>
<p>{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · <Link to="/upload">{t('res.inBrain')}</Link></>}</p>
{s.preview && <p className="source-preview">{s.preview}</p>}
{review.error && <span className="error">{review.error.message}</span>}
</div>

View File

@@ -24,7 +24,7 @@ export function SchedulePage() {
return (
<>
<PageHead title={t('sch.title')} description={t('sch.description')}
actions={<Link className="btn primary" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
actions={<Link className="btn primary" to="/brain"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
<div className="card">
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />}
@@ -48,7 +48,7 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
// Content kinds run as weekly production plans; research and video as one run per slot.
const production = !isRunSchedule(rule)
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(agent.id)), onSuccess: refresh })
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(schedule)), onSuccess: refresh })
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
const runNow = useMutation({

View File

@@ -180,3 +180,26 @@ textarea{resize:vertical;line-height:1.6}
@keyframes blink{50%{opacity:0}}
@keyframes appear{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.dots b,.agent-chat .ai-message,.appear,.ai-message .typing:after{animation:none}}
.upload-box.upload-large{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:56px 32px;margin-bottom:24px}
.upload-large .upload-symbol{width:64px;height:64px;justify-content:center;align-items:center;margin-bottom:10px}
.upload-large .upload-symbol svg{width:28px;height:28px}
.upload-large h3{font-size:20px}
.upload-large p{font-size:14px;max-width:520px;margin:6px auto 4px}
.upload-large small{font-size:12px;color:var(--muted)}
.upload-large .upload-progress{text-align:left;max-width:520px;margin:12px auto 0}
.upload-large .btn{margin-top:14px;padding-inline:22px}
@media(max-width:600px){.upload-box.upload-large{padding:32px 20px}.upload-large .btn{width:100%}}
.ai-column{display:grid;gap:22px;min-width:0}
.compact-brain .card-head h3{display:flex;align-items:center;gap:4px}
.compact-brain .card-head p{font-size:12px;color:var(--muted);margin-top:4px}
.compact-source-name .doc-title{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;overflow-wrap:normal}
.compact-source-name small{font-size:11px;color:var(--muted)}
.compact-source-name .brain-in{color:#067647}
.compact-brain .search input{width:100%;font-size:13px}
.compact-brain>.compact-brain-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;line-height:1.5}
.compact-brain-foot .btn{flex-shrink:0}
.rule-sources{display:flex;align-items:center;gap:6px;color:var(--ink);font-weight:600;margin-bottom:4px}
.rule-sources svg{width:14px;height:14px;color:var(--blue)}
.compact-source-row>input:disabled{opacity:.4;cursor:not-allowed}
.compact-brain .brain-selection label{font-size:13px}
.compact-brain .brain-selection{font-size:12px}

View File

@@ -105,6 +105,8 @@ button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--s
.page-head{margin-bottom:28px}
.page-head h1{font-size:38px;line-height:1.08;font-weight:800;letter-spacing:-.04em}
.page-head p{font-size:15px;margin-top:8px;max-width:720px}
.page-eyebrow{display:block;margin-bottom:10px;font-size:12px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:#667085}
.welcome-spark{font-size:.7em;vertical-align:.12em}
.app-footer{margin:auto 36px 0;padding:20px 0;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted)}
.app-footer>span:first-child{color:var(--blue-dark)}
.app-footer>span:last-child{font-size:12.5px}
@@ -290,9 +292,10 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.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))}
/* Production: the knowledge graph, then the metric row and the latest content */
.prod-metrics{grid-template-columns:repeat(3,minmax(0,1fr));margin-top:24px}
.prod-metrics .metric>span{display:flex;align-items:center;gap:8px}
.prod-metrics .metric strong em{font-style:normal;font-size:16px;font-weight:600;letter-spacing:0;opacity:.7}
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0}
@keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
@@ -369,7 +372,6 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.graph-empty a,.graph-more{color:#84caff}
.graph-more{display:block;font-size:12px;margin:8px 4px 0}
@media(max-width:1120px){.prod-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}.prod-metrics .metric:first-child{grid-column:1/-1}}
@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}
@@ -378,5 +380,13 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
.graph-legend{position:relative;left:auto;bottom:auto;margin:0 16px 12px}
.graph-panel{position:relative;inset:auto;width:auto;margin:0 16px 16px}
}
@media(max-width:700px){.prod-metrics{grid-template-columns:1fr 1fr}.prod-metrics .metric:first-child{grid-column:1/-1}}
@media(max-width:700px){.prod-metrics{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.live-dot,.graph-stars circle,.graph-glow,.graph-orbit,.graph-links path,.graph-node *{animation:none!important}}
/* Production: content activity */
.prod-activity .section-heading{margin-top:8px}
.prod-activity .result-tabs button{display:inline-flex;align-items:center;gap:8px}
.prod-activity .result-tabs svg{width:16px;height:16px}
.activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
.activity-empty{padding:28px 0;text-align:center}
@media(max-width:1000px){.activity-grid{grid-template-columns:1fr}}