The graph now represents the agent's Brain: its documents and the content made from them. Work in progress, failed runs, research suggestions and schedules are status, so they are left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
18 KiB
TypeScript
321 lines
18 KiB
TypeScript
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { useUser } from '@clerk/react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { Link } from 'react-router-dom'
|
|
import { listDocuments } from '../api/engine'
|
|
import { Icon } from '../components/Icon'
|
|
import { PageHead, Skeleton, SkeletonCards } from '../components/Page'
|
|
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
|
|
import { describeRule } from '../express/parse'
|
|
import { isWorking, listResults, materialKey, titleOf, type Material } from '../express/results'
|
|
import { listSchedules } from '../express/schedules'
|
|
import { locale, t, type Key } from '../i18n'
|
|
import { ContentCard, Preview } from './Results'
|
|
|
|
const HUB_LABEL: Record<Cluster, Key> = {
|
|
sources: 'prod.hub.sources', made: 'prod.hub.made',
|
|
}
|
|
|
|
// 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))
|
|
|
|
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
|
|
// knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
|
|
export function ProductionPage() {
|
|
const { user } = useUser()
|
|
// A short greeting: Clerk's first name can hold more than one word.
|
|
const name = user?.firstName?.trim().split(/\s+/)[0]
|
|
const docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
|
|
const results = useQuery({
|
|
queryKey: ['results'],
|
|
queryFn: listResults,
|
|
// 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 fullscreen = useFullscreen()
|
|
|
|
const materials = results.data?.materials ?? []
|
|
const working = materials.filter(isWorking)
|
|
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
|
|
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
|
|
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
|
|
const graph = useMemo(
|
|
() => (docs.data && results.data ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null),
|
|
[docs.data, results.data],
|
|
)
|
|
|
|
const num = (n: number, ready: boolean, unit: Key) => (ready
|
|
? <strong>{n.toLocaleString(locale())} <em>{t(unit, { n })}</em></strong>
|
|
: <Skeleton className="skel-num" />)
|
|
|
|
return (
|
|
<>
|
|
<PageHead eyebrow={t('prod.eyebrow')} description={t('prod.description')}
|
|
title={<>{name ? t('prod.welcomeName', { name }) : t('prod.welcome')} <span className="welcome-spark">✦</span></>}
|
|
actions={<>
|
|
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
|
|
<Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
|
|
</>} />
|
|
<section ref={fullscreen.ref} className={`graph-stage${fullscreen.full ? ' full' : ''}${fullscreen.pinned ? ' pinned' : ''}`} aria-label={t('prod.graphTitle')}>
|
|
<button type="button" className="graph-fullscreen" onClick={fullscreen.toggle} aria-pressed={fullscreen.full}
|
|
aria-label={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')} title={t(fullscreen.full ? 'prod.exitFullscreen' : 'prod.fullscreen')}>
|
|
<Icon name={fullscreen.full ? 'shrink' : 'expand'} />
|
|
</button>
|
|
{graph
|
|
? <KnowledgeGraph graph={graph} />
|
|
// 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">
|
|
<div className="graph-stats">
|
|
<div><small>{t('prod.nodes')}</small>{graph ? <b>{graph.nodes.filter(n => !n.hub).length}</b> : <b>-</b>}<span>{t('prod.nodesSub')}</span></div>
|
|
<div><small>{t('prod.links')}</small>{graph ? <b>{graph.links.filter(l => !l.hub).length}</b> : <b>-</b>}<span>{t('prod.linksSub')}</span></div>
|
|
</div>
|
|
<h3>{t('prod.scheduleTitle')}</h3>
|
|
{schedules.data?.length === 0 && <p className="graph-empty">{t('prod.noSchedules')} <Link to="/brain">{t('prod.giveInstruction')}</Link></p>}
|
|
<ul className="graph-list">
|
|
{schedules.data?.slice(0, 4).map(s => (
|
|
<li key={s.agent.id}>
|
|
<time>{s.active && s.agent.next_run_at ? when(s.agent.next_run_at) : '-'}</time>
|
|
<span>{describeRule(s.rule)}</span>
|
|
<em className={s.active ? 'on' : undefined}>{t(s.active ? 'sch.active' : 'sch.paused')}</em>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<h3>{t('prod.nowTitle')}</h3>
|
|
{results.data && working.length === 0 && <p className="graph-empty">{t('prod.nothingNow')}</p>}
|
|
<ul className="graph-list now">
|
|
{working.slice(0, 6).map(m => (
|
|
<li key={`${m.kind}-${m.agentId}-${m.id}`}>
|
|
<i className="node-dot working" />
|
|
<span>{titleOf(m)}</span>
|
|
<em>{t(`res.state.${m.status as 'preparing' | 'generating'}`)}</em>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
|
|
</aside>
|
|
</section>
|
|
|
|
<div className="metrics prod-metrics">
|
|
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data, docs.data?.length === 1 ? 'prod.unitMaterialOne' : 'prod.unitMaterial')}<small>{t('prod.metricMaterialSub')}</small></div>
|
|
<div className="metric"><span>{t('prod.metricMade')}</span>{num(materials.filter(m => m.status !== 'failed').length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricMadeSub')}</small></div>
|
|
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data, 'prod.unitContent')}<small>{t('prod.metricLmsSub')}</small></div>
|
|
</div>
|
|
|
|
<ContentActivity made={made} working={working} loading={results.isPending} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const ACTIVITY_LIMIT = 3
|
|
const newest = (list: Material[]) => [...list].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, ACTIVITY_LIMIT)
|
|
|
|
// The newest finished results and everything still being made (planned pieces included), as the same
|
|
// cards as the Results page; one opens in place.
|
|
function ContentActivity({ made, working, loading }: { made: Material[]; working: Material[]; loading: boolean }) {
|
|
const [tab, setTab] = useState<'latest' | 'working'>('latest')
|
|
const [openId, setOpenId] = useState<string | null>(null)
|
|
const shown = newest(tab === 'latest' ? made : working)
|
|
const open = [...made, ...working].find(m => materialKey(m) === openId) ?? null
|
|
return (
|
|
<section className="prod-activity">
|
|
<div className="section-heading">
|
|
<div><h3>{t('prod.activityTitle')}</h3><p>{t('prod.activityDescription')}</p></div>
|
|
<Link className="text-btn" to="/results">{t('prod.activityAll')}<Icon name="arrow-right" /></Link>
|
|
</div>
|
|
<div className="result-tabs" role="tablist">
|
|
<button role="tab" aria-selected={tab === 'latest'} className={tab === 'latest' ? 'active' : undefined} onClick={() => setTab('latest')}>
|
|
<Icon name="book" />{t('prod.activityLatest')} <b>{made.length}</b>
|
|
</button>
|
|
<button role="tab" aria-selected={tab === 'working'} className={tab === 'working' ? 'active' : undefined} onClick={() => setTab('working')}>
|
|
{working.length ? <span className="live-dot" /> : <Icon name="clock" />}{t('prod.activityWorking')} <b>{working.length}</b>
|
|
</button>
|
|
</div>
|
|
{loading
|
|
? <SkeletonCards count={3} label={t('common.loading')} />
|
|
: shown.length
|
|
? <div className="activity-grid">
|
|
{shown.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}
|
|
</div>
|
|
: <p className="graph-empty activity-empty">{t(tab === 'latest' ? 'prod.activityNoneLatest' : 'prod.activityNoneWorking')}</p>}
|
|
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// Motion: nodes grow out of their hub on first draw, then drift gently around their place while
|
|
// light travels along the links between clusters. Positions are written straight to the DOM each
|
|
// frame, so hovering or selecting (React state) never fights the animation.
|
|
const DRIFT = 5
|
|
const MAX_PARTICLES = 80
|
|
const ease = (x: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3)
|
|
|
|
function curve(ax: number, ay: number, bx: number, by: number, bend: number) {
|
|
const mx = (ax + bx) / 2, my = (ay + by) / 2
|
|
return { cx: mx - (by - ay) * bend, cy: my + (bx - ax) * bend }
|
|
}
|
|
|
|
function KnowledgeGraph({ graph }: { graph: Graph }) {
|
|
const [selected, setSelected] = useState<GraphNode | null>(null)
|
|
const [hover, setHover] = useState<string | null>(null)
|
|
const nodeEls = useRef<(SVGGElement | null)[]>([])
|
|
const linkEls = useRef<(SVGPathElement | null)[]>([])
|
|
const particleEls = useRef<(SVGCircleElement | null)[]>([])
|
|
const index = useMemo(() => new Map(graph.nodes.map((n, i) => [n.id, i])), [graph])
|
|
const pairs = useMemo(() => graph.links.map((l, i) => ({
|
|
a: index.get(l.source)!, b: index.get(l.target)!, hub: l.hub, bend: l.hub ? 0.08 * (i % 2 ? 1 : -1) : 0.18 * (i % 2 ? 1 : -1),
|
|
})), [graph, index])
|
|
const particles = useMemo(() => pairs.map((p, i) => ({ ...p, link: i })).filter(p => !p.hub).slice(0, MAX_PARTICLES), [pairs])
|
|
const degree = useMemo(() => {
|
|
const d = new Map<string, number>()
|
|
for (const l of graph.links) if (!l.hub) { d.set(l.source, (d.get(l.source) ?? 0) + 1); d.set(l.target, (d.get(l.target) ?? 0) + 1) }
|
|
return d
|
|
}, [graph])
|
|
const stars = useMemo(() => Array.from({ length: 70 }, (_, i) => {
|
|
const r = (k: number) => (Math.sin(i * 127.1 + k * 311.7) * 43758.5453) % 1
|
|
return { x: Math.abs(r(1)) * graph.width, y: Math.abs(r(2)) * graph.height, s: 0.4 + Math.abs(r(3)) * 1.1, d: Math.abs(r(4)) * 6 }
|
|
}), [graph])
|
|
|
|
useLayoutEffect(() => {
|
|
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
const hubOf = graph.nodes.map(n => index.get(`hub-${n.cluster}`)!)
|
|
const phase = graph.nodes.map((_, i) => i * 2.399)
|
|
const x = new Float64Array(graph.nodes.length)
|
|
const y = new Float64Array(graph.nodes.length)
|
|
const t0 = performance.now()
|
|
let frame = 0
|
|
const draw = (now: number) => {
|
|
const t = still ? 1e9 : now - t0
|
|
graph.nodes.forEach((n, i) => {
|
|
const hub = graph.nodes[hubOf[i]]
|
|
const grow = n.hub ? ease(t / 700) : ease((t - 250 - (i % 24) * 25) / 1100)
|
|
const drift = still ? 0 : (n.hub ? 0.4 : 1) * DRIFT
|
|
const k = t / 1000
|
|
x[i] = hub.x + (n.x - hub.x) * grow + Math.sin(k * 0.6 + phase[i]) * drift
|
|
y[i] = hub.y + (n.y - hub.y) * grow + Math.cos(k * 0.5 + phase[i] * 1.3) * drift
|
|
const el = nodeEls.current[i]
|
|
if (el) {
|
|
el.setAttribute('transform', `translate(${x[i].toFixed(1)} ${y[i].toFixed(1)})`)
|
|
el.style.opacity = n.hub ? '' : String(Math.min(1, grow * 1.4))
|
|
}
|
|
})
|
|
pairs.forEach((p, i) => {
|
|
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
|
|
linkEls.current[i]?.setAttribute('d', `M${x[p.a].toFixed(1)} ${y[p.a].toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x[p.b].toFixed(1)} ${y[p.b].toFixed(1)}`)
|
|
})
|
|
particles.forEach((p, i) => {
|
|
const el = particleEls.current[i]
|
|
if (!el) return
|
|
if (still) { el.style.display = 'none'; return }
|
|
const u = ((t / 1000) * (0.18 + (i % 5) * 0.03) + i * 0.37) % 1
|
|
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
|
|
const v = 1 - u
|
|
el.setAttribute('cx', (v * v * x[p.a] + 2 * v * u * cx + u * u * x[p.b]).toFixed(1))
|
|
el.setAttribute('cy', (v * v * y[p.a] + 2 * v * u * cy + u * u * y[p.b]).toFixed(1))
|
|
el.style.opacity = String(Math.sin(u * Math.PI) * Math.min(1, t / 1600))
|
|
})
|
|
if (!still) frame = requestAnimationFrame(draw)
|
|
}
|
|
draw(t0)
|
|
if (!still) frame = requestAnimationFrame(draw)
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [graph, index, pairs, particles])
|
|
|
|
const focus = hover ?? selected?.id ?? null
|
|
const neighbours = useMemo(() => {
|
|
if (!focus) return null
|
|
const set = new Set([focus])
|
|
for (const l of graph.links) {
|
|
if (l.source === focus) set.add(l.target)
|
|
if (l.target === focus) set.add(l.source)
|
|
}
|
|
return set
|
|
}, [focus, graph])
|
|
const lit = (p: { a: number; b: number }) => neighbours?.has(graph.nodes[p.a].id) && neighbours.has(graph.nodes[p.b].id)
|
|
|
|
return (
|
|
<>
|
|
<div className="graph-note">
|
|
<i className="live-dot" />
|
|
{selected
|
|
? <><b>{selected.label}</b>{!selected.hub && <span>{t(HUB_LABEL[selected.cluster])}</span>}<button type="button" aria-label={t('res.close')} onClick={() => setSelected(null)}><Icon name="close" /></button></>
|
|
: <><b>{t('prod.graphTitle')}</b><span>{t('prod.clickNode')}</span></>}
|
|
</div>
|
|
<svg className={`graph-svg${neighbours ? ' focused' : ''}`} viewBox={`0 0 ${graph.width} ${graph.height}`} role="img"
|
|
aria-label={t('prod.graphLabel', { nodes: graph.nodes.length - CLUSTERS.length })} onClick={() => setSelected(null)}>
|
|
<defs>
|
|
<radialGradient id="graph-glow"><stop offset="0" stopColor="#2e90fa" stopOpacity=".32" /><stop offset=".5" stopColor="#6941c6" stopOpacity=".08" /><stop offset="1" stopColor="#2e90fa" stopOpacity="0" /></radialGradient>
|
|
<radialGradient id="graph-core"><stop offset="0" stopColor="#fff" /><stop offset=".55" stopColor="#fff" stopOpacity=".9" /><stop offset="1" stopColor="#fff" stopOpacity=".2" /></radialGradient>
|
|
<filter id="graph-bloom" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
|
|
</defs>
|
|
<g className="graph-stars">
|
|
{stars.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.s} style={{ animationDelay: `${s.d}s` }} />)}
|
|
</g>
|
|
<circle className="graph-glow" cx={500} cy={320} r={320} fill="url(#graph-glow)" />
|
|
<ellipse className="graph-orbit slow" cx={500} cy={320} rx={430} ry={275} />
|
|
<ellipse className="graph-orbit" cx={500} cy={320} rx={300} ry={195} />
|
|
<g className="graph-links">
|
|
{pairs.map((p, i) => (
|
|
<path key={i} ref={el => { linkEls.current[i] = el }} style={{ ['--c' as string]: `var(--g-${graph.nodes[p.a].cluster})` }}
|
|
className={`${p.hub ? 'hub' : 'cross'}${lit(p) ? ' lit' : neighbours ? ' dim' : ''}`} />
|
|
))}
|
|
</g>
|
|
<g className="graph-particles">
|
|
{particles.map((p, i) => (
|
|
<circle key={i} r={1.8} ref={el => { particleEls.current[i] = el }} className={`c-${graph.nodes[p.a].cluster}${neighbours && !lit(p) ? ' dim' : ''}`} />
|
|
))}
|
|
</g>
|
|
<g>
|
|
{graph.nodes.map((n, i) => {
|
|
const r = n.hub ? 9 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
|
|
return (
|
|
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
|
|
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
|
|
onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)}
|
|
onClick={e => { e.stopPropagation(); setSelected(n) }}>
|
|
<circle className="hit" r={r + 8} />
|
|
{n.hub && <><circle className="ring" r={r + 4} /><circle className="ring late" r={r + 4} /><circle className="halo" r={26} /></>}
|
|
<circle className="core" r={r} filter="url(#graph-bloom)" />
|
|
{n.hub && <circle className="dot" r={r * 0.45} />}
|
|
<text y={-r - 8}>{n.label.length > 40 ? n.label.slice(0, 38) + '…' : n.label}</text>
|
|
<title>{n.label}</title>
|
|
</g>
|
|
)
|
|
})}
|
|
</g>
|
|
</svg>
|
|
<ul className="graph-legend">
|
|
{CLUSTERS.map(c => <li key={c}><i className={`node-dot ${c}`} />{t(HUB_LABEL[c])}</li>)}
|
|
</ul>
|
|
</>
|
|
)
|
|
}
|