Ask which language the content is written in

The Instruction agent now confirms the output language before the plan is
ready: Bahasa Indonesia, English or 中文, the languages the engine writes
in. It can also be named in the instruction ("pakai bahasa Inggris"), is
read from short replies, and can be changed in the plan. Research alone
is not asked about.

The engine's content production has no language field, so the choice
goes into the agent's instructions; video schedules also set
video_options.language. ruleOf reads it back, so editing a topic keeps
it. The audio player recognises Chinese scripts and uses a zh-CN voice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 11:32:06 +07:00
parent 2b37d80e26
commit f55cccb6eb
9 changed files with 164 additions and 31 deletions

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}