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 dist
.env .env
.env.local .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 status = 200
force = true force = true
# BrowserRouter: /brain, /results, ... all load index.html. # BrowserRouter: /upload, /results, ... all load index.html.
[[redirects]] [[redirects]]
from = "/*" from = "/*"
to = "/index.html" to = "/index.html"

View File

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

View File

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

View File

@@ -17,8 +17,8 @@ export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: Ic
{ {
label: 'nav.settings', label: 'nav.settings',
items: [ items: [
{ to: '/brain', label: 'nav.brain', icon: 'brain' }, { to: '/upload', label: 'nav.brain', icon: 'brain' },
{ to: '/instruction', label: 'nav.instruction', icon: 'spark' }, { to: '/brain', label: 'nav.instruction', icon: 'spark' },
{ to: '/schedule', label: 'nav.schedule', icon: 'calendar' }, { to: '/schedule', label: 'nav.schedule', icon: 'calendar' },
{ to: '/results', label: 'nav.results', icon: 'layers' }, { to: '/results', label: 'nav.results', icon: 'layers' },
], ],
@@ -174,11 +174,6 @@ export function AppLayout() {
<LanguageSwitch /> <LanguageSwitch />
</div> </div>
</header> </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. */} {/* Keyed by route so each page plays the fade-in from theme.css. */}
<main tabIndex={-1} key={pathname}><Outlet /></main> <main tabIndex={-1} key={pathname}><Outlet /></main>
<footer className="app-footer">Learning Lab Express <span>by Bawana</span><span>{t('layout.footer')}</span></footer> <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 type { ReactNode } from 'react'
import { Icon, type IconName } from './Icon' 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 ( return (
<div className="page-head"> <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>} {actions && <div className="head-actions">{actions}</div>}
</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') 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', () => { it('asks about a video count that has no cadence', () => {
const s = say('3 videos per week about onboarding') const s = say('3 videos per week about onboarding')
expect(nextQuestion(s)?.type).toBe('video') expect(nextQuestion(s)?.type).toBe('video')

View File

@@ -18,9 +18,11 @@ export interface AgentState {
settled: string[] settled: string[]
/** The language the content is written in, confirmed by the user; null until then. */ /** The language the content is written in, confirmed by the user; null until then. */
language: OutputLanguage | null 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 = export type Question =
| { key: string; type: 'unknown'; text: string } | { 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 === '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 } 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(',')}` const key = `topic:${ids.join(',')}`
if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids } 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. // 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 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 about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '')
const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule const 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': case 'unknown':
return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] } return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] }
case 'video': 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': 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': case 'weekday':
return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) } return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) }
case 'topic': case 'topic':
return { 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')], options: [t('agent.optWholeBrain')],
} }
case 'language': 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 if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
return { return {
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language }, 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 { function updated(s: AgentState, id: number, patch: Partial<ContentRule> | Partial<ResearchRule>): Turn {
const state = editRule(s, id, patch) 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. // 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')) { 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 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 === 'video') return per ? updated(s, q.id, { count: 1, per }) : null

View File

@@ -75,6 +75,8 @@ export interface ContentRule {
monthly?: number monthly?: number
/** The language the content is written in; unset means the UI language. */ /** The language the content is written in; unset means the UI language. */
language?: OutputLanguage language?: OutputLanguage
/** Made from material chosen in the Brain panel rather than the whole Brain. */
chosen?: boolean
} }
export interface ResearchRule { 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 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 }) : '' const about = rule.topic ? t('rule.about', { topic: rule.topic }) : ''
if (rule.type === 'research') { if (rule.type === 'research') {
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) }) 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), kind: kindLabel(rule.kind),
count: rule.count, count: rule.count,
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'), 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', () => { describe('ruleOf', () => {
it.each([ it.each([
rule('summary', 3), rule('summary', 3),

View File

@@ -73,6 +73,11 @@ function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
} }
export function ruleOf(agent: ExpressAgent): Rule { 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(' · ') const topic = agent.name.split(' · ').slice(2).join(' · ')
if (agent.enabled_tools.includes('studio.create_video')) { if (agent.enabled_tools.includes('studio.create_video')) {
const daily = agent.run_schedule?.cadence === 'daily' 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.` 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 language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage())
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined 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 budget = budgetFor(rule, videoCredits)
const common = { const common = {
name: scheduleName(rule), name: scheduleName(rule),
goal: goalText(rule), goal: goalText(rule),
instructions: instructionsFor(rule), instructions: instructionsFor(rule, chosen),
timezone: TIMEZONE, timezone: TIMEZONE,
run_budget: budget.run, run_budget: budget.run,
monthly_budget: budget.monthly, 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 return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50
} }
// The production corpus is the agent's own sources, so every ready Brain document is attached. // The production corpus is the agent's own sources: the chosen documents, or every ready Brain document.
async function attachBrain(agentId: number) { async function attachBrain(agentId: number, chosen?: number[]) {
const docs = (await listDocuments()).filter(d => d.knowledge === 'ready') const ids = chosen ?? (await listDocuments()).filter(d => d.knowledge === 'ready').map(d => d.docId)
for (const d of docs) { for (const id of ids) {
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: d.docId } }) 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 .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. // 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 credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits) }) const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits, !!only) })
await attachBrain(agent.id) await attachBrain(agent.id, only)
// Activating approves the production rules as they stand; until then nothing runs. // Activating approves the production rules as they stand; until then nothing runs.
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' }) 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. */ /** 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> { export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
const rule = { ...schedule.rule, topic: topic.trim() } 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 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' }) 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 const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
export async function resumeSchedule(id: number) { export async function resumeSchedule(schedule: Schedule) {
await attachBrain(id) const id = schedule.agent.id
if (!usesChosen(schedule.agent)) await attachBrain(id)
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' }) return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
} }
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { 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 = { const en = {
// Layout // Layout
'nav.brain': 'Brain', 'nav.brain': 'Upload material',
'nav.instruction': 'Instruction', 'nav.instruction': 'Brain and AI',
'nav.schedule': 'Schedule', 'nav.schedule': 'Schedule',
'nav.results': 'Results', 'nav.results': 'Results',
'nav.dashboard': 'Dashboard', 'nav.dashboard': 'Dashboard',
@@ -21,7 +21,6 @@ const en = {
'layout.engine': 'learning-lab engine', 'layout.engine': 'learning-lab engine',
'layout.creditsTitle': 'Workspace credit balance', 'layout.creditsTitle': 'Workspace credit balance',
'layout.toStudio': 'Switch to Learning Lab', '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.footer': 'A workspace for knowledge that keeps growing.',
'layout.mainNav': 'Main navigation', 'layout.mainNav': 'Main navigation',
'layout.language': 'Language', 'layout.language': 'Language',
@@ -49,17 +48,27 @@ const en = {
// Production // Production
'prod.title': '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.description': 'Connected knowledge. Learning that keeps growing.',
'prod.metricWorking': 'Content in progress', 'prod.unitMaterial': 'materials',
'prod.metricWorkingSub': 'being researched or generated now', 'prod.unitMaterialOne': 'material',
'prod.metricMaterial': 'Material', 'prod.unitContent': 'content',
'prod.metricMaterialSub': '{n} in the Brain', 'prod.metricMaterial': 'Total material in the Brain',
'prod.metricMade': 'Content made', 'prod.metricMaterialSub': 'Your team’s knowledge sources',
'prod.metricMadeSub': 'results the AI has produced', 'prod.metricMade': 'All content made',
'prod.metricLive': 'In production', 'prod.metricMadeSub': 'Every piece of content created',
'prod.metricLiveSub': 'approved for use', 'prod.metricLms': 'Uploaded to the LMS',
'prod.metricLms': 'On the LMS', 'prod.metricLmsSub': 'Upload status in this demo',
'prod.metricLmsSub': 'approved SCORM modules', '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.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Click a node', 'prod.clickNode': 'Click a node',
'prod.loadingGraph': 'Drawing the knowledge graph…', 'prod.loadingGraph': 'Drawing the knowledge graph…',
@@ -81,8 +90,8 @@ const en = {
'prod.more': '+{n} more in Results', 'prod.more': '+{n} more in Results',
// Brain // Brain
'brain.title': 'Brain', 'brain.title': 'Upload material',
'brain.description': 'All team material is processed into one Brain that keeps growing. New content the AI creates goes in here too.', '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.metricMaterial': 'Material',
'brain.metricMaterialSub': 'documents in the workspace', 'brain.metricMaterialSub': 'documents in the workspace',
'brain.metricReady': 'In the Brain', 'brain.metricReady': 'In the Brain',
@@ -126,8 +135,8 @@ const en = {
'brain.deleteConfirm': 'Delete “{name}” from the Brain?', 'brain.deleteConfirm': 'Delete “{name}” from the Brain?',
// Instruction // Instruction
'ins.title': 'Instruction', 'ins.title': 'Brain and AI',
'ins.description': 'Write a plain instruction. The AI turns it into research and content schedules that keep running until you stop them.', 'ins.description': 'Tell the 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.label': 'Instruction',
'ins.ex1': 'Make 2 summaries per day and 1 quiz per week about leadership', 'ins.ex1': 'Make 2 summaries per day and 1 quiz per week about leadership',
'ins.ex2': 'Research the internet every day about leadership', 'ins.ex2': 'Research the internet every day about leadership',
@@ -167,6 +176,8 @@ const en = {
'rule.research': '{when}: internet research{about}', 'rule.research': '{when}: internet research{about}',
'rule.about': ' about {topic}', 'rule.about': ' about {topic}',
'rule.wholeBrain': ' from the whole Brain', 'rule.wholeBrain': ' from the whole Brain',
'rule.from': ' from {source}',
'rule.chosenMaterial': 'chosen material',
'rule.content': '{kind}: {count} {per}{about}', 'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.', 'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
'kind.summary': 'Summary', 'kind.summary': 'Summary',
@@ -396,16 +407,33 @@ const en = {
'audio.ready': '{n} sentences · press Listen to start', 'audio.ready': '{n} sentences · press Listen to start',
'audio.sentence': 'Sentence {n} of {total}', 'audio.sentence': 'Sentence {n} of {total}',
'brain.deleted': '“{name}” deleted.', '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 // Guide
'help.boxTitle': 'How Express works', 'help.boxTitle': 'How Express works',
'help.boxBody': 'Four steps from your material to finished content.', 'help.boxBody': 'Four steps from your material to finished content.',
'help.open': 'View the guide', 'help.open': 'View the guide',
'help.title': 'How Learning Lab Express works', 'help.title': 'How Learning Lab Express works',
'help.subtitle': 'Brain → Instruction → Schedule → Results', 'help.subtitle': 'Upload material → Brain and AI → Schedule → Results',
'help.brainTitle': 'Brain', 'help.brainTitle': 'Upload material',
'help.brainBody': 'Upload your training material. Once its knowledge is built, the AI creates everything from it.', '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.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.scheduleTitle': 'Schedule',
'help.scheduleBody': 'Schedules run on their own. Pause them, change the topic, or produce now; each stays within its credit limit.', '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> = { const id: Record<Key, string> = {
'prod.title': 'Produksi', '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.description': 'Pengetahuan yang terhubung. Pembelajaran yang terus tumbuh.',
'prod.metricWorking': 'Konten sedang dibuat', 'prod.unitMaterial': 'materi',
'prod.metricWorkingSub': 'sedang diriset atau dibuat sekarang', 'prod.unitMaterialOne': 'materi',
'prod.metricMaterial': 'Materi', 'prod.unitContent': 'konten',
'prod.metricMaterialSub': '{n} sudah di Otak', 'prod.metricMaterial': 'Total materi di Brain',
'prod.metricMade': 'Konten dibuat', 'prod.metricMaterialSub': 'Sumber pengetahuan tim Anda',
'prod.metricMadeSub': 'hasil yang sudah dibuat AI', 'prod.metricMade': 'Semua konten dibuat',
'prod.metricLive': 'Sudah produksi', 'prod.metricMadeSub': 'Seluruh hasil pembuatan konten',
'prod.metricLiveSub': 'disetujui untuk dipakai', 'prod.metricLms': 'Sudah diupload ke LMS',
'prod.metricLms': 'Sudah di LMS', 'prod.metricLmsSub': 'Status upload pada demo ini',
'prod.metricLmsSub': 'modul SCORM yang disetujui', '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.graphTitle': 'Knowledge graph',
'prod.clickNode': 'Klik sebuah node', 'prod.clickNode': 'Klik sebuah node',
'prod.loadingGraph': 'Menggambar knowledge graph…', 'prod.loadingGraph': 'Menggambar knowledge graph…',
@@ -458,8 +496,8 @@ const id: Record<Key, string> = {
'prod.nowTitle': 'Sedang berjalan', 'prod.nowTitle': 'Sedang berjalan',
'prod.nothingNow': 'Tidak ada yang sedang dibuat.', 'prod.nothingNow': 'Tidak ada yang sedang dibuat.',
'prod.more': '+{n} lagi di Hasil', 'prod.more': '+{n} lagi di Hasil',
'nav.brain': 'Otak', 'nav.brain': 'Upload materi',
'nav.instruction': 'Perintah', 'nav.instruction': 'Brain and AI',
'nav.schedule': 'Jadwal', 'nav.schedule': 'Jadwal',
'nav.results': 'Hasil', 'nav.results': 'Hasil',
'nav.dashboard': 'Dasbor', 'nav.dashboard': 'Dasbor',
@@ -472,7 +510,6 @@ const id: Record<Key, string> = {
'layout.engine': 'Engine learning-lab', 'layout.engine': 'Engine learning-lab',
'layout.creditsTitle': 'Saldo kredit workspace', 'layout.creditsTitle': 'Saldo kredit workspace',
'layout.toStudio': 'Beralih ke Learning Lab', '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.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.',
'layout.mainNav': 'Navigasi utama', 'layout.mainNav': 'Navigasi utama',
'layout.language': 'Bahasa', 'layout.language': 'Bahasa',
@@ -496,8 +533,8 @@ const id: Record<Key, string> = {
'common.retry': 'Coba lagi', 'common.retry': 'Coba lagi',
'common.credits': '{n} kredit', 'common.credits': '{n} kredit',
'brain.title': 'Otak', 'brain.title': 'Upload materi',
'brain.description': 'Semua materi tim diproses menjadi satu Otak yang terus bertambah. Konten baru yang dihasilkan juga masuk ke sini.', 'brain.description': 'Unggah materi pelatihan tim di sini. Setiap file diproses ke dalam Otak yang menjadi sumber AI membuat konten.',
'brain.metricMaterial': 'Materi', 'brain.metricMaterial': 'Materi',
'brain.metricMaterialSub': 'dokumen di ruang kerja', 'brain.metricMaterialSub': 'dokumen di ruang kerja',
'brain.metricReady': 'Masuk Otak', 'brain.metricReady': 'Masuk Otak',
@@ -540,8 +577,8 @@ const id: Record<Key, string> = {
'brain.open': 'Buka {name}', 'brain.open': 'Buka {name}',
'brain.deleteConfirm': 'Hapus “{name}” dari Otak?', 'brain.deleteConfirm': 'Hapus “{name}” dari Otak?',
'ins.title': 'Perintah', 'ins.title': 'Brain and AI',
'ins.description': 'Tulis instruksi biasa. AI menyusunnya menjadi jadwal riset dan pembuatan konten, lalu terus berjalan sampai Anda menghentikannya.', '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.label': 'Instruksi',
'ins.ex1': 'Bikin 2 ringkasan per hari dan 1 kuis per minggu soal leadership', 'ins.ex1': 'Bikin 2 ringkasan per hari dan 1 kuis per minggu soal leadership',
'ins.ex2': 'Riset internet tiap hari 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.research': '{when}: riset internet{about}',
'rule.about': ' tentang {topic}', 'rule.about': ' tentang {topic}',
'rule.wholeBrain': ' dari seluruh Otak', 'rule.wholeBrain': ' dari seluruh Otak',
'rule.from': ' dari {source}',
'rule.chosenMaterial': 'materi pilihan',
'rule.content': '{kind}: {count} {per}{about}', 'rule.content': '{kind}: {count} {per}{about}',
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.', 'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
'kind.summary': 'Ringkasan', 'kind.summary': 'Ringkasan',
@@ -807,16 +846,33 @@ const id: Record<Key, string> = {
'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai', 'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai',
'audio.sentence': 'Kalimat {n} dari {total}', 'audio.sentence': 'Kalimat {n} dari {total}',
'brain.deleted': '“{name}” dihapus.', '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 // Panduan
'help.boxTitle': 'Cara kerja Express', 'help.boxTitle': 'Cara kerja Express',
'help.boxBody': 'Empat langkah dari materi sampai konten jadi.', 'help.boxBody': 'Empat langkah dari materi sampai konten jadi.',
'help.open': 'Lihat panduan', 'help.open': 'Lihat panduan',
'help.title': 'Cara kerja Learning Lab Express', 'help.title': 'Cara kerja Learning Lab Express',
'help.subtitle': 'Otak → Perintah → Jadwal → Hasil', 'help.subtitle': 'Upload materi → Brain and AI → Jadwal → Hasil',
'help.brainTitle': 'Otak', 'help.brainTitle': 'Upload materi',
'help.brainBody': 'Unggah materi pelatihan. Setelah pengetahuannya dibangun, AI membuat semua konten dari situ.', '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.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.scheduleTitle': 'Jadwal',
'help.scheduleBody': 'Jadwal berjalan sendiri. Jeda, ganti topik, atau produksi sekarang; masing-masing tetap dalam batas kreditnya.', '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 { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument, answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
type Source, type Source,
} from '../api/engine' } from '../api/engine'
import { Icon } from '../components/Icon' 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 { toast } from '../components/Toast'
import { locale, t } from '../i18n' import { locale, t } from '../i18n'
@@ -21,24 +22,19 @@ function phaseOf(s: Source): Phase {
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase() const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
export const useDocuments = () => useQuery({
queryKey: ['documents'],
queryFn: listDocuments,
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
})
export function BrainPage() { export function BrainPage() {
const docs = useQuery({ const docs = useDocuments()
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
return ( return (
<> <>
<PageHead title={t('brain.title')} description={t('brain.description')} /> <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 /> <UploadBox />
<div className="card"> <div className="card">
<div className="card-head"><h3>{t('brain.listTitle')}</h3><Icon name="brain" /></div> <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 } interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
let nextUploadId = 1 let nextUploadId = 1
@@ -116,7 +183,7 @@ function UploadBox() {
const busy = items.some(i => i.state === 'uploading') const busy = items.some(i => i.state === 'uploading')
return ( return (
<div className={`upload-box upload-compact${drag ? ' drag' : ''}`}> <div className={`upload-box upload-large${drag ? ' drag' : ''}`}>
{drag && ( {drag && (
<div className="drop-overlay" aria-hidden="true"> <div className="drop-overlay" aria-hidden="true">
<div><Icon name="upload" /><b>{t('brain.dropHere')}</b></div> <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 }) { function statusLabel(doc: Source, phase: Phase) {
const label = { return {
ready: t('brain.statusReady'), ready: t('brain.statusReady'),
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'), working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
answer: t('brain.statusAnswer'), answer: t('brain.statusAnswer'),
failed: t('brain.statusFailed'), failed: t('brain.statusFailed'),
}[phase] }[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> 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 { useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon' import { Icon, ruleIcon } from '../components/Icon'
import { PageHead } from '../components/Page' import { PageHead } from '../components/Page'
import { BrainPanel, choosable, useDocuments } from './Brain'
import { import {
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget, dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule, type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
@@ -35,6 +36,14 @@ export function InstructionPage() {
const [agent, setAgent] = useState<AgentState>(EMPTY) const [agent, setAgent] = useState<AgentState>(EMPTY)
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [saving, setSaving] = useState(false) 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') const [phase, setPhase] = useState<Phase>('idle')
// The live question whose typing has finished, so its reply options can show. // The live question whose typing has finished, so its reply options can show.
const [typedLive, setTypedLive] = useState('') const [typedLive, setTypedLive] = useState('')
@@ -46,14 +55,16 @@ export function InstructionPage() {
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) } const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
useEffect(() => () => timers.current.forEach(clearTimeout), []) 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 ready = agent.items.filter(i => canSchedule(i.rule))
const hasContent = agent.items.some(i => i.rule.type === 'content') 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. // 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 const live = inLang(lang, () => saving
? { text: t('agent.approving'), options: [] as string[] } ? { text: t('agent.approving'), options: [] as string[] }
: question : question
? ask(question, agent) ? ask(question, view)
: agent.items.length : agent.items.length
? { ? {
text: hasContent && agent.language 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. // 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 const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
setChatLang(replyLang) 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. // 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() }]) setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
setText('') 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. // One at a time so a failure (e.g. not an organization owner) is reported per schedule.
for (const { id, rule } of items) { for (const { id, rule } of items) {
try { try {
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule) await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen)
done.push({ rule, ok: true }) done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : rule, ok: true })
created.add(id) created.add(id)
} catch (e) { } catch (e) {
done.push({ rule, ok: false, message: (e as Error).message }) done.push({ rule, ok: false, message: (e as Error).message })
} }
} }
setSaving(false) 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 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 }))) 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 }]) setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done, typed: true }])
@@ -127,69 +139,75 @@ export function InstructionPage() {
<> <>
<PageHead title={t('ins.title')} description={t('ins.description')} /> <PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="ai-workspace"> <div className="unified-workspace">
<div className="card agent-chat"> <BrainPanel selected={selected} onSelect={setSelected} />
<div className="card-head"> <div className="ai-column">
<div className="assistant-title"> <div className="card agent-chat">
<span><Icon name="spark" /></span> <div className="card-head">
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div> <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>
</div> <div className="ai-messages" aria-live="polite" ref={chat}>
<div className="ai-messages" aria-live="polite" ref={chat}> {messages.map((m, i) => (
{messages.map((m, i) => ( <Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} /> ))}
))} {phase === 'thinking' && <Thinking lang={lang} />}
{phase === 'thinking' && <Thinking lang={lang} />} {phase === 'pausing' && <Typing />}
{phase === 'pausing' && <Typing />} {idle && live.text && (
{idle && live.text && ( <Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving} onTyped={() => setTypedLive(live.text)} onType={scroll} />
onTyped={() => setTypedLive(live.text)} onType={scroll} /> )}
</div>
{showOptions && (
<div className="prompt-chips appear">
{live.options.map((o, i) => (
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
))}
</div>
)} )}
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<div className="ai-compose-footer">
<small>{t('agent.hint')}</small>
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
</div>
</form>
</div> </div>
{showOptions && (
<div className="prompt-chips appear"> <div className="card">
{live.options.map((o, i) => ( <div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button> {agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
{hasContent && (
<label className="plan-language">
<span>{t('ins.language')}</span>
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
</select>
</label>
)}
<div className="rule-list">
{agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))} ))}
</div> </div>
)} {ready.length > 0 && (
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}> <div className="rule-approve">
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label> <small>
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving} <b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
onChange={e => setText(e.target.value)} {t('ins.approveNote')}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} /> </small>
<div className="ai-compose-footer"> <button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
<small>{t('agent.hint')}</small> <Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button> </button>
</div> </div>
</form> )}
</div>
<div className="card">
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
{hasContent && (
<label className="plan-language">
<span>{t('ins.language')}</span>
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
</select>
</label>
)}
<div className="rule-list">
{agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))}
</div> </div>
{ready.length > 0 && (
<div className="rule-approve">
<small>{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>
</div>
)}
</div> </div>
</div> </div>
</> </>
@@ -265,8 +283,9 @@ function Typing() {
) )
} }
function RuleCard({ rule, onChange, onRemove }: { function RuleCard({ rule, from, onChange, onRemove }: {
rule: Rule rule: Rule
from?: string
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
onRemove: () => void onRemove: () => void
}) { }) {
@@ -281,7 +300,7 @@ function RuleCard({ rule, onChange, onRemove }: {
<div className={`rule-card${badCadence ? ' muted' : ''}`}> <div className={`rule-card${badCadence ? ' muted' : ''}`}>
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span> <span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
<div className="rule-body"> <div className="rule-body">
<b>{describeRule(rule)}</b> <b>{describeRule(rule, from)}</b>
<div className="rule-fields"> <div className="rule-fields">
{rule.type === 'content' ? ( {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 })} /> onChange={e => onChange({ topic: e.target.value })} />
</div> </div>
{rule.type === 'research' && ( {rule.type === 'research' && (

View File

@@ -1,14 +1,16 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useUser } from '@clerk/react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { listDocuments } from '../api/engine' import { listDocuments } from '../api/engine'
import { Icon } from '../components/Icon' 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 { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
import { describeRule } from '../express/parse' 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 { listSchedules } from '../express/schedules'
import { locale, t, type Key } from '../i18n' import { locale, t, type Key } from '../i18n'
import { ContentCard, Preview } from './Results'
const HUB_LABEL: Record<Cluster, Key> = { const HUB_LABEL: Record<Cluster, Key> = {
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules', sources: 'prod.hub.sources', made: 'prod.hub.made', 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 // 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. // knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
export function ProductionPage() { 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 docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
const results = useQuery({ const results = useQuery({
queryKey: ['results'], queryKey: ['results'],
queryFn: listResults, 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 schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const materials = results.data?.materials ?? [] const materials = results.data?.materials ?? []
const working = materials.filter(isWorking) const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed') 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. // 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 lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
const graph = useMemo( const graph = useMemo(
() => (docs.data && results.data && schedules.data () => (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])) ? 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], [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 ( return (
<> <>
<PageHead title={t('prod.title')} description={t('prod.description')} /> <PageHead eyebrow={t('prod.eyebrow')} description={t('prod.description')}
<div className="metrics prod-metrics"> title={<>{name ? t('prod.welcomeName', { name }) : t('prod.welcome')} <span className="welcome-spark">✦</span></>}
<div className="metric"> actions={<>
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small> <Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
</div> <Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
<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')}> <section className="graph-stage" aria-label={t('prod.graphTitle')}>
{graph {graph
? <KnowledgeGraph graph={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><small>{t('prod.links')}</small>{graph ? <b>{graph.links.filter(l => !l.hub).length}</b> : <b>-</b>}<span>{t('prod.linksSub')}</span></div>
</div> </div>
<h3>{t('prod.scheduleTitle')}</h3> <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"> <ul className="graph-list">
{schedules.data?.slice(0, 4).map(s => ( {schedules.data?.slice(0, 4).map(s => (
<li key={s.agent.id}> <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>} {working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
</aside> </aside>
</section> </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 // 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 // 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. // 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> <span className="empty-icon"><Icon name={icon} /></span>
<h3>{t('res.empty')}</h3> <h3>{t('res.empty')}</h3>
<p>{t('res.emptyCopy')}</p> <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> </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> 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) const working = isWorking(m)
return ( return (
<article className="content-card"> <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 ref = useRef<HTMLDialogElement>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
// Every part's revision, so a SCORM module refetches when only its quiz changes. // 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> <div>
<span className={`status ${tone}`}><i className="dot"></i>{label}</span> <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> <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>} {s.preview && <p className="source-preview">{s.preview}</p>}
{review.error && <span className="error">{review.error.message}</span>} {review.error && <span className="error">{review.error.message}</span>}
</div> </div>

View File

@@ -24,7 +24,7 @@ export function SchedulePage() {
return ( return (
<> <>
<PageHead title={t('sch.title')} description={t('sch.description')} <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">
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div> <div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />} {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. // Content kinds run as weekly production plans; research and video as one run per slot.
const production = !isRunSchedule(rule) 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 retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } }) const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
const runNow = useMutation({ const runNow = useMutation({

View File

@@ -180,3 +180,26 @@ textarea{resize:vertical;line-height:1.6}
@keyframes blink{50%{opacity:0}} @keyframes blink{50%{opacity:0}}
@keyframes appear{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} @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}} @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{margin-bottom:28px}
.page-head h1{font-size:38px;line-height:1.08;font-weight:800;letter-spacing:-.04em} .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-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{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:first-child{color:var(--blue-dark)}
.app-footer>span:last-child{font-size:12.5px} .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} .sidebar nav .nav-label~.nav-label{margin-top:18px}
@media(max-width:700px){.sidebar nav .nav-label{display:none}} @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 */ /* Production: the knowledge graph, then the metric row and the latest content */
.prod-metrics{grid-template-columns:repeat(5,minmax(0,1fr))} .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>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} .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)}} @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-empty a,.graph-more{color:#84caff}
.graph-more{display:block;font-size:12px;margin:8px 4px 0} .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){ @media(max-width:900px){
.graph-stage{min-height:0;display:flex;flex-direction:column} .graph-stage{min-height:0;display:flex;flex-direction:column}
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640} .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-legend{position:relative;left:auto;bottom:auto;margin:0 16px 12px}
.graph-panel{position:relative;inset:auto;width:auto;margin:0 16px 16px} .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}} @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}}