From 3b56c27210c7c6eb0e6236ab10a4d0be3d0ff500 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Tue, 27 Oct 2026 22:53:38 +0700 Subject: [PATCH] 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 --- src/express/agent.test.ts | 10 +++ src/express/agent.ts | 22 ++--- src/express/parse.ts | 9 ++- src/express/schedules.test.ts | 11 +++ src/express/schedules.ts | 48 +++++++---- src/i18n.ts | 38 +++++++++ src/pages/Brain.tsx | 98 ++++++++++++++++++++-- src/pages/Instruction.tsx | 148 +++++++++++++++++++--------------- src/pages/Schedule.tsx | 2 +- src/styles/app.css | 14 ++++ 10 files changed, 298 insertions(+), 102 deletions(-) diff --git a/src/express/agent.test.ts b/src/express/agent.test.ts index 4cd6ae8..d3a31ac 100644 --- a/src/express/agent.test.ts +++ b/src/express/agent.test.ts @@ -23,6 +23,16 @@ describe('instruction agent', () => { expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language') }) + it('asks no topic for content when material is chosen, and names that material in the plan', () => { + const s = { ...say('make 1 scorm per week'), sources: '“LLM”' } + expect(nextQuestion(s)?.type).toBe('language') + expect(respond({ ...EMPTY, sources: '“LLM”' }, 'make 1 scorm per week').reply).toMatch(/from “LLM”/) + }) + + it('still asks what to research on the internet when material is chosen', () => { + expect(nextQuestion({ ...say('research every day'), sources: '“LLM”' })?.type).toBe('topic') + }) + it('asks about a video count that has no cadence', () => { const s = say('3 videos per week about onboarding') expect(nextQuestion(s)?.type).toBe('video') diff --git a/src/express/agent.ts b/src/express/agent.ts index e8773ea..a8ee06e 100644 --- a/src/express/agent.ts +++ b/src/express/agent.ts @@ -18,9 +18,11 @@ export interface AgentState { settled: string[] /** The language the content is written in, confirmed by the user; null until then. */ language: OutputLanguage | null + /** The material chosen in the Brain panel, as the plan names it; null for the whole Brain. */ + sources: string | null } -export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null } +export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null, sources: null } export type Question = | { key: string; type: 'unknown'; text: string } @@ -36,7 +38,8 @@ export function nextQuestion(s: AgentState): Question | null { if (rule.type === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id } if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id } } - const ids = s.items.filter(i => !i.rule.topic).map(i => i.id) + // Chosen material already says what content is about; research still needs a topic to search the internet for. + const ids = s.items.filter(i => !i.rule.topic && !(s.sources && i.rule.type === 'content')).map(i => i.id) const key = `topic:${ids.join(',')}` if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids } // Research only adds sources to the Brain, so the language matters once there is content to write. @@ -44,7 +47,8 @@ export function nextQuestion(s: AgentState): Question | null { return null } -const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n') +const describe = (s: AgentState, rule: Rule) => describeRule(rule, s.sources ?? undefined) +const bullets = (s: AgentState, rules: Rule[]) => rules.map(r => `• ${describe(s, r)}`).join('\n') const about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '') const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule @@ -54,14 +58,14 @@ export function ask(q: Question, s: AgentState): { text: string; options: string case 'unknown': return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] } case 'video': - return { text: t('agent.askVideo', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] } + return { text: t('agent.askVideo', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] } case 'tooMany': - return { text: t('agent.askTooMany', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] } + return { text: t('agent.askTooMany', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] } case 'weekday': return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) } case 'topic': return { - text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }), + text: t('agent.askTopic', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }), options: [t('agent.optWholeBrain')], } case 'language': @@ -116,7 +120,7 @@ function instruct(s: AgentState, text: string): Turn | null { if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null return { state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language }, - reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }) + languageNote, + reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + languageNote, } } @@ -131,7 +135,7 @@ function languageAnswer(lower: string): OutputLanguage | null { function updated(s: AgentState, id: number, patch: Partial | Partial): Turn { const state = editRule(s, id, patch) - return { state, reply: t('agent.updated', { rule: describeRule(ruleById(state, id)) }) } + return { state, reply: t('agent.updated', { rule: describe(state, ruleById(state, id)) }) } } // Reads a reply to the agent's question; null when it does not answer it. @@ -154,7 +158,7 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k: } if (REMOVE.test(lower) || is('agent.optRemove')) { - return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) } + return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describe(s, ruleById(s, q.id)) }) } } const per = DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week' : null if (q.type === 'video') return per ? updated(s, q.id, { count: 1, per }) : null diff --git a/src/express/parse.ts b/src/express/parse.ts index 344a62d..a674d74 100644 --- a/src/express/parse.ts +++ b/src/express/parse.ts @@ -75,6 +75,8 @@ export interface ContentRule { monthly?: number /** The language the content is written in; unset means the UI language. */ language?: OutputLanguage + /** Made from material chosen in the Brain panel rather than the whole Brain. */ + chosen?: boolean } export interface ResearchRule { @@ -200,7 +202,8 @@ export function videoCadence(rule: ContentRule): 'daily' | 'weekly' | null { export const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key) -export function describeRule(rule: Rule): string { +/** `from` names the chosen material the rule is made from; without it, content comes from the whole Brain. */ +export function describeRule(rule: Rule, from?: string): string { const about = rule.topic ? t('rule.about', { topic: rule.topic }) : '' if (rule.type === 'research') { const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) }) @@ -210,7 +213,9 @@ export function describeRule(rule: Rule): string { kind: kindLabel(rule.kind), count: rule.count, per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'), - about: about || t('rule.wholeBrain'), + about: about || (from ?? (rule.chosen ? t('rule.chosenMaterial') : null) + ? t('rule.from', { source: from ?? t('rule.chosenMaterial') }) + : t('rule.wholeBrain')), }) } diff --git a/src/express/schedules.test.ts b/src/express/schedules.test.ts index eeccf45..bdf30fe 100644 --- a/src/express/schedules.test.ts +++ b/src/express/schedules.test.ts @@ -63,6 +63,17 @@ describe('content language', () => { }) }) +describe('chosen material', () => { + it('marks a schedule made from chosen material in its brief, and reads it back as chosen', () => { + const r = rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia') + const config = configFor(r, 0, true) + expect(config.instructions).toMatch(/Use only the Brain material chosen for this schedule\./) + expect(configFor(r).instructions).not.toMatch(/chosen/) + expect(ruleOf(agentFrom(config))).toEqual({ ...r, chosen: true }) + expect(ruleOf(agentFrom(configFor(r)))).toEqual(r) + }) +}) + describe('ruleOf', () => { it.each([ rule('summary', 3), diff --git a/src/express/schedules.ts b/src/express/schedules.ts index 4f3965b..548495f 100644 --- a/src/express/schedules.ts +++ b/src/express/schedules.ts @@ -73,6 +73,11 @@ function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined { } export function ruleOf(agent: ExpressAgent): Rule { + const rule = baseRuleOf(agent) + return rule.type === 'content' && usesChosen(agent) ? { ...rule, chosen: true } : rule +} + +function baseRuleOf(agent: ExpressAgent): Rule { const topic = agent.name.split(' · ').slice(2).join(' · ') if (agent.enabled_tools.includes('studio.create_video')) { const daily = agent.run_schedule?.cadence === 'daily' @@ -161,18 +166,23 @@ function goalText(rule: Rule): string { return `Create new learning ${KIND_TEXT[rule.kind]}${about} from the L&D team's Brain, without repeating content that already exists.` } -function instructionsFor(rule: Rule): string { +// A schedule made from chosen material says so in its brief. That keeps it to those documents: +// editing or resuming it attaches nothing else, while a whole-Brain schedule picks up new material. +const CHOSEN_SENTENCE = 'Use only the Brain material chosen for this schedule.' +const usesChosen = (agent: ExpressAgent) => !!agent.instructions?.includes(CHOSEN_SENTENCE) + +function instructionsFor(rule: Rule, chosen = false): string { const language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage()) const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined - return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ') + return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, chosen && CHOSEN_SENTENCE, language].filter(Boolean).join(' ') } -export function configFor(rule: Rule, videoCredits = 0) { +export function configFor(rule: Rule, videoCredits = 0, chosen = false) { const budget = budgetFor(rule, videoCredits) const common = { name: scheduleName(rule), goal: goalText(rule), - instructions: instructionsFor(rule), + instructions: instructionsFor(rule, chosen), timezone: TIMEZONE, run_budget: budget.run, monthly_budget: budget.monthly, @@ -227,21 +237,23 @@ export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule { return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50 } -// The production corpus is the agent's own sources, so every ready Brain document is attached. -async function attachBrain(agentId: number) { - const docs = (await listDocuments()).filter(d => d.knowledge === 'ready') - for (const d of docs) { - await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: d.docId } }) +// The production corpus is the agent's own sources: the chosen documents, or every ready Brain document. +async function attachBrain(agentId: number, chosen?: number[]) { + const ids = chosen ?? (await listDocuments()).filter(d => d.knowledge === 'ready').map(d => d.docId) + for (const id of ids) { + await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } }) .catch(() => undefined) // a document someone else cannot share is skipped, not fatal } - return docs.length + return ids.length } -export async function createSchedule(rule: Rule): Promise { +/** With `chosen` document ids the schedule uses only those; without, the whole Brain. */ +export async function createSchedule(rule: Rule, chosen?: number[]): Promise { + const only = chosen?.length ? chosen : undefined // A video schedule's run budget is today's price of one video, so it is fetched first. const credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0 - const agent = await request(AGENTS, { method: 'POST', json: configFor(rule, credits) }) - await attachBrain(agent.id) + const agent = await request(AGENTS, { method: 'POST', json: configFor(rule, credits, !!only) }) + await attachBrain(agent.id, only) // Activating approves the production rules as they stand; until then nothing runs. return request(`${AGENTS}/${agent.id}/activate`, { method: 'POST' }) } @@ -249,15 +261,17 @@ export async function createSchedule(rule: Rule): Promise { /** Changing a rule changes what was approved, so the agent is re-activated right after. */ export async function updateTopic(schedule: Schedule, topic: string): Promise { const rule = { ...schedule.rule, topic: topic.trim() } - const { name, goal, instructions } = configFor(rule) + const chosen = usesChosen(schedule.agent) + const { name, goal, instructions } = configFor(rule, 0, chosen) await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } }) - await attachBrain(schedule.agent.id) + if (!chosen) await attachBrain(schedule.agent.id) return request(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' }) } export const pauseSchedule = (id: number) => request(`${AGENTS}/${id}/pause`, { method: 'POST' }) -export async function resumeSchedule(id: number) { - await attachBrain(id) +export async function resumeSchedule(schedule: Schedule) { + const id = schedule.agent.id + if (!usesChosen(schedule.agent)) await attachBrain(id) return request(`${AGENTS}/${id}/activate`, { method: 'POST' }) } export const archiveSchedule = (id: number) => request(`${AGENTS}/${id}/archive`, { method: 'POST' }) diff --git a/src/i18n.ts b/src/i18n.ts index 10e33ea..d8c6511 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -167,6 +167,8 @@ const en = { 'rule.research': '{when}: internet research{about}', 'rule.about': ' about {topic}', 'rule.wholeBrain': ' from the whole Brain', + 'rule.from': ' from {source}', + 'rule.chosenMaterial': 'chosen material', 'rule.content': '{kind}: {count} {per}{about}', 'rule.monthNote': '{count} per month is scheduled as {weekly} per week.', 'kind.summary': 'Summary', @@ -396,6 +398,23 @@ const en = { 'audio.ready': '{n} sentences · press Listen to start', 'audio.sentence': 'Sentence {n} of {total}', 'brain.deleted': '“{name}” deleted.', + 'brainPanel.title': 'Brain', + 'brainPanel.count': '{n} materials', + 'brainPanel.copy': 'Your team’s knowledge base.', + 'brainPanel.search': 'Search material…', + 'brainPanel.noMatch': 'No material matches', + 'brainPanel.noMatchCopy': 'Try another keyword or upload more material.', + 'brainPanel.note': 'Chosen material is what the AI uses. Leave everything unticked to use the whole Brain.', + 'brainPanel.selectAll': 'Select all', + 'brainPanel.nChosen': '{n} chosen materials', + 'ins.topicPlaceholderChosen': 'Topic (empty = all chosen material)', + 'brainPanel.selected': '{n} selected', + 'brainPanel.wholeBrain': 'Whole Brain in use', + 'brainPanel.select': 'Select {name}', + 'brainPanel.notReady': 'Can be chosen once it is in the Brain', + 'ins.sourcesChosen': 'Source: chosen material ({n})', + 'ins.sourcesAll': 'Source: the whole Brain', + 'brainPanel.upload': 'Upload material', // Guide 'help.boxTitle': 'How Express works', @@ -580,6 +599,8 @@ const id: Record = { 'rule.research': '{when}: riset internet{about}', 'rule.about': ' tentang {topic}', 'rule.wholeBrain': ' dari seluruh Otak', + 'rule.from': ' dari {source}', + 'rule.chosenMaterial': 'materi pilihan', 'rule.content': '{kind}: {count} {per}{about}', 'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.', 'kind.summary': 'Ringkasan', @@ -807,6 +828,23 @@ const id: Record = { 'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai', 'audio.sentence': 'Kalimat {n} dari {total}', 'brain.deleted': '“{name}” dihapus.', + 'brainPanel.title': 'Brain', + 'brainPanel.count': '{n} materi', + 'brainPanel.copy': 'Knowledge base tim Anda.', + 'brainPanel.search': 'Cari materi…', + 'brainPanel.noMatch': 'Belum ada materi yang cocok', + 'brainPanel.noMatchCopy': 'Coba kata kunci lain atau upload materi baru.', + 'brainPanel.note': 'Materi yang dipilih menjadi acuan AI. Kosongkan pilihan untuk memakai seluruh Brain.', + 'brainPanel.selectAll': 'Pilih semua', + 'brainPanel.nChosen': '{n} materi pilihan', + 'ins.topicPlaceholderChosen': 'Topik (kosong = semua materi pilihan)', + 'brainPanel.selected': '{n} dipilih', + 'brainPanel.wholeBrain': 'Seluruh Brain digunakan', + 'brainPanel.select': 'Pilih {name}', + 'brainPanel.notReady': 'Bisa dipilih setelah masuk Brain', + 'ins.sourcesChosen': 'Sumber: {n} materi dipilih', + 'ins.sourcesAll': 'Sumber: seluruh Brain', + 'brainPanel.upload': 'Upload materi', // Panduan 'help.boxTitle': 'Cara kerja Express', diff --git a/src/pages/Brain.tsx b/src/pages/Brain.tsx index 985bbae..2c82cfc 100644 --- a/src/pages/Brain.tsx +++ b/src/pages/Brain.tsx @@ -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; onSelect: (ids: Set) => 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 ( +
+
+
+

{t('brainPanel.title')}{listed && {t('brainPanel.count', { n: listed.length })}}

+

{t('brainPanel.copy')}

+
+ +
+
+
+ + setQuery(e.target.value)} /> +
+
+ {!!ready.length && ( +
+ + {selected.size ? t('brainPanel.selected', { n: selected.size }) : t('brainPanel.wholeBrain')} +
+ )} + {docs.isPending && } + {docs.isError && {docs.error.message}} + {listed?.length === 0 && {t('brain.emptyCopy')}} + {!!listed?.length && shown?.length === 0 && {t('brainPanel.noMatchCopy')}} + {!!shown?.length && ( +
+ {shown.map(d => ( +
+ toggle(d.docId)} /> + {d.format.slice(0, 4)} +
+ + {d.format.toUpperCase()} · +
+ +
+ ))} +
+ )} +
+ {t('brainPanel.note')} + {t('brainPanel.upload')} +
+
+ ) +} + 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 {statusLabel(doc, phase)} +} + +function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) { + const label = statusLabel(doc, phase) return {label} } diff --git a/src/pages/Instruction.tsx b/src/pages/Instruction.tsx index 55bd4c8..c808edf 100644 --- a/src/pages/Instruction.tsx +++ b/src/pages/Instruction.tsx @@ -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(EMPTY) const [messages, setMessages] = useState([]) const [saving, setSaving] = useState(false) + // Brain material the schedules are made from; none chosen means the whole Brain. + const [selected, setSelected] = useState>(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('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() { <> -
-
-
-
- -

{t('agent.name')}

{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}
+
+ +
+
+
+
+ +

{t('agent.name')}

{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}
+
-
-
- {messages.map((m, i) => ( - - ))} - {phase === 'thinking' && } - {phase === 'pausing' && } - {idle && live.text && ( - setTypedLive(live.text)} onType={scroll} /> +
+ {messages.map((m, i) => ( + + ))} + {phase === 'thinking' && } + {phase === 'pausing' && } + {idle && live.text && ( + setTypedLive(live.text)} onType={scroll} /> + )} +
+ {showOptions && ( +
+ {live.options.map((o, i) => ( + + ))} +
)} +
{ e.preventDefault(); send(text) }}> + +