Compare commits
4 Commits
c1063bfa7e
...
844c88912a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
844c88912a | ||
|
|
6f3a5234b4 | ||
|
|
3b56c27210 | ||
|
|
ef4fb7d971 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.netlify
|
||||
|
||||
38
deploy.sh
Executable file
38
deploy.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploys Learning Lab Express to Netlify production (https://express.bawana.com).
|
||||
# The site has no Git integration: it is built here, with .env.production, and the dist folder is uploaded.
|
||||
#
|
||||
# ./deploy.sh test, build and deploy to production
|
||||
# ./deploy.sh --draft same, but to a draft URL for checking first
|
||||
set -euo pipefail
|
||||
|
||||
SITE_ID="574369ce-8199-498e-bb7c-0a19b8684ab2" # Netlify project "lab-express"
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v netlify >/dev/null; then
|
||||
echo "Netlify CLI not found. Install it with: npm install -g netlify-cli" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f .env.production ]; then
|
||||
echo ".env.production is missing: the build would use the dev Clerk key." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$(git status --porcelain -- src index.html package.json package-lock.json netlify.toml)" ]; then
|
||||
echo "Warning: there are uncommitted changes; they will be deployed too." >&2
|
||||
fi
|
||||
|
||||
echo "› Running tests"
|
||||
npm test
|
||||
|
||||
echo "› Building"
|
||||
npm run build
|
||||
|
||||
MESSAGE="$(git log -1 --pretty='%h %s')"
|
||||
if [ "${1:-}" = "--draft" ]; then
|
||||
echo "› Deploying a draft"
|
||||
netlify deploy --no-build --dir dist --site "$SITE_ID" --message "Draft: $MESSAGE"
|
||||
else
|
||||
echo "› Deploying to production"
|
||||
netlify deploy --prod --no-build --dir dist --site "$SITE_ID" --message "$MESSAGE"
|
||||
fi
|
||||
@@ -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"
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -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" />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Icon, type IconName } from './Icon'
|
||||
|
||||
export function PageHead({ title, description, actions }: { title: string; description: string; actions?: ReactNode }) {
|
||||
export function PageHead({ title, description, actions, eyebrow }: { title: ReactNode; description: string; actions?: ReactNode; eyebrow?: string }) {
|
||||
return (
|
||||
<div className="page-head">
|
||||
<div><h1>{title}</h1><p>{description}</p></div>
|
||||
<div>{eyebrow && <small className="page-eyebrow">{eyebrow}</small>}<h1>{title}</h1><p>{description}</p></div>
|
||||
{actions && <div className="head-actions">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,16 @@ describe('instruction agent', () => {
|
||||
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
|
||||
})
|
||||
|
||||
it('asks no topic for content when material is chosen, and names that material in the plan', () => {
|
||||
const s = { ...say('make 1 scorm per week'), sources: '“LLM”' }
|
||||
expect(nextQuestion(s)?.type).toBe('language')
|
||||
expect(respond({ ...EMPTY, sources: '“LLM”' }, 'make 1 scorm per week').reply).toMatch(/from “LLM”/)
|
||||
})
|
||||
|
||||
it('still asks what to research on the internet when material is chosen', () => {
|
||||
expect(nextQuestion({ ...say('research every day'), sources: '“LLM”' })?.type).toBe('topic')
|
||||
})
|
||||
|
||||
it('asks about a video count that has no cadence', () => {
|
||||
const s = say('3 videos per week about onboarding')
|
||||
expect(nextQuestion(s)?.type).toBe('video')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' })
|
||||
|
||||
140
src/i18n.ts
140
src/i18n.ts
@@ -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.',
|
||||
|
||||
@@ -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()
|
||||
|
||||
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
|
||||
export const useDocuments = () => useQuery({
|
||||
queryKey: ['documents'],
|
||||
queryFn: listDocuments,
|
||||
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
|
||||
})
|
||||
|
||||
export function BrainPage() {
|
||||
const docs = useQuery({
|
||||
queryKey: ['documents'],
|
||||
queryFn: listDocuments,
|
||||
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
|
||||
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
|
||||
})
|
||||
const ready = docs.data?.filter(d => phaseOf(d) === 'ready').length ?? 0
|
||||
const waiting = docs.data?.filter(d => phaseOf(d) === 'answer').length ?? 0
|
||||
const docs = useDocuments()
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('brain.title')} description={t('brain.description')} />
|
||||
<div className="metrics">
|
||||
<div className="metric"><span>{t('brain.metricMaterial')}</span>{docs.data ? <strong>{docs.data.length}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricMaterialSub')}</small></div>
|
||||
<div className="metric"><span>{t('brain.metricReady')}</span>{docs.data ? <strong>{ready}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricReadySub')}</small></div>
|
||||
<div className="metric"><span>{t('brain.metricAction')}</span>{docs.data ? <strong>{waiting}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricActionSub')}</small></div>
|
||||
</div>
|
||||
<UploadBox />
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('brain.listTitle')}</h3><Icon name="brain" /></div>
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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,69 +139,75 @@ export function InstructionPage() {
|
||||
<>
|
||||
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
||||
|
||||
<div className="ai-workspace">
|
||||
<div className="card agent-chat">
|
||||
<div className="card-head">
|
||||
<div className="assistant-title">
|
||||
<span><Icon name="spark" /></span>
|
||||
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
|
||||
<div className="unified-workspace">
|
||||
<BrainPanel selected={selected} onSelect={setSelected} />
|
||||
<div className="ai-column">
|
||||
<div className="card agent-chat">
|
||||
<div className="card-head">
|
||||
<div className="assistant-title">
|
||||
<span><Icon name="spark" /></span>
|
||||
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-messages" aria-live="polite" ref={chat}>
|
||||
{messages.map((m, i) => (
|
||||
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
|
||||
))}
|
||||
{phase === 'thinking' && <Thinking lang={lang} />}
|
||||
{phase === 'pausing' && <Typing />}
|
||||
{idle && live.text && (
|
||||
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
|
||||
onTyped={() => setTypedLive(live.text)} onType={scroll} />
|
||||
<div className="ai-messages" aria-live="polite" ref={chat}>
|
||||
{messages.map((m, i) => (
|
||||
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
|
||||
))}
|
||||
{phase === 'thinking' && <Thinking lang={lang} />}
|
||||
{phase === 'pausing' && <Typing />}
|
||||
{idle && live.text && (
|
||||
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
|
||||
onTyped={() => setTypedLive(live.text)} onType={scroll} />
|
||||
)}
|
||||
</div>
|
||||
{showOptions && (
|
||||
<div className="prompt-chips appear">
|
||||
{live.options.map((o, i) => (
|
||||
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
|
||||
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
|
||||
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
|
||||
<div className="ai-compose-footer">
|
||||
<small>{t('agent.hint')}</small>
|
||||
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{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 className="card">
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
||||
{hasContent && (
|
||||
<label className="plan-language">
|
||||
<span>{t('ins.language')}</span>
|
||||
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
|
||||
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
|
||||
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{agent.items.map(({ id, rule }) => (
|
||||
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
|
||||
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
|
||||
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
|
||||
<div className="ai-compose-footer">
|
||||
<small>{t('agent.hint')}</small>
|
||||
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
||||
{hasContent && (
|
||||
<label className="plan-language">
|
||||
<span>{t('ins.language')}</span>
|
||||
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
|
||||
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
|
||||
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{agent.items.map(({ id, rule }) => (
|
||||
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
))}
|
||||
{ready.length > 0 && (
|
||||
<div className="rule-approve">
|
||||
<small>
|
||||
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
|
||||
{t('ins.approveNote')}
|
||||
</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>
|
||||
{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>
|
||||
</>
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user