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:
@@ -23,6 +23,16 @@ describe('instruction agent', () => {
|
|||||||
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
|
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('asks no topic for content when material is chosen, and names that material in the plan', () => {
|
||||||
|
const s = { ...say('make 1 scorm per week'), sources: '“LLM”' }
|
||||||
|
expect(nextQuestion(s)?.type).toBe('language')
|
||||||
|
expect(respond({ ...EMPTY, sources: '“LLM”' }, 'make 1 scorm per week').reply).toMatch(/from “LLM”/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still asks what to research on the internet when material is chosen', () => {
|
||||||
|
expect(nextQuestion({ ...say('research every day'), sources: '“LLM”' })?.type).toBe('topic')
|
||||||
|
})
|
||||||
|
|
||||||
it('asks about a video count that has no cadence', () => {
|
it('asks about a video count that has no cadence', () => {
|
||||||
const s = say('3 videos per week about onboarding')
|
const s = say('3 videos per week about onboarding')
|
||||||
expect(nextQuestion(s)?.type).toBe('video')
|
expect(nextQuestion(s)?.type).toBe('video')
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ export interface AgentState {
|
|||||||
settled: string[]
|
settled: string[]
|
||||||
/** The language the content is written in, confirmed by the user; null until then. */
|
/** The language the content is written in, confirmed by the user; null until then. */
|
||||||
language: OutputLanguage | null
|
language: OutputLanguage | null
|
||||||
|
/** The material chosen in the Brain panel, as the plan names it; null for the whole Brain. */
|
||||||
|
sources: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null }
|
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null, sources: null }
|
||||||
|
|
||||||
export type Question =
|
export type Question =
|
||||||
| { key: string; type: 'unknown'; text: string }
|
| { key: string; type: 'unknown'; text: string }
|
||||||
@@ -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 === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id }
|
||||||
if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id }
|
if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id }
|
||||||
}
|
}
|
||||||
const ids = s.items.filter(i => !i.rule.topic).map(i => i.id)
|
// Chosen material already says what content is about; research still needs a topic to search the internet for.
|
||||||
|
const ids = s.items.filter(i => !i.rule.topic && !(s.sources && i.rule.type === 'content')).map(i => i.id)
|
||||||
const key = `topic:${ids.join(',')}`
|
const key = `topic:${ids.join(',')}`
|
||||||
if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids }
|
if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids }
|
||||||
// Research only adds sources to the Brain, so the language matters once there is content to write.
|
// Research only adds sources to the Brain, so the language matters once there is content to write.
|
||||||
@@ -44,7 +47,8 @@ export function nextQuestion(s: AgentState): Question | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n')
|
const describe = (s: AgentState, rule: Rule) => describeRule(rule, s.sources ?? undefined)
|
||||||
|
const bullets = (s: AgentState, rules: Rule[]) => rules.map(r => `• ${describe(s, r)}`).join('\n')
|
||||||
const about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '')
|
const about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '')
|
||||||
const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule
|
const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule
|
||||||
|
|
||||||
@@ -54,14 +58,14 @@ export function ask(q: Question, s: AgentState): { text: string; options: string
|
|||||||
case 'unknown':
|
case 'unknown':
|
||||||
return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] }
|
return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] }
|
||||||
case 'video':
|
case 'video':
|
||||||
return { text: t('agent.askVideo', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
|
return { text: t('agent.askVideo', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
|
||||||
case 'tooMany':
|
case 'tooMany':
|
||||||
return { text: t('agent.askTooMany', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
|
return { text: t('agent.askTooMany', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] }
|
||||||
case 'weekday':
|
case 'weekday':
|
||||||
return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) }
|
return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) }
|
||||||
case 'topic':
|
case 'topic':
|
||||||
return {
|
return {
|
||||||
text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }),
|
text: t('agent.askTopic', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
|
||||||
options: [t('agent.optWholeBrain')],
|
options: [t('agent.optWholeBrain')],
|
||||||
}
|
}
|
||||||
case 'language':
|
case 'language':
|
||||||
@@ -116,7 +120,7 @@ function instruct(s: AgentState, text: string): Turn | null {
|
|||||||
if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
|
if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
|
||||||
return {
|
return {
|
||||||
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language },
|
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language },
|
||||||
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<ContentRule> | Partial<ResearchRule>): Turn {
|
function updated(s: AgentState, id: number, patch: Partial<ContentRule> | Partial<ResearchRule>): Turn {
|
||||||
const state = editRule(s, id, patch)
|
const state = editRule(s, id, patch)
|
||||||
return { state, reply: t('agent.updated', { rule: describeRule(ruleById(state, id)) }) }
|
return { state, reply: t('agent.updated', { rule: describe(state, ruleById(state, id)) }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads a reply to the agent's question; null when it does not answer it.
|
// Reads a reply to the agent's question; null when it does not answer it.
|
||||||
@@ -154,7 +158,7 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (REMOVE.test(lower) || is('agent.optRemove')) {
|
if (REMOVE.test(lower) || is('agent.optRemove')) {
|
||||||
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) }
|
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describe(s, ruleById(s, q.id)) }) }
|
||||||
}
|
}
|
||||||
const per = DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week' : null
|
const per = DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week' : null
|
||||||
if (q.type === 'video') return per ? updated(s, q.id, { count: 1, per }) : null
|
if (q.type === 'video') return per ? updated(s, q.id, { count: 1, per }) : null
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface ContentRule {
|
|||||||
monthly?: number
|
monthly?: number
|
||||||
/** The language the content is written in; unset means the UI language. */
|
/** The language the content is written in; unset means the UI language. */
|
||||||
language?: OutputLanguage
|
language?: OutputLanguage
|
||||||
|
/** Made from material chosen in the Brain panel rather than the whole Brain. */
|
||||||
|
chosen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResearchRule {
|
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 const dayName = (weekday: number) => t(`day.${weekday >= 0 && weekday <= 6 ? weekday : 0}` as Key)
|
||||||
|
|
||||||
export function describeRule(rule: Rule): string {
|
/** `from` names the chosen material the rule is made from; without it, content comes from the whole Brain. */
|
||||||
|
export function describeRule(rule: Rule, from?: string): string {
|
||||||
const about = rule.topic ? t('rule.about', { topic: rule.topic }) : ''
|
const about = rule.topic ? t('rule.about', { topic: rule.topic }) : ''
|
||||||
if (rule.type === 'research') {
|
if (rule.type === 'research') {
|
||||||
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) })
|
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) })
|
||||||
@@ -210,7 +213,9 @@ export function describeRule(rule: Rule): string {
|
|||||||
kind: kindLabel(rule.kind),
|
kind: kindLabel(rule.kind),
|
||||||
count: rule.count,
|
count: rule.count,
|
||||||
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
|
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')),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ describe('content language', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('chosen material', () => {
|
||||||
|
it('marks a schedule made from chosen material in its brief, and reads it back as chosen', () => {
|
||||||
|
const r = rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia')
|
||||||
|
const config = configFor(r, 0, true)
|
||||||
|
expect(config.instructions).toMatch(/Use only the Brain material chosen for this schedule\./)
|
||||||
|
expect(configFor(r).instructions).not.toMatch(/chosen/)
|
||||||
|
expect(ruleOf(agentFrom(config))).toEqual({ ...r, chosen: true })
|
||||||
|
expect(ruleOf(agentFrom(configFor(r)))).toEqual(r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('ruleOf', () => {
|
describe('ruleOf', () => {
|
||||||
it.each([
|
it.each([
|
||||||
rule('summary', 3),
|
rule('summary', 3),
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ruleOf(agent: ExpressAgent): Rule {
|
export function ruleOf(agent: ExpressAgent): Rule {
|
||||||
|
const rule = baseRuleOf(agent)
|
||||||
|
return rule.type === 'content' && usesChosen(agent) ? { ...rule, chosen: true } : rule
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseRuleOf(agent: ExpressAgent): Rule {
|
||||||
const topic = agent.name.split(' · ').slice(2).join(' · ')
|
const topic = agent.name.split(' · ').slice(2).join(' · ')
|
||||||
if (agent.enabled_tools.includes('studio.create_video')) {
|
if (agent.enabled_tools.includes('studio.create_video')) {
|
||||||
const daily = agent.run_schedule?.cadence === 'daily'
|
const daily = agent.run_schedule?.cadence === 'daily'
|
||||||
@@ -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.`
|
return `Create new learning ${KIND_TEXT[rule.kind]}${about} from the L&D team's Brain, without repeating content that already exists.`
|
||||||
}
|
}
|
||||||
|
|
||||||
function instructionsFor(rule: Rule): string {
|
// A schedule made from chosen material says so in its brief. That keeps it to those documents:
|
||||||
|
// editing or resuming it attaches nothing else, while a whole-Brain schedule picks up new material.
|
||||||
|
const CHOSEN_SENTENCE = 'Use only the Brain material chosen for this schedule.'
|
||||||
|
const usesChosen = (agent: ExpressAgent) => !!agent.instructions?.includes(CHOSEN_SENTENCE)
|
||||||
|
|
||||||
|
function instructionsFor(rule: Rule, chosen = false): string {
|
||||||
const language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage())
|
const language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage())
|
||||||
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
|
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
|
||||||
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
|
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, chosen && CHOSEN_SENTENCE, language].filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configFor(rule: Rule, videoCredits = 0) {
|
export function configFor(rule: Rule, videoCredits = 0, chosen = false) {
|
||||||
const budget = budgetFor(rule, videoCredits)
|
const budget = budgetFor(rule, videoCredits)
|
||||||
const common = {
|
const common = {
|
||||||
name: scheduleName(rule),
|
name: scheduleName(rule),
|
||||||
goal: goalText(rule),
|
goal: goalText(rule),
|
||||||
instructions: instructionsFor(rule),
|
instructions: instructionsFor(rule, chosen),
|
||||||
timezone: TIMEZONE,
|
timezone: TIMEZONE,
|
||||||
run_budget: budget.run,
|
run_budget: budget.run,
|
||||||
monthly_budget: budget.monthly,
|
monthly_budget: budget.monthly,
|
||||||
@@ -227,21 +237,23 @@ export function canSchedule(rule: Rule): rule is ResearchRule | ContentRule {
|
|||||||
return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50
|
return SCHEDULABLE.has(rule.kind) && weeklyTarget(rule) <= 50
|
||||||
}
|
}
|
||||||
|
|
||||||
// The production corpus is the agent's own sources, so every ready Brain document is attached.
|
// The production corpus is the agent's own sources: the chosen documents, or every ready Brain document.
|
||||||
async function attachBrain(agentId: number) {
|
async function attachBrain(agentId: number, chosen?: number[]) {
|
||||||
const docs = (await listDocuments()).filter(d => d.knowledge === 'ready')
|
const ids = chosen ?? (await listDocuments()).filter(d => d.knowledge === 'ready').map(d => d.docId)
|
||||||
for (const d of docs) {
|
for (const id of ids) {
|
||||||
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: d.docId } })
|
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } })
|
||||||
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
|
.catch(() => undefined) // a document someone else cannot share is skipped, not fatal
|
||||||
}
|
}
|
||||||
return docs.length
|
return ids.length
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
|
/** With `chosen` document ids the schedule uses only those; without, the whole Brain. */
|
||||||
|
export async function createSchedule(rule: Rule, chosen?: number[]): Promise<ExpressAgent> {
|
||||||
|
const only = chosen?.length ? chosen : undefined
|
||||||
// A video schedule's run budget is today's price of one video, so it is fetched first.
|
// A video schedule's run budget is today's price of one video, so it is fetched first.
|
||||||
const credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0
|
const credits = rule.type === 'content' && rule.kind === 'video' ? (await videoQuote()).max_credits : 0
|
||||||
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits) })
|
const agent = await request<ExpressAgent>(AGENTS, { method: 'POST', json: configFor(rule, credits, !!only) })
|
||||||
await attachBrain(agent.id)
|
await attachBrain(agent.id, only)
|
||||||
// Activating approves the production rules as they stand; until then nothing runs.
|
// Activating approves the production rules as they stand; until then nothing runs.
|
||||||
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
|
return request<ExpressAgent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
@@ -249,15 +261,17 @@ export async function createSchedule(rule: Rule): Promise<ExpressAgent> {
|
|||||||
/** Changing a rule changes what was approved, so the agent is re-activated right after. */
|
/** Changing a rule changes what was approved, so the agent is re-activated right after. */
|
||||||
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
|
export async function updateTopic(schedule: Schedule, topic: string): Promise<ExpressAgent> {
|
||||||
const rule = { ...schedule.rule, topic: topic.trim() }
|
const rule = { ...schedule.rule, topic: topic.trim() }
|
||||||
const { name, goal, instructions } = configFor(rule)
|
const chosen = usesChosen(schedule.agent)
|
||||||
|
const { name, goal, instructions } = configFor(rule, 0, chosen)
|
||||||
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
|
await request(`${AGENTS}/${schedule.agent.id}`, { method: 'PATCH', json: { name, goal, instructions } })
|
||||||
await attachBrain(schedule.agent.id)
|
if (!chosen) await attachBrain(schedule.agent.id)
|
||||||
return request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
|
return request<ExpressAgent>(`${AGENTS}/${schedule.agent.id}/${schedule.active ? 'activate' : 'pause'}`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
|
export const pauseSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/pause`, { method: 'POST' })
|
||||||
export async function resumeSchedule(id: number) {
|
export async function resumeSchedule(schedule: Schedule) {
|
||||||
await attachBrain(id)
|
const id = schedule.agent.id
|
||||||
|
if (!usesChosen(schedule.agent)) await attachBrain(id)
|
||||||
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
|
return request<ExpressAgent>(`${AGENTS}/${id}/activate`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })
|
export const archiveSchedule = (id: number) => request<ExpressAgent>(`${AGENTS}/${id}/archive`, { method: 'POST' })
|
||||||
|
|||||||
38
src/i18n.ts
38
src/i18n.ts
@@ -167,6 +167,8 @@ const en = {
|
|||||||
'rule.research': '{when}: internet research{about}',
|
'rule.research': '{when}: internet research{about}',
|
||||||
'rule.about': ' about {topic}',
|
'rule.about': ' about {topic}',
|
||||||
'rule.wholeBrain': ' from the whole Brain',
|
'rule.wholeBrain': ' from the whole Brain',
|
||||||
|
'rule.from': ' from {source}',
|
||||||
|
'rule.chosenMaterial': 'chosen material',
|
||||||
'rule.content': '{kind}: {count} {per}{about}',
|
'rule.content': '{kind}: {count} {per}{about}',
|
||||||
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
|
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
|
||||||
'kind.summary': 'Summary',
|
'kind.summary': 'Summary',
|
||||||
@@ -396,6 +398,23 @@ const en = {
|
|||||||
'audio.ready': '{n} sentences · press Listen to start',
|
'audio.ready': '{n} sentences · press Listen to start',
|
||||||
'audio.sentence': 'Sentence {n} of {total}',
|
'audio.sentence': 'Sentence {n} of {total}',
|
||||||
'brain.deleted': '“{name}” deleted.',
|
'brain.deleted': '“{name}” deleted.',
|
||||||
|
'brainPanel.title': 'Brain',
|
||||||
|
'brainPanel.count': '{n} materials',
|
||||||
|
'brainPanel.copy': 'Your team’s knowledge base.',
|
||||||
|
'brainPanel.search': 'Search material…',
|
||||||
|
'brainPanel.noMatch': 'No material matches',
|
||||||
|
'brainPanel.noMatchCopy': 'Try another keyword or upload more material.',
|
||||||
|
'brainPanel.note': 'Chosen material is what the AI uses. Leave everything unticked to use the whole Brain.',
|
||||||
|
'brainPanel.selectAll': 'Select all',
|
||||||
|
'brainPanel.nChosen': '{n} chosen materials',
|
||||||
|
'ins.topicPlaceholderChosen': 'Topic (empty = all chosen material)',
|
||||||
|
'brainPanel.selected': '{n} selected',
|
||||||
|
'brainPanel.wholeBrain': 'Whole Brain in use',
|
||||||
|
'brainPanel.select': 'Select {name}',
|
||||||
|
'brainPanel.notReady': 'Can be chosen once it is in the Brain',
|
||||||
|
'ins.sourcesChosen': 'Source: chosen material ({n})',
|
||||||
|
'ins.sourcesAll': 'Source: the whole Brain',
|
||||||
|
'brainPanel.upload': 'Upload material',
|
||||||
|
|
||||||
// Guide
|
// Guide
|
||||||
'help.boxTitle': 'How Express works',
|
'help.boxTitle': 'How Express works',
|
||||||
@@ -580,6 +599,8 @@ const id: Record<Key, string> = {
|
|||||||
'rule.research': '{when}: riset internet{about}',
|
'rule.research': '{when}: riset internet{about}',
|
||||||
'rule.about': ' tentang {topic}',
|
'rule.about': ' tentang {topic}',
|
||||||
'rule.wholeBrain': ' dari seluruh Otak',
|
'rule.wholeBrain': ' dari seluruh Otak',
|
||||||
|
'rule.from': ' dari {source}',
|
||||||
|
'rule.chosenMaterial': 'materi pilihan',
|
||||||
'rule.content': '{kind}: {count} {per}{about}',
|
'rule.content': '{kind}: {count} {per}{about}',
|
||||||
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
|
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
|
||||||
'kind.summary': 'Ringkasan',
|
'kind.summary': 'Ringkasan',
|
||||||
@@ -807,6 +828,23 @@ const id: Record<Key, string> = {
|
|||||||
'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai',
|
'audio.ready': '{n} kalimat · tekan Dengarkan untuk mulai',
|
||||||
'audio.sentence': 'Kalimat {n} dari {total}',
|
'audio.sentence': 'Kalimat {n} dari {total}',
|
||||||
'brain.deleted': '“{name}” dihapus.',
|
'brain.deleted': '“{name}” dihapus.',
|
||||||
|
'brainPanel.title': 'Brain',
|
||||||
|
'brainPanel.count': '{n} materi',
|
||||||
|
'brainPanel.copy': 'Knowledge base tim Anda.',
|
||||||
|
'brainPanel.search': 'Cari materi…',
|
||||||
|
'brainPanel.noMatch': 'Belum ada materi yang cocok',
|
||||||
|
'brainPanel.noMatchCopy': 'Coba kata kunci lain atau upload materi baru.',
|
||||||
|
'brainPanel.note': 'Materi yang dipilih menjadi acuan AI. Kosongkan pilihan untuk memakai seluruh Brain.',
|
||||||
|
'brainPanel.selectAll': 'Pilih semua',
|
||||||
|
'brainPanel.nChosen': '{n} materi pilihan',
|
||||||
|
'ins.topicPlaceholderChosen': 'Topik (kosong = semua materi pilihan)',
|
||||||
|
'brainPanel.selected': '{n} dipilih',
|
||||||
|
'brainPanel.wholeBrain': 'Seluruh Brain digunakan',
|
||||||
|
'brainPanel.select': 'Pilih {name}',
|
||||||
|
'brainPanel.notReady': 'Bisa dipilih setelah masuk Brain',
|
||||||
|
'ins.sourcesChosen': 'Sumber: {n} materi dipilih',
|
||||||
|
'ins.sourcesAll': 'Sumber: seluruh Brain',
|
||||||
|
'brainPanel.upload': 'Upload materi',
|
||||||
|
|
||||||
// Panduan
|
// Panduan
|
||||||
'help.boxTitle': 'Cara kerja Express',
|
'help.boxTitle': 'Cara kerja Express',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
||||||
@@ -21,13 +22,15 @@ function phaseOf(s: Source): Phase {
|
|||||||
|
|
||||||
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
|
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
|
||||||
|
|
||||||
export function BrainPage() {
|
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
|
||||||
const docs = useQuery({
|
export const useDocuments = () => useQuery({
|
||||||
queryKey: ['documents'],
|
queryKey: ['documents'],
|
||||||
queryFn: listDocuments,
|
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),
|
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function BrainPage() {
|
||||||
|
const docs = useDocuments()
|
||||||
|
|
||||||
return (
|
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 }
|
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
|
||||||
|
|
||||||
let nextUploadId = 1
|
let nextUploadId = 1
|
||||||
@@ -189,13 +263,21 @@ function OpenButton({ doc }: { doc: Source }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
function statusLabel(doc: Source, phase: Phase) {
|
||||||
const label = {
|
return {
|
||||||
ready: t('brain.statusReady'),
|
ready: t('brain.statusReady'),
|
||||||
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
|
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
|
||||||
answer: t('brain.statusAnswer'),
|
answer: t('brain.statusAnswer'),
|
||||||
failed: t('brain.statusFailed'),
|
failed: t('brain.statusFailed'),
|
||||||
}[phase]
|
}[phase]
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusLabel({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||||
|
return <span className={phase === 'ready' ? 'brain-in' : undefined}>{statusLabel(doc, phase)}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||||
|
const label = statusLabel(doc, phase)
|
||||||
return <span className={`status ${phase === 'ready' ? 'ready' : 'draft'}`}><i className="dot"></i>{label}</span>
|
return <span className={`status ${phase === 'ready' ? 'ready' : 'draft'}`}><i className="dot"></i>{label}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +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 {
|
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,
|
||||||
@@ -35,6 +36,14 @@ 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()
|
||||||
|
// 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('')
|
||||||
@@ -46,14 +55,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), [])
|
||||||
|
|
||||||
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 ready = agent.items.filter(i => canSchedule(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, agent)
|
? ask(question, view)
|
||||||
: agent.items.length
|
: agent.items.length
|
||||||
? {
|
? {
|
||||||
text: hasContent && agent.language
|
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.
|
// 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(agent, input))
|
const turn = inLang(replyLang, () => respond(view, 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('')
|
||||||
@@ -106,8 +117,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)
|
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, chosen)
|
||||||
done.push({ rule, ok: true })
|
done.push({ rule: rule.type === 'content' && chosen.length ? { ...rule, chosen: true } : 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 })
|
||||||
@@ -127,7 +138,9 @@ export function InstructionPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
||||||
|
|
||||||
<div className="ai-workspace">
|
<div className="unified-workspace">
|
||||||
|
<BrainPanel selected={selected} onSelect={setSelected} />
|
||||||
|
<div className="ai-column">
|
||||||
<div className="card agent-chat">
|
<div className="card agent-chat">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<div className="assistant-title">
|
<div className="assistant-title">
|
||||||
@@ -179,12 +192,15 @@ export function InstructionPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="rule-list">
|
<div className="rule-list">
|
||||||
{agent.items.map(({ id, rule }) => (
|
{agent.items.map(({ id, rule }) => (
|
||||||
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
<RuleCard key={id} rule={rule} from={sources ?? undefined} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{ready.length > 0 && (
|
{ready.length > 0 && (
|
||||||
<div className="rule-approve">
|
<div className="rule-approve">
|
||||||
<small>{t('ins.approveNote')}</small>
|
<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()}>
|
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
|
||||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||||
</button>
|
</button>
|
||||||
@@ -192,6 +208,7 @@ export function InstructionPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -265,8 +282,9 @@ function Typing() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleCard({ rule, onChange, onRemove }: {
|
function RuleCard({ rule, from, onChange, onRemove }: {
|
||||||
rule: Rule
|
rule: Rule
|
||||||
|
from?: string
|
||||||
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
|
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -281,7 +299,7 @@ function RuleCard({ rule, onChange, onRemove }: {
|
|||||||
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
|
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
|
||||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||||
<div className="rule-body">
|
<div className="rule-body">
|
||||||
<b>{describeRule(rule)}</b>
|
<b>{describeRule(rule, from)}</b>
|
||||||
<div className="rule-fields">
|
<div className="rule-fields">
|
||||||
{rule.type === 'content' ? (
|
{rule.type === 'content' ? (
|
||||||
<>
|
<>
|
||||||
@@ -308,7 +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 })} />
|
onChange={e => onChange({ topic: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
{rule.type === 'research' && (
|
{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.
|
// Content kinds run as weekly production plans; research and video as one run per slot.
|
||||||
const production = !isRunSchedule(rule)
|
const production = !isRunSchedule(rule)
|
||||||
|
|
||||||
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(agent.id)), onSuccess: refresh })
|
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(schedule)), onSuccess: refresh })
|
||||||
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
|
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
|
||||||
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
|
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
|
||||||
const runNow = useMutation({
|
const runNow = useMutation({
|
||||||
|
|||||||
@@ -189,3 +189,17 @@ textarea{resize:vertical;line-height:1.6}
|
|||||||
.upload-large .upload-progress{text-align:left;max-width:520px;margin:12px auto 0}
|
.upload-large .upload-progress{text-align:left;max-width:520px;margin:12px auto 0}
|
||||||
.upload-large .btn{margin-top:14px;padding-inline:22px}
|
.upload-large .btn{margin-top:14px;padding-inline:22px}
|
||||||
@media(max-width:600px){.upload-box.upload-large{padding:32px 20px}.upload-large .btn{width:100%}}
|
@media(max-width:600px){.upload-box.upload-large{padding:32px 20px}.upload-large .btn{width:100%}}
|
||||||
|
.ai-column{display:grid;gap:22px;min-width:0}
|
||||||
|
.compact-brain .card-head h3{display:flex;align-items:center;gap:4px}
|
||||||
|
.compact-brain .card-head p{font-size:12px;color:var(--muted);margin-top:4px}
|
||||||
|
.compact-source-name .doc-title{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;overflow-wrap:normal}
|
||||||
|
.compact-source-name small{font-size:11px;color:var(--muted)}
|
||||||
|
.compact-source-name .brain-in{color:#067647}
|
||||||
|
.compact-brain .search input{width:100%;font-size:13px}
|
||||||
|
.compact-brain>.compact-brain-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;line-height:1.5}
|
||||||
|
.compact-brain-foot .btn{flex-shrink:0}
|
||||||
|
.rule-sources{display:flex;align-items:center;gap:6px;color:var(--ink);font-weight:600;margin-bottom:4px}
|
||||||
|
.rule-sources svg{width:14px;height:14px;color:var(--blue)}
|
||||||
|
.compact-source-row>input:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
.compact-brain .brain-selection label{font-size:13px}
|
||||||
|
.compact-brain .brain-selection{font-size:12px}
|
||||||
|
|||||||
Reference in New Issue
Block a user