diff --git a/.changeset/chatbot-and-inspector-i18n.md b/.changeset/chatbot-and-inspector-i18n.md new file mode 100644 index 000000000..3d20e5cd3 --- /dev/null +++ b/.changeset/chatbot-and-inspector-i18n.md @@ -0,0 +1,47 @@ +--- +"@object-ui/i18n": patch +"@object-ui/plugin-chatbot": patch +"@object-ui/app-shell": patch +--- + +fix(i18n): unconditional Chinese in the chatbot confirm card and the field inspector (objectui#2884, objectui#2885) + +Two issues split out of the objectui#2871 survey because neither is a language +*branch* — both are copy that renders in Chinese for every user regardless of +locale. + +**objectui#2884 — the confirm-before-change card.** Heading, buttons, hint and +the verb column of each change row were Chinese literals, so an English user +read the whole confirm gate in Chinese. They now follow the same +prop-with-English-default convention the plan card already uses +(`changesTitleLabel`, `changesConfirmLabel`, `changeVerbLabels`, …), with the +console passing translated values from `console.ai.*`. + +The serious half was the outbound message. Clicking Confirm sent +`'确认修改,应用你刚才提议的改动。'` unconditionally — an English user's click +told the agent, in Chinese, to apply the changes, and the agent answered in +Chinese for the rest of the thread. That message now routes through the same +`convZh` (conversation-language) switch as `planApproveMessage`, so it matches +the language actually being spoken rather than the UI or a hard-coded literal. + +Note this is deliberately *not* "always send English": the repo already decided +outbound agent text follows the CONVERSATION, and the cloud confirm gate +(`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) matches on approval +keywords. The Chinese string is unchanged, so that path is byte-for-byte what +the gate already accepted; `i18n.test.ts` now pins it against the mirrored gate +regex alongside the two plan messages. + +Also in this component: the error banner's `Response failed` / `Details` / +`Retry` were hard-coded English, and both it and the quota banner used a bare +`t(key)` that renders the raw key when the chat is mounted without an +`I18nProvider`. Both now use `useSafeTranslate`, so they degrade to English +instead of to `chatbotError.title`. The `「…」` corner brackets around the +target-app name are now neutral quotes. + +**objectui#2885 — the draft-field suffix.** `ObjectFieldInspector` appended a +bare `(草稿)` to draft objects in the lookup picker — the only Chinese literal +in a 1500-line file where the other 101 strings all go through `t(key, locale)`. +It now reads `engine.inspector.draftSuffix` from the Studio catalog. + +The 18 new keys were added to all ten locale packs, so the objectui#2872 part +(a) gap held at 469/471 rather than widening. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 8b16ed663..66b7db02f 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -1596,6 +1596,29 @@ export function ChatPane({ : t('console.ai.planApproveDefaultsMessage', { defaultValue: 'Build it with your best assumptions; use sensible defaults for the open questions.', }); + // Same rule for the granular change-confirm card. It used to send a hard-coded + // Chinese sentence regardless of the conversation, so an English user clicking + // Confirm flipped the agent into Chinese for the rest of the thread (#2884). + const changesConfirmMessage = convZh + ? '确认修改,应用你刚才提议的改动。' + : t('console.ai.changesConfirmMessage', { + defaultValue: 'Confirm the changes — apply what you just proposed.', + }); + // Verb column of the change rows. Unlike the message above these are LABELS, + // so they follow the UI locale like every other label on the card. + const changeVerbLabels = useMemo( + () => ({ + create_object: t('console.ai.changeVerb.createObject', { defaultValue: 'Create object' }), + add_field: t('console.ai.changeVerb.addField', { defaultValue: 'Add field' }), + modify_field: t('console.ai.changeVerb.modifyField', { defaultValue: 'Modify field' }), + delete_field: t('console.ai.changeVerb.deleteField', { defaultValue: 'Delete field' }), + create_metadata: t('console.ai.changeVerb.createMetadata', { defaultValue: 'Create' }), + update_metadata: t('console.ai.changeVerb.updateMetadata', { defaultValue: 'Modify' }), + create_seed: t('console.ai.changeVerb.createSeed', { defaultValue: 'Generate sample data' }), + create_package: t('console.ai.changeVerb.createPackage', { defaultValue: 'Create app package' }), + }), + [t], + ); // ADR-0037: refresh the live preview when a turn finishes while the canvas is // open. The per-artifact `onDraftArtifacts` signal covers a build streaming in, @@ -2176,6 +2199,14 @@ export function ChatPane({ })} planApproveMessage={planApproveMessage} planApproveDefaultsMessage={planApproveDefaultsMessage} + changesTitleLabel={t('console.ai.changesTitle', { defaultValue: 'Confirm changes' })} + changesConfirmedLabel={t('console.ai.changesConfirmed', { defaultValue: 'Confirmed' })} + changesConfirmLabel={t('console.ai.changesConfirm', { defaultValue: 'Confirm' })} + changesConfirmHintLabel={t('console.ai.changesConfirmHint', { + defaultValue: 'Reply to confirm or adjust this change.', + })} + changesConfirmMessage={changesConfirmMessage} + changeVerbLabels={changeVerbLabels} planAnswerMessage={(question, option) => t('console.ai.planAnswerMessage', { question, diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 775bf1300..5d1d78e0f 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -306,6 +306,9 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.widget.width': 'Width', 'engine.inspector.widget.height': 'Height', 'engine.inspector.widget.remove': 'Remove widget', + // Marks an object that exists only as an unpublished draft in the object + // picker, so the author can tell it apart from a published sibling. + 'engine.inspector.draftSuffix': '(draft)', // Dataset binding (ADR-0021) — governed cross-object semantic layer. 'engine.inspector.widget.datasetSection': 'Dataset binding', 'engine.inspector.widget.dataset': 'Dataset', @@ -1866,6 +1869,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.widget.width': '宽度', 'engine.inspector.widget.height': '高度', 'engine.inspector.widget.remove': '删除组件', + 'engine.inspector.draftSuffix': '(草稿)', // Dataset binding (ADR-0021) — governed cross-object semantic layer. 'engine.inspector.widget.datasetSection': '数据集绑定', 'engine.inspector.widget.dataset': '数据集', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx new file mode 100644 index 000000000..2d6200843 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; + +// A draft object AND a published one, so the assertions can tell the two +// label shapes apart. The sibling test file stubs both surfaces empty; this +// one needs a draft to exist at all. +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ + list: vi.fn().mockResolvedValue([{ name: 'crm_account', label: 'Account' }]), + listDrafts: vi.fn().mockResolvedValue([{ name: 'crm_quote' }]), + }), +})); + +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; + +afterEach(cleanup); + +/** The lookup branch is the only one that renders the object picker. */ +function renderLookup(locale: string) { + return render( + , + ); +} + +async function draftOptionLabel(container: HTMLElement): Promise { + const opt = await waitFor(() => { + const found = [...container.querySelectorAll('option')].find( + (o) => o.getAttribute('value') === 'crm_quote', + ); + expect(found).toBeTruthy(); + return found!; + }); + return opt.textContent ?? ''; +} + +const CJK = /[一-鿿]/; + +describe('ObjectFieldInspector — draft-object suffix is localized', () => { + it('renders no CJK in the draft label under an English locale', async () => { + const { container } = renderLookup('en-US'); + const label = await draftOptionLabel(container); + + expect(label).toBe('crm_quote (draft)'); + // The regression this pins: the suffix used to be a bare `(草稿)` + // literal, so an English user saw `crm_quote (草稿)`. + expect(label).not.toMatch(CJK); + }); + + it('still renders the Chinese suffix under a zh locale', async () => { + const { container } = renderLookup('zh-CN'); + expect(await draftOptionLabel(container)).toBe('crm_quote (草稿)'); + }); + + it('leaves published objects unsuffixed in both locales', async () => { + for (const locale of ['en-US', 'zh-CN']) { + const { container, unmount } = renderLookup(locale); + const opt = await waitFor(() => { + const found = [...container.querySelectorAll('option')].find( + (o) => o.getAttribute('value') === 'crm_account', + ); + expect(found).toBeTruthy(); + return found!; + }); + expect(opt.textContent).toBe('Account (crm_account)'); + unmount(); + } + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 75d620099..55790c0ce 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -192,7 +192,7 @@ export function ObjectFieldInspector({ ? ((draft as any).fieldGroups as Array<{ key?: string; label?: string }>) : []; - const objectOptions = useObjectOptions(); + const objectOptions = useObjectOptions(locale); // Spec `Field.returnType` is stamped from the formula's inferred CEL type, // but ONLY once the author actually edits the formula in this session — @@ -1479,7 +1479,7 @@ function SummaryConfigFields({ /* ─────────────── Hook: load object list for lookup picker ─────────────── */ -function useObjectOptions(): Array<{ value: string; label: string }> { +function useObjectOptions(locale?: string): Array<{ value: string; label: string }> { const client: MetadataClient = useMetadataClient(); const [opts, setOpts] = React.useState>([]); @@ -1507,7 +1507,10 @@ function useObjectOptions(): Array<{ value: string; label: string }> { for (const d of drafts ?? []) { const name = (d as { name?: string }).name; if (typeof name === 'string' && name && !byName.has(name)) { - byName.set(name, { value: name, label: `${name} (草稿)` }); + byName.set(name, { + value: name, + label: `${name} ${t('engine.inspector.draftSuffix', locale)}`, + }); } } setOpts([...byName.values()].sort((a, b) => a.value.localeCompare(b.value))); @@ -1518,7 +1521,7 @@ function useObjectOptions(): Array<{ value: string; label: string }> { return () => { cancelled = true; }; - }, [client]); + }, [client, locale]); return opts; } diff --git a/packages/i18n/src/__tests__/i18n.test.ts b/packages/i18n/src/__tests__/i18n.test.ts index 00cb38f9f..596689beb 100644 --- a/packages/i18n/src/__tests__/i18n.test.ts +++ b/packages/i18n/src/__tests__/i18n.test.ts @@ -111,6 +111,24 @@ describe('@object-ui/i18n', () => { expect(i18n.t('console.ai.planApproveMessage')).toMatch(gate); expect(i18n.t('console.ai.planApproveDefaultsMessage')).toMatch(gate); expect('帮我搭建一个 CRM').not.toMatch(gate); + + // The granular change-confirm card (objectui#2884) sends its own message + // through the SAME gate, so it carries the same 确认 anchor. This one is + // unchanged from the string that was previously hard-coded in the + // component, so the Chinese path is byte-for-byte what the gate already + // accepted — the fix only stopped it being sent to English conversations. + expect(i18n.t('console.ai.changesConfirmMessage')).toMatch(gate); + }); + + // The English half of the same contract. The cloud APPROVAL_RE's English + // clause is not mirrored here (nothing in this repo can see it), so this + // only pins the two tokens the message is built around — enough to catch a + // careless reword that drops the approval verb entirely. + it('AI change-confirm message keeps its approval verb in English', () => { + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + const msg = i18n.t('console.ai.changesConfirmMessage'); + expect(msg).toMatch(/^Confirm\b/i); + expect(msg).toMatch(/\bapply\b/i); }); it('translates common keys in Japanese', () => { diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 8b6a59a80..304058925 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -852,6 +852,23 @@ const ar = { }, }, console: { + ai: { + changesTitle: "تأكيد التغييرات", + changesConfirmed: "تم التأكيد", + changesConfirm: "تأكيد", + changesConfirmHint: "أجب لتأكيد هذا التغيير أو تعديله.", + changesConfirmMessage: "تم تأكيد التغييرات — طبّق ما اقترحته للتو.", + changeVerb: { + createObject: "إنشاء كائن", + addField: "إضافة حقل", + modifyField: "تعديل حقل", + deleteField: "حذف حقل", + createMetadata: "إنشاء", + updateMetadata: "تعديل", + createSeed: "إنشاء بيانات تجريبية", + createPackage: "إنشاء حزمة تطبيق", + }, + }, title: "وحدة تحكم ObjectStack", initializing: "جاري تهيئة التطبيق...", loadingHint: "قد يستغرق إعداد بيئة جديدة بعض الوقت.", @@ -1911,6 +1928,13 @@ const ar = { sourceInherits: "مثل build/ask", sourcePinned: "مثبّت بواسطة {{source}}", }, + chatbotError: { + title: "فشل الرد", + fallbackDetail: "حدث خطأ ما. يرجى المحاولة مرة أخرى.", + details: "التفاصيل", + hide: "إخفاء", + retry: "إعادة المحاولة", + }, chatbotQuota: { title: "الترقية مطلوبة", fallbackMessage: "لقد بلغت حصتك من الذكاء الاصطناعي.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 632d09e15..a0f927210 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -854,6 +854,23 @@ const de = { }, }, console: { + ai: { + changesTitle: "Änderungen bestätigen", + changesConfirmed: "Bestätigt", + changesConfirm: "Bestätigen", + changesConfirmHint: "Antworten Sie, um diese Änderung zu bestätigen oder anzupassen.", + changesConfirmMessage: "Änderungen bestätigt — wende an, was du gerade vorgeschlagen hast.", + changeVerb: { + createObject: "Objekt erstellen", + addField: "Feld hinzufügen", + modifyField: "Feld ändern", + deleteField: "Feld löschen", + createMetadata: "Erstellen", + updateMetadata: "Ändern", + createSeed: "Beispieldaten erzeugen", + createPackage: "App-Paket erstellen", + }, + }, title: "ObjectStack Konsole", initializing: "Anwendung wird initialisiert...", loadingHint: "Die Einrichtung einer neuen Umgebung kann einen Moment dauern.", @@ -1913,6 +1930,13 @@ const de = { sourceInherits: "wie build/ask", sourcePinned: "festgelegt durch {{source}}", }, + chatbotError: { + title: "Antwort fehlgeschlagen", + fallbackDetail: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", + details: "Details", + hide: "Ausblenden", + retry: "Erneut versuchen", + }, chatbotQuota: { title: "Upgrade erforderlich", fallbackMessage: "Sie haben Ihr KI-Kontingent erreicht.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 51bbcf71b..d64ac72d2 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1490,6 +1490,21 @@ const en = { planApproveDefaultsMessage: 'Build it with your best assumptions; use sensible defaults for the open questions.', planAnswerMessage: 'For "{{question}}", go with: {{option}}.', + changesTitle: 'Confirm changes', + changesConfirmed: 'Confirmed', + changesConfirm: 'Confirm', + changesConfirmHint: 'Reply to confirm or adjust this change.', + changesConfirmMessage: 'Confirm the changes — apply what you just proposed.', + changeVerb: { + createObject: 'Create object', + addField: 'Add field', + modifyField: 'Modify field', + deleteField: 'Delete field', + createMetadata: 'Create', + updateMetadata: 'Modify', + createSeed: 'Generate sample data', + createPackage: 'Create app package', + }, justNow: 'just now', minutesAgo: '{{count}}m ago', hoursAgo: '{{count}}h ago', @@ -2568,6 +2583,13 @@ const en = { sourceInherits: 'same as build/ask', sourcePinned: 'pinned by {{source}}', }, + chatbotError: { + title: 'Response failed', + fallbackDetail: 'Something went wrong. Please try again.', + details: 'Details', + hide: 'Hide', + retry: 'Retry', + }, chatbotQuota: { title: 'Upgrade needed', fallbackMessage: 'You have reached your AI quota.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index d9dc9317d..570d1eead 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -854,6 +854,23 @@ const es = { }, }, console: { + ai: { + changesTitle: "Confirmar cambios", + changesConfirmed: "Confirmado", + changesConfirm: "Confirmar", + changesConfirmHint: "Responda para confirmar o ajustar este cambio.", + changesConfirmMessage: "Cambios confirmados: aplica lo que acabas de proponer.", + changeVerb: { + createObject: "Crear objeto", + addField: "Añadir campo", + modifyField: "Modificar campo", + deleteField: "Eliminar campo", + createMetadata: "Crear", + updateMetadata: "Modificar", + createSeed: "Generar datos de ejemplo", + createPackage: "Crear paquete de aplicación", + }, + }, title: "Consola ObjectStack", initializing: "Inicializando aplicación...", loadingHint: "Configurar un entorno nuevo puede tardar unos momentos.", @@ -1913,6 +1930,13 @@ const es = { sourceInherits: "igual que build/ask", sourcePinned: "fijado por {{source}}", }, + chatbotError: { + title: "Error en la respuesta", + fallbackDetail: "Algo salió mal. Vuelva a intentarlo.", + details: "Detalles", + hide: "Ocultar", + retry: "Reintentar", + }, chatbotQuota: { title: "Se requiere actualizar", fallbackMessage: "Ha alcanzado su cuota de IA.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 03cb21d57..90f4655e3 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -852,6 +852,23 @@ const fr = { }, }, console: { + ai: { + changesTitle: "Confirmer les modifications", + changesConfirmed: "Confirmé", + changesConfirm: "Confirmer", + changesConfirmHint: "Répondez pour confirmer ou ajuster cette modification.", + changesConfirmMessage: "Modifications confirmées — applique ce que tu viens de proposer.", + changeVerb: { + createObject: "Créer un objet", + addField: "Ajouter un champ", + modifyField: "Modifier un champ", + deleteField: "Supprimer un champ", + createMetadata: "Créer", + updateMetadata: "Modifier", + createSeed: "Générer des données d'exemple", + createPackage: "Créer un package d'application", + }, + }, title: "Console ObjectStack", initializing: "Initialisation de l'application...", loadingHint: "La configuration d'un nouvel environnement peut prendre quelques instants.", @@ -1911,6 +1928,13 @@ const fr = { sourceInherits: "identique à build/ask", sourcePinned: "figé par {{source}}", }, + chatbotError: { + title: "Échec de la réponse", + fallbackDetail: "Une erreur s'est produite. Veuillez réessayer.", + details: "Détails", + hide: "Masquer", + retry: "Réessayer", + }, chatbotQuota: { title: "Mise à niveau requise", fallbackMessage: "Vous avez atteint votre quota d'IA.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 91dfa92ee..76e6f1378 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -854,6 +854,23 @@ const ja = { }, }, console: { + ai: { + changesTitle: "変更の確認", + changesConfirmed: "確認済み", + changesConfirm: "変更を確定", + changesConfirmHint: "返信してこの変更を確定または調整してください。", + changesConfirmMessage: "変更を確定します。提案された内容を適用してください。", + changeVerb: { + createObject: "オブジェクトを作成", + addField: "項目を追加", + modifyField: "項目を変更", + deleteField: "項目を削除", + createMetadata: "作成", + updateMetadata: "変更", + createSeed: "サンプルデータを生成", + createPackage: "アプリパッケージを作成", + }, + }, title: "ObjectStack コンソール", initializing: "アプリケーションを初期化中...", loadingHint: "新しい環境のセットアップには少し時間がかかることがあります。", @@ -1913,6 +1930,13 @@ const ja = { sourceInherits: "build/ask と同じ", sourcePinned: "{{source}} で固定", }, + chatbotError: { + title: "応答に失敗しました", + fallbackDetail: "問題が発生しました。もう一度お試しください。", + details: "詳細", + hide: "非表示", + retry: "再試行", + }, chatbotQuota: { title: "アップグレードが必要です", fallbackMessage: "AI の利用上限に達しました。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 9ebc9a483..a5be83cf4 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -854,6 +854,23 @@ const ko = { }, }, console: { + ai: { + changesTitle: "변경 확인", + changesConfirmed: "확인됨", + changesConfirm: "변경 확정", + changesConfirmHint: "회신하여 이 변경을 확정하거나 조정하세요.", + changesConfirmMessage: "변경을 확정합니다. 방금 제안한 내용을 적용해 주세요.", + changeVerb: { + createObject: "객체 생성", + addField: "필드 추가", + modifyField: "필드 수정", + deleteField: "필드 삭제", + createMetadata: "생성", + updateMetadata: "수정", + createSeed: "샘플 데이터 생성", + createPackage: "앱 패키지 생성", + }, + }, title: "ObjectStack 콘솔", initializing: "애플리케이션 초기화 중...", loadingHint: "새 환경을 설정하는 데 시간이 조금 걸릴 수 있습니다.", @@ -1913,6 +1930,13 @@ const ko = { sourceInherits: "build/ask와 동일", sourcePinned: "{{source}}에 의해 고정됨", }, + chatbotError: { + title: "응답 실패", + fallbackDetail: "문제가 발생했습니다. 다시 시도해 주세요.", + details: "세부 정보", + hide: "숨기기", + retry: "다시 시도", + }, chatbotQuota: { title: "업그레이드 필요", fallbackMessage: "AI 사용 한도에 도달했습니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index e54f59b3d..3a48bfcc8 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -852,6 +852,23 @@ const pt = { }, }, console: { + ai: { + changesTitle: "Confirmar alterações", + changesConfirmed: "Confirmado", + changesConfirm: "Confirmar", + changesConfirmHint: "Responda para confirmar ou ajustar esta alteração.", + changesConfirmMessage: "Alterações confirmadas — aplique o que você acabou de propor.", + changeVerb: { + createObject: "Criar objeto", + addField: "Adicionar campo", + modifyField: "Modificar campo", + deleteField: "Excluir campo", + createMetadata: "Criar", + updateMetadata: "Modificar", + createSeed: "Gerar dados de exemplo", + createPackage: "Criar pacote de aplicativo", + }, + }, title: "Console ObjectStack", initializing: "Inicializando aplicação...", loadingHint: "Configurar um novo ambiente pode levar alguns instantes.", @@ -1911,6 +1928,13 @@ const pt = { sourceInherits: "igual a build/ask", sourcePinned: "fixado por {{source}}", }, + chatbotError: { + title: "Falha na resposta", + fallbackDetail: "Algo deu errado. Tente novamente.", + details: "Detalhes", + hide: "Ocultar", + retry: "Tentar novamente", + }, chatbotQuota: { title: "Upgrade necessário", fallbackMessage: "Você atingiu sua cota de IA.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 05cadc901..ec44e3291 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -852,6 +852,23 @@ const ru = { }, }, console: { + ai: { + changesTitle: "Подтвердите изменения", + changesConfirmed: "Подтверждено", + changesConfirm: "Подтвердить", + changesConfirmHint: "Ответьте, чтобы подтвердить или скорректировать это изменение.", + changesConfirmMessage: "Изменения подтверждены — примени то, что ты только что предложил.", + changeVerb: { + createObject: "Создать объект", + addField: "Добавить поле", + modifyField: "Изменить поле", + deleteField: "Удалить поле", + createMetadata: "Создать", + updateMetadata: "Изменить", + createSeed: "Сгенерировать примеры данных", + createPackage: "Создать пакет приложения", + }, + }, title: "Консоль ObjectStack", initializing: "Инициализация приложения...", loadingHint: "Настройка нового окружения может занять некоторое время.", @@ -1911,6 +1928,13 @@ const ru = { sourceInherits: "как у build/ask", sourcePinned: "закреплено через {{source}}", }, + chatbotError: { + title: "Ошибка ответа", + fallbackDetail: "Что-то пошло не так. Попробуйте ещё раз.", + details: "Подробности", + hide: "Скрыть", + retry: "Повторить", + }, chatbotQuota: { title: "Требуется обновление тарифа", fallbackMessage: "Вы исчерпали квоту ИИ.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b92c3a8f0..f3dc8286e 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1496,6 +1496,21 @@ const zh = { planApproveMessage: '确认,开始搭建。', planApproveDefaultsMessage: '确认搭建,未决问题按你的合理假设和默认处理。', planAnswerMessage: '关于「{{question}}」,我选择「{{option}}」。', + changesTitle: '确认改动', + changesConfirmed: '已确认', + changesConfirm: '确认修改', + changesConfirmHint: '回复以确认或调整该改动。', + changesConfirmMessage: '确认修改,应用你刚才提议的改动。', + changeVerb: { + createObject: '新建对象', + addField: '新增字段', + modifyField: '修改字段', + deleteField: '删除字段', + createMetadata: '新建', + updateMetadata: '修改', + createSeed: '生成示例数据', + createPackage: '新建应用包', + }, justNow: '刚刚', minutesAgo: '{{count}} 分钟前', hoursAgo: '{{count}} 小时前', @@ -2558,6 +2573,13 @@ const zh = { sourceInherits: '与 build/ask 相同', sourcePinned: '被 {{source}} 钉住', }, + chatbotError: { + title: '响应失败', + fallbackDetail: '出了点问题,请重试。', + details: '详情', + hide: '隐藏', + retry: '重试', + }, chatbotQuota: { title: '需要升级', fallbackMessage: 'AI 额度已用完。', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index bb6992670..6d8911336 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { cn } from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { useObjectTranslation, useSafeTranslate } from '@object-ui/i18n'; import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; import type { ChatStatus } from 'ai'; import { @@ -688,6 +688,34 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes string; + /** + * Labels for the granular confirm-before-change card (`changes_proposed`). + * Separate from the plan card's: that one approves a whole build, this one + * approves a specific set of metadata edits. They were hard-coded Chinese + * until objectui#2884, so an English user read them in Chinese. + */ + /** Heading of the confirm-before-change card (default "Confirm changes"). */ + changesTitleLabel?: string; + /** Badge replacing the buttons once this change set has been confirmed (default "Confirmed"). */ + changesConfirmedLabel?: string; + /** Label for the card's primary confirm button (default "Confirm"). */ + changesConfirmLabel?: string; + /** Footer hint shown when `onSendMessage` is absent, so there is no one-click gate (default "Reply to confirm or adjust this change."). */ + changesConfirmHintLabel?: string; + /** + * Message sent to the agent when the user confirms the change set. Like + * `planApproveMessage` this is OUTBOUND text, so the console picks it by the + * language of the CONVERSATION, not of the UI — sending Chinese from an + * English session would flip the agent's reply language (objectui#2884). + * Default "Confirm the changes — apply what you just proposed." + */ + changesConfirmMessage?: string; + /** + * Display names for the change verbs rendered in each row of that card + * (`create_object`, `add_field`, …). Falls back to the raw verb for any key + * not supplied, so an unrecognised server-side verb still renders. + */ + changeVerbLabels?: Record; /** * Live draft-status resolver: how many drafts are still PENDING in a * package (e.g. `GET /metadata/_drafts?packageId=` count). When provided, @@ -893,28 +921,37 @@ export function getToolState(tool: ChatToolInvocation): ToolSummaryState { return 'running'; } +/** + * English display names for the change verbs. Overridable per-consumer via the + * `changeVerbLabels` prop — the console passes the translated set. These were + * hard-coded Chinese until objectui#2884. + */ +const DEFAULT_CHANGE_VERB_LABELS: Record = { + create_object: 'Create object', + add_field: 'Add field', + modify_field: 'Modify field', + delete_field: 'Delete field', + create_metadata: 'Create', + update_metadata: 'Modify', + create_seed: 'Generate sample data', + create_package: 'Create app package', +}; + /** Render one granular change descriptor as a readable line for the confirm card. */ -function formatChangeRow(c: { - verb: string; - object?: string; - field?: string; - type?: string; - name?: string; - details?: string; -}): string { - const VERB: Record = { - create_object: '新建对象', - add_field: '新增字段', - modify_field: '修改字段', - delete_field: '删除字段', - create_metadata: '新建', - update_metadata: '修改', - create_seed: '生成示例数据', - create_package: '新建应用包', - }; - const verb = VERB[c.verb] ?? c.verb; +function formatChangeRow( + c: { + verb: string; + object?: string; + field?: string; + type?: string; + name?: string; + details?: string; + }, + verbLabels: Record = DEFAULT_CHANGE_VERB_LABELS, +): string { + const verb = verbLabels[c.verb] ?? DEFAULT_CHANGE_VERB_LABELS[c.verb] ?? c.verb; const target = c.field ? `${c.object ? `${c.object}.` : ''}${c.field}` : (c.object ?? c.name ?? ''); - const typePart = c.type ? `(${c.type})` : ''; + const typePart = c.type ? ` (${c.type})` : ''; return [verb, `${target}${typePart}`, c.details].filter(Boolean).join(' '); } @@ -1222,6 +1259,12 @@ const ChatbotEnhanced = React.forwardRef( planApproveMessage = 'Looks good — build it as proposed.', planApproveDefaultsMessage = 'Build it with your best assumptions; use sensible defaults for the open questions.', planAnswerMessage = (question: string, option: string) => `For "${question}", go with: ${option}.`, + changesTitleLabel = 'Confirm changes', + changesConfirmedLabel = 'Confirmed', + changesConfirmLabel = 'Confirm', + changesConfirmHintLabel = 'Reply to confirm or adjust this change.', + changesConfirmMessage = 'Confirm the changes — apply what you just proposed.', + changeVerbLabels = DEFAULT_CHANGE_VERB_LABELS, fetchPendingDraftCount, autoPublishDrafts = false, processVisibility = 'summary', @@ -2047,7 +2090,7 @@ const ChatbotEnhanced = React.forwardRef( className="inline-flex w-fit items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-300" data-testid="proposed-plan-extend" > - + {planExtendLabel} 「{tool.proposedPlan.targetApp}」 + + {planExtendLabel} "{tool.proposedPlan.targetApp}" ) : null} {tool.proposedPlan.summary ? ( @@ -2307,7 +2350,8 @@ const ChatbotEnhanced = React.forwardRef( {/* Granular confirm-before-change card — a mutating tool that was not approved this turn returns status:'changes_proposed' (a preview) instead of committing. Same confirm gate as the plan - card: see the change, then 确认修改 / 调整. Nothing changed yet. */} + card: see the change, then confirm / adjust. Nothing changed + yet. */} {tool.proposedChanges ? (
( > - 确认改动 + {changesTitleLabel} {tool.proposedChanges.summary ? (

{tool.proposedChanges.summary}

@@ -2327,7 +2371,7 @@ const ChatbotEnhanced = React.forwardRef( className="flex items-start gap-1.5 rounded-md border bg-background px-2 py-1 text-[11px] text-foreground/80" > - {formatChangeRow(c)} + {formatChangeRow(c, changeVerbLabels)}
))} @@ -2339,19 +2383,19 @@ const ChatbotEnhanced = React.forwardRef( data-testid="proposed-changes-confirmed" > - 已确认 + {changesConfirmedLabel} ) : (
) ) : ( - 回复以确认或调整该改动。 + + {changesConfirmHintLabel} + )} ) : null} @@ -3361,7 +3407,7 @@ function BlueprintProgressPanel({ className="inline-flex shrink-0 items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-300" data-testid="blueprint-progress-extend" > - + {extendLabel} 「{targetApp}」 + + {extendLabel} "{targetApp}" ) : null} {!isDone ? ( @@ -3733,7 +3779,12 @@ function ErrorBanner({ onReload?: () => void; onUpgrade?: () => void; }) { - const { t, language } = useObjectTranslation(); + // `tt` (not the bare `t`) because this banner is the surface that renders + // when things are already broken, including in hosts that mount the chat + // WITHOUT an I18nProvider — where a bare `t(key)` returns the raw key and the + // user would read "chatbotError.title" instead of an error message. + const { language } = useObjectTranslation(); + const tt = useSafeTranslate(); const quota = React.useMemo(() => parseAiQuotaError(error), [error]); const { summary, details } = React.useMemo(() => summarizeChatError(error), [error]); const [expanded, setExpanded] = React.useState(false); @@ -3749,9 +3800,11 @@ function ErrorBanner({ const isZh = language.toLowerCase().startsWith('zh'); const text = (isZh ? quota.message : quota.messageEn ?? quota.message) || - t('chatbotQuota.fallbackMessage'); - const cta = quota.topUp ? t('chatbotQuota.buyCredits') : t('chatbotQuota.upgradePlan'); - const title = t('chatbotQuota.title'); + tt('chatbotQuota.fallbackMessage', 'You have reached your AI quota.'); + const cta = quota.topUp + ? tt('chatbotQuota.buyCredits', 'Buy a credit pack') + : tt('chatbotQuota.upgradePlan', 'Upgrade plan'); + const title = tt('chatbotQuota.title', 'Upgrade needed'); return (
@@ -3784,9 +3837,11 @@ function ErrorBanner({
-
Response failed
+
+ {tt('chatbotError.title', 'Response failed')} +
- {summary || 'Something went wrong. Please try again.'} + {summary || tt('chatbotError.fallbackDetail', 'Something went wrong. Please try again.')}
@@ -3797,7 +3852,9 @@ function ErrorBanner({ className="inline-flex h-7 items-center rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-destructive/10 hover:text-foreground" aria-expanded={expanded} > - {expanded ? 'Hide' : 'Details'} + {expanded + ? tt('chatbotError.hide', 'Hide') + : tt('chatbotError.details', 'Details')} ) : null} {onReload ? ( @@ -3807,7 +3864,7 @@ function ErrorBanner({ className="inline-flex h-7 items-center rounded-md border border-destructive/30 bg-background px-2 text-xs font-medium text-destructive hover:bg-destructive/10" > - Retry + {tt('chatbotError.retry', 'Retry')} ) : null}
diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.changesCard.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.changesCard.test.tsx new file mode 100644 index 000000000..c0ee5182d --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.changesCard.test.tsx @@ -0,0 +1,116 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * The granular confirm-before-change card (`status:'changes_proposed'`). + * + * Every string on this card — heading, buttons, hint, and the verb column of + * each change row — was a hard-coded Chinese literal until objectui#2884, so an + * English user read the whole confirm gate in Chinese. Worse, the confirm + * button SENT a Chinese sentence to the agent, which flips the agent's reply + * language for the rest of the thread. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced'; + +const CJK = /[一-鿿]/; + +function changesMessage(): ChatMessage[] { + return [ + { + id: 'a1', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 't1', + toolName: 'update_metadata', + state: 'output-available', + proposedChanges: { + summary: 'Two edits to the task object', + changes: [ + { verb: 'add_field', object: 'task', field: 'priority', type: 'select' }, + { verb: 'delete_field', object: 'task', field: 'legacy_code' }, + ], + }, + }, + ], + }, + ]; +} + +describe('ChatbotEnhanced — confirm-before-change card', () => { + it('renders every label in English by default, with no CJK anywhere on the card', () => { + render(); + + const card = screen.getByTestId('proposed-changes'); + expect(card).toBeInTheDocument(); + expect(screen.getByText('Confirm changes')).toBeInTheDocument(); + expect(screen.getByTestId('proposed-changes-confirm')).toHaveTextContent('Confirm'); + expect(screen.getByTestId('proposed-changes-adjust')).toHaveTextContent('Adjust'); + + // The verb column — the part that used to read 新增字段 / 删除字段. + expect(card).toHaveTextContent('Add field task.priority (select)'); + expect(card).toHaveTextContent('Delete field task.legacy_code'); + + expect(card.textContent ?? '').not.toMatch(CJK); + }); + + it('sends an English confirmation, not a Chinese one, from an English surface', () => { + const onSendMessage = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('proposed-changes-confirm')); + + expect(onSendMessage).toHaveBeenCalledTimes(1); + const sent = onSendMessage.mock.calls[0][0] as string; + // The bug: this used to be '确认修改,应用你刚才提议的改动。' unconditionally, + // so clicking Confirm in an English session told the agent — in Chinese — + // to apply the changes, and the agent answered in Chinese from then on. + expect(sent).not.toMatch(CJK); + expect(sent).toMatch(/^Confirm\b/i); + }); + + it('honours caller-supplied labels and confirm message (the console passes translated ones)', () => { + const onSendMessage = vi.fn(); + render( + , + ); + + const card = screen.getByTestId('proposed-changes'); + expect(card).toHaveTextContent('新增字段 task.priority'); + expect(screen.getByTestId('proposed-changes-confirm')).toHaveTextContent('确认修改'); + + fireEvent.click(screen.getByTestId('proposed-changes-confirm')); + expect(onSendMessage).toHaveBeenCalledWith('确认修改,应用你刚才提议的改动。'); + }); + + it('falls back to the English verb name for a verb the caller did not translate', () => { + render( + , + ); + // `delete_field` is absent from the supplied map — it must not render as + // the raw `delete_field` token. + expect(screen.getByTestId('proposed-changes')).toHaveTextContent('Delete field task.legacy_code'); + }); + + it('shows the reply hint instead of buttons when there is no send handler', () => { + render(); + expect(screen.queryByTestId('proposed-changes-actions')).not.toBeInTheDocument(); + expect(screen.getByText('Reply to confirm or adjust this change.')).toBeInTheDocument(); + }); +});