Compare commits
18 Commits
production
...
d00561f75d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00561f75d | ||
|
|
8d745ad96e | ||
|
|
bcd747721d | ||
|
|
bdae539e9e | ||
|
|
643c3c39f9 | ||
|
|
adbcfcc7ba | ||
|
|
278c66897a | ||
|
|
bcd7964e0b | ||
| 43a977017b | |||
|
|
a73a5a3fdf | ||
|
|
d1492b9c76 | ||
|
|
b63e9c2ca8 | ||
|
|
d018c6826f | ||
|
|
844c88912a | ||
|
|
6f3a5234b4 | ||
|
|
3b56c27210 | ||
|
|
ef4fb7d971 | ||
| c1063bfa7e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
.env.local
|
.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
|
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"
|
||||||
|
|||||||
11
src/App.tsx
11
src/App.tsx
@@ -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" />} />
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export interface KnowledgeDetail {
|
|||||||
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
|
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
|
||||||
|
|
||||||
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
|
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
|
||||||
|
// Reads a failed document's stored file again, for free. A knowledge base it already has is kept.
|
||||||
|
export const reprocessDocument = (id: number) => request<{ id: number; status: string }>(`/upload/${id}/reprocess`, { method: 'POST' })
|
||||||
export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' })
|
export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' })
|
||||||
|
|
||||||
// The engine asks whether an upload is the next version of a similar document before building it.
|
// The engine asks whether an upload is the next version of a similar document before building it.
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
@@ -128,6 +128,12 @@ function OrgSwitcher() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remembered per browser; storage can throw in private windows, so it falls back to expanded.
|
||||||
|
const COLLAPSED_KEY = 'sidebar-collapsed'
|
||||||
|
function readCollapsed() {
|
||||||
|
try { return localStorage.getItem(COLLAPSED_KEY) === '1' } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
useLang()
|
useLang()
|
||||||
const [help, setHelp] = useState(false)
|
const [help, setHelp] = useState(false)
|
||||||
@@ -135,37 +141,62 @@ export function AppLayout() {
|
|||||||
const { signOut } = useClerk()
|
const { signOut } = useClerk()
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const current = NAV.find(n => pathname.startsWith(n.to))
|
const current = NAV.find(n => pathname.startsWith(n.to))
|
||||||
|
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
|
||||||
|
const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({})
|
||||||
|
useEffect(() => setToggled({}), [pathname])
|
||||||
|
const [collapsed, setCollapsed] = useState(readCollapsed)
|
||||||
|
const toggleCollapsed = () => setCollapsed(c => {
|
||||||
|
try { localStorage.setItem(COLLAPSED_KEY, c ? '0' : '1') } catch { /* keep it for this visit only */ }
|
||||||
|
return !c
|
||||||
|
})
|
||||||
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className={collapsed ? 'app sidebar-collapsed' : 'app'}>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<NavLink to="/production"><Brand /></NavLink>
|
<NavLink to="/production"><Brand /></NavLink>
|
||||||
<OrgSwitcher />
|
<OrgSwitcher />
|
||||||
<nav aria-label={t('layout.mainNav')}>
|
<nav aria-label={t('layout.mainNav')}>
|
||||||
{NAV_GROUPS.map(g => [
|
{NAV_GROUPS.map(g => {
|
||||||
<div key={g.label} className="nav-label">{t(g.label)}</div>,
|
// The icon rail has no group labels, so every item shows.
|
||||||
...g.items.map(n => (
|
const open = collapsed || (toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)))
|
||||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
return [
|
||||||
<Icon name={n.icon} />{t(n.label)}
|
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
|
||||||
</NavLink>
|
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
|
||||||
)),
|
{t(g.label)}<Icon name="chevron-down" />
|
||||||
])}
|
</button>,
|
||||||
|
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
|
||||||
|
{g.items.map(n => (
|
||||||
|
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||||
|
title={collapsed ? t(n.label) : undefined}>
|
||||||
|
<Icon name={n.icon} /><span className="nav-text">{t(n.label)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="sidebar-help">
|
{collapsed ? (
|
||||||
|
<button type="button" className="icon-btn sidebar-help-icon" title={t('help.open')} aria-label={t('help.open')}
|
||||||
|
onClick={() => setHelp(true)}><Icon name="info" /></button>
|
||||||
|
) : <div className="sidebar-help">
|
||||||
<Icon name="info" />
|
<Icon name="info" />
|
||||||
<b>{t('help.boxTitle')}</b>
|
<b>{t('help.boxTitle')}</b>
|
||||||
<p>{t('help.boxBody')}</p>
|
<p>{t('help.boxBody')}</p>
|
||||||
<button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button>
|
<button type="button" onClick={() => setHelp(true)}>{t('help.open')} <Icon name="arrow-right" /></button>
|
||||||
</div>
|
</div>}
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<span className="avatar">{name.charAt(0).toUpperCase()}</span>
|
<span className="avatar" title={collapsed ? name : undefined}>{name.charAt(0).toUpperCase()}</span>
|
||||||
<div><b>{name}</b><small>{user?.primaryEmailAddress?.emailAddress}</small></div>
|
<div><b title={name}>{name}</b><small title={user?.primaryEmailAddress?.emailAddress}>{user?.primaryEmailAddress?.emailAddress}</small></div>
|
||||||
<button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button>
|
<button className="icon-btn" aria-label={t('layout.signOut')} onClick={() => signOut({ redirectUrl: '/' })}><Icon name="logout" /></button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="workspace-main">
|
<div className="workspace-main">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
|
<button type="button" className="icon-btn sidebar-toggle" aria-pressed={collapsed}
|
||||||
|
aria-label={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
|
||||||
|
title={t(collapsed ? 'layout.expandSidebar' : 'layout.collapseSidebar')}
|
||||||
|
onClick={toggleCollapsed}><Icon name="sidebar" /></button>
|
||||||
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
|
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
|
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
|
||||||
@@ -174,11 +205,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>
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ const paths: Record<string, string> = {
|
|||||||
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
|
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
|
||||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
|
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
|
||||||
check: '<path d="m5 12 4 4L19 6"/>',
|
check: '<path d="m5 12 4 4L19 6"/>',
|
||||||
|
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||||
|
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
||||||
|
shrink: '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',
|
||||||
|
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>',
|
||||||
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
|
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
|
||||||
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
|
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
|
||||||
|
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c1-4 4-6 8-6s7 2 8 6"/>',
|
||||||
plus: '<path d="M12 4v16M4 12h16"/>',
|
plus: '<path d="M12 4v16M4 12h16"/>',
|
||||||
trash: '<path d="M3 6h18M9 3h6l1 3M6 6l1 15h10l1-15M10 10v7M14 10v7"/>',
|
trash: '<path d="M3 6h18M9 3h6l1 3M6 6l1 15h10l1-15M10 10v7M14 10v7"/>',
|
||||||
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -77,6 +87,53 @@ describe('instruction agent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('instruction agent with vague or long instructions', () => {
|
||||||
|
const LONG = `Buat 5 konten pembelajaran baru bertema Management Skills setiap hari secara otomatis pada pukul 09.00 WIB.
|
||||||
|
|
||||||
|
Gunakan seluruh materi pembelajaran yang tersedia di dalam sistem sebagai referensi. Pastikan setiap konten memiliki topik yang berbeda, relevan, informatif, dan tidak menduplikasi konten yang sudah pernah dibuat.
|
||||||
|
|
||||||
|
Simpan seluruh konten yang berhasil dibuat ke dalam sistem dengan status Draft untuk ditinjau oleh admin sebelum dipublikasikan.
|
||||||
|
|
||||||
|
Jalankan proses ini menggunakan scheduler atau background job yang tersedia di sistem.`
|
||||||
|
|
||||||
|
it('reads a long brief as one rule and answers the rest truthfully instead of asking about it', () => {
|
||||||
|
const turn = respond(EMPTY, LONG)
|
||||||
|
expect(rules(turn.state)).toEqual([expect.objectContaining({ count: 5, per: 'day', topic: 'Management Skills', kindGuessed: true })])
|
||||||
|
expect(turn.state.unknown).toEqual([])
|
||||||
|
for (const key of ['note.draft', 'note.novelty', 'note.automatic', 'note.wholeBrain'] as const) expect(turn.reply).toContain(t(key))
|
||||||
|
expect(turn.reply).toContain(t('note.hourMatches', { at: '09.00' }))
|
||||||
|
expect(nextQuestion(turn.state)?.type).toBe('kind')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks which kind "content" means, and splits the count over the kinds named', () => {
|
||||||
|
const s = say(LONG, 'ringkasan dan kuis')
|
||||||
|
expect(rules(s)).toMatchObject([{ kind: 'summary', count: 3 }, { kind: 'quiz', count: 2 }])
|
||||||
|
expect(rules(say(LONG, t('agent.optMix'))).map(r => r.type === 'content' && r.kind)).toEqual(['summary', 'quiz', 'flashcards'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks how often instead of assuming per week', () => {
|
||||||
|
const s = say('bikin 10 konten baru dalam 20 menit', 'kuis')
|
||||||
|
expect(nextQuestion(s)?.type).toBe('per')
|
||||||
|
expect(rules(say('bikin 10 konten baru dalam 20 menit', 'kuis', t('agent.optEveryWeek')))[0]).toMatchObject({ kind: 'quiz', count: 10, per: 'week', perGuessed: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says plainly that it cannot work in minutes', () => {
|
||||||
|
expect(respond(EMPTY, 'bikin 10 konten baru dalam 20 menit').reply).toContain(t('note.subDay'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces the placeholder when the user restates the request, rather than adding another', () => {
|
||||||
|
const s = say('10 pdf per day', '10 konten')
|
||||||
|
expect(rules(s)).toHaveLength(1)
|
||||||
|
expect(rules(s)[0]).toMatchObject({ count: 10, per: 'day', kindGuessed: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not repeat the same question when a rephrasing still makes no sense', () => {
|
||||||
|
const s = say('2 summaries per day about sales. hello there', 'blabla')
|
||||||
|
expect(s.unknown).toEqual([])
|
||||||
|
expect(nextQuestion(s)?.type).toBe('language')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('chatLangOf', () => {
|
describe('chatLangOf', () => {
|
||||||
it('tells Indonesian from English, and stays undecided on short replies', () => {
|
it('tells Indonesian from English, and stays undecided on short replies', () => {
|
||||||
expect(chatLangOf('Saya mau buat video dari file LLM')).toBe('id')
|
expect(chatLangOf('Saya mau buat video dari file LLM')).toBe('id')
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// user to spot alone.
|
// user to spot alone.
|
||||||
import { t, type Key, type Lang } from '../i18n'
|
import { t, type Key, type Lang } from '../i18n'
|
||||||
import {
|
import {
|
||||||
dayName, describeRule, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf, weeklyTarget,
|
dayName, describeRule, KINDS, kindLabel, kindsIn, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf,
|
||||||
type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
|
weeklyTarget, type ContentKind, type ContentRule, type Note, type OutputLanguage, type ResearchRule, type Rule,
|
||||||
} from './parse'
|
} from './parse'
|
||||||
|
|
||||||
export interface Item { id: number; rule: Rule }
|
export interface Item { id: number; rule: Rule }
|
||||||
@@ -18,25 +18,35 @@ 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 }
|
||||||
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
|
| { key: string; type: 'kind' | 'video' | 'tooMany' | 'weekday'; id: number }
|
||||||
| { key: string; type: 'topic'; ids: number[] }
|
| { key: string; type: 'topic'; ids: number[] }
|
||||||
|
| { key: string; type: 'per'; ids: number[] }
|
||||||
| { key: string; type: 'language' }
|
| { key: string; type: 'language' }
|
||||||
|
|
||||||
/** What the plan still needs, in the order the agent asks about it. */
|
/** What the plan still needs, in the order the agent asks about it. */
|
||||||
export function nextQuestion(s: AgentState): Question | null {
|
export function nextQuestion(s: AgentState): Question | null {
|
||||||
if (s.unknown.length) return { key: `unknown:${s.unknown[0]}`, type: 'unknown', text: s.unknown[0] }
|
if (s.unknown.length) return { key: `unknown:${s.unknown[0]}`, type: 'unknown', text: s.unknown[0] }
|
||||||
|
// The kind and the frequency come first: the other checks depend on them.
|
||||||
|
const generic = s.items.find(i => i.rule.type === 'content' && i.rule.kindGuessed)
|
||||||
|
if (generic) return { key: `kind:${generic.id}`, type: 'kind', id: generic.id }
|
||||||
|
// One question for every rule with no frequency, since they usually come from the same request.
|
||||||
|
const unset = s.items.filter(i => i.rule.type === 'content' && i.rule.perGuessed).map(i => i.id)
|
||||||
|
if (unset.length) return { key: `per:${unset.join(',')}`, type: 'per', ids: unset }
|
||||||
for (const { id, rule } of s.items) {
|
for (const { id, rule } of s.items) {
|
||||||
if (rule.type === 'content' && rule.kind === 'video' && videoCadence(rule) === null) return { key: `video:${id}`, type: 'video', id }
|
if (rule.type === 'content' && rule.kind === 'video' && videoCadence(rule) === null) return { key: `video:${id}`, type: 'video', id }
|
||||||
if (rule.type === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id }
|
if (rule.type === '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,24 +54,40 @@ 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
|
||||||
|
const contentById = (s: AgentState, id: number) => ruleById(s, id) as ContentRule
|
||||||
|
// "Mix" splits a generic request over the three kinds most teams start with.
|
||||||
|
const MIX: ContentKind[] = ['summary', 'quiz', 'flashcards']
|
||||||
|
|
||||||
/** The agent's question, with replies the user can pick instead of typing. */
|
/** The agent's question, with replies the user can pick instead of typing. */
|
||||||
export function ask(q: Question, s: AgentState): { text: string; options: string[] } {
|
export function ask(q: Question, s: AgentState): { text: string; options: string[] } {
|
||||||
switch (q.type) {
|
switch (q.type) {
|
||||||
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 'kind': {
|
||||||
|
const rule = contentById(s, q.id)
|
||||||
|
return {
|
||||||
|
text: t('agent.askKind', { count: rule.count, about: about(rule) }),
|
||||||
|
options: [...KINDS.map(kindLabel), t('agent.optMix')],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'per':
|
||||||
|
return {
|
||||||
|
text: t('agent.askPer', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
|
||||||
|
options: [t('agent.optEveryDay'), t('agent.optEveryWeek')],
|
||||||
|
}
|
||||||
case 'video':
|
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':
|
||||||
@@ -75,6 +101,8 @@ export function editRule(s: AgentState, id: number, patch: Partial<ContentRule>
|
|||||||
// A count or frequency typed in by hand replaces the monthly request it was derived from.
|
// A count or frequency typed in by hand replaces the monthly request it was derived from.
|
||||||
...('count' in patch || 'per' in patch ? { monthly: undefined } : {}),
|
...('count' in patch || 'per' in patch ? { monthly: undefined } : {}),
|
||||||
...('weekday' in patch || 'cadence' in patch ? { weekdayGuessed: undefined } : {}),
|
...('weekday' in patch || 'cadence' in patch ? { weekdayGuessed: undefined } : {}),
|
||||||
|
...('kind' in patch ? { kindGuessed: undefined } : {}),
|
||||||
|
...('per' in patch ? { perGuessed: undefined } : {}),
|
||||||
}
|
}
|
||||||
return { ...s, items: s.items.map(i => (i.id === id ? { ...i, rule: { ...i.rule, ...patch, ...guessed } as Rule } : i)) }
|
return { ...s, items: s.items.map(i => (i.id === id ? { ...i, rule: { ...i.rule, ...patch, ...guessed } as Rule } : i)) }
|
||||||
}
|
}
|
||||||
@@ -84,10 +112,11 @@ export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, it
|
|||||||
// Words that tell which language a message is written in, so the agent can answer in it.
|
// Words that tell which language a message is written in, so the agent can answer in it.
|
||||||
const ID_CHAT = new Set(('saya aku mau ingin bikin bikinin buat buatkan tolong dong ya iya tidak gak nggak enggak yang dan dengan untuk dari '
|
const ID_CHAT = new Set(('saya aku mau ingin bikin bikinin buat buatkan tolong dong ya iya tidak gak nggak enggak yang dan dengan untuk dari '
|
||||||
+ 'ke di ini itu apa bagaimana gimana hari minggu bulan setiap tiap soal tentang mengenai riset ringkasan kuis lewati hapus seluruh '
|
+ 'ke di ini itu apa bagaimana gimana hari minggu bulan setiap tiap soal tentang mengenai riset ringkasan kuis lewati hapus seluruh '
|
||||||
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai').split(' '))
|
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai konten baru materi '
|
||||||
|
+ 'pembelajaran menit jam dalam simpan gunakan pastikan').split(' '))
|
||||||
const EN_CHAT = new Set(('i me my want would like make create please the and with for from to in this that what how day week month every '
|
const EN_CHAT = new Set(('i me my want would like make create please the and with for from to in this that what how day week month every '
|
||||||
+ 'each about research summary summaries quiz quizzes skip remove whole brain monday tuesday wednesday thursday friday saturday '
|
+ 'each about research summary summaries quiz quizzes skip remove whole brain monday tuesday wednesday thursday friday saturday '
|
||||||
+ 'sunday yes no okay sure start use language also just now').split(' '))
|
+ 'sunday yes no okay sure start use language also just now content new minutes hours save make sure').split(' '))
|
||||||
|
|
||||||
/** The language a message is written in, or null when it is too short or mixed to tell. */
|
/** The language a message is written in, or null when it is too short or mixed to tell. */
|
||||||
export function chatLangOf(text: string): Lang | null {
|
export function chatLangOf(text: string): Lang | null {
|
||||||
@@ -105,19 +134,72 @@ const REMOVE = /^(remove|hapus|buang|delete)\b/
|
|||||||
const WHOLE = /^(whole brain|the whole brain|seluruh otak|semua|semuanya|all|everything|apa saja|bebas)$/
|
const WHOLE = /^(whole brain|the whole brain|seluruh otak|semua|semuanya|all|everything|apa saja|bebas)$/
|
||||||
const DAILY = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/
|
const DAILY = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/
|
||||||
const WEEKLY = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/
|
const WEEKLY = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/
|
||||||
|
const MONTHLY = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/
|
||||||
|
const MIX_REPLY = /^(campur|campuran|mix|mixed|bebas|terserah|apa saja|any)\b/
|
||||||
|
|
||||||
let seq = 0
|
let seq = 0
|
||||||
const withIds = (rules: Rule[]) => rules.map(rule => ({ id: ++seq, rule }))
|
const withIds = (rules: Rule[]) => rules.map(rule => ({ id: ++seq, rule }))
|
||||||
|
|
||||||
// A new instruction: its rules join the plan and its unclear parts are queued as questions.
|
const noteText = (notes: Note[]) => notes.map(n => `\n${t(n.key, n.vars)}`).join('')
|
||||||
|
|
||||||
|
// A new instruction: its rules join the plan and its unclear parts are queued as questions. Requests about
|
||||||
|
// how schedules run (drafts, the hour, "automatically") get a truthful note instead of being ignored.
|
||||||
function instruct(s: AgentState, text: string): Turn | null {
|
function instruct(s: AgentState, text: string): Turn | null {
|
||||||
const { rules, unknown, language } = parseInstruction(text)
|
const { rules, unknown, language, notes } = parseInstruction(text)
|
||||||
const languageNote = language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : ''
|
const extra = (language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : '') + noteText(notes)
|
||||||
if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
|
if (!rules.length) return extra ? { state: { ...s, language: language ?? s.language }, reply: extra.trim() } : null
|
||||||
return {
|
let state: AgentState = { ...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 },
|
// "Gunakan seluruh materi" already answers the topic question with the whole Brain.
|
||||||
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }) + languageNote,
|
const q = nextQuestion(state)
|
||||||
|
if (q?.type === 'topic' && notes.some(n => n.key === 'note.wholeBrain')) state = { ...state, settled: [...state.settled, q.key] }
|
||||||
|
return { state, reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A placeholder rule becomes the rules the reply asks for, in its place in the plan.
|
||||||
|
function replaceRule(s: AgentState, id: number, rules: Rule[], notes: Note[] = []): Turn {
|
||||||
|
const at = s.items.findIndex(i => i.id === id)
|
||||||
|
const items = [...s.items.slice(0, at), ...withIds(rules), ...s.items.slice(at + 1)]
|
||||||
|
return { state: { ...s, items }, reply: t('agent.kindSet', { list: bullets(s, rules) }) + noteText(notes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Ringkasan dan kuis" for "10 konten" splits the count: 5 summaries and 5 quizzes.
|
||||||
|
function answerKind(s: AgentState, id: number, text: string, lower: string, mix: boolean): Turn | null {
|
||||||
|
const rule = contentById(s, id)
|
||||||
|
// A full instruction ("2 ringkasan per hari", or "10 konten" again) restates the request, so it
|
||||||
|
// replaces the placeholder instead of adding a second one; what it leaves out is kept from before.
|
||||||
|
if (/\d/.test(lower)) {
|
||||||
|
const { rules: parsed, notes } = parseInstruction(text)
|
||||||
|
if (!parsed.length) return null
|
||||||
|
return replaceRule(s, id, parsed.map(r => {
|
||||||
|
if (r.type === 'research') return r.topic ? r : { ...r, topic: rule.topic }
|
||||||
|
const keepPer = r.perGuessed && !rule.perGuessed ? { per: rule.per, perGuessed: undefined } : {}
|
||||||
|
return { ...r, topic: r.topic || rule.topic, ...keepPer }
|
||||||
|
}), notes)
|
||||||
}
|
}
|
||||||
|
const kinds = mix ? MIX : [...new Set([...kindsIn(lower), ...KINDS.filter(k => lower === kindLabel(k).toLowerCase())])]
|
||||||
|
if (!kinds.length) return null
|
||||||
|
const { kindGuessed: _, ...base } = rule
|
||||||
|
return replaceRule(s, id, kinds.map((kind, i) => ({
|
||||||
|
...base,
|
||||||
|
kind,
|
||||||
|
count: Math.max(1, Math.floor(rule.count / kinds.length) + (i < rule.count % kinds.length ? 1 : 0)),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Per hari" sets the frequency of every rule asked about; a number ("5 per hari") only when there is one.
|
||||||
|
function answerPer(s: AgentState, ids: number[], lower: string): Turn | null {
|
||||||
|
// A reply naming a kind or research is a new request, not a frequency for these.
|
||||||
|
if (kindsIn(lower).length || /\b(riset|research)\b/.test(lower)) return null
|
||||||
|
const n = ids.length === 1 ? Number(lower.match(/\d+/)?.[0]) || null : null
|
||||||
|
const monthly = MONTHLY.test(lower) || /\b(bulan|month)\b/.test(lower)
|
||||||
|
const per = monthly || WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week'
|
||||||
|
: DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : null
|
||||||
|
if (!per) return null
|
||||||
|
const state = ids.reduce((st, id) => {
|
||||||
|
const count = n ?? contentById(st, id).count
|
||||||
|
return editRule(st, id, { per, count: monthly ? Math.max(1, Math.round(count / 4)) : count })
|
||||||
|
}, s)
|
||||||
|
return { state, reply: t('agent.kindSet', { list: bullets(state, ids.map(id => ruleById(state, id))) }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reply to the language question may be short ("indo", "inggris", "zh"), so it is read more loosely.
|
// A reply to the language question may be short ("indo", "inggris", "zh"), so it is read more loosely.
|
||||||
@@ -131,7 +213,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.
|
||||||
@@ -153,9 +235,11 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
|
|||||||
return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) }
|
return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (q.type === 'per') return answerPer(s, q.ids, is('agent.optEveryDay') ? 'per hari' : is('agent.optEveryWeek') ? 'per minggu' : lower)
|
||||||
if (REMOVE.test(lower) || is('agent.optRemove')) {
|
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)) }) }
|
||||||
}
|
}
|
||||||
|
if (q.type === 'kind') return answerKind(s, q.id, text, lower, MIX_REPLY.test(lower) || is('agent.optMix'))
|
||||||
const per = DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week' : null
|
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
|
||||||
if (q.type === 'tooMany') {
|
if (q.type === 'tooMany') {
|
||||||
@@ -178,8 +262,13 @@ export function respond(s: AgentState, input: string): Turn {
|
|||||||
|
|
||||||
const q = nextQuestion(s)
|
const q = nextQuestion(s)
|
||||||
if (q) {
|
if (q) {
|
||||||
// Not an answer, so maybe a new instruction; otherwise the same question is asked again.
|
const turn = answer(q, s, text, lower, is) ?? instruct(s, text)
|
||||||
return answer(q, s, text, lower, is) ?? instruct(s, text) ?? { state: s, reply: t('agent.unclear') }
|
if (turn) return turn
|
||||||
|
// A rephrasing that still made no sense: asking about the old wording again would loop, so it is
|
||||||
|
// dropped and the agent says what it can do instead.
|
||||||
|
if (q.type === 'unknown') return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.notUnderstood') }
|
||||||
|
// Otherwise the same question is asked again.
|
||||||
|
return { state: s, reply: t('agent.unclear') }
|
||||||
}
|
}
|
||||||
if (s.items.length && (YES.test(lower) || is('agent.optApprove'))) return { state: s, reply: '', approve: true }
|
if (s.items.length && (YES.test(lower) || is('agent.optApprove'))) return { state: s, reply: '', approve: true }
|
||||||
return instruct(s, text) ?? { state: s, reply: t('agent.notUnderstood') }
|
return instruct(s, text) ?? { state: s, reply: t('agent.notUnderstood') }
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { Source } from '../api/engine'
|
import type { Source } from '../api/engine'
|
||||||
import { buildGraph, HEIGHT, WIDTH } from './graph'
|
import { buildGraph, HEIGHT, WIDTH } from './graph'
|
||||||
import type { FoundSource, Material } from './results'
|
import type { Material } from './results'
|
||||||
import type { Schedule } from './schedules'
|
|
||||||
|
|
||||||
const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source
|
const doc = (docId: number, title: string, status = 'completed') => ({ docId, title, originalName: `${title}.pdf`, status }) as Source
|
||||||
const material = (id: number, status: Material['status'], sources: string[]) =>
|
const material = (id: number, status: Material['status'], sources: string[]) =>
|
||||||
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
|
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material
|
||||||
const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule
|
|
||||||
|
|
||||||
describe('buildGraph', () => {
|
describe('buildGraph', () => {
|
||||||
const data = {
|
const data = {
|
||||||
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')],
|
docs: [doc(1, 'Onboarding'), doc(2, 'Safety'), doc(3, 'Broken', 'failed')],
|
||||||
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])],
|
materials: [
|
||||||
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource],
|
material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', []),
|
||||||
schedules: [schedule],
|
material(12, 'failed', ['Safety']), material(13, 'ready', ['Safety']),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
const graph = buildGraph(data, c => c)
|
const graph = buildGraph(data, c => c)
|
||||||
|
|
||||||
it('puts results in progress in their own cluster', () => {
|
it('holds only the knowledge base and the content made from it', () => {
|
||||||
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made')
|
expect(graph.nodes.filter(n => !n.hub).map(n => `${n.cluster}:${n.id}`).sort())
|
||||||
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working')
|
.toEqual(['made:mat-7-10', 'made:mat-7-13', 'sources:doc-1', 'sources:doc-2'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('links results to their sources and schedule, and found sources to their document', () => {
|
it('links content to its sources', () => {
|
||||||
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
|
const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`)
|
||||||
expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2']))
|
expect(cross.sort()).toEqual(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-13>doc-2'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lays out the same data the same way, inside the canvas', () => {
|
it('lays out the same data the same way, inside the canvas', () => {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// The Production knowledge graph: every Brain document, result, found source and schedule as a node,
|
// The Production knowledge graph: what the agent's Brain holds, its knowledge base (the documents)
|
||||||
// grouped around one hub per cluster and linked where the engine says they are related (a result's
|
// and the content made from it, grouped around one hub per cluster and linked where a piece of
|
||||||
// sources, the schedule that made it, the document a found source became). Laid out once with a
|
// content names a document as its source. Work in progress, failed runs and schedules are status,
|
||||||
// small seeded force simulation, so the same data always draws the same picture.
|
// not knowledge, so they stay out. Laid out once with a small seeded force simulation, so the same
|
||||||
|
// data always draws the same picture.
|
||||||
import type { Source } from '../api/engine'
|
import type { Source } from '../api/engine'
|
||||||
import { isWorking, type FoundSource, type Material } from './results'
|
import { isWorking, type Material } from './results'
|
||||||
import type { Schedule } from './schedules'
|
|
||||||
|
|
||||||
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules'
|
export type Cluster = 'sources' | 'made'
|
||||||
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules']
|
export const CLUSTERS: Cluster[] = ['sources', 'made']
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
id: string
|
id: string
|
||||||
@@ -27,13 +27,16 @@ export const HEIGHT = 640
|
|||||||
|
|
||||||
// Where each cluster settles, around the middle of the canvas.
|
// Where each cluster settles, around the middle of the canvas.
|
||||||
const ANCHOR: Record<Cluster, [number, number]> = {
|
const ANCHOR: Record<Cluster, [number, number]> = {
|
||||||
sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230],
|
sources: [580, 320], made: [420, 320],
|
||||||
}
|
}
|
||||||
|
|
||||||
const norm = (s: string) => s.trim().toLowerCase()
|
const norm = (s: string) => s.trim().toLowerCase()
|
||||||
|
|
||||||
|
// Content still being made, failed or turned down is not part of the Brain.
|
||||||
|
const inBrain = (m: Material) => !isWorking(m) && m.status !== 'failed' && m.status !== 'rejected'
|
||||||
|
|
||||||
export function buildGraph(
|
export function buildGraph(
|
||||||
{ docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] },
|
{ docs, materials }: { docs: Source[]; materials: Material[] },
|
||||||
hubLabel: (c: Cluster) => string,
|
hubLabel: (c: Cluster) => string,
|
||||||
): Graph {
|
): Graph {
|
||||||
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
|
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 }))
|
||||||
@@ -46,30 +49,19 @@ export function buildGraph(
|
|||||||
|
|
||||||
// Results name their sources by title, so documents are found by title or file name.
|
// Results name their sources by title, so documents are found by title or file name.
|
||||||
const docByName = new Map<string, string>()
|
const docByName = new Map<string, string>()
|
||||||
for (const d of docs) {
|
for (const d of docs.filter(d => d.status !== 'failed' && d.knowledge !== 'failed')) {
|
||||||
add(`doc-${d.docId}`, 'sources', d.title)
|
add(`doc-${d.docId}`, 'sources', d.title)
|
||||||
docByName.set(norm(d.title), `doc-${d.docId}`)
|
docByName.set(norm(d.title), `doc-${d.docId}`)
|
||||||
docByName.set(norm(d.originalName), `doc-${d.docId}`)
|
docByName.set(norm(d.originalName), `doc-${d.docId}`)
|
||||||
}
|
}
|
||||||
for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name)
|
for (const m of materials.filter(inBrain)) {
|
||||||
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
|
|
||||||
|
|
||||||
for (const m of materials) {
|
|
||||||
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
|
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
|
||||||
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind)
|
add(id, 'made', m.planTitle ?? m.kind)
|
||||||
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
|
|
||||||
for (const title of new Set(m.sources)) {
|
for (const title of new Set(m.sources)) {
|
||||||
const doc = docByName.get(norm(title))
|
const doc = docByName.get(norm(title))
|
||||||
if (doc) link(id, doc)
|
if (doc) link(id, doc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const s of sources) {
|
|
||||||
const id = `found-${s.agentId}-${s.id}`
|
|
||||||
add(id, 'research', s.title)
|
|
||||||
if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`)
|
|
||||||
if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
layout(nodes, links)
|
layout(nodes, links)
|
||||||
return { nodes, links, width: WIDTH, height: HEIGHT }
|
return { nodes, links, width: WIDTH, height: HEIGHT }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,3 +78,26 @@ describe('counts', () => {
|
|||||||
expect(parseInstruction('buat 0 ringkasan per minggu').rules[0]).toMatchObject({ kind: 'summary', count: 1 })
|
expect(parseInstruction('buat 0 ringkasan per minggu').rules[0]).toMatchObject({ kind: 'summary', count: 1 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('generic content and sentences', () => {
|
||||||
|
it('reads "konten" and "pdf" with a count as content of a kind still to choose', () => {
|
||||||
|
expect(content('10 pdf per day')[0]).toMatchObject({ kind: 'summary', count: 10, per: 'day', kindGuessed: true })
|
||||||
|
expect(content('bikin konten tiap minggu soal sales')[0]).toMatchObject({ kindGuessed: true, topic: 'sales' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not read "gunakan seluruh materi" as a request', () => {
|
||||||
|
expect(parseInstruction('gunakan seluruh materi pembelajaran sebagai referensi').rules).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a rule with no frequency instead of silently making it weekly', () => {
|
||||||
|
expect(content('3 kuis soal sales')[0]).toMatchObject({ per: 'week', perGuessed: true })
|
||||||
|
expect(content('3 kuis per minggu soal sales')[0].perGuessed).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not end a sentence inside a time such as 09.00', () => {
|
||||||
|
const { rules, unknown } = parseInstruction('buat 2 kuis tiap hari soal sales pada pukul 09.00 WIB. simpan sebagai draft')
|
||||||
|
expect(rules).toHaveLength(1)
|
||||||
|
expect(rules[0]).toMatchObject({ topic: 'sales' })
|
||||||
|
expect(unknown).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ const KIND_WORDS: [ContentKind, RegExp][] = [
|
|||||||
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
|
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Every content kind a text names, in the order it names them: "ringkasan dan kuis" → summary, quiz. */
|
||||||
|
export function kindsIn(text: string): ContentKind[] {
|
||||||
|
return KIND_WORDS
|
||||||
|
.map(([kind, re]) => [kind, text.search(re)] as const)
|
||||||
|
.filter(([, at]) => at >= 0)
|
||||||
|
.sort((a, b) => a[1] - b[1])
|
||||||
|
.map(([kind]) => kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "10 konten", "5 materi pembelajaran", "10 pdf": content of no particular kind, so the agent asks which.
|
||||||
|
// Only with a count or a create verb before it, so "gunakan seluruh materi" is not a request.
|
||||||
|
const GENERIC = /\b(konten|content|contents|materi|materials?|bahan ajar|pdf|pdfs|dokumen|documents?|modul|modules?|items?)\b/
|
||||||
|
const CREATE = /\b(bikin|bikinin|buat|buatkan|membuat|hasilkan|produksi|make|create|generate|produce|write|tulis)\b/
|
||||||
|
|
||||||
const NUMBER_WORDS: Record<string, number> = {
|
const NUMBER_WORDS: Record<string, number> = {
|
||||||
satu: 1, se: 1, sebuah: 1, dua: 2, tiga: 3, empat: 4, lima: 5, enam: 6, tujuh: 7, delapan: 8, sembilan: 9, sepuluh: 10,
|
satu: 1, se: 1, sebuah: 1, dua: 2, tiga: 3, empat: 4, lima: 5, enam: 6, tujuh: 7, delapan: 8, sembilan: 9, sepuluh: 10,
|
||||||
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
||||||
@@ -75,6 +89,12 @@ 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
|
||||||
|
/** The instruction said "content" without a kind, so the kind is only a placeholder the agent asks about. */
|
||||||
|
kindGuessed?: boolean
|
||||||
|
/** The instruction named no frequency, so per week is only a placeholder the agent asks about. */
|
||||||
|
perGuessed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResearchRule {
|
export interface ResearchRule {
|
||||||
@@ -89,37 +109,81 @@ export interface ResearchRule {
|
|||||||
|
|
||||||
export type Rule = ContentRule | ResearchRule
|
export type Rule = ContentRule | ResearchRule
|
||||||
|
|
||||||
|
/** Something the instruction asked for that is not a rule: the agent answers it truthfully instead of ignoring it. */
|
||||||
|
export interface Note { key: Key; vars?: Record<string, string | number> }
|
||||||
|
|
||||||
export interface ParseResult {
|
export interface ParseResult {
|
||||||
rules: Rule[]
|
rules: Rule[]
|
||||||
/** Clauses that said something but matched no rule, so the user can rephrase them. */
|
/** Clauses that said something but matched no rule, so the user can rephrase them. */
|
||||||
unknown: string[]
|
unknown: string[]
|
||||||
/** The output language the instruction named. */
|
/** The output language the instruction named. */
|
||||||
language?: OutputLanguage
|
language?: OutputLanguage
|
||||||
|
notes: Note[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Requests about how schedules work rather than what they make. Each is answered with what Express
|
||||||
|
// actually does, so the agent neither ignores them nor promises something it cannot do.
|
||||||
|
const META: [RegExp, Key | null][] = [
|
||||||
|
[/\b(?:seluruh|semua)\s+(?:materi|bahan|dokumen|otak|brain|isi)|\bwhole brain\b|\ball (?:the )?(?:materials?|documents?|brain)\b/, 'note.wholeBrain'],
|
||||||
|
[/duplika|duplicat|\b(?:tidak|jangan|tanpa|bukan)\s+(?:meng)?ulang|\b(?:without|not|don'?t|no)\s+repeat|\b(?:topik|topics?)\s+(?:yang\s+)?(?:berbeda|beda|different|unik|unique)|\bdifferent topics?\b/, 'note.novelty'],
|
||||||
|
[/\b(?:draf|draft|drafts|ditinjau|tinjau|peninjauan|reviews?|reviewed|approval|persetujuan|disetujui|dipublikasikan|publikasi|published?)\b/, 'note.draft'],
|
||||||
|
[/\b(?:scheduler|penjadwal|background|latar belakang|cron|otomatis|automatic(?:ally)?|terjadwal)\b/, 'note.automatic'],
|
||||||
|
// Wishes every schedule already aims for; nothing to add, but not a part the agent failed to understand.
|
||||||
|
[/\b(?:relevan|relevant|informatif|informative|berkualitas|bermanfaat|menarik|useful|engaging|quality|referensi|reference)\b/, null],
|
||||||
|
]
|
||||||
|
const HOUR = /\b(?:pukul|pkl|jam|at)\s*(\d{1,2})(?:[.:](\d{2}))?\s*(am|pm)?/
|
||||||
|
const SUB_DAY = /\b\d+\s*(?:menit|jam|detik|minutes?|mins?|hours?|seconds?)\b|\b(?:tiap|setiap|per|every|each)\s+(?:menit|jam|minute|hour)\b|\bhourly\b/
|
||||||
|
|
||||||
|
function notesOf(text: string): Note[] {
|
||||||
|
const notes: Note[] = META.flatMap(([re, key]) => (key && re.test(text) ? [{ key }] : []))
|
||||||
|
const hour = text.match(HOUR)
|
||||||
|
if (hour) {
|
||||||
|
let h = Number(hour[1])
|
||||||
|
if (hour[3] === 'pm' && h < 12) h += 12
|
||||||
|
if (hour[3] === 'am' && h === 12) h = 0
|
||||||
|
const at = `${String(h).padStart(2, '0')}.${hour[2] ?? '00'}`
|
||||||
|
notes.push({ key: at === '09.00' ? 'note.hourMatches' : 'note.hourFixed', vars: { at } })
|
||||||
|
}
|
||||||
|
if (SUB_DAY.test(text)) notes.push({ key: 'note.subDay' })
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMeta = (clause: string) => META.some(([re]) => re.test(clause)) || HOUR.test(clause) || SUB_DAY.test(clause)
|
||||||
|
|
||||||
// "dari file LLM" / "from the document onboarding" names a Brain file, which works as the topic.
|
// "dari file LLM" / "from the document onboarding" names a Brain file, which works as the topic.
|
||||||
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic|(?:dari|from)(?: the)? (?:file|berkas|dokumen|documents?|materi|material))\s+(?!latihan\b)(.+)$/
|
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic|(?:dari|from)(?: the)? (?:file|berkas|dokumen|documents?|materi|material))\s+(?!latihan\b|yang\b)(.+)$/
|
||||||
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
|
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month|pada pukul|pada jam|pukul|pkl|secara|otomatis|automatically|dalam \d|in \d|at \d)\b.*$/
|
||||||
|
|
||||||
function topicOf(clause: string): string {
|
function topicOf(clause: string): string {
|
||||||
return clause.match(TOPIC)?.[1].replace(FREQUENCY_TAIL, '').replace(/[\s,]+$/, '').trim() ?? ''
|
return clause.match(TOPIC)?.[1].replace(FREQUENCY_TAIL, '').replace(/[\s,]+$/, '').trim() ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function countOf(clause: string, kindAt: number): number {
|
/** The count right before a kind word, or null when the instruction named none. */
|
||||||
|
function countBefore(clause: string, kindAt: number): number | null {
|
||||||
const before = clause.slice(0, kindAt)
|
const before = clause.slice(0, kindAt)
|
||||||
const digits = before.match(/(\d+)\s*(?:buah|x|kali|new)?\s*$/)
|
const digits = before.match(/(\d+)\s*(?:buah|x|kali|new)?\s*$/)
|
||||||
// "0" schedules nothing; the preview shows 1, which the user can still change.
|
// "0" schedules nothing; the preview shows 1, which the user can still change.
|
||||||
if (digits) return Math.max(1, Number(digits[1]))
|
if (digits) return Math.max(1, Number(digits[1]))
|
||||||
const word = before.match(/(\p{L}+)\s*(?:buah|new)?\s*$/u)?.[1]
|
const word = before.match(/(\p{L}+)\s*(?:buah|new)?\s*$/u)?.[1]
|
||||||
return (word && NUMBER_WORDS[word]) || 1
|
return (word && NUMBER_WORDS[word]) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countOf = (clause: string, kindAt: number) => countBefore(clause, kindAt) ?? 1
|
||||||
|
|
||||||
|
const DAY_WORDS = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/
|
||||||
|
const WEEK_WORDS = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/
|
||||||
|
const MONTH_WORDS = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/
|
||||||
|
|
||||||
export function perOf(clause: string): Per | 'month' {
|
export function perOf(clause: string): Per | 'month' {
|
||||||
if (/\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/.test(clause)) return 'day'
|
if (DAY_WORDS.test(clause)) return 'day'
|
||||||
if (/\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/.test(clause)) return 'month'
|
if (MONTH_WORDS.test(clause)) return 'month'
|
||||||
return 'week'
|
return 'week'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a clause says how often, rather than leaving perOf to fall back to per week. */
|
||||||
|
export const namesFrequency = (clause: string) =>
|
||||||
|
DAY_WORDS.test(clause) || WEEK_WORDS.test(clause) || MONTH_WORDS.test(clause) || weekdayOf(clause) !== null
|
||||||
|
|
||||||
export function weekdayOf(clause: string): number | null {
|
export function weekdayOf(clause: string): number | null {
|
||||||
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
|
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
|
||||||
return null
|
return null
|
||||||
@@ -128,7 +192,8 @@ export function weekdayOf(clause: string): number | null {
|
|||||||
const CONNECTORS = 'dan|lalu|serta|terus|kemudian|juga|and|then|also|plus'
|
const CONNECTORS = 'dan|lalu|serta|terus|kemudian|juga|and|then|also|plus'
|
||||||
// A connector or comma starts a new rule only when a new request follows; "about sales and marketing" stays one topic.
|
// A connector or comma starts a new rule only when a new request follows; "about sales and marketing" stays one topic.
|
||||||
const NEXT_REQUEST = String.raw`(?=(?:\d|satu|dua|tiga|empat|lima|enam|tujuh|delapan|sembilan|sepuluh|se(?:buah)?\s|one|two|three|four|five|six|seven|eight|nine|ten|an?\s|riset|research|bikin|buat|cari|telusuri|pantau|make|create|generate|search|monitor|tiap|setiap|every|each))`
|
const NEXT_REQUEST = String.raw`(?=(?:\d|satu|dua|tiga|empat|lima|enam|tujuh|delapan|sembilan|sepuluh|se(?:buah)?\s|one|two|three|four|five|six|seven|eight|nine|ten|an?\s|riset|research|bikin|buat|cari|telusuri|pantau|make|create|generate|search|monitor|tiap|setiap|every|each))`
|
||||||
const SPLIT = new RegExp(String.raw`[\n.;]+|\s*,\s*(?:(?:${CONNECTORS})\s+)?${NEXT_REQUEST}|\s+(?:${CONNECTORS})\s+${NEXT_REQUEST}`)
|
// A full stop ends a sentence, but not inside a time or number such as "09.00".
|
||||||
|
const SPLIT = new RegExp(String.raw`(?:[\n;]|\.(?!\d))+|\s*,\s*(?:(?:${CONNECTORS})\s+)?${NEXT_REQUEST}|\s+(?:${CONNECTORS})\s+${NEXT_REQUEST}`)
|
||||||
|
|
||||||
function parseClause(clause: string): Rule | null {
|
function parseClause(clause: string): Rule | null {
|
||||||
if (/\b(riset|research|telusuri|cari(?:kan)?|pantau|look up|search|monitor)\b/.test(clause)) {
|
if (/\b(riset|research|telusuri|cari(?:kan)?|pantau|look up|search|monitor)\b/.test(clause)) {
|
||||||
@@ -141,17 +206,25 @@ function parseClause(clause: string): Rule | null {
|
|||||||
}
|
}
|
||||||
for (const [kind, re] of KIND_WORDS) {
|
for (const [kind, re] of KIND_WORDS) {
|
||||||
const m = re.exec(clause)
|
const m = re.exec(clause)
|
||||||
if (!m) continue
|
if (m) return contentRule(clause, kind, countOf(clause, m.index))
|
||||||
const count = countOf(clause, m.index)
|
}
|
||||||
const per = perOf(clause)
|
const g = GENERIC.exec(clause)
|
||||||
if (per === 'month') {
|
if (g) {
|
||||||
return { type: 'content', kind, count: Math.max(1, Math.round(count / 4)), per: 'week', topic: topicOf(clause), monthly: count }
|
const count = countBefore(clause, g.index)
|
||||||
}
|
if (count !== null || CREATE.test(clause.slice(0, g.index))) return { ...contentRule(clause, 'summary', count ?? 1), kindGuessed: true }
|
||||||
return { type: 'content', kind, count, per, topic: topicOf(clause) }
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contentRule(clause: string, kind: ContentKind, count: number): ContentRule {
|
||||||
|
const per = perOf(clause)
|
||||||
|
const guessed = namesFrequency(clause) ? {} : { perGuessed: true }
|
||||||
|
if (per === 'month') {
|
||||||
|
return { type: 'content', kind, count: Math.max(1, Math.round(count / 4)), per: 'week', topic: topicOf(clause), monthly: count }
|
||||||
|
}
|
||||||
|
return { type: 'content', kind, count, per, topic: topicOf(clause), ...guessed }
|
||||||
|
}
|
||||||
|
|
||||||
export function parseInstruction(text: string): ParseResult {
|
export function parseInstruction(text: string): ParseResult {
|
||||||
const language = languageOf(text)
|
const language = languageOf(text)
|
||||||
const clauses = text
|
const clauses = text
|
||||||
@@ -168,6 +241,8 @@ export function parseInstruction(text: string): ParseResult {
|
|||||||
for (const clause of clauses) {
|
for (const clause of clauses) {
|
||||||
const rule = parseClause(clause)
|
const rule = parseClause(clause)
|
||||||
if (rule) rules.push(rule)
|
if (rule) rules.push(rule)
|
||||||
|
// "simpan sebagai draft", "jalankan otomatis": answered as notes, not asked about.
|
||||||
|
else if (isMeta(clause)) continue
|
||||||
// A clause that only names a topic ("about leadership") applies to the rules around it.
|
// A clause that only names a topic ("about leadership") applies to the rules around it.
|
||||||
else if (topicOf(clause) || /^(soal|tentang|topik|about|topic)\b/.test(clause)) shared = topicOf(clause) || clause.replace(/^\S+\s*/, '')
|
else if (topicOf(clause) || /^(soal|tentang|topik|about|topic)\b/.test(clause)) shared = topicOf(clause) || clause.replace(/^\S+\s*/, '')
|
||||||
else if (!/^(tolong|bikin|buat|buatkan|ya|please|make|create|ok)$/.test(clause)) unknown.push(clause)
|
else if (!/^(tolong|bikin|buat|buatkan|ya|please|make|create|ok)$/.test(clause)) unknown.push(clause)
|
||||||
@@ -185,7 +260,8 @@ export function parseInstruction(text: string): ParseResult {
|
|||||||
if (at >= 0) r.topic = text.slice(at, at + r.topic.length)
|
if (at >= 0) r.topic = text.slice(at, at + r.topic.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return language ? { rules, unknown, language } : { rules, unknown }
|
const notes = notesOf(text.toLowerCase())
|
||||||
|
return language ? { rules, unknown, language, notes } : { rules, unknown, notes }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function weeklyTarget(rule: ContentRule): number {
|
export function weeklyTarget(rule: ContentRule): number {
|
||||||
@@ -200,17 +276,21 @@ export function videoCadence(rule: ContentRule): 'daily' | 'weekly' | null {
|
|||||||
|
|
||||||
export const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key)
|
export 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) })
|
||||||
return t('rule.research', { when, about })
|
return t('rule.research', { when, about })
|
||||||
}
|
}
|
||||||
|
// A placeholder kind or frequency is shown as still open, not as if the user had chosen it.
|
||||||
return t('rule.content', {
|
return t('rule.content', {
|
||||||
kind: kindLabel(rule.kind),
|
kind: rule.kindGuessed ? t('rule.anyKind') : kindLabel(rule.kind),
|
||||||
count: rule.count,
|
count: rule.count,
|
||||||
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
|
per: rule.perGuessed ? t('rule.perUnset') : 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')),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { correctIndex, questionsOf, toMarkdown, withPlanItem, type Material } from './results'
|
import { correctIndex, keepSettled, questionsOf, toMarkdown, withPlanItem, type Material } from './results'
|
||||||
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
|
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
|
||||||
|
|
||||||
describe('correctIndex', () => {
|
describe('correctIndex', () => {
|
||||||
@@ -65,3 +65,20 @@ describe('withPlanItem', () => {
|
|||||||
expect(withPlanItem('preparing', undefined)).toBe('preparing')
|
expect(withPlanItem('preparing', undefined)).toBe('preparing')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('keepSettled', () => {
|
||||||
|
const ok = (v: number[]): PromiseSettledResult<number[]> => ({ status: 'fulfilled', value: v })
|
||||||
|
const bad = (message: string): PromiseSettledResult<number[]> => ({ status: 'rejected', reason: new Error(message) })
|
||||||
|
|
||||||
|
it('leaves out a schedule that failed', () => {
|
||||||
|
expect(keepSettled([ok([1, 2]), bad('404'), ok([3])])).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails when every schedule failed', () => {
|
||||||
|
expect(() => keepSettled([bad('500'), bad('502')])).toThrow('500')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty without schedules', () => {
|
||||||
|
expect(keepSettled([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -172,14 +172,24 @@ async function sourcesOf({ agent, rule }: Schedule): Promise<FoundSource[]> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One schedule the engine cannot answer for (e.g. an archived agent) leaves its results out
|
||||||
|
// instead of failing them all; only when every schedule fails is that an error.
|
||||||
|
export function keepSettled<T>(results: PromiseSettledResult<T[]>[]): T[] {
|
||||||
|
const kept = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
|
||||||
|
const failed = results.filter(r => r.status === 'rejected')
|
||||||
|
if (failed.length) console.warn(`${failed.length} of ${results.length} schedules could not be loaded`, failed.map(r => r.reason))
|
||||||
|
if (results.length && failed.length === results.length) throw failed[0].reason
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
|
export async function listResults(): Promise<{ materials: Material[]; sources: FoundSource[] }> {
|
||||||
const schedules = await expressAgents({ archived: true })
|
const schedules = await expressAgents({ archived: true })
|
||||||
const [materials, sources] = await Promise.all([
|
const [materials, sources] = await Promise.all([
|
||||||
Promise.all(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
|
Promise.allSettled(schedules.filter(s => s.rule.type === 'content').map(materialsOf)),
|
||||||
Promise.all(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
|
Promise.allSettled(schedules.filter(s => s.rule.type === 'research').map(sourcesOf)),
|
||||||
])
|
])
|
||||||
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
|
const newest = <T extends { createdAt: string }>(a: T, b: T) => b.createdAt.localeCompare(a.createdAt)
|
||||||
return { materials: materials.flat().sort(newest), sources: sources.flat().sort(newest) }
|
return { materials: keepSettled(materials).sort(newest), sources: keepSettled(sources).sort(newest) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftContent = (m: Material, id: number) =>
|
const draftContent = (m: Material, id: number) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { ContentRule, ResearchRule } from './parse'
|
import type { ContentRule, ResearchRule } from './parse'
|
||||||
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
|
import { budgetFor, canSchedule, configFor, isMine, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules'
|
||||||
|
|
||||||
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule =>
|
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule =>
|
||||||
({ type: 'content', kind, count, per, topic, language })
|
({ type: 'content', kind, count, per, topic, language })
|
||||||
@@ -9,7 +9,7 @@ const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'w
|
|||||||
function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent {
|
function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent {
|
||||||
const c = config as Record<string, unknown>
|
const c = config as Record<string, unknown>
|
||||||
return {
|
return {
|
||||||
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', auto_approve_sources: false,
|
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', created_by_user_id: 7, auto_approve_sources: false,
|
||||||
name: c.name as string, goal: c.goal as string, instructions: c.instructions as string,
|
name: c.name as string, goal: c.goal as string, instructions: c.instructions as string,
|
||||||
enabled_tools: c.enabled_tools as string[],
|
enabled_tools: c.enabled_tools as string[],
|
||||||
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
|
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
|
||||||
@@ -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),
|
||||||
@@ -97,3 +108,34 @@ describe('canSchedule', () => {
|
|||||||
expect(canSchedule(rule('summary', 0))).toBe(false)
|
expect(canSchedule(rule('summary', 0))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('production sources', () => {
|
||||||
|
it('needs two sources for planned content, not for video or research', () => {
|
||||||
|
expect(needsTwoSources(rule('quiz'))).toBe(true)
|
||||||
|
expect(needsTwoSources(rule('video', 1))).toBe(false)
|
||||||
|
expect(needsTwoSources({ type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: false })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explains the engine's refusal for too few sources and passes others through", () => {
|
||||||
|
expect(productionError('At least two ready sources with complementary evidence are required')).toMatch(/2/)
|
||||||
|
expect(productionError('At least two ready sources with complementary evidence are required')).not.toMatch(/complementary/)
|
||||||
|
expect(productionError('Something else')).toBe('Something else')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isMine', () => {
|
||||||
|
const owners = { me: 7, names: new Map<number, string>() }
|
||||||
|
const schedule = (by: number | null) => {
|
||||||
|
const agent = { ...agentFrom(configFor(rule('summary'))), created_by_user_id: by }
|
||||||
|
return { agent, rule: ruleOf(agent), active: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps only the viewer’s own schedules', () => {
|
||||||
|
expect(isMine(schedule(7), owners)).toBe(true)
|
||||||
|
expect(isMine(schedule(8), owners)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts a schedule whose creator was removed as someone else’s', () => {
|
||||||
|
expect(isMine(schedule(null), owners)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// use run_schedule (one run per day or week; a video run renders one video).
|
// use run_schedule (one run per day or week; a video run renders one video).
|
||||||
import { opKey, request } from '../api/client'
|
import { opKey, request } from '../api/client'
|
||||||
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
|
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
|
||||||
import { getLang } from '../i18n'
|
import { getLang, t } from '../i18n'
|
||||||
import {
|
import {
|
||||||
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
|
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
|
||||||
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
|
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
|
||||||
@@ -41,6 +41,7 @@ export interface ExpressAgent extends Agent {
|
|||||||
run_budget: number
|
run_budget: number
|
||||||
monthly_budget: number
|
monthly_budget: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
created_by_user_id: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Schedule {
|
export interface Schedule {
|
||||||
@@ -73,6 +74,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'
|
||||||
@@ -102,9 +108,35 @@ export async function expressAgents({ archived = false } = {}): Promise<Schedule
|
|||||||
|
|
||||||
export const listSchedules = () => expressAgents()
|
export const listSchedules = () => expressAgents()
|
||||||
|
|
||||||
|
// An organization admin sees every schedule in the organization, a member only their own (and any
|
||||||
|
// shared with them), so the Schedule page says who made each one and starts with the viewer's own.
|
||||||
|
export interface Owners { me: number; names: Map<number, string> }
|
||||||
|
|
||||||
|
/** The viewer's engine user id and the organization roster's names. The roster is admin-only;
|
||||||
|
* a member gets no names, which is fine: a member's list holds their own schedules. */
|
||||||
|
export async function scheduleOwners(): Promise<Owners> {
|
||||||
|
const [me, roster] = await Promise.all([
|
||||||
|
request<{ id: number }>('/auth/me'),
|
||||||
|
request<{ members: { user_id: number; name: string | null; email: string | null }[] }>('/organizations/current/dashboard/members')
|
||||||
|
.catch(() => ({ members: [] })),
|
||||||
|
])
|
||||||
|
return { me: me.id, names: new Map(roster.members.map(m => [m.user_id, m.name || m.email || `#${m.user_id}`])) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isMine = (s: Schedule, owners: Owners) => s.agent.created_by_user_id === owners.me
|
||||||
|
|
||||||
/** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */
|
/** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */
|
||||||
export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video'
|
export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video'
|
||||||
|
|
||||||
|
// The engine plans each production piece from a pair of documents, so a content schedule
|
||||||
|
// with fewer ready sources than this cannot plan anything. Video and research runs need one.
|
||||||
|
export const MIN_PRODUCTION_SOURCES = 2
|
||||||
|
export const needsTwoSources = (rule: Rule) => !isRunSchedule(rule)
|
||||||
|
const TOO_FEW_SOURCES = 'At least two ready sources with complementary evidence are required'
|
||||||
|
/** The engine's planner refusal, said in the UI language; any other message as it came. */
|
||||||
|
export const productionError = (message: string) =>
|
||||||
|
(message.includes(TOO_FEW_SOURCES) ? t('sch.tooFewSources', { n: MIN_PRODUCTION_SOURCES }) : message)
|
||||||
|
|
||||||
// Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene).
|
// Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene).
|
||||||
export const VIDEO = { scenes: 4, seconds: 40 }
|
export const VIDEO = { scenes: 4, seconds: 40 }
|
||||||
|
|
||||||
@@ -161,18 +193,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 +264,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 +288,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' })
|
||||||
@@ -341,8 +382,24 @@ export interface RunSummary {
|
|||||||
trigger_kind: string
|
trigger_kind: string
|
||||||
safe_error_code: string | null
|
safe_error_code: string | null
|
||||||
budget_spent: number | null
|
budget_spent: number | null
|
||||||
|
has_readiness_issues: boolean
|
||||||
|
/** Research runs: what came of the sources found, e.g. no_new_sources when nothing was kept. */
|
||||||
|
source_outcome?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
finished_at: string | null
|
finished_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
|
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
|
||||||
|
|
||||||
|
// What stops a run with knowledge_not_ready: the schedule's own material that is not ready (the whole
|
||||||
|
// Library only for an agent with no material). This is the current state, not the run's.
|
||||||
|
export interface ReadinessIssue {
|
||||||
|
kind: 'document' | 'compose_set'
|
||||||
|
resource_id: number
|
||||||
|
title: string
|
||||||
|
reason: string
|
||||||
|
action_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeReadiness = (id: number) =>
|
||||||
|
request<{ ready: boolean; issues: ReadinessIssue[] }>(`${AGENTS}/${id}/knowledge-readiness`)
|
||||||
|
|||||||
284
src/i18n.ts
284
src/i18n.ts
@@ -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,9 +21,10 @@ 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.collapseSidebar': 'Collapse sidebar',
|
||||||
|
'layout.expandSidebar': 'Expand sidebar',
|
||||||
'layout.language': 'Language',
|
'layout.language': 'Language',
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
@@ -49,26 +50,35 @@ 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.fullscreen': 'Full screen',
|
||||||
|
'prod.exitFullscreen': 'Exit full screen',
|
||||||
'prod.loadingGraph': 'Drawing the knowledge graph…',
|
'prod.loadingGraph': 'Drawing the knowledge graph…',
|
||||||
'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
|
'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
|
||||||
'prod.hub.sources': 'Sources',
|
'prod.hub.sources': 'Knowledge base',
|
||||||
'prod.hub.made': 'Made',
|
'prod.hub.made': 'Content',
|
||||||
'prod.hub.working': 'In progress',
|
|
||||||
'prod.hub.research': 'Research',
|
|
||||||
'prod.hub.schedules': 'Repeats',
|
|
||||||
'prod.nodes': 'Nodes',
|
'prod.nodes': 'Nodes',
|
||||||
'prod.nodesSub': 'in this Brain',
|
'prod.nodesSub': 'in this Brain',
|
||||||
'prod.links': 'Links',
|
'prod.links': 'Links',
|
||||||
@@ -81,8 +91,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',
|
||||||
@@ -117,7 +127,11 @@ const en = {
|
|||||||
'brain.needsCreditsNeed': ' (needs {credits})',
|
'brain.needsCreditsNeed': ' (needs {credits})',
|
||||||
'brain.failed': 'Could not add this to the Brain.',
|
'brain.failed': 'Could not add this to the Brain.',
|
||||||
'brain.retryWith': 'Try again ({credits})',
|
'brain.retryWith': 'Try again ({credits})',
|
||||||
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is removed: delete it and upload it again.',
|
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use it stop until it is fixed. Process it again: its knowledge is kept.',
|
||||||
|
'brain.failedProcessing': 'This file could not be processed. Schedules that use it stop until it is fixed. Process it again, or delete it.',
|
||||||
|
'brain.failedVideoAfterBuild': 'Processing this video failed after its knowledge was built. Schedules that use it stop until it is fixed: upload it again.',
|
||||||
|
'brain.reprocess': 'Process again',
|
||||||
|
'brain.reprocessing': 'Starting…',
|
||||||
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
|
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
|
||||||
'brain.confirmCreditsFallback': 'credits',
|
'brain.confirmCreditsFallback': 'credits',
|
||||||
'brain.addToBrain': 'Add to Brain',
|
'brain.addToBrain': 'Add to Brain',
|
||||||
@@ -126,8 +140,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 +181,10 @@ 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.anyKind': 'Content (kind not chosen yet)',
|
||||||
|
'rule.perUnset': '(how often not set yet)',
|
||||||
'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',
|
||||||
@@ -188,6 +206,13 @@ const en = {
|
|||||||
'sch.loading': 'Loading schedules…',
|
'sch.loading': 'Loading schedules…',
|
||||||
'sch.loadFailed': 'Could not load schedules',
|
'sch.loadFailed': 'Could not load schedules',
|
||||||
'sch.empty': 'No schedules yet',
|
'sch.empty': 'No schedules yet',
|
||||||
|
'sch.mine': 'Mine',
|
||||||
|
'sch.all': 'Everyone in the organization',
|
||||||
|
'sch.emptyMine': 'You have no schedules yet',
|
||||||
|
'sch.emptyMineCopy': 'Other people in this organization have schedules; see them under “Everyone in the organization”.',
|
||||||
|
'sch.byYou': 'Created by you',
|
||||||
|
'sch.by': 'Created by {name}',
|
||||||
|
'sch.byUnknown': 'Created by someone else',
|
||||||
'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.',
|
'sch.emptyCopy': 'Give an instruction like “2 summaries per day about leadership” to start.',
|
||||||
'sch.nextResearch': 'Next research: {when}',
|
'sch.nextResearch': 'Next research: {when}',
|
||||||
'sch.paused': 'Paused',
|
'sch.paused': 'Paused',
|
||||||
@@ -231,6 +256,34 @@ const en = {
|
|||||||
'sch.scheduled': 'Scheduled',
|
'sch.scheduled': 'Scheduled',
|
||||||
'sch.manual': 'Manual',
|
'sch.manual': 'Manual',
|
||||||
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
|
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
|
||||||
|
'run.paused_configuration': 'Needs attention', 'run.paused_budget': 'Paused: credits',
|
||||||
|
'runErr.knowledge_not_ready': 'Some material this schedule uses is not ready, so the engine will not start.',
|
||||||
|
'runErr.video_sources_unavailable': 'No ready material matched this schedule for a video.',
|
||||||
|
'runErr.material_sources_unavailable': 'No ready material matched this schedule.',
|
||||||
|
'runErr.knowledge_search_failed': 'Searching the material failed. Try again.',
|
||||||
|
'runErr.organization_wallet': 'The organization does not have enough credits.',
|
||||||
|
'runErr.run_cap': 'This run would exceed the per-run credit limit.',
|
||||||
|
'runErr.monthly_cap': 'The monthly credit limit for this schedule is reached.',
|
||||||
|
'runErr.reservation_cap': 'Too many credits are already reserved by other runs.',
|
||||||
|
'runErr.price_changed': 'The price changed. Try again.',
|
||||||
|
'runErr.video_price_changed': 'The video price changed. Try again.',
|
||||||
|
'runErr.agent_not_active': 'The schedule is paused or deleted.',
|
||||||
|
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
|
||||||
|
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
|
||||||
|
'sch.noNewSources': 'No new sources found. A more specific topic may help.',
|
||||||
|
'sch.blockedBy': 'Blocking material:',
|
||||||
|
'sch.blockedNone': 'All material is ready now. Try again.',
|
||||||
|
'sch.fixInStudio': 'Open in Learning Studio',
|
||||||
|
'issue.document_processing': 'still processing',
|
||||||
|
'issue.document_failed': 'failed to process; reprocess or delete it',
|
||||||
|
'issue.graph_building': 'knowledge graph still building',
|
||||||
|
'issue.graph_not_ready': 'knowledge graph not prepared yet',
|
||||||
|
'issue.legacy_set_needs_conversion': 'old Compose set; convert it',
|
||||||
|
'issue.no_knowledge_sources': 'Compose set has no sources',
|
||||||
|
'issue.source_not_accessible': 'Compose set uses a document you cannot access',
|
||||||
|
'issue.source_graph_not_ready': 'a source document is not ready',
|
||||||
|
'issue.source_version_changed': 'a source document changed; refresh the set',
|
||||||
|
'issue.snapshot_not_ready': 'Compose graph not prepared yet',
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
'res.title': 'Results',
|
'res.title': 'Results',
|
||||||
@@ -316,7 +369,20 @@ const en = {
|
|||||||
'agent.greeting': 'Hi! Tell me what to create from the Brain and how often, or what to research on the internet. If anything is unclear, I will ask you here.',
|
'agent.greeting': 'Hi! Tell me what to create from the Brain and how often, or what to research on the internet. If anything is unclear, I will ask you here.',
|
||||||
'agent.understood': 'Got it. Here is the plan so far:\n{list}',
|
'agent.understood': 'Got it. Here is the plan so far:\n{list}',
|
||||||
'agent.added': 'Added to the plan:\n{list}',
|
'agent.added': 'Added to the plan:\n{list}',
|
||||||
'agent.notUnderstood': 'I could not find a content type or research request in that. Try something like “2 summaries per day about leadership”.',
|
'agent.notUnderstood': 'I could not find a content type or research request in that. I can make summaries, quizzes, flashcards, roleplays, simulations, slides, videos, audio and SCORM modules from the Brain, or research the internet. Try something like “2 summaries per day about leadership”.',
|
||||||
|
'agent.askKind': '{count} pieces of content{about}: which kind? Pick one, or name several (for example “summaries and quizzes”) to split them.',
|
||||||
|
'agent.askPer': 'How often should this be made? You did not say, and I would rather not guess.\n{list}',
|
||||||
|
'agent.optMix': 'Mix (summaries, quizzes, flashcards)',
|
||||||
|
'agent.optEveryDay': 'Every day',
|
||||||
|
'agent.optEveryWeek': 'Every week',
|
||||||
|
'agent.kindSet': 'Updated the plan:\n{list}',
|
||||||
|
'note.wholeBrain': 'Sources: the whole Brain (every ready document), unless you tick specific material in the Brain panel.',
|
||||||
|
'note.novelty': 'Each production plan is checked against what was already made, so topics are not repeated.',
|
||||||
|
'note.draft': 'Everything made lands in Results as a draft marked “Needs review”: an admin approves or rejects it there before it is used.',
|
||||||
|
'note.automatic': 'Once you approve, the schedules run on their own in the background; nothing needs starting by hand.',
|
||||||
|
'note.hourMatches': 'Content is produced at {at} WIB, as you asked.',
|
||||||
|
'note.hourFixed': 'Content is produced at 09.00 WIB and research runs at 07.00 WIB; a different hour such as {at} cannot be set here yet.',
|
||||||
|
'note.subDay': 'Schedules run per day or per week, so I cannot promise results within minutes or hours. Once a schedule is active, “Produce now” on the Schedule page starts it early.',
|
||||||
'agent.unclear': 'Sorry, I did not catch that. Pick one of the options below or answer in your own words.',
|
'agent.unclear': 'Sorry, I did not catch that. Pick one of the options below or answer in your own words.',
|
||||||
'agent.askUnknown': 'I did not understand “{part}”. What did you mean? Rephrase it, or skip it.',
|
'agent.askUnknown': 'I did not understand “{part}”. What did you mean? Rephrase it, or skip it.',
|
||||||
'agent.askVideo': '{rule}: a video schedule makes one video per run, so it can be 1 per week or 1 per day. Which one?',
|
'agent.askVideo': '{rule}: a video schedule makes one video per run, so it can be 1 per week or 1 per day. Which one?',
|
||||||
@@ -396,16 +462,36 @@ 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',
|
||||||
|
'ins.tooFewChosen': ' Content schedules (summary, quiz and the like) need at least {n} materials. Choose one more in the Brain panel.',
|
||||||
|
'ins.tooFewInBrain': ' Content schedules (summary, quiz and the like) need at least {n} ready materials in the Brain. Upload another one first.',
|
||||||
|
'sch.tooFewSources': 'This schedule has fewer than {n} ready materials, and content is planned from pairs of materials. Delete it and make it again with {n} or more.',
|
||||||
|
'brainPanel.upload': 'Upload material',
|
||||||
|
|
||||||
// Guide
|
// 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,26 +514,35 @@ 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.fullscreen': 'Layar penuh',
|
||||||
|
'prod.exitFullscreen': 'Keluar layar penuh',
|
||||||
'prod.loadingGraph': 'Menggambar knowledge graph…',
|
'prod.loadingGraph': 'Menggambar knowledge graph…',
|
||||||
'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
|
'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
|
||||||
'prod.hub.sources': 'Sumber',
|
'prod.hub.sources': 'Basis pengetahuan',
|
||||||
'prod.hub.made': 'Dibuat',
|
'prod.hub.made': 'Konten',
|
||||||
'prod.hub.working': 'Sedang dibuat',
|
|
||||||
'prod.hub.research': 'Riset',
|
|
||||||
'prod.hub.schedules': 'Berulang',
|
|
||||||
'prod.nodes': 'Node',
|
'prod.nodes': 'Node',
|
||||||
'prod.nodesSub': 'di Otak ini',
|
'prod.nodesSub': 'di Otak ini',
|
||||||
'prod.links': 'Tautan',
|
'prod.links': 'Tautan',
|
||||||
@@ -458,8 +553,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,9 +567,10 @@ 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.collapseSidebar': 'Ciutkan sidebar',
|
||||||
|
'layout.expandSidebar': 'Lebarkan sidebar',
|
||||||
'layout.language': 'Bahasa',
|
'layout.language': 'Bahasa',
|
||||||
|
|
||||||
'auth.loading': 'Memuat…',
|
'auth.loading': 'Memuat…',
|
||||||
@@ -496,8 +592,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',
|
||||||
@@ -532,7 +628,11 @@ const id: Record<Key, string> = {
|
|||||||
'brain.needsCreditsNeed': ' (butuh {credits})',
|
'brain.needsCreditsNeed': ' (butuh {credits})',
|
||||||
'brain.failed': 'Gagal memasukkan ke Otak.',
|
'brain.failed': 'Gagal memasukkan ke Otak.',
|
||||||
'brain.retryWith': 'Coba lagi ({credits})',
|
'brain.retryWith': 'Coba lagi ({credits})',
|
||||||
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai file ini dihapus: hapus lalu unggah ulang.',
|
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang: pengetahuannya tetap disimpan.',
|
||||||
|
'brain.failedProcessing': 'File ini gagal diproses. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang, atau hapus.',
|
||||||
|
'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki: unggah ulang.',
|
||||||
|
'brain.reprocess': 'Proses ulang',
|
||||||
|
'brain.reprocessing': 'Memulai…',
|
||||||
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
|
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
|
||||||
'brain.confirmCreditsFallback': 'kredit',
|
'brain.confirmCreditsFallback': 'kredit',
|
||||||
'brain.addToBrain': 'Masukkan ke Otak',
|
'brain.addToBrain': 'Masukkan ke Otak',
|
||||||
@@ -540,8 +640,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 +680,10 @@ 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.anyKind': 'Konten (jenis belum dipilih)',
|
||||||
|
'rule.perUnset': '(frekuensi belum ditentukan)',
|
||||||
'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',
|
||||||
@@ -600,6 +704,13 @@ const id: Record<Key, string> = {
|
|||||||
'sch.loading': 'Memuat jadwal…',
|
'sch.loading': 'Memuat jadwal…',
|
||||||
'sch.loadFailed': 'Jadwal tidak dapat dimuat',
|
'sch.loadFailed': 'Jadwal tidak dapat dimuat',
|
||||||
'sch.empty': 'Belum ada jadwal',
|
'sch.empty': 'Belum ada jadwal',
|
||||||
|
'sch.mine': 'Punya saya',
|
||||||
|
'sch.all': 'Semua di organisasi',
|
||||||
|
'sch.emptyMine': 'Anda belum punya jadwal',
|
||||||
|
'sch.emptyMineCopy': 'Orang lain di organisasi ini punya jadwal; lihat di “Semua di organisasi”.',
|
||||||
|
'sch.byYou': 'Dibuat oleh Anda',
|
||||||
|
'sch.by': 'Dibuat oleh {name}',
|
||||||
|
'sch.byUnknown': 'Dibuat oleh orang lain',
|
||||||
'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.',
|
'sch.emptyCopy': 'Beri perintah seperti “2 ringkasan per hari soal leadership” untuk mulai.',
|
||||||
'sch.nextResearch': 'Riset berikutnya: {when}',
|
'sch.nextResearch': 'Riset berikutnya: {when}',
|
||||||
'sch.paused': 'Dijeda',
|
'sch.paused': 'Dijeda',
|
||||||
@@ -643,6 +754,34 @@ const id: Record<Key, string> = {
|
|||||||
'sch.scheduled': 'Terjadwal',
|
'sch.scheduled': 'Terjadwal',
|
||||||
'sch.manual': 'Manual',
|
'sch.manual': 'Manual',
|
||||||
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
|
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
|
||||||
|
'run.paused_configuration': 'Perlu tindakan', 'run.paused_budget': 'Tertahan: kredit',
|
||||||
|
'runErr.knowledge_not_ready': 'Sebagian materi yang dipakai jadwal ini belum siap, jadi engine tidak mau mulai.',
|
||||||
|
'runErr.video_sources_unavailable': 'Tidak ada materi siap yang cocok untuk video jadwal ini.',
|
||||||
|
'runErr.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.',
|
||||||
|
'runErr.knowledge_search_failed': 'Pencarian materi gagal. Coba lagi.',
|
||||||
|
'runErr.organization_wallet': 'Kredit organisasi tidak cukup.',
|
||||||
|
'runErr.run_cap': 'Run ini melebihi batas kredit per run.',
|
||||||
|
'runErr.monthly_cap': 'Batas kredit bulanan jadwal ini sudah tercapai.',
|
||||||
|
'runErr.reservation_cap': 'Terlalu banyak kredit sudah dipesan oleh run lain.',
|
||||||
|
'runErr.price_changed': 'Harga berubah. Coba lagi.',
|
||||||
|
'runErr.video_price_changed': 'Harga video berubah. Coba lagi.',
|
||||||
|
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
|
||||||
|
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
|
||||||
|
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
|
||||||
|
'sch.noNewSources': 'Tidak ada source baru yang ditemukan. Topik yang lebih spesifik bisa membantu.',
|
||||||
|
'sch.blockedBy': 'Materi yang menahan:',
|
||||||
|
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
|
||||||
|
'sch.fixInStudio': 'Buka di Learning Studio',
|
||||||
|
'issue.document_processing': 'masih diproses',
|
||||||
|
'issue.document_failed': 'gagal diproses; proses ulang atau hapus',
|
||||||
|
'issue.graph_building': 'knowledge graph masih dibangun',
|
||||||
|
'issue.graph_not_ready': 'knowledge graph belum disiapkan',
|
||||||
|
'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi',
|
||||||
|
'issue.no_knowledge_sources': 'set Compose tidak punya sumber',
|
||||||
|
'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses',
|
||||||
|
'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap',
|
||||||
|
'issue.source_version_changed': 'dokumen sumber berubah; perbarui set-nya',
|
||||||
|
'issue.snapshot_not_ready': 'graph Compose belum disiapkan',
|
||||||
|
|
||||||
'res.title': 'Hasil',
|
'res.title': 'Hasil',
|
||||||
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
|
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
|
||||||
@@ -727,7 +866,20 @@ const id: Record<Key, string> = {
|
|||||||
'agent.greeting': 'Halo! Ceritakan konten apa yang perlu dibuat dari Otak dan seberapa sering, atau apa yang perlu diriset di internet. Kalau ada yang belum jelas, saya tanyakan di sini.',
|
'agent.greeting': 'Halo! Ceritakan konten apa yang perlu dibuat dari Otak dan seberapa sering, atau apa yang perlu diriset di internet. Kalau ada yang belum jelas, saya tanyakan di sini.',
|
||||||
'agent.understood': 'Siap. Ini rencananya sejauh ini:\n{list}',
|
'agent.understood': 'Siap. Ini rencananya sejauh ini:\n{list}',
|
||||||
'agent.added': 'Ditambahkan ke rencana:\n{list}',
|
'agent.added': 'Ditambahkan ke rencana:\n{list}',
|
||||||
'agent.notUnderstood': 'Saya belum menemukan jenis konten atau permintaan riset di situ. Coba misalnya “2 ringkasan per hari soal leadership”.',
|
'agent.notUnderstood': 'Saya belum menemukan jenis konten atau permintaan riset di situ. Saya bisa membuat ringkasan, kuis, flashcard, roleplay, simulasi, slide, video, audio, dan modul SCORM dari Otak, atau riset internet. Coba misalnya “2 ringkasan per hari soal leadership”.',
|
||||||
|
'agent.askKind': '{count} konten{about}: jenisnya apa? Pilih satu, atau sebut beberapa (misalnya “ringkasan dan kuis”) untuk membaginya.',
|
||||||
|
'agent.askPer': 'Seberapa sering ini dibuat? Anda belum menyebutkannya, dan saya tidak mau menebak.\n{list}',
|
||||||
|
'agent.optMix': 'Campuran (ringkasan, kuis, flashcard)',
|
||||||
|
'agent.optEveryDay': 'Setiap hari',
|
||||||
|
'agent.optEveryWeek': 'Setiap minggu',
|
||||||
|
'agent.kindSet': 'Rencana diperbarui:\n{list}',
|
||||||
|
'note.wholeBrain': 'Sumbernya seluruh Otak (semua dokumen yang siap), kecuali Anda mencentang materi tertentu di panel Otak.',
|
||||||
|
'note.novelty': 'Setiap rencana produksi dicek terhadap konten yang sudah ada, jadi topiknya tidak berulang.',
|
||||||
|
'note.draft': 'Semua hasil masuk ke Hasil sebagai draf berstatus “Perlu ditinjau”: admin menyetujui atau menolaknya di sana sebelum dipakai.',
|
||||||
|
'note.automatic': 'Setelah Anda setujui, jadwal berjalan sendiri di latar belakang; tidak perlu dijalankan manual.',
|
||||||
|
'note.hourMatches': 'Konten diproduksi pukul {at} WIB, sesuai permintaan.',
|
||||||
|
'note.hourFixed': 'Konten diproduksi pukul 09.00 WIB dan riset pukul 07.00 WIB; jam lain seperti {at} belum bisa diatur dari sini.',
|
||||||
|
'note.subDay': 'Jadwal berjalan per hari atau per minggu, jadi saya tidak bisa menjanjikan hasil dalam hitungan menit atau jam. Setelah jadwal aktif, tombol “Produksi sekarang” di halaman Jadwal memulainya lebih awal.',
|
||||||
'agent.unclear': 'Maaf, saya belum menangkap maksudnya. Pilih salah satu opsi di bawah atau jawab dengan kata-kata sendiri.',
|
'agent.unclear': 'Maaf, saya belum menangkap maksudnya. Pilih salah satu opsi di bawah atau jawab dengan kata-kata sendiri.',
|
||||||
'agent.askUnknown': 'Saya belum paham “{part}”. Maksudnya apa? Tulis ulang, atau lewati saja.',
|
'agent.askUnknown': 'Saya belum paham “{part}”. Maksudnya apa? Tulis ulang, atau lewati saja.',
|
||||||
'agent.askVideo': '{rule}: jadwal video membuat satu video per proses, jadi pilihannya 1 per minggu atau 1 per hari. Mau yang mana?',
|
'agent.askVideo': '{rule}: jadwal video membuat satu video per proses, jadi pilihannya 1 per minggu atau 1 per hari. Mau yang mana?',
|
||||||
@@ -807,16 +959,36 @@ 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',
|
||||||
|
'ins.tooFewChosen': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi. Pilih satu lagi di panel Brain.',
|
||||||
|
'ins.tooFewInBrain': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi siap di Brain. Unggah satu lagi dulu.',
|
||||||
|
'sch.tooFewSources': 'Jadwal ini punya kurang dari {n} materi siap, padahal konten direncanakan dari pasangan materi. Hapus lalu buat ulang dengan {n} materi atau lebih.',
|
||||||
|
'brainPanel.upload': 'Upload materi',
|
||||||
|
|
||||||
// Panduan
|
// 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.',
|
||||||
|
|||||||
@@ -1,44 +1,42 @@
|
|||||||
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, reprocessDocument, 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'
|
||||||
|
|
||||||
type Phase = 'ready' | 'working' | 'answer' | 'failed'
|
type Phase = 'ready' | 'working' | 'answer' | 'failed'
|
||||||
|
|
||||||
function phaseOf(s: Source): Phase {
|
function phaseOf(s: Source): Phase {
|
||||||
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base.
|
// A failed document blocks every schedule that uses it, even with a built knowledge base.
|
||||||
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
||||||
|
// A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules.
|
||||||
|
if (s.status !== 'completed') return 'working'
|
||||||
if (s.knowledge === 'ready') return 'ready'
|
if (s.knowledge === 'ready') return 'ready'
|
||||||
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
|
if (s.knowledge === 'building') return 'working'
|
||||||
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
|
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +49,77 @@ export function BrainPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Brain documents a schedule can be made from; nothing chosen means the whole Brain. */
|
||||||
|
export const choosable = (docs: Source[] | undefined) => docs?.filter(d => phaseOf(d) === 'ready') ?? []
|
||||||
|
|
||||||
|
// The Brain beside the AI chat: what schedules will draw on, with the same open and delete as Upload material.
|
||||||
|
// Failed documents are left out: they cannot be chosen, and Upload material is where they are fixed.
|
||||||
|
export function BrainPanel({ selected, onSelect }: { selected: Set<number>; onSelect: (ids: Set<number>) => void }) {
|
||||||
|
const docs = useDocuments()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const listed = docs.data?.filter(d => phaseOf(d) !== 'failed')
|
||||||
|
const shown = listed?.filter(d => !q || `${d.title} ${d.originalName}`.toLowerCase().includes(q))
|
||||||
|
const ready = choosable(docs.data)
|
||||||
|
const allChosen = ready.length > 0 && ready.every(d => selected.has(d.docId))
|
||||||
|
const toggle = (id: number) => {
|
||||||
|
const next = new Set(selected)
|
||||||
|
if (!next.delete(id)) next.add(id)
|
||||||
|
onSelect(next)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="card compact-brain">
|
||||||
|
<div className="card-head">
|
||||||
|
<div>
|
||||||
|
<h3>{t('brainPanel.title')}{listed && <span className="badge neutral">{t('brainPanel.count', { n: listed.length })}</span>}</h3>
|
||||||
|
<p>{t('brainPanel.copy')}</p>
|
||||||
|
</div>
|
||||||
|
<Icon name="brain" />
|
||||||
|
</div>
|
||||||
|
<div className="compact-brain-search">
|
||||||
|
<div className="search">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input type="search" aria-label={t('brainPanel.search')} placeholder={t('brainPanel.search')} value={query} onChange={e => setQuery(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!!ready.length && (
|
||||||
|
<div className="brain-selection">
|
||||||
|
<label className="check-label">
|
||||||
|
<input type="checkbox" checked={allChosen} onChange={() => onSelect(allChosen ? new Set() : new Set(ready.map(d => d.docId)))} />
|
||||||
|
{t('brainPanel.selectAll')}
|
||||||
|
</label>
|
||||||
|
<span>{selected.size ? t('brainPanel.selected', { n: selected.size }) : t('brainPanel.wholeBrain')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{docs.isPending && <SkeletonRows label={t('brain.loading')} />}
|
||||||
|
{docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>}
|
||||||
|
{listed?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>}
|
||||||
|
{!!listed?.length && shown?.length === 0 && <Empty icon="search" title={t('brainPanel.noMatch')}>{t('brainPanel.noMatchCopy')}</Empty>}
|
||||||
|
{!!shown?.length && (
|
||||||
|
<div className="compact-source-list">
|
||||||
|
{shown.map(d => (
|
||||||
|
<div key={d.docId} className="compact-source-row">
|
||||||
|
<input type="checkbox" checked={selected.has(d.docId)} disabled={phaseOf(d) !== 'ready'}
|
||||||
|
aria-label={t('brainPanel.select', { name: d.title })} title={phaseOf(d) === 'ready' ? undefined : t('brainPanel.notReady')}
|
||||||
|
onChange={() => toggle(d.docId)} />
|
||||||
|
<span className={`file-icon ${d.format.toLowerCase()}`}>{d.format.slice(0, 4)}</span>
|
||||||
|
<div className="compact-source-name">
|
||||||
|
<OpenButton doc={d} />
|
||||||
|
<small>{d.format.toUpperCase()} · <StatusLabel doc={d} phase={phaseOf(d)} /></small>
|
||||||
|
</div>
|
||||||
|
<DeleteButton doc={d} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="table-note compact-brain-foot">
|
||||||
|
<span>{t('brainPanel.note')}</span>
|
||||||
|
<Link className="btn small" to="/upload"><Icon name="upload" />{t('brainPanel.upload')}</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
|
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
|
||||||
|
|
||||||
let nextUploadId = 1
|
let nextUploadId = 1
|
||||||
@@ -116,7 +185,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 +265,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>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +300,26 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
|||||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||||
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
|
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
|
||||||
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
|
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
|
||||||
const error = build.error ?? version.error
|
const reprocess = useMutation({ mutationFn: () => reprocessDocument(doc.docId), onSuccess: refresh })
|
||||||
const busy = build.isPending || version.isPending
|
const error = build.error ?? version.error ?? reprocess.error
|
||||||
|
const busy = build.isPending || version.isPending || reprocess.isPending
|
||||||
|
|
||||||
|
// A failed file is read again, free; a knowledge base it already has is kept, so Compose sets
|
||||||
|
// that pin it (and block deleting it) keep working. Video goes back through upload instead.
|
||||||
|
if (doc.status === 'failed' && (doc.knowledge === 'ready' || doc.knowledge === 'not_built' || doc.knowledge === null)
|
||||||
|
&& doc.format.toLowerCase() !== 'mp4') {
|
||||||
|
return (
|
||||||
|
<div className="doc-action">
|
||||||
|
<small>{t(doc.knowledge === 'ready' ? 'brain.failedAfterBuild' : 'brain.failedProcessing')}</small>
|
||||||
|
<div className="doc-action-buttons">
|
||||||
|
<button className="btn small primary" disabled={busy} onClick={() => reprocess.mutate()}>
|
||||||
|
{reprocess.isPending ? t('brain.reprocessing') : t('brain.reprocess')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <span className="error">{error.message}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const d = detail.data
|
const d = detail.data
|
||||||
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
|
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
|
||||||
@@ -248,8 +343,8 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
|||||||
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
||||||
break
|
break
|
||||||
case 'ready':
|
case 'ready':
|
||||||
// Processing failed after the build: building again is refused, only removing it unblocks schedules.
|
// Processing failed after the build. Documents are reprocessed above; this is a video.
|
||||||
text = t('brain.failedAfterBuild')
|
text = t('brain.failedVideoAfterBuild')
|
||||||
break
|
break
|
||||||
case 'failed':
|
case 'failed':
|
||||||
text = d.error || t('brain.failed')
|
text = d.error || t('brain.failed')
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ 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 { 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,
|
||||||
} from '../express/parse'
|
} from '../express/parse'
|
||||||
import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
|
import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
|
||||||
import { inLang, t, useLang, type Key, type Lang } from '../i18n'
|
import { inLang, t, useLang, type Key, type Lang } from '../i18n'
|
||||||
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
|
import { budgetFor, canSchedule, createSchedule, MIN_PRODUCTION_SOURCES, needsTwoSources, VIDEO, videoQuote } from '../express/schedules'
|
||||||
|
|
||||||
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
|
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
|
||||||
const THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3']
|
const THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3']
|
||||||
@@ -35,6 +36,7 @@ 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)
|
||||||
|
const docs = useDocuments()
|
||||||
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('')
|
||||||
@@ -48,6 +50,8 @@ export function InstructionPage() {
|
|||||||
|
|
||||||
const question = nextQuestion(agent)
|
const question = nextQuestion(agent)
|
||||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||||
|
// The whole Brain is attached, so every ready document counts.
|
||||||
|
const tooFewSources = choosable(docs.data).length < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(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
|
||||||
@@ -97,7 +101,7 @@ export function InstructionPage() {
|
|||||||
|
|
||||||
async function approve(state = agent, replyLang = lang) {
|
async function approve(state = agent, replyLang = lang) {
|
||||||
const items = state.items.filter(i => canSchedule(i.rule))
|
const items = state.items.filter(i => canSchedule(i.rule))
|
||||||
if (!items.length) return
|
if (!items.length || tooFewSources) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const done: Outcome[] = []
|
const done: Outcome[] = []
|
||||||
const created = new Set<number>()
|
const created = new Set<number>()
|
||||||
@@ -114,7 +118,8 @@ export function InstructionPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 }])
|
||||||
@@ -184,8 +189,12 @@ export function InstructionPage() {
|
|||||||
</div>
|
</div>
|
||||||
{ready.length > 0 && (
|
{ready.length > 0 && (
|
||||||
<div className="rule-approve">
|
<div className="rule-approve">
|
||||||
<small>{t('ins.approveNote')}</small>
|
<small>
|
||||||
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
|
<b className="rule-sources"><Icon name="brain" />{t('ins.sourcesAll')}</b>
|
||||||
|
{t('ins.approveNote')}
|
||||||
|
{tooFewSources && <span className="error">{t('ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
|
||||||
|
</small>
|
||||||
|
<button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
|
||||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,8 +274,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 +291,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 +318,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' && (
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, 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',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses the Fullscreen API on the whole stage (graph + panel); where it is missing (iPhone Safari)
|
||||||
|
// the stage is pinned over the page instead, and Escape leaves it.
|
||||||
|
function useFullscreen() {
|
||||||
|
const ref = useRef<HTMLElement>(null)
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [native, setNative] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setNative(document.fullscreenElement != null && document.fullscreenElement === ref.current)
|
||||||
|
document.addEventListener('fullscreenchange', sync)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', sync)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pinned) return
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPinned(false) }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [pinned])
|
||||||
|
const toggle = () => {
|
||||||
|
if (native) void document.exitFullscreen()
|
||||||
|
else if (pinned) setPinned(false)
|
||||||
|
else if (ref.current?.requestFullscreen && document.fullscreenEnabled) ref.current.requestFullscreen().catch(() => setPinned(true))
|
||||||
|
else setPinned(true)
|
||||||
|
}
|
||||||
|
return { ref, full: native || pinned, pinned, toggle }
|
||||||
}
|
}
|
||||||
|
|
||||||
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||||
@@ -19,47 +47,51 @@ 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 fullscreen = useFullscreen()
|
||||||
|
|
||||||
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 ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null),
|
||||||
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
|
[docs.data, results.data],
|
||||||
: null),
|
|
||||||
[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>
|
<section ref={fullscreen.ref} className={`graph-stage${fullscreen.full ? ' full' : ''}${fullscreen.pinned ? ' pinned' : ''}`} aria-label={t('prod.graphTitle')}>
|
||||||
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
|
<button type="button" className="graph-fullscreen" onClick={fullscreen.toggle} aria-pressed={fullscreen.full}
|
||||||
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
|
aria-label={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')} title={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')}>
|
||||||
</div>
|
<Icon name={fullscreen.full ? 'shrink' : 'expand'} />
|
||||||
|
</button>
|
||||||
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
|
|
||||||
{graph
|
{graph
|
||||||
? <KnowledgeGraph graph={graph} />
|
? <KnowledgeGraph graph={graph} />
|
||||||
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}
|
// The engine's own message: an unreachable engine says so, any other failure says what it was.
|
||||||
|
: <div className="graph-loading" role="status">{(docs.error ?? results.error)?.message ?? t('prod.loadingGraph')}</div>}
|
||||||
|
|
||||||
<aside className="graph-panel">
|
<aside className="graph-panel">
|
||||||
<div className="graph-stats">
|
<div className="graph-stats">
|
||||||
@@ -67,7 +99,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 +123,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.
|
||||||
@@ -219,7 +295,7 @@ function KnowledgeGraph({ graph }: { graph: Graph }) {
|
|||||||
</g>
|
</g>
|
||||||
<g>
|
<g>
|
||||||
{graph.nodes.map((n, i) => {
|
{graph.nodes.map((n, i) => {
|
||||||
const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
const r = n.hub ? 9 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
||||||
return (
|
return (
|
||||||
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
|
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
|
||||||
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,36 +7,87 @@ import { toast } from '../components/Toast'
|
|||||||
import { describeRule } from '../express/parse'
|
import { describeRule } from '../express/parse'
|
||||||
import { locale, t, type Key } from '../i18n'
|
import { locale, t, type Key } from '../i18n'
|
||||||
import {
|
import {
|
||||||
archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
archiveSchedule, confirmProduction, isMine, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, scheduleOwners, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
||||||
planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule,
|
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type Owners, type RunSummary, type Schedule,
|
||||||
} from '../express/schedules'
|
} from '../express/schedules'
|
||||||
|
|
||||||
|
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||||
|
|
||||||
const fmt = (iso: string | null | undefined) => (iso
|
const fmt = (iso: string | null | undefined) => (iso
|
||||||
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||||
: '–')
|
: '–')
|
||||||
|
|
||||||
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled'])
|
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled', 'paused_configuration', 'paused_budget'])
|
||||||
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
|
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
|
||||||
|
const RUN_ERRORS = new Set([
|
||||||
|
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
|
||||||
|
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
|
||||||
|
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable',
|
||||||
|
])
|
||||||
|
const ISSUE_REASONS = new Set([
|
||||||
|
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',
|
||||||
|
'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready',
|
||||||
|
])
|
||||||
|
const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready'])
|
||||||
|
const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code)
|
||||||
|
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
|
||||||
|
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
|
||||||
|
|
||||||
|
// Which schedules the page starts with; remembered per device.
|
||||||
|
type Scope = 'mine' | 'all'
|
||||||
|
const SCOPE_KEY = 'express.scheduleScope'
|
||||||
|
const savedScope = (): Scope => {
|
||||||
|
try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'mine' } catch { return 'mine' }
|
||||||
|
}
|
||||||
|
|
||||||
export function SchedulePage() {
|
export function SchedulePage() {
|
||||||
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||||
|
const owners = useQuery({ queryKey: ['scheduleOwners'], queryFn: scheduleOwners, staleTime: 5 * 60_000 })
|
||||||
|
const [scope, setScope] = useState<Scope>(savedScope)
|
||||||
|
const choose = (next: Scope) => {
|
||||||
|
setScope(next)
|
||||||
|
try { localStorage.setItem(SCOPE_KEY, next) } catch { /* keep it for this visit only */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = schedules.data
|
||||||
|
const mine = all && owners.data ? all.filter(s => isMine(s, owners.data)) : undefined
|
||||||
|
// Members only ever see their own, so the switch appears only when someone else's schedules are listed.
|
||||||
|
const mixed = !!all && !!mine && mine.length < all.length
|
||||||
|
const shown = mixed && scope === 'mine' ? mine : all
|
||||||
|
|
||||||
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>} />
|
||||||
|
{mixed && (
|
||||||
|
<div className="result-tabs" role="tablist">
|
||||||
|
<button role="tab" aria-selected={scope === 'mine'} className={scope === 'mine' ? 'active' : undefined} onClick={() => choose('mine')}>
|
||||||
|
{t('sch.mine')} <b>{mine.length}</b>
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected={scope === 'all'} className={scope === 'all' ? 'active' : undefined} onClick={() => choose('all')}>
|
||||||
|
{t('sch.all')} <b>{all.length}</b>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<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')} />}
|
||||||
{schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>}
|
{schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>}
|
||||||
{schedules.data?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
|
{all?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
|
||||||
{!!schedules.data?.length && <div className="result-list">{schedules.data.map(s => <ScheduleRow key={s.agent.id} schedule={s} />)}</div>}
|
{mixed && shown?.length === 0 && <Empty icon="calendar" title={t('sch.emptyMine')}>{t('sch.emptyMineCopy')}</Empty>}
|
||||||
|
{!!shown?.length && <div className="result-list">{shown.map(s => <ScheduleRow key={s.agent.id} schedule={s} owners={owners.data} />)}</div>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
function createdBy(schedule: Schedule, owners: Owners) {
|
||||||
|
if (isMine(schedule, owners)) return t('sch.byYou')
|
||||||
|
const name = schedule.agent.created_by_user_id != null ? owners.names.get(schedule.agent.created_by_user_id) : undefined
|
||||||
|
return name ? t('sch.by', { name }) : t('sch.byUnknown')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleRow({ schedule, owners }: { schedule: Schedule; owners?: Owners }) {
|
||||||
const { agent, rule, active } = schedule
|
const { agent, rule, active } = schedule
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
@@ -48,7 +99,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({
|
||||||
@@ -69,6 +120,7 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
|||||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<b>{describeRule(rule)}</b>
|
<b>{describeRule(rule)}</b>
|
||||||
|
{owners && <div className="schedule-owner"><small><Icon name="user" />{createdBy(schedule, owners)}</small></div>}
|
||||||
<div><small>{production
|
<div><small>{production
|
||||||
? <ProductionLine agentId={agent.id} active={active} />
|
? <ProductionLine agentId={agent.id} active={active} />
|
||||||
: t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div>
|
: t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div>
|
||||||
@@ -186,7 +238,7 @@ function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedu
|
|||||||
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
|
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{error && <span className="error">{error.message}</span>}
|
{error && <span className="error">{productionError(error.message)}</span>}
|
||||||
<div className="produce-actions">
|
<div className="produce-actions">
|
||||||
{!running && remaining > 0 && (quote.data
|
{!running && remaining > 0 && (quote.data
|
||||||
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>
|
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>
|
||||||
@@ -206,17 +258,47 @@ function RunHistory({ agentId }: { agentId: number }) {
|
|||||||
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
|
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
|
||||||
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
|
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
|
||||||
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
|
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
|
||||||
|
const shown = runs.data.slice(0, 8)
|
||||||
|
// Readiness is today's state, so it is shown once, under the newest run it stopped.
|
||||||
|
const blockedId = shown.find(isNotReady)?.id
|
||||||
return (
|
return (
|
||||||
<ul className="run-history">
|
<ul className="run-history">
|
||||||
{runs.data.slice(0, 8).map(r => (
|
{shown.map(r => (
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<span>{fmt(r.created_at)}</span>
|
<span>{fmt(r.created_at)}</span>
|
||||||
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
|
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
|
||||||
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
|
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
|
||||||
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
|
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
|
||||||
{r.safe_error_code && <span className="error">{r.safe_error_code}</span>}
|
{r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
|
||||||
|
{r.source_outcome === 'no_new_sources' && <span>{t('sch.noNewSources')}</span>}
|
||||||
|
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReadinessIssues({ agentId }: { agentId: number }) {
|
||||||
|
const readiness = useQuery({
|
||||||
|
queryKey: ['readiness', agentId],
|
||||||
|
queryFn: () => knowledgeReadiness(agentId),
|
||||||
|
// Processing and building finish on their own; keep the list current until they do.
|
||||||
|
refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false),
|
||||||
|
})
|
||||||
|
if (readiness.isPending) return <div className="run-issues"><SkeletonLines count={1} label={t('sch.loadingHistory')} /></div>
|
||||||
|
if (readiness.isError) return <div className="run-issues"><span className="error">{readiness.error.message}</span></div>
|
||||||
|
if (!readiness.data.issues.length) return <div className="run-issues"><small>{t('sch.blockedNone')}</small></div>
|
||||||
|
return (
|
||||||
|
<div className="run-issues">
|
||||||
|
<strong>{t('sch.blockedBy')}</strong>
|
||||||
|
<ul>
|
||||||
|
{readiness.data.issues.map(issue => (
|
||||||
|
<li key={`${issue.kind}-${issue.resource_id}`}>
|
||||||
|
<span><b>{issue.title}</b> · {issueReason(issue.reason)}</span>
|
||||||
|
<a href={`${STUDIO_URL}${issue.action_url}`} target="_blank" rel="noreferrer">{t('sch.fixInStudio')}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ textarea{resize:vertical;line-height:1.6}
|
|||||||
.rule-outcomes .error{margin:0;font-size:11px}
|
.rule-outcomes .error{margin:0;font-size:11px}
|
||||||
.schedule-row{border-bottom:1px solid var(--line);padding-bottom:14px}
|
.schedule-row{border-bottom:1px solid var(--line);padding-bottom:14px}
|
||||||
.schedule-row:last-child{border-bottom:0}
|
.schedule-row:last-child{border-bottom:0}
|
||||||
|
.schedule-owner small{display:inline-flex;align-items:center;gap:5px;color:var(--muted)}
|
||||||
|
.schedule-owner svg{width:12px;height:12px}
|
||||||
.schedule-row .result-row{border-bottom:0;padding-bottom:10px}
|
.schedule-row .result-row{border-bottom:0;padding-bottom:10px}
|
||||||
.schedule-row .error{display:block;margin-top:6px;font-size:11px}
|
.schedule-row .error{display:block;margin-top:6px;font-size:11px}
|
||||||
.schedule-actions{display:flex;gap:7px;flex-wrap:wrap;padding-left:61px}
|
.schedule-actions{display:flex;gap:7px;flex-wrap:wrap;padding-left:61px}
|
||||||
@@ -49,6 +51,10 @@ textarea{resize:vertical;line-height:1.6}
|
|||||||
.run-history li{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
.run-history li{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||||||
.run-history .status{margin-left:0}
|
.run-history .status{margin-left:0}
|
||||||
.run-history .error{margin:0;font-size:11px}
|
.run-history .error{margin:0;font-size:11px}
|
||||||
|
.run-issues{flex-basis:100%;padding:8px 10px;border-radius:8px;background:#fff7ed;color:#7a4b1f;display:grid;gap:4px}
|
||||||
|
.run-issues ul{list-style:none;margin:0;padding:0;display:grid;gap:4px}
|
||||||
|
.run-issues li{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||||
|
.run-issues a{color:var(--blue);font-weight:600}
|
||||||
@media(max-width:700px){.schedule-actions,.run-history{padding-left:0;margin-left:0}.rule-approve{flex-direction:column;align-items:stretch}}
|
@media(max-width:700px){.schedule-actions,.run-history{padding-left:0;margin-left:0}.rule-approve{flex-direction:column;align-items:stretch}}
|
||||||
.lang-switch{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:white}
|
.lang-switch{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:white}
|
||||||
.lang-switch button{border:0;background:transparent;padding:5px 9px;font-size:10px;font-weight:650;color:#8b97a9}
|
.lang-switch button{border:0;background:transparent;padding:5px 9px;font-size:10px;font-weight:650;color:#8b97a9}
|
||||||
@@ -180,3 +186,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}
|
||||||
|
|||||||
@@ -77,8 +77,11 @@ button.workspace:hover,button.workspace[aria-expanded=true]{border-color:var(--s
|
|||||||
.sidebar-help p{font-size:12px}
|
.sidebar-help p{font-size:12px}
|
||||||
.sidebar-help button{color:var(--blue-dark);font-size:12.5px}
|
.sidebar-help button{color:var(--blue-dark);font-size:12.5px}
|
||||||
.profile{border-top:1px solid var(--line);padding:14px 4px 0}
|
.profile{border-top:1px solid var(--line);padding:14px 4px 0}
|
||||||
|
.profile>div{flex:1;min-width:0}
|
||||||
|
.profile b,.profile small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.profile b{font-size:13px}
|
.profile b{font-size:13px}
|
||||||
.profile small{font-size:11.5px}
|
.profile small{font-size:11.5px}
|
||||||
|
.profile>.icon-btn{flex-shrink:0}
|
||||||
.avatar{background:linear-gradient(140deg,#2e90fa,#1570ef);color:#fff;width:34px;height:34px;font-weight:700;box-shadow:0 0 0 2px #fff,0 0 0 3.5px rgba(46,144,250,.5);flex-shrink:0}
|
.avatar{background:linear-gradient(140deg,#2e90fa,#1570ef);color:#fff;width:34px;height:34px;font-weight:700;box-shadow:0 0 0 2px #fff,0 0 0 3.5px rgba(46,144,250,.5);flex-shrink:0}
|
||||||
|
|
||||||
/* Main column: glass top bar and a soft blue glow at the top */
|
/* Main column: glass top bar and a soft blue glow at the top */
|
||||||
@@ -105,6 +108,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}
|
||||||
@@ -285,19 +290,25 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
||||||
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
||||||
|
|
||||||
/* Sidebar groups: Dashboard, then Settings */
|
/* Sidebar groups: Dashboard, then Settings, each a dropdown that opens on its active page */
|
||||||
.sidebar nav .nav-label{margin:0 12px 6px}
|
.sidebar nav button.nav-label{display:flex;align-items:center;justify-content:space-between;width:auto;margin:0 0 2px;padding:6px 12px;border-radius:8px;color:var(--muted);font-size:11px;font-weight:600;box-shadow:none;background:none}
|
||||||
.sidebar nav .nav-label~.nav-label{margin-top:18px}
|
.sidebar nav button.nav-label:hover{background:#f2f7ff;color:var(--ink)}
|
||||||
@media(max-width:700px){.sidebar nav .nav-label{display:none}}
|
.sidebar nav button.nav-label svg{width:14px;height:14px;color:inherit;transition:transform .15s}
|
||||||
|
.sidebar nav button.nav-label[aria-expanded=false] svg{transform:rotate(-90deg)}
|
||||||
|
.sidebar nav .nav-items~.nav-label{margin-top:12px}
|
||||||
|
.nav-items{display:contents}
|
||||||
|
@media(min-width:701px){.nav-items.closed{display:none}}
|
||||||
|
@media(max-width:700px){.sidebar nav button.nav-label{display:none}}
|
||||||
|
|
||||||
/* Production: 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)}}
|
||||||
|
|
||||||
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;--g-research:#84caff;--g-schedules:#fdb022;
|
.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;
|
||||||
position:relative;min-height:640px;border-radius:22px;overflow:hidden;color:#e4e7ec;
|
position:relative;height:640px;max-height:calc(100vh - 260px);max-height:calc(100dvh - 260px);min-height:440px;border-radius:22px;overflow:hidden;color:#e4e7ec;
|
||||||
background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)}
|
background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)}
|
||||||
.graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block}
|
.graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block}
|
||||||
.graph-loading{position:absolute;inset:0 300px 0 0;display:grid;place-items:center;color:#98a2b3;font-size:13px}
|
.graph-loading{position:absolute;inset:0 300px 0 0;display:grid;place-items:center;color:#98a2b3;font-size:13px}
|
||||||
@@ -333,9 +344,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
.graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none}
|
||||||
.graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s}
|
.graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s}
|
||||||
.graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none}
|
.graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none}
|
||||||
.graph-node.c-working:not(.hub) .core{animation:node-pulse 1.6s ease-in-out infinite}
|
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}
|
||||||
@keyframes node-pulse{50%{opacity:.4}}
|
|
||||||
.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}.c-working{--c:var(--g-working)}.c-research{--c:var(--g-research)}.c-schedules{--c:var(--g-schedules)}
|
|
||||||
|
|
||||||
.graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px));
|
.graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px));
|
||||||
padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)}
|
padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)}
|
||||||
@@ -346,7 +355,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3}
|
.graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3}
|
||||||
.graph-legend li{display:flex;align-items:center;gap:6px}
|
.graph-legend li{display:flex;align-items:center;gap:6px}
|
||||||
.node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0}
|
.node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0}
|
||||||
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}.node-dot.research{--c:var(--g-research)}.node-dot.schedules{--c:var(--g-schedules)}
|
.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}
|
||||||
|
|
||||||
.graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px;
|
.graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px;
|
||||||
background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}
|
background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}
|
||||||
@@ -369,14 +378,51 @@ 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}}
|
.graph-fullscreen{position:absolute;top:16px;right:316px;z-index:3;display:grid;place-items:center;width:34px;height:34px;padding:0;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:rgba(10,22,40,.6);backdrop-filter:blur(8px);color:#d0d5dd;cursor:pointer;transition:background .15s,color .15s}
|
||||||
|
.graph-fullscreen:hover{background:rgba(46,144,250,.25);color:#fff}
|
||||||
|
.graph-fullscreen svg{width:16px;height:16px}
|
||||||
|
.graph-stage.full{border-radius:0;min-height:0;max-height:none;height:100vh;height:100dvh;box-shadow:none}
|
||||||
|
.graph-stage.pinned{position:fixed;inset:0;z-index:50}
|
||||||
@media(max-width:900px){
|
@media(max-width:900px){
|
||||||
.graph-stage{min-height:0;display:flex;flex-direction:column}
|
.graph-stage{height:auto;max-height:none;min-height:0;display:flex;flex-direction:column}
|
||||||
|
.graph-stage.full{overflow:auto}
|
||||||
|
.graph-fullscreen{right:16px}
|
||||||
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640}
|
.graph-svg{position:relative;width:100%;height:auto;aspect-ratio:1000/640}
|
||||||
.graph-loading{position:relative;inset:auto;min-height:280px}
|
.graph-loading{position:relative;inset:auto;min-height:280px}
|
||||||
.graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start}
|
.graph-note{position:relative;top:auto;left:auto;margin:16px 16px 0;max-width:none;align-self:flex-start}
|
||||||
.graph-legend{position:relative;left:auto;bottom:auto;margin:0 16px 12px}
|
.graph-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}}
|
||||||
|
|
||||||
|
/* Collapsible sidebar: the toggle in the topbar shrinks it to an icon rail (desktop only) */
|
||||||
|
.sidebar,.workspace-main{transition:width .2s,margin-left .2s}
|
||||||
|
.sidebar-toggle{flex-shrink:0;margin-right:12px}
|
||||||
|
.topbar{justify-content:flex-start}.topbar-right{margin-left:auto}
|
||||||
|
.sidebar-help-icon{align-self:center;margin:auto 0 12px}
|
||||||
|
@media(min-width:701px){
|
||||||
|
.sidebar-collapsed .sidebar{width:76px;padding-left:12px;padding-right:12px}
|
||||||
|
.sidebar-collapsed .workspace-main{margin-left:76px;width:calc(100% - 76px)}
|
||||||
|
.sidebar-collapsed .sidebar>a>.brand{margin:0 0 24px;justify-content:center}
|
||||||
|
.sidebar-collapsed .sidebar .brand>span:last-child,
|
||||||
|
.sidebar-collapsed .workspace>div,.sidebar-collapsed .workspace>svg,
|
||||||
|
.sidebar-collapsed .sidebar nav button.nav-label,.sidebar-collapsed .nav-text,
|
||||||
|
.sidebar-collapsed .profile>div{display:none}
|
||||||
|
.sidebar-collapsed button.workspace{justify-content:center;padding:8px}
|
||||||
|
.sidebar-collapsed .workspace-menu{right:auto;width:240px}
|
||||||
|
.sidebar-collapsed .sidebar nav :is(button,a){justify-content:center;padding:11px 0}
|
||||||
|
.sidebar-collapsed .sidebar nav .nav-items~.nav-items>:first-child{margin-top:12px}
|
||||||
|
.sidebar-collapsed .profile{flex-direction:column;padding-left:0;padding-right:0}
|
||||||
|
.sidebar-collapsed .profile>.icon-btn{margin-left:0}
|
||||||
|
}
|
||||||
|
@media(max-width:700px){.sidebar-toggle,.sidebar-help-icon{display:none}}
|
||||||
|
@media(prefers-reduced-motion:reduce){.sidebar,.workspace-main{transition:none}}
|
||||||
|
|||||||
Reference in New Issue
Block a user