production-dashboard #1

Merged
iqbal merged 13 commits from production-dashboard into main 2026-10-27 14:58:53 +00:00
9 changed files with 164 additions and 31 deletions
Showing only changes of commit f55cccb6eb - Show all commits

View File

@@ -11,7 +11,8 @@ export function sentencesOf(text: string): string[] {
return text return text
.replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes .replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes
.replace(/[*_`]+/g, '') .replace(/[*_`]+/g, '')
.split(/(?<=[.!?…])\s+|\n+/) // Chinese ends sentences with 。!? and no space after them.
.split(/(?<=[.!?…])\s+|(?<=[。!?])|\n+/)
.map(s => s.replace(/\s+/g, ' ').trim()) .map(s => s.replace(/\s+/g, ' ').trim())
.filter(s => /\p{L}/u.test(s)) .filter(s => /\p{L}/u.test(s))
} }
@@ -19,7 +20,12 @@ export function sentencesOf(text: string): string[] {
// The script's language decides the voice; the UI language may differ from the content's. // The script's language decides the voice; the UI language may differ from the content's.
const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi
const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi
export const scriptLang = (text: string) => ((text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en') // Chinese has no spaces, so it is told apart by its characters before the word counts.
export function scriptLang(text: string): 'id' | 'en' | 'zh' {
if ((text.match(/\p{Script=Han}/gu)?.length ?? 0) > (text.match(/[a-z]/gi)?.length ?? 0)) return 'zh'
return (text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en'
}
const SPEECH_LANG = { id: 'id-ID', en: 'en-US', zh: 'zh-CN' }
const supported = typeof window !== 'undefined' && 'speechSynthesis' in window const supported = typeof window !== 'undefined' && 'speechSynthesis' in window
@@ -44,7 +50,7 @@ export function AudioPlayer({ title, text }: { title: string; text: string }) {
at.current = i at.current = i
setIndex(i) setIndex(i)
const u = new SpeechSynthesisUtterance(sentences[i]) const u = new SpeechSynthesisUtterance(sentences[i])
u.lang = lang === 'id' ? 'id-ID' : 'en-US' u.lang = SPEECH_LANG[lang]
u.voice = voice() u.voice = voice()
u.rate = rate u.rate = rate
u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) } u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) }

View File

@@ -7,7 +7,8 @@ const rules = (s: AgentState) => s.items.map(i => i.rule)
describe('instruction agent', () => { describe('instruction agent', () => {
it('needs no question when the instruction is complete', () => { it('needs no question when the instruction is complete', () => {
const s = say('make 2 summaries per day about leadership') const s = say('make 2 summaries per day about leadership in english')
expect(s.language).toBe('English')
expect(nextQuestion(s)).toBeNull() expect(nextQuestion(s)).toBeNull()
expect(respond(s, 'yes').approve).toBe(true) expect(respond(s, 'yes').approve).toBe(true)
}) })
@@ -19,7 +20,7 @@ describe('instruction agent', () => {
}) })
it('stops asking for a topic once the whole Brain is chosen', () => { it('stops asking for a topic once the whole Brain is chosen', () => {
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))).toBeNull() expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
}) })
it('asks about a video count that has no cadence', () => { it('asks about a video count that has no cadence', () => {
@@ -39,7 +40,25 @@ describe('instruction agent', () => {
it('asks about parts it did not understand, and can skip them', () => { it('asks about parts it did not understand, and can skip them', () => {
const s = say('2 summaries per day about sales. hello there') const s = say('2 summaries per day about sales. hello there')
expect(nextQuestion(s)).toMatchObject({ type: 'unknown', text: 'hello there' }) expect(nextQuestion(s)).toMatchObject({ type: 'unknown', text: 'hello there' })
expect(nextQuestion(say('2 summaries per day about sales. hello there', 'skip'))).toBeNull() expect(nextQuestion(say('2 summaries per day about sales. hello there', 'skip'))?.type).toBe('language')
})
it('asks which language the content is written in', () => {
expect(nextQuestion(say('3 quizzes per week about sales'))?.type).toBe('language')
expect(say('3 quizzes per week about sales', 'mandarin').language).toBe('中文')
expect(say('3 quizzes per week about sales', 'indo').language).toBe('Bahasa Indonesia')
expect(nextQuestion(say('3 quizzes per week about sales', '中文 (Mandarin)'))).toBeNull()
})
it('reads the language from the instruction without leaving it in the topic', () => {
const s = say('bikin 3 kuis per minggu soal negosiasi pakai bahasa inggris')
expect(s.language).toBe('English')
expect(rules(s)[0]).toMatchObject({ topic: 'negosiasi' })
expect(s.unknown).toEqual([])
})
it('does not ask about the language for research alone', () => {
expect(nextQuestion(say('research every day about sales in China'))).toBeNull()
}) })
it('treats a reply with a frequency as a new request', () => { it('treats a reply with a frequency as a new request', () => {

View File

@@ -1,8 +1,12 @@
// The Instruction agent: a conversation on top of the deterministic parser. Each message updates the // The Instruction agent: a conversation on top of the deterministic parser. Each message updates the
// draft plan, and the agent asks about one gap at a time (a part it did not understand, a video // draft plan, and the agent asks about one gap at a time (a part it did not understand, a video
// cadence, a weekday, a topic) right in the chat, so nothing is left for the user to spot alone. // cadence, a weekday, a topic, the output language) right in the chat, so nothing is left for the
// user to spot alone.
import { t, type Key } from '../i18n' import { t, type Key } from '../i18n'
import { dayName, describeRule, parseInstruction, videoCadence, weekdayOf, weeklyTarget, type ContentRule, type ResearchRule, type Rule } from './parse' import {
dayName, describeRule, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf, weeklyTarget,
type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
} from './parse'
export interface Item { id: number; rule: Rule } export interface Item { id: number; rule: Rule }
@@ -12,14 +16,17 @@ export interface AgentState {
unknown: string[] unknown: string[]
/** Questions settled without changing the plan, e.g. "use the whole Brain". */ /** Questions settled without changing the plan, e.g. "use the whole Brain". */
settled: string[] settled: string[]
/** The language the content is written in, confirmed by the user; null until then. */
language: OutputLanguage | null
} }
export const EMPTY: AgentState = { items: [], unknown: [], settled: [] } export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null }
export type Question = export type Question =
| { key: string; type: 'unknown'; text: string } | { key: string; type: 'unknown'; text: string }
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number } | { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
| { key: string; type: 'topic'; ids: number[] } | { key: string; type: 'topic'; ids: number[] }
| { key: string; type: 'language' }
/** What the plan still needs, in the order the agent asks about it. */ /** What the plan still needs, in the order the agent asks about it. */
export function nextQuestion(s: AgentState): Question | null { export function nextQuestion(s: AgentState): Question | null {
@@ -31,7 +38,10 @@ export function nextQuestion(s: AgentState): Question | null {
} }
const ids = s.items.filter(i => !i.rule.topic).map(i => i.id) const ids = s.items.filter(i => !i.rule.topic).map(i => i.id)
const key = `topic:${ids.join(',')}` const key = `topic:${ids.join(',')}`
return ids.length && !s.settled.includes(key) ? { key, type: 'topic', ids } : null 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.
if (!s.language && s.items.some(i => i.rule.type === 'content')) return { key: 'language', type: 'language' }
return null
} }
const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n') const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n')
@@ -54,6 +64,8 @@ export function ask(q: Question, s: AgentState): { text: string; options: string
text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }), text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }),
options: [t('agent.optWholeBrain')], options: [t('agent.optWholeBrain')],
} }
case 'language':
return { text: t('agent.askLanguage'), options: LANGUAGES.map(languageLabel) }
} }
} }
@@ -83,14 +95,24 @@ const withIds = (rules: Rule[]) => rules.map(rule => ({ id: ++seq, rule }))
// A new instruction: its rules join the plan and its unclear parts are queued as questions. // A new instruction: its rules join the plan and its unclear parts are queued as questions.
function instruct(s: AgentState, text: string): Turn | null { function instruct(s: AgentState, text: string): Turn | null {
const { rules, unknown } = parseInstruction(text) const { rules, unknown, language } = parseInstruction(text)
if (!rules.length) return null const languageNote = language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : ''
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] }, 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) }), reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }) + languageNote,
} }
} }
// A reply to the language question may be short ("indo", "inggris", "zh"), so it is read more loosely.
function languageAnswer(lower: string): OutputLanguage | null {
return languageOf(lower)
?? LANGUAGES.find(l => lower === languageLabel(l).toLowerCase())
?? (/^(id|indo|indonesia)$/.test(lower) ? 'Bahasa Indonesia'
: /^(en|eng|inggris)$/.test(lower) ? 'English'
: /^(zh|cn|cina|china|tionghoa|mandarin|chinese)$/.test(lower) ? '中文' : 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: describeRule(ruleById(state, id)) }) }
@@ -102,10 +124,14 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
if (SKIP.test(lower) || is('agent.optSkip')) return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.skipped', { part: q.text }) } if (SKIP.test(lower) || is('agent.optSkip')) return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.skipped', { part: q.text }) }
return instruct({ ...s, unknown: s.unknown.slice(1) }, text) return instruct({ ...s, unknown: s.unknown.slice(1) }, text)
} }
if (q.type === 'language') {
const language = languageAnswer(lower)
return language ? { state: { ...s, language }, reply: t('agent.languageSet', { language: languageLabel(language) }) } : null
}
if (q.type === 'topic') { if (q.type === 'topic') {
if (WHOLE.test(lower) || SKIP.test(lower) || is('agent.optWholeBrain')) return { state: { ...s, settled: [...s.settled, q.key] }, reply: t('agent.wholeBrain') } if (WHOLE.test(lower) || SKIP.test(lower) || is('agent.optWholeBrain')) return { state: { ...s, settled: [...s.settled, q.key] }, reply: t('agent.wholeBrain') }
// A reply with a frequency is a new request, not a topic. // A reply with a frequency is a new request, not a topic.
if (DAILY.test(lower) || WEEKLY.test(lower) || /\bper\b/.test(lower)) return null if (DAILY.test(lower) || WEEKLY.test(lower) || /\bper\b/.test(lower) || languageOf(lower)) return null
const topic = text.replace(/^(about|regarding|tentang|soal|mengenai|topik|topic)\s+/i, '').replace(/[.!]+$/, '').trim() const topic = text.replace(/^(about|regarding|tentang|soal|mengenai|topik|topic)\s+/i, '').replace(/[.!]+$/, '').trim()
if (!topic) return null if (!topic) return null
return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) } return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) }

View File

@@ -45,6 +45,26 @@ const WEEKDAYS: [RegExp, number][] = [
export type Per = 'day' | 'week' export type Per = 'day' | 'week'
// The output languages the engine writes in (generation_brief.OutputLanguage, AgentVideoOptions.language).
export type OutputLanguage = 'English' | 'Bahasa Indonesia' | '中文'
export const LANGUAGES: OutputLanguage[] = ['Bahasa Indonesia', 'English', '中文']
export const languageLabel = (l: OutputLanguage) => (l === '中文' ? '中文 (Mandarin)' : l)
// "in English", "pakai bahasa Inggris", "dalam bahasa Mandarin". A bare country name needs "bahasa",
// so a topic such as "sales in China" is not read as a language.
const LANGUAGE_CORE = String.raw`bahasa\s+(?:indonesia|indo|inggris|english|mandarin|cina|china|tionghoa)|indonesian|english|inggris|mandarin|chinese|中文`
const LANGUAGE_PHRASE = new RegExp(
String.raw`(?<!\p{L})(?:(?:in|dalam|pakai|pake|memakai|menggunakan|gunakan|using|use|with|berbahasa)\s+)?(?:the\s+)?(?:${LANGUAGE_CORE})(?:\s+language)?(?!\p{L})`,
'gu',
)
/** The output language a text names, if any. */
export function languageOf(text: string): OutputLanguage | null {
const m = text.toLowerCase().match(LANGUAGE_PHRASE)?.[0]
if (!m) return null
return /indo/.test(m) ? 'Bahasa Indonesia' : /english|inggris/.test(m) ? 'English' : '中文'
}
export interface ContentRule { export interface ContentRule {
type: 'content' type: 'content'
kind: ContentKind kind: ContentKind
@@ -53,6 +73,8 @@ export interface ContentRule {
topic: string topic: string
/** The monthly count the instruction asked for, when it had to be turned into a weekly one. */ /** The monthly count the instruction asked for, when it had to be turned into a weekly one. */
monthly?: number monthly?: number
/** The language the content is written in; unset means the UI language. */
language?: OutputLanguage
} }
export interface ResearchRule { export interface ResearchRule {
@@ -71,6 +93,8 @@ export interface ParseResult {
rules: Rule[] rules: Rule[]
/** Clauses that said something but matched no rule, so the user can rephrase them. */ /** Clauses that said something but matched no rule, so the user can rephrase them. */
unknown: string[] unknown: string[]
/** The output language the instruction named. */
language?: OutputLanguage
} }
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/ const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/
@@ -128,8 +152,10 @@ function parseClause(clause: string): Rule | null {
} }
export function parseInstruction(text: string): ParseResult { export function parseInstruction(text: string): ParseResult {
const language = languageOf(text)
const clauses = text const clauses = text
.toLowerCase() .toLowerCase()
.replace(LANGUAGE_PHRASE, ' ')
.replace(/[“”"]/g, '') .replace(/[“”"]/g, '')
.split(SPLIT) .split(SPLIT)
.map(s => s.trim()) .map(s => s.trim())
@@ -150,7 +176,7 @@ export function parseInstruction(text: string): ParseResult {
const topics = new Set(rules.map(r => r.topic).filter(Boolean)) const topics = new Set(rules.map(r => r.topic).filter(Boolean))
const fallback = shared || (topics.size === 1 ? [...topics][0] : '') const fallback = shared || (topics.size === 1 ? [...topics][0] : '')
for (const r of rules) if (!r.topic) r.topic = fallback for (const r of rules) if (!r.topic) r.topic = fallback
return { rules, unknown } return language ? { rules, unknown, language } : { rules, unknown }
} }
export function weeklyTarget(rule: ContentRule): number { export function weeklyTarget(rule: ContentRule): number {

View File

@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'
import type { ContentRule, ResearchRule } from './parse' import type { ContentRule, ResearchRule } from './parse'
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules' import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding'): 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 }) ({ type: 'content', kind, count, per, topic, language })
// What the engine sends back for an agent created from configFor. // What the engine sends back for an agent created from configFor.
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', auto_approve_sources: false,
name: c.name as string, goal: c.goal 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']) ?? [],
content_goal: (c.content_goal as ExpressAgent['content_goal']) ?? null, content_goal: (c.content_goal as ExpressAgent['content_goal']) ?? null,
@@ -54,6 +54,15 @@ describe('configFor', () => {
}) })
}) })
describe('content language', () => {
it('writes the chosen language into the brief and the video options', () => {
expect((configFor(rule('summary', 2, 'week', 'sales', '中文')) as { instructions: string }).instructions)
.toMatch(/Write all content in Simplified Chinese \(中文\)\./)
expect((configFor(rule('video', 1, 'week', 'sales', 'Bahasa Indonesia'), 50) as { video_options: { language: string } }).video_options.language)
.toBe('Bahasa Indonesia')
})
})
describe('ruleOf', () => { describe('ruleOf', () => {
it.each([ it.each([
rule('summary', 3), rule('summary', 3),
@@ -62,6 +71,8 @@ describe('ruleOf', () => {
rule('quiz', 1, 'day'), rule('quiz', 1, 'day'),
rule('video', 1, 'week'), rule('video', 1, 'week'),
rule('video', 1, 'day'), rule('video', 1, 'day'),
rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia'),
rule('video', 1, 'week', 'sales', '中文'),
])('reads back the rule a schedule was created from: $count $kind per $per', r => { ])('reads back the rule a schedule was created from: $count $kind per $per', r => {
expect(ruleOf(agentFrom(configFor(r, 50)))).toEqual(r) expect(ruleOf(agentFrom(configFor(r, 50)))).toEqual(r)
}) })

View File

@@ -4,7 +4,10 @@
import { opKey, request } from '../api/client' import { opKey, request } from '../api/client'
import { listDocuments, type Agent, type RunSchedule } from '../api/engine' import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
import { getLang } from '../i18n' import { getLang } from '../i18n'
import { KINDS, SCHEDULABLE, videoCadence, weeklyTarget, type ContentKind, type ContentRule, type Per, type ResearchRule, type Rule } from './parse' import {
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
} from './parse'
const AGENTS = '/organizations/current/agents' const AGENTS = '/organizations/current/agents'
export const PREFIX = 'Express · ' export const PREFIX = 'Express · '
@@ -25,7 +28,7 @@ export interface VideoOptions {
max_scenes: number max_scenes: number
max_duration_seconds: number max_duration_seconds: number
art_style: 'realistic' | 'cartoon' | 'anime' | 'watercolor' | '3d' art_style: 'realistic' | 'cartoon' | 'anime' | 'watercolor' | '3d'
language: 'English' | 'Bahasa Indonesia' language: OutputLanguage
} }
export interface ExpressAgent extends Agent { export interface ExpressAgent extends Agent {
@@ -56,11 +59,24 @@ function scheduleName(rule: Rule): string {
const isKind = (k: string | undefined): k is ContentKind => KINDS.includes(k as ContentKind) const isKind = (k: string | undefined): k is ContentKind => KINDS.includes(k as ContentKind)
/** A rule with no language chosen is written in the UI language, as before languages could be chosen. */
export const defaultLanguage = (): OutputLanguage => (getLang() === 'id' ? 'Bahasa Indonesia' : 'English')
// How the agent's English brief names each language; read back by languageOfAgent.
const LANGUAGE_TEXT: Record<OutputLanguage, string> = {
English: 'English', 'Bahasa Indonesia': 'Bahasa Indonesia', 中文: 'Simplified Chinese (中文)',
}
const languageSentence = (l: OutputLanguage) => `Write all content in ${LANGUAGE_TEXT[l]}.`
function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
return agent.video_options?.language ?? LANGUAGES.find(l => agent.instructions?.includes(languageSentence(l)))
}
export function ruleOf(agent: ExpressAgent): Rule { export function ruleOf(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'
return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic } return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic, language: languageOfAgent(agent) }
} }
if (agent.content_goal?.enabled && agent.material_outputs[0]) { if (agent.content_goal?.enabled && agent.material_outputs[0]) {
// " per hari" comes from schedules created before names were in English. // " per hari" comes from schedules created before names were in English.
@@ -69,7 +85,7 @@ export function ruleOf(agent: ExpressAgent): Rule {
// Audio and SCORM are Express kinds made of Compose outputs, so the name carries the kind. // Audio and SCORM are Express kinds made of Compose outputs, so the name carries the kind.
const named = agent.name.match(/^Express · \d+ (\w+) per /)?.[1] const named = agent.name.match(/^Express · \d+ (\w+) per /)?.[1]
const kind = isKind(named) ? named : agent.material_outputs[0].kind const kind = isKind(named) ? named : agent.material_outputs[0].kind
return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic } return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic, language: languageOfAgent(agent) }
} }
const s = agent.run_schedule ?? { cadence: 'off' } const s = agent.run_schedule ?? { cadence: 'off' }
return { type: 'research', cadence: s.cadence === 'weekly' ? 'weekly' : 'daily', weekday: s.weekday ?? 0, topic, autoApprove: agent.auto_approve_sources } return { type: 'research', cadence: s.cadence === 'weekly' ? 'weekly' : 'daily', weekday: s.weekday ?? 0, topic, autoApprove: agent.auto_approve_sources }
@@ -135,7 +151,8 @@ function outputsFor(kind: ContentKind): ExpressAgent['material_outputs'] {
return [{ kind, options: {} }] return [{ kind, options: {} }]
} }
// The agent's brief is in English; the content language follows the UI language at creation time. // The agent's brief is in English; the content language is the one chosen in the Instruction chat.
// The engine's content production has no language field, so it is part of the instructions.
function goalText(rule: Rule): string { function goalText(rule: Rule): string {
const about = rule.topic ? ` about ${rule.topic}` : '' const about = rule.topic ? ` about ${rule.topic}` : ''
if (rule.type === 'research') { if (rule.type === 'research') {
@@ -145,7 +162,7 @@ function goalText(rule: Rule): string {
} }
function instructionsFor(rule: Rule): string { function instructionsFor(rule: Rule): string {
const language = getLang() === 'id' ? 'Write all content in Bahasa Indonesia.' : 'Write all content in English.' 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, language].filter(Boolean).join(' ')
} }
@@ -178,7 +195,7 @@ export function configFor(rule: Rule, videoCredits = 0) {
max_scenes: VIDEO.scenes, max_scenes: VIDEO.scenes,
max_duration_seconds: VIDEO.seconds, max_duration_seconds: VIDEO.seconds,
art_style: 'realistic', art_style: 'realistic',
language: getLang() === 'id' ? 'Bahasa Indonesia' : 'English', language: rule.language ?? defaultLanguage(),
} satisfies VideoOptions, } satisfies VideoOptions,
run_schedule: { cadence: videoCadence(rule) ?? 'weekly', hour: 9, minute: 0, weekday: 0 } satisfies RunSchedule, run_schedule: { cadence: videoCadence(rule) ?? 'weekly', hour: 9, minute: 0, weekday: 0 } satisfies RunSchedule,
} }

View File

@@ -280,6 +280,11 @@ const en = {
'agent.askWeekday': 'Weekly internet research{about}: which day should it run? It is set to Monday for now.', 'agent.askWeekday': 'Weekly internet research{about}: which day should it run? It is set to Monday for now.',
'agent.askTopic': 'What topic should this be about?\n{list}\nType a topic, or use the whole Brain.', 'agent.askTopic': 'What topic should this be about?\n{list}\nType a topic, or use the whole Brain.',
'agent.ready': 'Everything is clear: {n} schedules are ready. You can still change them in the plan. Shall I start them?', 'agent.ready': 'Everything is clear: {n} schedules are ready. You can still change them in the plan. Shall I start them?',
'agent.readyLanguage': 'Everything is clear: {n} schedules are ready, with content written in {language}. You can still change them in the plan. Shall I start them?',
'agent.askLanguage': 'Which language should the content be written in?',
'agent.languageSet': 'Content will be written in {language}.',
'ins.language': 'Content language',
'ins.languagePending': 'Not chosen yet',
'agent.skipped': 'OK, I skipped “{part}”.', 'agent.skipped': 'OK, I skipped “{part}”.',
'agent.removed': 'Removed {rule}.', 'agent.removed': 'Removed {rule}.',
'agent.updated': 'Updated: {rule}.', 'agent.updated': 'Updated: {rule}.',
@@ -644,6 +649,11 @@ const id: Record<Key, string> = {
'agent.askWeekday': 'Riset internet mingguan{about}: dijalankan hari apa? Sementara diatur ke Senin.', 'agent.askWeekday': 'Riset internet mingguan{about}: dijalankan hari apa? Sementara diatur ke Senin.',
'agent.askTopic': 'Topiknya tentang apa?\n{list}\nTulis topiknya, atau pakai seluruh Otak.', 'agent.askTopic': 'Topiknya tentang apa?\n{list}\nTulis topiknya, atau pakai seluruh Otak.',
'agent.ready': 'Semua sudah jelas: {n} jadwal siap. Anda masih bisa mengubahnya di rencana. Mulai sekarang?', 'agent.ready': 'Semua sudah jelas: {n} jadwal siap. Anda masih bisa mengubahnya di rencana. Mulai sekarang?',
'agent.readyLanguage': 'Semua sudah jelas: {n} jadwal siap, dengan konten berbahasa {language}. Anda masih bisa mengubahnya di rencana. Mulai sekarang?',
'agent.askLanguage': 'Kontennya mau ditulis dalam bahasa apa?',
'agent.languageSet': 'Konten akan ditulis dalam {language}.',
'ins.language': 'Bahasa konten',
'ins.languagePending': 'Belum dipilih',
'agent.skipped': 'Oke, “{part}” saya lewati.', 'agent.skipped': 'Oke, “{part}” saya lewati.',
'agent.removed': '{rule} dihapus.', 'agent.removed': '{rule} dihapus.',
'agent.updated': 'Diperbarui: {rule}.', 'agent.updated': 'Diperbarui: {rule}.',

View File

@@ -4,12 +4,12 @@ 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 { import {
dayName, describeRule, kindLabel, KINDS, monthNote, videoCadence, weeklyTarget, dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type ResearchRule, type Rule, type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
} from '../express/parse' } from '../express/parse'
import { ask, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent' import { ask, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
import { t, type Key } from '../i18n' import { t, type Key } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules' import { budgetFor, canSchedule, createSchedule, defaultLanguage, VIDEO, videoQuote } from '../express/schedules'
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5'] const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
@@ -29,13 +29,18 @@ export function InstructionPage() {
const question = nextQuestion(agent) const question = nextQuestion(agent)
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')
// 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 = saving const live = saving
? { text: t('agent.approving'), options: [] } ? { text: t('agent.approving'), options: [] }
: question : question
? ask(question, agent) ? ask(question, agent)
: agent.items.length : agent.items.length
? { text: t('agent.ready', { n: ready.length }), options: [t('agent.optApprove'), t('agent.optReset')] } ? {
text: hasContent && agent.language
? t('agent.readyLanguage', { n: ready.length, language: languageLabel(agent.language) })
: t('agent.ready', { n: ready.length }),
options: [t('agent.optApprove'), t('agent.optReset')] }
// The greeting comes once; after that the examples alone invite the next request. // The greeting comes once; after that the examples alone invite the next request.
: { text: messages.length ? '' : t('agent.greeting'), options: EXAMPLES.map(k => t(k)) } : { text: messages.length ? '' : t('agent.greeting'), options: EXAMPLES.map(k => t(k)) }
@@ -64,9 +69,11 @@ export function InstructionPage() {
const done: Outcome[] = [] const done: Outcome[] = []
const created = new Set<number>() const created = new Set<number>()
// 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.
// Approving from the plan before the agent asked about the language keeps the old default.
const language = state.language ?? defaultLanguage()
for (const { id, rule } of items) { for (const { id, rule } of items) {
try { try {
await createSchedule(rule) await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
done.push({ rule, ok: true }) done.push({ rule, ok: true })
created.add(id) created.add(id)
} catch (e) { } catch (e) {
@@ -120,6 +127,15 @@ export function InstructionPage() {
<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 && (
<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"> <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} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />

View File

@@ -156,3 +156,5 @@ textarea{resize:vertical;line-height:1.6}
.rule-body .error{font-size:12px} .rule-body .error{font-size:12px}
.rule-approve small{font-size:12px;color:#667085} .rule-approve small{font-size:12px;color:#667085}
.rule-fields select,.rule-fields input{font-size:13px} .rule-fields select,.rule-fields input{font-size:13px}
.plan-language{display:flex;align-items:center;gap:12px;margin:0;padding:14px 22px;border-bottom:1px solid var(--line);font-size:13px;font-weight:600;color:#344054}
.plan-language select{width:auto!important;min-height:36px!important;padding:7px 10px!important;font-size:13px}