Compare commits
9 Commits
show-run-e
...
d00561f75d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00561f75d | ||
|
|
8d745ad96e | ||
|
|
bcd747721d | ||
|
|
bdae539e9e | ||
|
|
643c3c39f9 | ||
|
|
adbcfcc7ba | ||
|
|
278c66897a | ||
|
|
bcd7964e0b | ||
| 43a977017b |
@@ -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)
|
||||||
@@ -138,16 +144,22 @@ export function AppLayout() {
|
|||||||
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
|
// 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>>>({})
|
const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({})
|
||||||
useEffect(() => setToggled({}), [pathname])
|
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 => {
|
||||||
const open = toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to))
|
// The icon rail has no group labels, so every item shows.
|
||||||
|
const open = collapsed || (toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)))
|
||||||
return [
|
return [
|
||||||
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
|
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
|
||||||
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
|
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
|
||||||
@@ -155,28 +167,36 @@ export function AppLayout() {
|
|||||||
</button>,
|
</button>,
|
||||||
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
|
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
|
||||||
{g.items.map(n => (
|
{g.items.map(n => (
|
||||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||||
<Icon name={n.icon} />{t(n.label)}
|
title={collapsed ? t(n.label) : undefined}>
|
||||||
|
<Icon name={n.icon} /><span className="nav-text">{t(n.label)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>,
|
</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>
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ const paths: Record<string, string> = {
|
|||||||
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"/>',
|
'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,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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, needsTwoSources, productionError, 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']) ?? [],
|
||||||
@@ -122,3 +122,20 @@ describe('production sources', () => {
|
|||||||
expect(productionError('Something else')).toBe('Something else')
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -107,6 +108,23 @@ 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'
|
||||||
|
|
||||||
@@ -365,6 +383,8 @@ export interface RunSummary {
|
|||||||
safe_error_code: string | null
|
safe_error_code: string | null
|
||||||
budget_spent: number | null
|
budget_spent: number | null
|
||||||
has_readiness_issues: boolean
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/i18n.ts
38
src/i18n.ts
@@ -23,6 +23,8 @@ const en = {
|
|||||||
'layout.toStudio': 'Switch to Learning Lab',
|
'layout.toStudio': 'Switch to Learning Lab',
|
||||||
'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
|
||||||
@@ -71,13 +73,12 @@ const en = {
|
|||||||
'prod.newContent': 'Create new content',
|
'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',
|
||||||
@@ -205,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',
|
||||||
@@ -262,6 +270,7 @@ const en = {
|
|||||||
'runErr.agent_not_active': 'The schedule is paused or deleted.',
|
'runErr.agent_not_active': 'The schedule is paused or deleted.',
|
||||||
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
|
'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.',
|
'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.blockedBy': 'Blocking material:',
|
||||||
'sch.blockedNone': 'All material is ready now. Try again.',
|
'sch.blockedNone': 'All material is ready now. Try again.',
|
||||||
'sch.fixInStudio': 'Open in Learning Studio',
|
'sch.fixInStudio': 'Open in Learning Studio',
|
||||||
@@ -528,13 +537,12 @@ const id: Record<Key, string> = {
|
|||||||
'prod.newContent': 'Buat konten baru',
|
'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',
|
||||||
@@ -561,6 +569,8 @@ const id: Record<Key, string> = {
|
|||||||
'layout.toStudio': 'Beralih ke Learning Lab',
|
'layout.toStudio': 'Beralih ke Learning Lab',
|
||||||
'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…',
|
||||||
@@ -694,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',
|
||||||
@@ -751,6 +768,7 @@ const id: Record<Key, string> = {
|
|||||||
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
|
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
|
||||||
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
|
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
|
||||||
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
|
'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.blockedBy': 'Materi yang menahan:',
|
||||||
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
|
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
|
||||||
'sch.fixInStudio': 'Buka di Learning Studio',
|
'sch.fixInStudio': 'Buka di Learning Studio',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Icon, ruleIcon } from '../components/Icon'
|
import { Icon, ruleIcon } from '../components/Icon'
|
||||||
import { PageHead } from '../components/Page'
|
import { PageHead } from '../components/Page'
|
||||||
import { BrainPanel, choosable, useDocuments } from './Brain'
|
import { 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,
|
||||||
@@ -36,14 +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)
|
||||||
// Brain material the schedules are made from; none chosen means the whole Brain.
|
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
|
||||||
const docs = useDocuments()
|
const docs = useDocuments()
|
||||||
// A chosen document that was deleted, or failed since, no longer counts.
|
|
||||||
const chosenDocs = choosable(docs.data).filter(d => selected.has(d.docId))
|
|
||||||
const chosen = chosenDocs.map(d => d.docId)
|
|
||||||
// How the chat and the plan name the chosen material: by title when there is one.
|
|
||||||
const sources = chosenDocs.length === 1 ? `“${chosenDocs[0].title}”` : chosenDocs.length ? t('brainPanel.nChosen', { n: chosenDocs.length }) : null
|
|
||||||
const [phase, setPhase] = useState<Phase>('idle')
|
const [phase, setPhase] = useState<Phase>('idle')
|
||||||
// The live question whose typing has finished, so its reply options can show.
|
// The live question whose typing has finished, so its reply options can show.
|
||||||
const [typedLive, setTypedLive] = useState('')
|
const [typedLive, setTypedLive] = useState('')
|
||||||
@@ -55,19 +48,16 @@ export function InstructionPage() {
|
|||||||
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
|
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
|
||||||
useEffect(() => () => timers.current.forEach(clearTimeout), [])
|
useEffect(() => () => timers.current.forEach(clearTimeout), [])
|
||||||
|
|
||||||
// The agent sees the current choice, so ticking material in the Brain panel updates its question and plan.
|
const question = nextQuestion(agent)
|
||||||
const view: AgentState = { ...agent, sources }
|
|
||||||
const question = nextQuestion(view)
|
|
||||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||||
// Without a choice the whole Brain is attached, so every ready document counts.
|
// The whole Brain is attached, so every ready document counts.
|
||||||
const sourceCount = chosen.length || choosable(docs.data).length
|
const tooFewSources = choosable(docs.data).length < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
|
||||||
const tooFewSources = sourceCount < 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
|
||||||
? { text: t('agent.approving'), options: [] as string[] }
|
? { text: t('agent.approving'), options: [] as string[] }
|
||||||
: question
|
: question
|
||||||
? ask(question, view)
|
? ask(question, agent)
|
||||||
: agent.items.length
|
: agent.items.length
|
||||||
? {
|
? {
|
||||||
text: hasContent && agent.language
|
text: hasContent && agent.language
|
||||||
@@ -89,7 +79,7 @@ export function InstructionPage() {
|
|||||||
// A picked option is in the conversation's language already; typed text may switch it.
|
// A picked option is in the conversation's language already; typed text may switch it.
|
||||||
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
|
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
|
||||||
setChatLang(replyLang)
|
setChatLang(replyLang)
|
||||||
const turn = inLang(replyLang, () => respond(view, input))
|
const turn = inLang(replyLang, () => respond(agent, input))
|
||||||
// The question being answered is kept in the history, with the reply after it.
|
// The question being answered is kept in the history, with the reply after it.
|
||||||
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
|
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
|
||||||
setText('')
|
setText('')
|
||||||
@@ -120,8 +110,8 @@ export function InstructionPage() {
|
|||||||
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
|
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
|
||||||
for (const { id, rule } of items) {
|
for (const { id, rule } of items) {
|
||||||
try {
|
try {
|
||||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen)
|
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
|
||||||
done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : rule, ok: true })
|
done.push({ rule, ok: true })
|
||||||
created.add(id)
|
created.add(id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
done.push({ rule, ok: false, message: (e as Error).message })
|
done.push({ rule, ok: false, message: (e as Error).message })
|
||||||
@@ -142,76 +132,73 @@ export function InstructionPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
||||||
|
|
||||||
<div className="unified-workspace">
|
<div className="ai-workspace">
|
||||||
<BrainPanel selected={selected} onSelect={setSelected} />
|
<div className="card agent-chat">
|
||||||
<div className="ai-column">
|
<div className="card-head">
|
||||||
<div className="card agent-chat">
|
<div className="assistant-title">
|
||||||
<div className="card-head">
|
<span><Icon name="spark" /></span>
|
||||||
<div className="assistant-title">
|
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
|
||||||
<span><Icon name="spark" /></span>
|
|
||||||
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ai-messages" aria-live="polite" ref={chat}>
|
|
||||||
{messages.map((m, i) => (
|
|
||||||
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
|
|
||||||
))}
|
|
||||||
{phase === 'thinking' && <Thinking lang={lang} />}
|
|
||||||
{phase === 'pausing' && <Typing />}
|
|
||||||
{idle && live.text && (
|
|
||||||
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
|
|
||||||
onTyped={() => setTypedLive(live.text)} onType={scroll} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showOptions && (
|
|
||||||
<div className="prompt-chips appear">
|
|
||||||
{live.options.map((o, i) => (
|
|
||||||
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
|
|
||||||
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
|
|
||||||
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
|
|
||||||
onChange={e => setText(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
|
|
||||||
<div className="ai-compose-footer">
|
|
||||||
<small>{t('agent.hint')}</small>
|
|
||||||
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ai-messages" aria-live="polite" ref={chat}>
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
|
||||||
|
))}
|
||||||
|
{phase === 'thinking' && <Thinking lang={lang} />}
|
||||||
|
{phase === 'pausing' && <Typing />}
|
||||||
|
{idle && live.text && (
|
||||||
|
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
|
||||||
|
onTyped={() => setTypedLive(live.text)} onType={scroll} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showOptions && (
|
||||||
|
<div className="prompt-chips appear">
|
||||||
|
{live.options.map((o, i) => (
|
||||||
|
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
|
||||||
|
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
|
||||||
|
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
|
||||||
|
<div className="ai-compose-footer">
|
||||||
|
<small>{t('agent.hint')}</small>
|
||||||
|
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||||
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
||||||
{hasContent && (
|
{hasContent && (
|
||||||
<label className="plan-language">
|
<label className="plan-language">
|
||||||
<span>{t('ins.language')}</span>
|
<span>{t('ins.language')}</span>
|
||||||
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
|
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
|
||||||
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
|
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
|
||||||
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
|
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className="rule-list">
|
<div className="rule-list">
|
||||||
{agent.items.map(({ id, rule }) => (
|
{agent.items.map(({ id, rule }) => (
|
||||||
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
{ready.length > 0 && (
|
|
||||||
<div className="rule-approve">
|
|
||||||
<small>
|
|
||||||
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
|
|
||||||
{t('ins.approveNote')}
|
|
||||||
{tooFewSources && <span className="error">{t(chosen.length ? 'ins.tooFewChosen' : '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 })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{ready.length > 0 && (
|
||||||
|
<div className="rule-approve">
|
||||||
|
<small>
|
||||||
|
<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 })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useUser } from '@clerk/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'
|
||||||
@@ -13,7 +13,33 @@ import { locale, t, type Key } from '../i18n'
|
|||||||
import { ContentCard, Preview } from './Results'
|
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))
|
||||||
@@ -33,16 +59,16 @@ export function ProductionPage() {
|
|||||||
})
|
})
|
||||||
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')
|
||||||
// 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 = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
|
const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
|
||||||
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, unit: Key) => (ready
|
const num = (n: number, ready: boolean, unit: Key) => (ready
|
||||||
@@ -57,10 +83,15 @@ export function ProductionPage() {
|
|||||||
<Link className="btn" to="/upload"><Icon name="upload" />{t('nav.brain')}</Link>
|
<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>
|
<Link className="btn primary" to="/brain"><Icon name="spark" />{t('prod.newContent')}</Link>
|
||||||
</>} />
|
</>} />
|
||||||
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
|
<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
|
{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">
|
||||||
@@ -264,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' : ''}`}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ 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, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
archiveSchedule, confirmProduction, isMine, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, scheduleOwners, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
||||||
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, 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 STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||||
@@ -33,25 +33,61 @@ const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as
|
|||||||
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
|
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
|
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="/brain"><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)
|
||||||
@@ -84,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>
|
||||||
@@ -233,6 +270,7 @@ function RunHistory({ agentId }: { agentId: number }) {
|
|||||||
<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" title={r.safe_error_code}>{runError(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} />}
|
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */
|
||||||
@@ -304,8 +307,8 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.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}
|
||||||
@@ -341,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)}
|
||||||
@@ -354,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)}
|
||||||
@@ -377,8 +378,15 @@ 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}
|
||||||
|
|
||||||
|
.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}
|
||||||
@@ -395,3 +403,26 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
|||||||
.activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
|
.activity-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
|
||||||
.activity-empty{padding:28px 0;text-align:center}
|
.activity-empty{padding:28px 0;text-align:center}
|
||||||
@media(max-width:1000px){.activity-grid{grid-template-columns:1fr}}
|
@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