From bf2f2287e9200bd0bc11a84066c0d083b1b5bda6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 04:41:53 +0000 Subject: [PATCH 1/2] fix(i18n): unconditional Chinese in the chatbot confirm card and field inspector (#2884, #2885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues split out of the #2871 survey because neither is a language *branch* — both render Chinese for every user regardless of locale. #2884 — the confirm-before-change card. Heading, buttons, hint and the verb column of each change row were Chinese literals. They now follow the same prop-with-English-default convention the plan card already uses, with the console passing translated values from `console.ai.*`. The serious half was the outbound message: clicking Confirm sent '确认修改,应用你刚才提议的改动。' unconditionally, so 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. It now routes through the same `convZh` conversation-language switch as `planApproveMessage`. The Chinese string is unchanged, so the cloud confirm gate sees byte-for-byte what it already accepted; `i18n.test.ts` pins it against the mirrored `APPROVAL_RE`. Also here: the error banner's `Response failed` / `Details` / `Retry` were hard-coded English, and it and the quota banner used a bare `t(key)` that renders the raw key with no `I18nProvider` mounted — both now use `useSafeTranslate`. The `「…」` corner brackets are now neutral quotes. #2885 — `ObjectFieldInspector` appended a bare `(草稿)` to draft objects in the lookup picker, the only Chinese literal in a file whose other 101 strings all go through `t(key, locale)`. It now reads `engine.inspector.draftSuffix`. The 18 new keys went into all ten locale packs, so the #2872 part (a) gap held at 469/471 rather than widening. Both new guards were mutation-tested. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC --- .changeset/chatbot-and-inspector-i18n.md | 47 ++++++ .../app-shell/src/console/ai/AiChatPage.tsx | 31 ++++ .../src/views/metadata-admin/i18n.ts | 4 + ...ObjectFieldInspector.draft-locale.test.tsx | 85 +++++++++++ .../inspectors/ObjectFieldInspector.tsx | 11 +- packages/i18n/src/__tests__/i18n.test.ts | 18 +++ packages/i18n/src/locales/ar.ts | 24 ++++ packages/i18n/src/locales/de.ts | 24 ++++ packages/i18n/src/locales/en.ts | 22 +++ packages/i18n/src/locales/es.ts | 24 ++++ packages/i18n/src/locales/fr.ts | 24 ++++ packages/i18n/src/locales/ja.ts | 24 ++++ packages/i18n/src/locales/ko.ts | 24 ++++ packages/i18n/src/locales/pt.ts | 24 ++++ packages/i18n/src/locales/ru.ts | 24 ++++ packages/i18n/src/locales/zh.ts | 22 +++ .../plugin-chatbot/src/ChatbotEnhanced.tsx | 135 +++++++++++++----- .../ChatbotEnhanced.changesCard.test.tsx | 116 +++++++++++++++ 18 files changed, 640 insertions(+), 43 deletions(-) create mode 100644 .changeset/chatbot-and-inspector-i18n.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.changesCard.test.tsx 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..33f5d7e42 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"). + 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(); + }); +}); From 1f2df34f5e749719796a90a4a1c026595c8feb9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 04:57:07 +0000 Subject: [PATCH 2/2] fix(plugin-chatbot): terminate the changesTitleLabel doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `*/` was lost in an editing pass, so `changesTitleLabel?: string;` was swallowed into the comment and never declared on `ChatbotEnhancedProps`. `tsc --noEmit` in app-shell caught it as "Property 'changesTitleLabel' does not exist", which failed `@object-ui/app-shell#build` and left the Console Performance Budget job reporting blank metrics. The documented default was wrong too — the heading defaults to "Confirm changes"; only the button was shortened to "Confirm". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC --- packages/plugin-chatbot/src/ChatbotEnhanced.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 33f5d7e42..6d8911336 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -694,7 +694,7 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes