Show the Brain beside the AI chat and make schedules from chosen material
The Brain and AI page lists Brain material (failed documents hidden) with search, open, delete and per-material or select-all ticks. New schedules use only the ticked material, kept through topic edits and resumes; nothing ticked means the whole Brain. With material ticked, the agent skips the content topic question and names the material in the chat and plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
||||
@@ -21,13 +22,15 @@ function phaseOf(s: Source): Phase {
|
||||
|
||||
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
|
||||
|
||||
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
|
||||
export const useDocuments = () => useQuery({
|
||||
queryKey: ['documents'],
|
||||
queryFn: listDocuments,
|
||||
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
|
||||
})
|
||||
|
||||
export function BrainPage() {
|
||||
const docs = useQuery({
|
||||
queryKey: ['documents'],
|
||||
queryFn: listDocuments,
|
||||
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
|
||||
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
|
||||
})
|
||||
const docs = useDocuments()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,6 +47,77 @@ export function BrainPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Brain documents a schedule can be made from; nothing chosen means the whole Brain. */
|
||||
export const choosable = (docs: Source[] | undefined) => docs?.filter(d => phaseOf(d) === 'ready') ?? []
|
||||
|
||||
// The Brain beside the AI chat: what schedules will draw on, with the same open and delete as Upload material.
|
||||
// Failed documents are left out: they cannot be chosen, and Upload material is where they are fixed.
|
||||
export function BrainPanel({ selected, onSelect }: { selected: Set<number>; onSelect: (ids: Set<number>) => void }) {
|
||||
const docs = useDocuments()
|
||||
const [query, setQuery] = useState('')
|
||||
const q = query.trim().toLowerCase()
|
||||
const listed = docs.data?.filter(d => phaseOf(d) !== 'failed')
|
||||
const shown = listed?.filter(d => !q || `${d.title} ${d.originalName}`.toLowerCase().includes(q))
|
||||
const ready = choosable(docs.data)
|
||||
const allChosen = ready.length > 0 && ready.every(d => selected.has(d.docId))
|
||||
const toggle = (id: number) => {
|
||||
const next = new Set(selected)
|
||||
if (!next.delete(id)) next.add(id)
|
||||
onSelect(next)
|
||||
}
|
||||
return (
|
||||
<section className="card compact-brain">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>{t('brainPanel.title')}{listed && <span className="badge neutral">{t('brainPanel.count', { n: listed.length })}</span>}</h3>
|
||||
<p>{t('brainPanel.copy')}</p>
|
||||
</div>
|
||||
<Icon name="brain" />
|
||||
</div>
|
||||
<div className="compact-brain-search">
|
||||
<div className="search">
|
||||
<Icon name="search" />
|
||||
<input type="search" aria-label={t('brainPanel.search')} placeholder={t('brainPanel.search')} value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
{!!ready.length && (
|
||||
<div className="brain-selection">
|
||||
<label className="check-label">
|
||||
<input type="checkbox" checked={allChosen} onChange={() => onSelect(allChosen ? new Set() : new Set(ready.map(d => d.docId)))} />
|
||||
{t('brainPanel.selectAll')}
|
||||
</label>
|
||||
<span>{selected.size ? t('brainPanel.selected', { n: selected.size }) : t('brainPanel.wholeBrain')}</span>
|
||||
</div>
|
||||
)}
|
||||
{docs.isPending && <SkeletonRows label={t('brain.loading')} />}
|
||||
{docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>}
|
||||
{listed?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>}
|
||||
{!!listed?.length && shown?.length === 0 && <Empty icon="search" title={t('brainPanel.noMatch')}>{t('brainPanel.noMatchCopy')}</Empty>}
|
||||
{!!shown?.length && (
|
||||
<div className="compact-source-list">
|
||||
{shown.map(d => (
|
||||
<div key={d.docId} className="compact-source-row">
|
||||
<input type="checkbox" checked={selected.has(d.docId)} disabled={phaseOf(d) !== 'ready'}
|
||||
aria-label={t('brainPanel.select', { name: d.title })} title={phaseOf(d) === 'ready' ? undefined : t('brainPanel.notReady')}
|
||||
onChange={() => toggle(d.docId)} />
|
||||
<span className={`file-icon ${d.format.toLowerCase()}`}>{d.format.slice(0, 4)}</span>
|
||||
<div className="compact-source-name">
|
||||
<OpenButton doc={d} />
|
||||
<small>{d.format.toUpperCase()} · <StatusLabel doc={d} phase={phaseOf(d)} /></small>
|
||||
</div>
|
||||
<DeleteButton doc={d} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="table-note compact-brain-foot">
|
||||
<span>{t('brainPanel.note')}</span>
|
||||
<Link className="btn small" to="/upload"><Icon name="upload" />{t('brainPanel.upload')}</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
|
||||
|
||||
let nextUploadId = 1
|
||||
@@ -189,13 +263,21 @@ function OpenButton({ doc }: { doc: Source }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||
const label = {
|
||||
function statusLabel(doc: Source, phase: Phase) {
|
||||
return {
|
||||
ready: t('brain.statusReady'),
|
||||
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
|
||||
answer: t('brain.statusAnswer'),
|
||||
failed: t('brain.statusFailed'),
|
||||
}[phase]
|
||||
}
|
||||
|
||||
function StatusLabel({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||
return <span className={phase === 'ready' ? 'brain-in' : undefined}>{statusLabel(doc, phase)}</span>
|
||||
}
|
||||
|
||||
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||
const label = statusLabel(doc, phase)
|
||||
return <span className={`status ${phase === 'ready' ? 'ready' : 'draft'}`}><i className="dot"></i>{label}</span>
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Icon, ruleIcon } from '../components/Icon'
|
||||
import { PageHead } from '../components/Page'
|
||||
import { BrainPanel, choosable, useDocuments } from './Brain'
|
||||
import {
|
||||
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
|
||||
@@ -35,6 +36,14 @@ export function InstructionPage() {
|
||||
const [agent, setAgent] = useState<AgentState>(EMPTY)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Brain material the schedules are made from; none chosen means the whole Brain.
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const docs = useDocuments()
|
||||
// A chosen document that was deleted, or failed since, no longer counts.
|
||||
const chosenDocs = choosable(docs.data).filter(d => selected.has(d.docId))
|
||||
const chosen = chosenDocs.map(d => d.docId)
|
||||
// How the chat and the plan name the chosen material: by title when there is one.
|
||||
const sources = chosenDocs.length === 1 ? `“${chosenDocs[0].title}”` : chosenDocs.length ? t('brainPanel.nChosen', { n: chosenDocs.length }) : null
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
// The live question whose typing has finished, so its reply options can show.
|
||||
const [typedLive, setTypedLive] = useState('')
|
||||
@@ -46,14 +55,16 @@ export function InstructionPage() {
|
||||
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
|
||||
useEffect(() => () => timers.current.forEach(clearTimeout), [])
|
||||
|
||||
const question = nextQuestion(agent)
|
||||
// The agent sees the current choice, so ticking material in the Brain panel updates its question and plan.
|
||||
const view: AgentState = { ...agent, sources }
|
||||
const question = nextQuestion(view)
|
||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||
const hasContent = agent.items.some(i => i.rule.type === 'content')
|
||||
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
|
||||
const live = inLang(lang, () => saving
|
||||
? { text: t('agent.approving'), options: [] as string[] }
|
||||
: question
|
||||
? ask(question, agent)
|
||||
? ask(question, view)
|
||||
: agent.items.length
|
||||
? {
|
||||
text: hasContent && agent.language
|
||||
@@ -75,7 +86,7 @@ export function InstructionPage() {
|
||||
// A picked option is in the conversation's language already; typed text may switch it.
|
||||
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
|
||||
setChatLang(replyLang)
|
||||
const turn = inLang(replyLang, () => respond(agent, input))
|
||||
const turn = inLang(replyLang, () => respond(view, input))
|
||||
// The question being answered is kept in the history, with the reply after it.
|
||||
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
|
||||
setText('')
|
||||
@@ -106,8 +117,8 @@ export function InstructionPage() {
|
||||
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
|
||||
for (const { id, rule } of items) {
|
||||
try {
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
|
||||
done.push({ rule, ok: true })
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen)
|
||||
done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : rule, ok: true })
|
||||
created.add(id)
|
||||
} catch (e) {
|
||||
done.push({ rule, ok: false, message: (e as Error).message })
|
||||
@@ -127,69 +138,75 @@ export function InstructionPage() {
|
||||
<>
|
||||
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
||||
|
||||
<div className="ai-workspace">
|
||||
<div className="card agent-chat">
|
||||
<div className="card-head">
|
||||
<div className="assistant-title">
|
||||
<span><Icon name="spark" /></span>
|
||||
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
|
||||
<div className="unified-workspace">
|
||||
<BrainPanel selected={selected} onSelect={setSelected} />
|
||||
<div className="ai-column">
|
||||
<div className="card agent-chat">
|
||||
<div className="card-head">
|
||||
<div className="assistant-title">
|
||||
<span><Icon name="spark" /></span>
|
||||
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-messages" aria-live="polite" ref={chat}>
|
||||
{messages.map((m, i) => (
|
||||
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
|
||||
))}
|
||||
{phase === 'thinking' && <Thinking lang={lang} />}
|
||||
{phase === 'pausing' && <Typing />}
|
||||
{idle && live.text && (
|
||||
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
|
||||
onTyped={() => setTypedLive(live.text)} onType={scroll} />
|
||||
<div className="ai-messages" aria-live="polite" ref={chat}>
|
||||
{messages.map((m, i) => (
|
||||
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
|
||||
))}
|
||||
{phase === 'thinking' && <Thinking lang={lang} />}
|
||||
{phase === 'pausing' && <Typing />}
|
||||
{idle && live.text && (
|
||||
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
|
||||
onTyped={() => setTypedLive(live.text)} onType={scroll} />
|
||||
)}
|
||||
</div>
|
||||
{showOptions && (
|
||||
<div className="prompt-chips appear">
|
||||
{live.options.map((o, i) => (
|
||||
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
|
||||
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
|
||||
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
|
||||
<div className="ai-compose-footer">
|
||||
<small>{t('agent.hint')}</small>
|
||||
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{showOptions && (
|
||||
<div className="prompt-chips appear">
|
||||
{live.options.map((o, i) => (
|
||||
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
||||
{hasContent && (
|
||||
<label className="plan-language">
|
||||
<span>{t('ins.language')}</span>
|
||||
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
|
||||
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
|
||||
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{agent.items.map(({ id, rule }) => (
|
||||
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
|
||||
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
|
||||
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
|
||||
<div className="ai-compose-footer">
|
||||
<small>{t('agent.hint')}</small>
|
||||
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
||||
{hasContent && (
|
||||
<label className="plan-language">
|
||||
<span>{t('ins.language')}</span>
|
||||
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
|
||||
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
|
||||
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{agent.items.map(({ id, rule }) => (
|
||||
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
))}
|
||||
{ready.length > 0 && (
|
||||
<div className="rule-approve">
|
||||
<small>
|
||||
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
|
||||
{t('ins.approveNote')}
|
||||
</small>
|
||||
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ready.length > 0 && (
|
||||
<div className="rule-approve">
|
||||
<small>{t('ins.approveNote')}</small>
|
||||
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -265,8 +282,9 @@ function Typing() {
|
||||
)
|
||||
}
|
||||
|
||||
function RuleCard({ rule, onChange, onRemove }: {
|
||||
function RuleCard({ rule, from, onChange, onRemove }: {
|
||||
rule: Rule
|
||||
from?: string
|
||||
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
@@ -281,7 +299,7 @@ function RuleCard({ rule, onChange, onRemove }: {
|
||||
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
|
||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||
<div className="rule-body">
|
||||
<b>{describeRule(rule)}</b>
|
||||
<b>{describeRule(rule, from)}</b>
|
||||
<div className="rule-fields">
|
||||
{rule.type === 'content' ? (
|
||||
<>
|
||||
@@ -308,7 +326,7 @@ function RuleCard({ rule, onChange, onRemove }: {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t('ins.topicPlaceholder')}
|
||||
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t(from && rule.type === 'content' ? 'ins.topicPlaceholderChosen' : 'ins.topicPlaceholder')}
|
||||
onChange={e => onChange({ topic: e.target.value })} />
|
||||
</div>
|
||||
{rule.type === 'research' && (
|
||||
|
||||
@@ -48,7 +48,7 @@ function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
||||
// Content kinds run as weekly production plans; research and video as one run per slot.
|
||||
const production = !isRunSchedule(rule)
|
||||
|
||||
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(agent.id)), onSuccess: refresh })
|
||||
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(schedule)), onSuccess: refresh })
|
||||
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
|
||||
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
|
||||
const runNow = useMutation({
|
||||
|
||||
Reference in New Issue
Block a user