mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(orbit): respect selected app language (#2522)
* fix(orbit): respect selected app language Generated-By: looper 0.8.1 (runner=worker, agent=opencode) * fix(orbit): respect selected app language Generated-By: looper 0.8.1 (runner=fixer, agent=opencode)
This commit is contained in:
parent
f892daae05
commit
047d2bdd95
6 changed files with 235 additions and 18 deletions
|
|
@ -115,7 +115,8 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
|
|||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
try {
|
||||
res.json(await orbitService.start('manual'));
|
||||
const locale = typeof req.body?.locale === 'string' ? req.body.locale : null;
|
||||
res.json(await orbitService.start('manual', { locale }));
|
||||
} catch (err: any) {
|
||||
res
|
||||
.status(500)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,57 @@ export type OrbitRunHandler = (request: {
|
|||
template: OrbitTemplateSelection | null;
|
||||
}) => Promise<OrbitRunHandlerStart>;
|
||||
|
||||
type OrbitOutputLocale = 'en' | 'zh-CN' | 'zh-TW';
|
||||
|
||||
function normalizeOrbitOutputLocale(locale?: string | null): OrbitOutputLocale {
|
||||
const normalized = locale?.trim().toLowerCase();
|
||||
if (!normalized) return 'en';
|
||||
const localeParts = normalized.split('-').filter(Boolean);
|
||||
const hasTraditionalChineseScript = localeParts.includes('hant');
|
||||
const hasTraditionalChineseRegion = localeParts.some((part) => part === 'tw' || part === 'hk' || part === 'mo');
|
||||
if (normalized === 'zh-tw' || normalized === 'zh-hk' || normalized === 'zh-mo' || normalized === 'zh-hant' || hasTraditionalChineseScript || hasTraditionalChineseRegion) {
|
||||
return 'zh-TW';
|
||||
}
|
||||
if (normalized.startsWith('zh')) return 'zh-CN';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function localizeOrbitTemplateExamplePrompt(
|
||||
template: OrbitTemplateSelection | null,
|
||||
locale: OrbitOutputLocale,
|
||||
): OrbitTemplateSelection | null {
|
||||
if (!template || locale === 'en') return template;
|
||||
const localizedExamplePrompt = locale === 'zh-TW'
|
||||
? {
|
||||
'orbit-general': '生成今天的 Open Orbit 晨間簡報。我已連接約 10 個整合(GitHub、Linear、Notion、Calendar、飛書、Sentry、Vercel、Slack、Gmail、Drive)。請拉取昨天各來源的活動,並將其渲染為編輯感 bento 儀表板。',
|
||||
'orbit-github': '生成今天的 Open Orbit GitHub 簡報。GitHub 是我唯一已連接的整合——請拉取昨天的 PR、審查請求、Issue、CI 執行與合併記錄,並將其渲染為 GitHub Notifications + PR diff 風格頁面。',
|
||||
}[template.id]
|
||||
: {
|
||||
'orbit-general': '生成今天的 Open Orbit 早间简报。我已连接约 10 个集成(GitHub、Linear、Notion、Calendar、飞书、Sentry、Vercel、Slack、Gmail、Drive)。请拉取昨天各来源的活动,并将其渲染为编辑感 bento 仪表板。',
|
||||
'orbit-github': '生成今天的 Open Orbit GitHub 简报。GitHub 是我唯一已连接的集成——请拉取昨天的 PR、审查请求、Issue、CI 运行与合并记录,并将其渲染为 GitHub Notifications + PR diff 风格页面。',
|
||||
}[template.id];
|
||||
if (!localizedExamplePrompt) return template;
|
||||
return { ...template, examplePrompt: localizedExamplePrompt };
|
||||
}
|
||||
|
||||
function buildOrbitOutputLanguageDirective(locale: OrbitOutputLocale): string[] {
|
||||
if (locale === 'zh-TW') {
|
||||
return [
|
||||
'App language: Traditional Chinese (zh-TW).',
|
||||
'Write all user-facing artifact copy, labels, headings, summaries, timestamps, and recommendations in Traditional Chinese unless a proper noun or source identifier must remain unchanged.',
|
||||
'If the selected template guidance or examples are written in another language, treat them as structural and visual guidance only. The final Orbit artifact itself must stay in Traditional Chinese.',
|
||||
];
|
||||
}
|
||||
if (locale === 'zh-CN') {
|
||||
return [
|
||||
'App language: Simplified Chinese (zh-CN).',
|
||||
'Write all user-facing artifact copy, labels, headings, summaries, timestamps, and recommendations in Simplified Chinese unless a proper noun or source identifier must remain unchanged.',
|
||||
'If the selected template guidance or examples are written in another language, treat them as structural and visual guidance only. The final Orbit artifact itself must stay in Simplified Chinese.',
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function formatLocalProjectTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const yyyy = d.getFullYear();
|
||||
|
|
@ -275,21 +326,50 @@ function renderMarkdown(summary: Omit<OrbitActivitySummary, 'markdown'>): string
|
|||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
|
||||
export function buildOrbitPrompt(now = new Date(), template?: OrbitTemplateSelection | null): string {
|
||||
export function buildOrbitPrompt(
|
||||
now = new Date(),
|
||||
template?: OrbitTemplateSelection | null,
|
||||
locale?: string | null,
|
||||
): string {
|
||||
const outputLocale = normalizeOrbitOutputLocale(locale);
|
||||
const end = formatLocalOrbitPromptTimestamp(now);
|
||||
const start = formatLocalOrbitPromptTimestamp(new Date(now.getTime() - 24 * 60 * 60_000));
|
||||
const lines = [
|
||||
'Create today\'s Orbit daily digest as a Live Artifact.',
|
||||
'',
|
||||
`Use my connected work data from ${start} through ${end}.`,
|
||||
];
|
||||
const lines = outputLocale === 'zh-TW'
|
||||
? [
|
||||
'請將今天的 Orbit 每日摘要製作成 Live Artifact。',
|
||||
'',
|
||||
`使用我從 ${start} 到 ${end} 的已連接工作資料。`,
|
||||
]
|
||||
: outputLocale === 'zh-CN'
|
||||
? [
|
||||
'请将今天的 Orbit 每日摘要制作成 Live Artifact。',
|
||||
'',
|
||||
`使用我从 ${start} 到 ${end} 的已连接工作数据。`,
|
||||
]
|
||||
: [
|
||||
'Create today\'s Orbit daily digest as a Live Artifact.',
|
||||
'',
|
||||
`Use my connected work data from ${start} through ${end}.`,
|
||||
];
|
||||
if (template) {
|
||||
lines.push('', `Use the selected Orbit template: ${template.name}.`);
|
||||
lines.push(
|
||||
'',
|
||||
outputLocale === 'zh-TW'
|
||||
? `使用已選取的 Orbit 範本:${template.name}。`
|
||||
: outputLocale === 'zh-CN'
|
||||
? `使用已选中的 Orbit 模板:${template.name}。`
|
||||
: `Use the selected Orbit template: ${template.name}.`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildOrbitSystemPrompt(now = new Date(), template?: OrbitTemplateSelection | null): string {
|
||||
export function buildOrbitSystemPrompt(
|
||||
now = new Date(),
|
||||
template?: OrbitTemplateSelection | null,
|
||||
locale?: string | null,
|
||||
): string {
|
||||
const outputLocale = normalizeOrbitOutputLocale(locale);
|
||||
const end = now.toISOString();
|
||||
const start = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
|
||||
const lines = [
|
||||
|
|
@ -297,6 +377,8 @@ export function buildOrbitSystemPrompt(now = new Date(), template?: OrbitTemplat
|
|||
'',
|
||||
`Time window: ${start} through ${end}.`,
|
||||
'',
|
||||
...buildOrbitOutputLanguageDirective(outputLocale),
|
||||
...(outputLocale === 'en' ? [] : ['']),
|
||||
'Work autonomously. Do not ask follow-up questions, do not emit a question form, and do not wait for user input. Use sensible defaults and proceed.',
|
||||
'Optimize for fast completion: sample at most 3 relevant data sources. DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED: first run `tools connectors list --use-case personal_daily_digest --format compact` with a 120s timeout, and if that curated list command times out or returns no output, retry it once with another 120s timeout. If the curated command is unsupported, rejected, or succeeds but returns no usable tools, immediately fall back to the unfiltered read-only list via `tools connectors list --format compact`; do not stop just because `--use-case` is unsupported. If connector discovery still fails, or if both the curated and fallback lists yield zero usable connected read-only data tools, do not create an empty-state artifact; send one concise final message explaining that data loading failed and stop. For individual source calls after discovery succeeds, if a source fails because of auth, permissions, timeout, malformed output, empty output, oversized output, or any other data-loading problem, do not get stuck trying to fix it; drop that source and continue with the others. After the artifact is registered successfully, send one concise final message with the artifact id and stop.',
|
||||
'',
|
||||
|
|
@ -402,20 +484,26 @@ export class OrbitService {
|
|||
};
|
||||
}
|
||||
|
||||
async start(trigger: 'manual' | 'scheduled'): Promise<{ projectId: string; agentRunId: string }> {
|
||||
async start(
|
||||
trigger: 'manual' | 'scheduled',
|
||||
options?: { locale?: string | null },
|
||||
): Promise<{ projectId: string; agentRunId: string }> {
|
||||
if (this.inflight && this.inflightProjectId && this.inflightAgentRunId) {
|
||||
return { projectId: this.inflightProjectId, agentRunId: this.inflightAgentRunId };
|
||||
}
|
||||
if (this.starting) return this.starting;
|
||||
if (!this.runHandler) throw new Error('Orbit agent runner is not configured');
|
||||
|
||||
this.starting = this.startRun(trigger).finally(() => {
|
||||
this.starting = this.startRun(trigger, options).finally(() => {
|
||||
this.starting = null;
|
||||
});
|
||||
return this.starting;
|
||||
}
|
||||
|
||||
private async startRun(trigger: 'manual' | 'scheduled'): Promise<{ projectId: string; agentRunId: string }> {
|
||||
private async startRun(
|
||||
trigger: 'manual' | 'scheduled',
|
||||
options?: { locale?: string | null },
|
||||
): Promise<{ projectId: string; agentRunId: string }> {
|
||||
if (!this.runHandler) throw new Error('Orbit agent runner is not configured');
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
|
|
@ -424,16 +512,20 @@ export class OrbitService {
|
|||
const template = configuredTemplateSkillId && this.templateResolver
|
||||
? await this.templateResolver(configuredTemplateSkillId).catch(() => null)
|
||||
: null;
|
||||
const localizedTemplate = localizeOrbitTemplateExamplePrompt(
|
||||
template,
|
||||
normalizeOrbitOutputLocale(options?.locale),
|
||||
);
|
||||
const now = new Date(startedAt);
|
||||
const prompt = buildOrbitPrompt(now, template);
|
||||
const systemPrompt = buildOrbitSystemPrompt(now, template);
|
||||
const prompt = buildOrbitPrompt(now, localizedTemplate, options?.locale);
|
||||
const systemPrompt = buildOrbitSystemPrompt(now, localizedTemplate, options?.locale);
|
||||
const handlerStart = await this.runHandler({
|
||||
runId,
|
||||
trigger,
|
||||
startedAt,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
template,
|
||||
template: localizedTemplate,
|
||||
});
|
||||
|
||||
this.inflightProjectId = handlerStart.projectId;
|
||||
|
|
|
|||
|
|
@ -8477,7 +8477,8 @@ export async function startServer({
|
|||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
try {
|
||||
res.json(await orbitService.start('manual'));
|
||||
const locale = typeof req.body?.locale === 'string' ? req.body.locale : null;
|
||||
res.json(await orbitService.start('manual', { locale }));
|
||||
} catch (err) {
|
||||
res
|
||||
.status(500)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,22 @@ describe('buildOrbitPrompt', () => {
|
|||
expect(prompt).not.toContain('Selected template example prompt:');
|
||||
expect(prompt).not.toContain('Render the editorial bento dashboard.');
|
||||
});
|
||||
|
||||
it('localizes the user-visible Orbit prompt when the app language is Chinese', () => {
|
||||
const template: OrbitTemplateSelection = {
|
||||
id: 'orbit-github',
|
||||
name: 'orbit-github',
|
||||
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
|
||||
dir: path.join('/repo', 'skills', 'orbit-github'),
|
||||
body: 'Mirror the shipped `example.html` before writing output.',
|
||||
designSystemRequired: false,
|
||||
};
|
||||
|
||||
const prompt = buildOrbitPrompt(new Date('2026-05-06T15:32:52.361Z'), template, 'zh-CN');
|
||||
|
||||
expect(prompt).toContain('请将今天的 Orbit 每日摘要制作成 Live Artifact。');
|
||||
expect(prompt).toContain('使用已选中的 Orbit 模板:orbit-github。');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOrbitSystemPrompt', () => {
|
||||
|
|
@ -103,6 +119,39 @@ describe('buildOrbitSystemPrompt', () => {
|
|||
expect(prompt).toContain('Open and mirror the shipped `example.html`');
|
||||
expect(prompt).toContain('Use exclusively the canvas tokens.');
|
||||
});
|
||||
|
||||
it('pins Chinese as the final output language when the app locale is Chinese', () => {
|
||||
const template: OrbitTemplateSelection = {
|
||||
id: 'orbit-github',
|
||||
name: 'orbit-github',
|
||||
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
|
||||
dir: path.join('/repo', 'skills', 'orbit-github'),
|
||||
body: 'Mirror the shipped `example.html` before writing output.',
|
||||
designSystemRequired: false,
|
||||
};
|
||||
|
||||
const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template, 'zh-CN');
|
||||
|
||||
expect(prompt).toContain('App language: Simplified Chinese (zh-CN).');
|
||||
expect(prompt).toContain('The final Orbit artifact itself must stay in Simplified Chinese.');
|
||||
expect(prompt).toContain('Generate today\'s Open Orbit GitHub briefing.');
|
||||
});
|
||||
|
||||
it('treats script-tagged Traditional Chinese locales as zh-TW', () => {
|
||||
const template: OrbitTemplateSelection = {
|
||||
id: 'orbit-github',
|
||||
name: 'orbit-github',
|
||||
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
|
||||
dir: path.join('/repo', 'skills', 'orbit-github'),
|
||||
body: 'Mirror the shipped `example.html` before writing output.',
|
||||
designSystemRequired: false,
|
||||
};
|
||||
|
||||
const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template, 'zh-Hant-TW');
|
||||
|
||||
expect(prompt).toContain('App language: Traditional Chinese (zh-TW).');
|
||||
expect(prompt).toContain('The final Orbit artifact itself must stay in Traditional Chinese.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OrbitService', () => {
|
||||
|
|
@ -144,6 +193,47 @@ describe('OrbitService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('localizes the template example prompt passed to the run handler for Chinese Orbit runs', async () => {
|
||||
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
|
||||
try {
|
||||
const service = new OrbitService(dataDir);
|
||||
const captured: { request?: Parameters<OrbitRunHandler>[0] } = {};
|
||||
service.setTemplateResolver(async () => ({
|
||||
id: 'orbit-github',
|
||||
name: 'orbit-github',
|
||||
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
|
||||
dir: path.join('/repo', 'skills', 'orbit-github'),
|
||||
body: 'Mirror the shipped `example.html` before writing output.',
|
||||
designSystemRequired: false,
|
||||
}));
|
||||
service.configure({ enabled: true, time: '08:00', templateSkillId: 'orbit-github' });
|
||||
service.setRunHandler(async (request) => {
|
||||
captured.request = request;
|
||||
return {
|
||||
projectId: 'project-1',
|
||||
agentRunId: 'agent-1',
|
||||
completion: Promise.resolve({
|
||||
agentRunId: 'agent-1',
|
||||
status: 'succeeded',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await service.start('manual', { locale: 'zh-CN' });
|
||||
|
||||
let status = await service.status();
|
||||
for (let attempt = 0; attempt < 10 && !status.lastRun; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
status = await service.status();
|
||||
}
|
||||
|
||||
expect(captured.request?.template?.examplePrompt).toContain('生成今天的 Open Orbit GitHub 简报');
|
||||
expect(captured.request?.systemPrompt).toContain('The final Orbit artifact itself must stay in Simplified Chinese.');
|
||||
} finally {
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the default template when config omits the field', async () => {
|
||||
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3944,6 +3944,7 @@ export async function persistConfigAndRunOrbit(
|
|||
options?: {
|
||||
daemonProviders?: AppConfig['mediaProviders'] | null;
|
||||
syncMediaProviders?: boolean;
|
||||
locale?: string | null;
|
||||
},
|
||||
): Promise<OrbitRunStartResponse> {
|
||||
if (options?.syncMediaProviders !== false) {
|
||||
|
|
@ -3952,7 +3953,11 @@ export async function persistConfigAndRunOrbit(
|
|||
});
|
||||
}
|
||||
await syncConfigToDaemon(config, { throwOnError: true });
|
||||
const response = await fetch('/api/orbit/run', { method: 'POST' });
|
||||
const response = await fetch('/api/orbit/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale: options?.locale ?? null }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Orbit run failed');
|
||||
return await response.json() as OrbitRunStartResponse;
|
||||
}
|
||||
|
|
@ -4016,7 +4021,7 @@ function OrbitSection({
|
|||
* parent dialog can persist any unsaved Orbit edits and close itself. */
|
||||
onLeaveForOrbitProject: (runConfig: AppConfig) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { locale, t } = useI18n();
|
||||
const orbit = cfg.orbit ?? DEFAULT_ORBIT;
|
||||
const [status, setStatus] = useState<OrbitStatusResponse | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
|
@ -4171,6 +4176,7 @@ function OrbitSection({
|
|||
const payload = await persistConfigAndRunOrbit(runConfig, {
|
||||
daemonProviders: daemonMediaProviders,
|
||||
syncMediaProviders: daemonMediaProvidersFetchState === 'ok',
|
||||
locale,
|
||||
});
|
||||
if (!payload.projectId) throw new Error('Orbit run did not return a project');
|
||||
|
||||
|
|
|
|||
|
|
@ -500,6 +500,7 @@ describe('SettingsDialog Orbit run behavior', () => {
|
|||
url: '/api/orbit/run',
|
||||
method: 'POST',
|
||||
});
|
||||
expect(JSON.parse(calls[1]!.body ?? '{}')).toEqual({ locale: null });
|
||||
});
|
||||
|
||||
it('does not sync an unsaved Composio draft before starting a manual Orbit run', async () => {
|
||||
|
|
@ -543,6 +544,7 @@ describe('SettingsDialog Orbit run behavior', () => {
|
|||
'/api/orbit/run',
|
||||
]);
|
||||
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({ force: false });
|
||||
expect(JSON.parse(calls[2]!.body ?? '{}')).toEqual({ locale: null });
|
||||
});
|
||||
|
||||
it('does not force an explicit empty media provider map before starting a manual Orbit run', async () => {
|
||||
|
|
@ -582,6 +584,7 @@ describe('SettingsDialog Orbit run behavior', () => {
|
|||
providers: {},
|
||||
force: false,
|
||||
});
|
||||
expect(JSON.parse(calls[2]!.body ?? '{}')).toEqual({ locale: null });
|
||||
});
|
||||
|
||||
it('preserves masked daemon media keys before starting a manual Orbit run', async () => {
|
||||
|
|
@ -695,6 +698,30 @@ describe('SettingsDialog Orbit run behavior', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('passes the selected UI locale through to the manual Orbit run', async () => {
|
||||
const calls: Array<{ url: string; method: string; body?: string }> = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
const method = init?.method ?? 'GET';
|
||||
const body = typeof init?.body === 'string' ? init.body : undefined;
|
||||
calls.push({ url, method, body });
|
||||
|
||||
if (url === '/api/app-config') {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url === '/api/orbit/run') {
|
||||
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-zh' }), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
persistConfigAndRunOrbit(baseConfig, { locale: 'zh-CN' }),
|
||||
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-zh' });
|
||||
|
||||
expect(JSON.parse(calls[1]!.body ?? '{}')).toEqual({ locale: 'zh-CN' });
|
||||
});
|
||||
|
||||
it('persists the displayed default template before starting a legacy null-template run', async () => {
|
||||
const calls: Array<{ url: string; method: string; body?: string }> = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue