diff --git a/.changeset/12894-copilot-draft-card.md b/.changeset/12894-copilot-draft-card.md new file mode 100644 index 0000000000..f23407dd04 --- /dev/null +++ b/.changeset/12894-copilot-draft-card.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-chatbot': minor +'@object-ui/i18n': minor +--- + +**The copilot's change card says when a change will be saved as a draft, and the applied card offers review and undo** (objectui#12894). + +- **The change card.** When the copilot proposes a change, the card is headed 「修改方案 · 2 项」 / "Change plan · 2 changes" and lists one row per change: a + / ✎ / - symbol, the kind (字段 / 视图 …), the object and field it touches, and its type and parameters in small type (「number · 续借次数 · 默认 0 · 不必填」). For an app that is already live and holds records, the change is saved as a Studio draft: the card says so in an amber note, and its main button reads 「存为草稿」 / "Save as draft". +- **The applied card.** After you confirm, the card shows what happened, for example 「已存为草稿 · 2 项未发布」 with 「已有 12 条记录的续借次数为空」 and 「核对:结构 ✓」. It offers 「查看变更并发布」 / "Review changes and publish" and 「撤销」 / "Undo" when the host provides them. Undo discards every unpublished draft of the app package, so when other drafts are pending the button says how many, and the card asks before it runs. + +Each of these reads a field the copilot's tools now send. When an older version does not send it, the card looks as it did before. + +`@object-ui/plugin-chatbot`: `ProposedChanges` gains optional `posture` and `primaryAction`; `ReplayOutcome` gains optional `draftState`, `existingRows` and `appliedCard`; `ChatbotEnhancedToolInvocation` now references both types instead of repeating them; `ChatbotEnhancedProps` gains optional `onReviewDrafts` and `onDiscardDrafts`. `@object-ui/i18n`: new `chatbot.changes.*` keys in every pack. diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index 24b3b0cf36..6a32b4a77f 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -416,6 +416,24 @@ turn) reads `checklist`: a passed/total chip, the verified lines in two columns, and the app and package that "Open app" and "View in Studio" open. A result without those keys keeps the simpler rows the cards drew before. +The change card (a `changes_proposed` result) draws what the copilot's tools +say about how a change will land. When the result carries a `posture`, the +card is headed "Change plan · N changes" and draws one row per change: a +symbol for add, change or remove, the kind (field, view, object), the +`object · field` it touches, and its type and parameters in small type. A +change to an app that is live and holds records (`posture.mode: 'draft'`) is +saved as a Studio draft: the card's note says so in amber, and the primary +button reads "Save as draft" when `primaryAction` is `save_draft` (the message +it sends is still `changesConfirmMessage`). Once applied, the card shows the +result's `appliedCard` title and lines, "Review changes and publish" through +`onReviewDrafts(packageId)`, and "Undo" through `onDiscardDrafts(packageId)`. +Undo is meant for `POST /api/v1/packages/:id/discard-drafts`, which discards +every pending draft of the package, so the card asks first whenever other +drafts are pending: it reads `draftState.others` from the result, and the live +count from `fetchPendingDraftCount` when the host provides one. A host without +`onReviewDrafts` keeps the inline Publish button; a result without these keys +draws the card as before. + Use `surface="plain"` for full-page chat workspaces where the surrounding app already provides navigation chrome. The default `surface="card"` remains a better fit for embedded dashboards, side panels, and floating chat windows. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index dce1b47e1b..409613d170 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3751,6 +3751,47 @@ const ar = { draftPosture: "تُحفظ التغييرات اللاحقة كمسودات، وأنت من ينشرها في Studio.", checksPassed: "اجتازت الفحوصات {{passed}}/{{total}}", }, + changes: { + planTitle: "خطة التغييرات · {{count}} تغييرات", + planTitle_zero: "خطة التغييرات · {{count}} تغيير", + planTitle_one: "خطة التغييرات · {{count}} تغيير", + planTitle_two: "خطة التغييرات · تغييران ({{count}})", + planTitle_few: "خطة التغييرات · {{count}} تغييرات", + planTitle_many: "خطة التغييرات · {{count}} تغييرًا", + planTitle_other: "خطة التغييرات · {{count}} تغيير", + saveDraft: "حفظ كمسودة", + reviewAndPublish: "مراجعة التغييرات ونشرها", + undo: "تراجع", + undoWithOthers: "تراجع (مع {{count}} مسودات أخرى)", + undoWithOthers_zero: "تراجع (مع {{count}} مسودة أخرى)", + undoWithOthers_one: "تراجع (مع {{count}} مسودة أخرى)", + undoWithOthers_two: "تراجع (مع مسودتين أخريين: {{count}})", + undoWithOthers_few: "تراجع (مع {{count}} مسودات أخرى)", + undoWithOthers_many: "تراجع (مع {{count}} مسودة أخرى)", + undoWithOthers_other: "تراجع (مع {{count}} مسودة أخرى)", + undoConfirm: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة وعددها {{total}}، منها {{count}} لا تخص هذا التغيير.", + undoConfirm_zero: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة وعددها {{total}}، منها {{count}} لا تخص هذا التغيير.", + undoConfirm_one: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة وعددها {{total}}، منها {{count}} لا تخص هذا التغيير.", + undoConfirm_two: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة وعددها {{total}}، منها {{count}} لا تخص هذا التغيير.", + undoConfirm_few: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة وعددها {{total}}، منها {{count}} لا تخص هذا التغيير.", + undoConfirm_many: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة وعددها {{total}}، منها {{count}} لا تخص هذا التغيير.", + undoConfirm_other: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة وعددها {{total}}، منها {{count}} لا تخص هذا التغيير.", + undoConfirmUnknown: "يؤدي التراجع إلى حذف جميع المسودات غير المنشورة في هذه الحزمة، وليس هذا التغيير فقط.", + undoConfirmAction: "تراجع عن الكل", + undoCancel: "إلغاء", + undoing: "جارٍ التراجع…", + undone: "تم التراجع", + undoFailed: "تعذّر التراجع", + kind: { + field: "حقل", + object: "كائن", + view: "طريقة عرض", + dashboard: "لوحة معلومات", + app: "تطبيق", + seed: "بيانات تجريبية", + package: "حزمة التطبيق", + }, + }, }, chatbotError: { title: "فشل الرد", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index bc29d053a8..d96b90f5a4 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3468,6 +3468,35 @@ const de = { draftPosture: "Spätere Änderungen werden als Entwurf gespeichert, und Sie veröffentlichen sie in Studio.", checksPassed: "Prüfungen bestanden {{passed}}/{{total}}", }, + changes: { + planTitle: "Änderungsplan · {{count}} Änderungen", + planTitle_one: "Änderungsplan · {{count}} Änderung", + planTitle_other: "Änderungsplan · {{count}} Änderungen", + saveDraft: "Als Entwurf speichern", + reviewAndPublish: "Änderungen prüfen und veröffentlichen", + undo: "Rückgängig", + undoWithOthers: "Rückgängig (mit {{count}} weiteren Entwürfen)", + undoWithOthers_one: "Rückgängig (mit {{count}} weiteren Entwurf)", + undoWithOthers_other: "Rückgängig (mit {{count}} weiteren Entwürfen)", + undoConfirm: "Rückgängig verwirft alle {{total}} unveröffentlichten Entwürfe dieses Pakets, davon {{count}}, die nicht zu dieser Änderung gehören.", + undoConfirm_one: "Rückgängig verwirft alle {{total}} unveröffentlichten Entwürfe dieses Pakets, davon {{count}}, der nicht zu dieser Änderung gehört.", + undoConfirm_other: "Rückgängig verwirft alle {{total}} unveröffentlichten Entwürfe dieses Pakets, davon {{count}}, die nicht zu dieser Änderung gehören.", + undoConfirmUnknown: "Rückgängig verwirft alle unveröffentlichten Entwürfe dieses Pakets, nicht nur diese Änderung.", + undoConfirmAction: "Alle verwerfen", + undoCancel: "Abbrechen", + undoing: "Wird rückgängig gemacht…", + undone: "Rückgängig gemacht", + undoFailed: "Rückgängig machen fehlgeschlagen", + kind: { + field: "Feld", + object: "Objekt", + view: "Ansicht", + dashboard: "Dashboard", + app: "App", + seed: "Beispieldaten", + package: "App-Paket", + }, + }, }, chatbotError: { title: "Antwort fehlgeschlagen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index be7c3f6701..fb54121de3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -4145,6 +4145,35 @@ const en = { draftPosture: 'Later changes are saved as drafts, and you publish them in Studio.', checksPassed: 'Checks passed {{passed}}/{{total}}', }, + changes: { + planTitle: 'Change plan · {{count}} changes', + planTitle_one: 'Change plan · {{count}} change', + planTitle_other: 'Change plan · {{count}} changes', + saveDraft: 'Save as draft', + reviewAndPublish: 'Review changes and publish', + undo: 'Undo', + undoWithOthers: 'Undo (and {{count}} other drafts)', + undoWithOthers_one: 'Undo (and {{count}} other draft)', + undoWithOthers_other: 'Undo (and {{count}} other drafts)', + undoConfirm: 'Undo discards all {{total}} unpublished drafts of this package, including {{count}} that are not part of this change.', + undoConfirm_one: 'Undo discards all {{total}} unpublished drafts of this package, including {{count}} that is not part of this change.', + undoConfirm_other: 'Undo discards all {{total}} unpublished drafts of this package, including {{count}} that are not part of this change.', + undoConfirmUnknown: 'Undo discards every unpublished draft of this package, not only this change.', + undoConfirmAction: 'Undo all', + undoCancel: 'Cancel', + undoing: 'Undoing…', + undone: 'Undone', + undoFailed: 'Undo failed', + kind: { + field: 'Field', + object: 'Object', + view: 'View', + dashboard: 'Dashboard', + app: 'App', + seed: 'Sample data', + package: 'App package', + }, + }, }, chatbotError: { title: 'Response failed', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 3102081a32..39dc66e3cf 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3537,6 +3537,38 @@ const es = { draftPosture: "Los cambios posteriores se guardan como borrador y usted los publica en Studio.", checksPassed: "Comprobaciones superadas {{passed}}/{{total}}", }, + changes: { + planTitle: "Plan de cambios · {{count}} cambios", + planTitle_one: "Plan de cambios · {{count}} cambio", + planTitle_many: "Plan de cambios · {{count}} de cambios", + planTitle_other: "Plan de cambios · {{count}} cambios", + saveDraft: "Guardar como borrador", + reviewAndPublish: "Revisar cambios y publicar", + undo: "Deshacer", + undoWithOthers: "Deshacer (y {{count}} borradores más)", + undoWithOthers_one: "Deshacer (y {{count}} borrador más)", + undoWithOthers_many: "Deshacer (y {{count}} de borradores más)", + undoWithOthers_other: "Deshacer (y {{count}} borradores más)", + undoConfirm: "Deshacer descarta los {{total}} borradores sin publicar de este paquete, incluidos {{count}} que no forman parte de este cambio.", + undoConfirm_one: "Deshacer descarta los {{total}} borradores sin publicar de este paquete, incluido {{count}} que no forma parte de este cambio.", + undoConfirm_many: "Deshacer descarta los {{total}} borradores sin publicar de este paquete, incluidos {{count}} de borradores que no forman parte de este cambio.", + undoConfirm_other: "Deshacer descarta los {{total}} borradores sin publicar de este paquete, incluidos {{count}} que no forman parte de este cambio.", + undoConfirmUnknown: "Deshacer descarta todos los borradores sin publicar de este paquete, no solo este cambio.", + undoConfirmAction: "Deshacer todo", + undoCancel: "Cancelar", + undoing: "Deshaciendo…", + undone: "Deshecho", + undoFailed: "No se pudo deshacer", + kind: { + field: "Campo", + object: "Objeto", + view: "Vista", + dashboard: "Panel", + app: "Aplicación", + seed: "Datos de ejemplo", + package: "Paquete de aplicación", + }, + }, }, chatbotError: { title: "Error en la respuesta", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5c4d244b6a..9c38e3c692 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3534,6 +3534,38 @@ const fr = { draftPosture: "Les modifications suivantes sont enregistrées comme brouillons, et vous les publiez dans Studio.", checksPassed: "Vérifications réussies {{passed}}/{{total}}", }, + changes: { + planTitle: "Plan de modifications · {{count}} modifications", + planTitle_one: "Plan de modifications · {{count}} modification", + planTitle_many: "Plan de modifications · {{count}} de modifications", + planTitle_other: "Plan de modifications · {{count}} modifications", + saveDraft: "Enregistrer comme brouillon", + reviewAndPublish: "Vérifier les modifications et publier", + undo: "Annuler", + undoWithOthers: "Annuler (avec {{count}} autres brouillons)", + undoWithOthers_one: "Annuler (avec {{count}} autre brouillon)", + undoWithOthers_many: "Annuler (avec {{count}} d'autres brouillons)", + undoWithOthers_other: "Annuler (avec {{count}} autres brouillons)", + undoConfirm: "Annuler supprime les {{total}} brouillons non publiés de ce package, dont {{count}} qui ne font pas partie de cette modification.", + undoConfirm_one: "Annuler supprime les {{total}} brouillons non publiés de ce package, dont {{count}} qui ne fait pas partie de cette modification.", + undoConfirm_many: "Annuler supprime les {{total}} brouillons non publiés de ce package, dont {{count}} de brouillons qui ne font pas partie de cette modification.", + undoConfirm_other: "Annuler supprime les {{total}} brouillons non publiés de ce package, dont {{count}} qui ne font pas partie de cette modification.", + undoConfirmUnknown: "Annuler supprime tous les brouillons non publiés de ce package, pas seulement cette modification.", + undoConfirmAction: "Tout annuler", + undoCancel: "Conserver", + undoing: "Annulation…", + undone: "Annulé", + undoFailed: "Échec de l'annulation", + kind: { + field: "Champ", + object: "Objet", + view: "Vue", + dashboard: "Tableau de bord", + app: "Application", + seed: "Données d'exemple", + package: "Package d'application", + }, + }, }, chatbotError: { title: "Échec de la réponse", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 5710a3c03f..b2905ebaae 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3470,6 +3470,35 @@ const ja = { draftPosture: "以降の変更は下書きとして保存され、Studio で公開します。", checksPassed: "照合済み {{passed}}/{{total}}", }, + changes: { + planTitle: "変更案 · {{count}} 件", + planTitle_one: "変更案 · {{count}} 件", + planTitle_other: "変更案 · {{count}} 件", + saveDraft: "下書きとして保存", + reviewAndPublish: "変更を確認して公開", + undo: "取り消す", + undoWithOthers: "取り消す(ほかの下書き {{count}} 件も含む)", + undoWithOthers_one: "取り消す(ほかの下書き {{count}} 件も含む)", + undoWithOthers_other: "取り消す(ほかの下書き {{count}} 件も含む)", + undoConfirm: "取り消すと、このアプリパッケージの未公開の下書き {{total}} 件がすべて破棄されます。うち {{count}} 件は今回の変更ではありません。", + undoConfirm_one: "取り消すと、このアプリパッケージの未公開の下書き {{total}} 件がすべて破棄されます。うち {{count}} 件は今回の変更ではありません。", + undoConfirm_other: "取り消すと、このアプリパッケージの未公開の下書き {{total}} 件がすべて破棄されます。うち {{count}} 件は今回の変更ではありません。", + undoConfirmUnknown: "取り消すと、今回の変更だけでなく、このアプリパッケージの未公開の下書きがすべて破棄されます。", + undoConfirmAction: "すべて取り消す", + undoCancel: "キャンセル", + undoing: "取り消し中…", + undone: "取り消しました", + undoFailed: "取り消せませんでした", + kind: { + field: "フィールド", + object: "オブジェクト", + view: "ビュー", + dashboard: "ダッシュボード", + app: "アプリ", + seed: "サンプルデータ", + package: "アプリパッケージ", + }, + }, }, chatbotError: { title: "応答に失敗しました", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index c7ec2882fa..b8b453f97c 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3467,6 +3467,35 @@ const ko = { draftPosture: "이후 변경 사항은 초안으로 저장되며, Studio에서 직접 게시합니다.", checksPassed: "검증 통과 {{passed}}/{{total}}", }, + changes: { + planTitle: "변경안 · {{count}}개", + planTitle_one: "변경안 · {{count}}개", + planTitle_other: "변경안 · {{count}}개", + saveDraft: "초안으로 저장", + reviewAndPublish: "변경 사항 확인 후 게시", + undo: "되돌리기", + undoWithOthers: "되돌리기(다른 초안 {{count}}개 포함)", + undoWithOthers_one: "되돌리기(다른 초안 {{count}}개 포함)", + undoWithOthers_other: "되돌리기(다른 초안 {{count}}개 포함)", + undoConfirm: "되돌리면 이 앱 패키지의 게시되지 않은 초안 {{total}}개가 모두 폐기되며, 그중 {{count}}개는 이번 변경이 아닙니다.", + undoConfirm_one: "되돌리면 이 앱 패키지의 게시되지 않은 초안 {{total}}개가 모두 폐기되며, 그중 {{count}}개는 이번 변경이 아닙니다.", + undoConfirm_other: "되돌리면 이 앱 패키지의 게시되지 않은 초안 {{total}}개가 모두 폐기되며, 그중 {{count}}개는 이번 변경이 아닙니다.", + undoConfirmUnknown: "되돌리면 이번 변경뿐 아니라 이 앱 패키지의 게시되지 않은 초안이 모두 폐기됩니다.", + undoConfirmAction: "모두 되돌리기", + undoCancel: "취소", + undoing: "되돌리는 중…", + undone: "되돌림", + undoFailed: "되돌리지 못했습니다", + kind: { + field: "필드", + object: "오브젝트", + view: "뷰", + dashboard: "대시보드", + app: "앱", + seed: "샘플 데이터", + package: "앱 패키지", + }, + }, }, chatbotError: { title: "응답 실패", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index a332f92f21..425846ae54 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3532,6 +3532,38 @@ const pt = { draftPosture: "As alterações seguintes ficam salvas como rascunho, e você as publica no Studio.", checksPassed: "Verificações aprovadas {{passed}}/{{total}}", }, + changes: { + planTitle: "Plano de alterações · {{count}} alterações", + planTitle_one: "Plano de alterações · {{count}} alteração", + planTitle_many: "Plano de alterações · {{count}} de alterações", + planTitle_other: "Plano de alterações · {{count}} alterações", + saveDraft: "Salvar como rascunho", + reviewAndPublish: "Revisar alterações e publicar", + undo: "Desfazer", + undoWithOthers: "Desfazer (e mais {{count}} rascunhos)", + undoWithOthers_one: "Desfazer (e mais {{count}} rascunho)", + undoWithOthers_many: "Desfazer (e mais {{count}} de rascunhos)", + undoWithOthers_other: "Desfazer (e mais {{count}} rascunhos)", + undoConfirm: "Desfazer descarta todos os {{total}} rascunhos não publicados deste pacote, incluindo {{count}} que não fazem parte desta alteração.", + undoConfirm_one: "Desfazer descarta todos os {{total}} rascunhos não publicados deste pacote, incluindo {{count}} que não faz parte desta alteração.", + undoConfirm_many: "Desfazer descarta todos os {{total}} rascunhos não publicados deste pacote, incluindo {{count}} de rascunhos que não fazem parte desta alteração.", + undoConfirm_other: "Desfazer descarta todos os {{total}} rascunhos não publicados deste pacote, incluindo {{count}} que não fazem parte desta alteração.", + undoConfirmUnknown: "Desfazer descarta todos os rascunhos não publicados deste pacote, não só esta alteração.", + undoConfirmAction: "Desfazer tudo", + undoCancel: "Cancelar", + undoing: "Desfazendo…", + undone: "Desfeito", + undoFailed: "Não foi possível desfazer", + kind: { + field: "Campo", + object: "Objeto", + view: "Visão", + dashboard: "Painel", + app: "Aplicativo", + seed: "Dados de exemplo", + package: "Pacote do aplicativo", + }, + }, }, chatbotError: { title: "Falha na resposta", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index fb3e43b52c..d77ee9d299 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3622,6 +3622,41 @@ const ru = { draftPosture: "Дальнейшие изменения сохраняются как черновики, а публикуете их вы в Studio.", checksPassed: "Проверки пройдены {{passed}}/{{total}}", }, + changes: { + planTitle: "План изменений · изменений: {{count}}", + planTitle_one: "План изменений · {{count}} изменение", + planTitle_few: "План изменений · {{count}} изменения", + planTitle_many: "План изменений · {{count}} изменений", + planTitle_other: "План изменений · {{count}} изменения", + saveDraft: "Сохранить как черновик", + reviewAndPublish: "Проверить изменения и опубликовать", + undo: "Отменить", + undoWithOthers: "Отменить (других черновиков: {{count}})", + undoWithOthers_one: "Отменить (и ещё {{count}} черновик)", + undoWithOthers_few: "Отменить (и ещё {{count}} черновика)", + undoWithOthers_many: "Отменить (и ещё {{count}} черновиков)", + undoWithOthers_other: "Отменить (и ещё {{count}} черновика)", + undoConfirm: "Отмена удалит все неопубликованные черновики этого пакета ({{total}}), из них не относятся к этому изменению: {{count}}.", + undoConfirm_one: "Отмена удалит все неопубликованные черновики этого пакета ({{total}}), из них {{count}} не относится к этому изменению.", + undoConfirm_few: "Отмена удалит все неопубликованные черновики этого пакета ({{total}}), из них {{count}} не относятся к этому изменению.", + undoConfirm_many: "Отмена удалит все неопубликованные черновики этого пакета ({{total}}), из них {{count}} не относятся к этому изменению.", + undoConfirm_other: "Отмена удалит все неопубликованные черновики этого пакета ({{total}}), из них {{count}} не относятся к этому изменению.", + undoConfirmUnknown: "Отмена удалит все неопубликованные черновики этого пакета, а не только это изменение.", + undoConfirmAction: "Отменить всё", + undoCancel: "Не отменять", + undoing: "Отмена…", + undone: "Отменено", + undoFailed: "Не удалось отменить", + kind: { + field: "Поле", + object: "Объект", + view: "Представление", + dashboard: "Дашборд", + app: "Приложение", + seed: "Демоданные", + package: "Пакет приложения", + }, + }, }, chatbotError: { title: "Ошибка ответа", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 3db8823e4a..e2551d6afd 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3607,6 +3607,35 @@ const zh = { draftPosture: '之后的修改先存草稿,由你在 Studio 发布。', checksPassed: '核对通过 {{passed}}/{{total}}', }, + changes: { + planTitle: '修改方案 · {{count}} 项', + planTitle_one: '修改方案 · {{count}} 项', + planTitle_other: '修改方案 · {{count}} 项', + saveDraft: '存为草稿', + reviewAndPublish: '查看变更并发布', + undo: '撤销', + undoWithOthers: '撤销(连同另外 {{count}} 项草稿)', + undoWithOthers_one: '撤销(连同另外 {{count}} 项草稿)', + undoWithOthers_other: '撤销(连同另外 {{count}} 项草稿)', + undoConfirm: '撤销会丢弃这个应用包全部 {{total}} 项未发布的草稿,其中 {{count}} 项不属于这次修改。', + undoConfirm_one: '撤销会丢弃这个应用包全部 {{total}} 项未发布的草稿,其中 {{count}} 项不属于这次修改。', + undoConfirm_other: '撤销会丢弃这个应用包全部 {{total}} 项未发布的草稿,其中 {{count}} 项不属于这次修改。', + undoConfirmUnknown: '撤销会丢弃这个应用包里全部未发布的草稿,不只是这次修改。', + undoConfirmAction: '全部撤销', + undoCancel: '取消', + undoing: '正在撤销…', + undone: '已撤销', + undoFailed: '撤销失败', + kind: { + field: '字段', + object: '对象', + view: '视图', + dashboard: '仪表板', + app: '应用', + seed: '示例数据', + package: '应用包', + }, + }, }, chatbotError: { title: '响应失败', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 12d398b5c6..286768dd2d 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -21,7 +21,7 @@ import * as React from 'react'; import { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; import { useObjectTranslation, useSafeTranslate } from '@object-ui/i18n'; -import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, ChevronDown, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; +import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, ChevronDown, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Info, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; import type { ChatStatus } from 'ai'; import { humanizeToolName, @@ -90,8 +90,10 @@ import { buildProgressFromDraftReview, type BuildResultChecklist, type DraftReview, + type ProposedChanges, type ProposedPlan, type ProposedPlanCard, + type ReplayOutcome, } from './mapMessages'; /** @@ -331,11 +333,13 @@ export interface ChatbotEnhancedToolInvocation { proposedPlan?: ProposedPlan; /** Granular metadata-change preview (confirm-before-change). Set only * when a mutating tool returned status:'changes_proposed' — i.e. it was - * NOT yet approved this turn, so nothing changed. Rendered as a 确认修改 card. */ - proposedChanges?: { - summary?: string; - changes: Array<{ verb: string; object?: string; field?: string; type?: string; name?: string; details?: string }>; - }; + * NOT yet approved this turn, so nothing changed. Rendered as a 确认修改 card. + * + * The shape is `mapMessages.ts`'s `ProposedChanges`, the type + * `detectProposedChanges` returns, referenced rather than repeated for the + * reason `draftReview` above gives (objectui#12894 added the posture and + * the primary action to it). */ + proposedChanges?: ProposedChanges; /** * ADR-0057 P4 — the `ask` agent's structured decline-and-redirect. Set when * the ask agent called `suggest_builder` (`{ handoff:'build', prompt, @@ -365,14 +369,12 @@ export interface ChatbotEnhancedToolInvocation { * state on the ORIGINAL 确认修改 card — 已生效 / 已暂存为草稿 / 未生效 — so a * rolled-back publish exists somewhere a USER can see it, independent of * whether the model narrates it honestly. + * + * The shape is `mapMessages.ts`'s `ReplayOutcome`, referenced rather than + * repeated (objectui#12894 added the applied card of a change saved as a + * draft to it). */ - replayOutcome?: { - kind: 'published' | 'drafted' | 'failed'; - outcome?: string; - error?: string; - packageId?: string; - dispatchError?: boolean; - }; + replayOutcome?: ReplayOutcome; } export interface ChatSource { @@ -623,6 +625,30 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes void | boolean | PublishOutcome | Promise; + /** + * objectui#12894 — 查看变更并发布 on the applied card of a change the copilot + * saved as a draft (cloud#2924): open the host's review of the package's + * pending drafts (Studio's 变更), where the user checks them and publishes. + * Absent, the applied card keeps the inline Publish button `onPublishDrafts` + * draws, as before. + */ + onReviewDrafts?: (packageId: string) => void; + /** + * objectui#12894 — 撤销 on the same applied card. The host wires it to + * `POST /api/v1/packages/:packageId/discard-drafts`, which discards EVERY + * pending draft of the package, not only this change's; the card says so, + * and asks first, whenever other drafts are pending (`draftState.others`, or + * the live count from `fetchPendingDraftCount` when the host gives one). + * Return `false` / `{ ok: false, error? }` on failure; any other outcome + * (incl. void) counts as done. Absent, no 撤销 is drawn. + */ + onDiscardDrafts?: ( + packageId: string, + ) => + | void + | boolean + | { ok: boolean; error?: string } + | Promise; /** * When provided, a finished build tree (`buildProgress.phase === 'done'`) that * created an `app` renders an "Open app" action so the user can jump straight @@ -1180,6 +1206,362 @@ function formatChangeRow( return [verb, `${target}${typePart}`, c.details].filter(Boolean).join(' '); } +/** + * objectui#12894 — where 撤销 stands on one applied card: asking first (with + * the pending counts it can name), running, done, or refused by the host. + */ +type DraftDiscardState = + | { phase: 'confirm'; others?: number; total?: number } + | { phase: 'running' } + | { phase: 'done' } + | { phase: 'failed'; error?: string }; + +/** + * objectui#12894 — the change card draws v2 when the envelope carries + * cloud#2924's posture (`posture` / `primaryAction`, which the producer sends + * together). An older cloud, or an environment that does not publish AI + * changes in the turn, sends neither, and the card draws as before. + */ +function isChangePlanV2(changes: ProposedChanges): boolean { + return changes.posture !== undefined || changes.primaryAction !== undefined; +} + +type ChangeRow = ProposedChanges['changes'][number]; +type ChangeFace = 'add' | 'modify' | 'remove'; + +/** What a row does to the app, by its verb: + adds, ✎ changes, - removes. */ +function changeFaceOf(verb: string): ChangeFace { + if (verb === 'delete_field') return 'remove'; + if (verb === 'add_field' || verb.startsWith('create_')) return 'add'; + return 'modify'; +} + +const CHANGE_FACE_SYMBOL: Record = { add: '+', modify: '✎', remove: '-' }; +const CHANGE_FACE_CLASS: Record = { + add: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300', + modify: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300', + remove: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300', +}; + +/** + * objectui#12894 — the kind a change row names (字段 / 视图 / 对象 …), through the + * pack. A STATIC switch for the reason `useBuildGroupLabel` gives: a computed + * key is invisible to `check-i18n-call-site-keys`. A metadata row names its + * `type`; a type the pack has no word for renders as its machine name, and a + * verb no arm knows renders as its verb label, as the v1 row did. + */ +function useChangeKindLabel(verbLabels: Record): (c: ChangeRow) => string { + const { t } = useObjectTranslation(); + return React.useCallback( + (c: ChangeRow) => { + const kindOfType = (type: string | undefined): string | undefined => { + switch (type) { + case 'object': + return t('chatbot.changes.kind.object', { defaultValue: 'Object' }); + case 'view': + return t('chatbot.changes.kind.view', { defaultValue: 'View' }); + case 'dashboard': + return t('chatbot.changes.kind.dashboard', { defaultValue: 'Dashboard' }); + case 'app': + return t('chatbot.changes.kind.app', { defaultValue: 'App' }); + default: + return type; + } + }; + switch (c.verb) { + case 'add_field': + case 'modify_field': + case 'delete_field': + return t('chatbot.changes.kind.field', { defaultValue: 'Field' }); + case 'create_object': + return kindOfType('object')!; + case 'create_seed': + return t('chatbot.changes.kind.seed', { defaultValue: 'Sample data' }); + case 'create_package': + return t('chatbot.changes.kind.package', { defaultValue: 'App package' }); + case 'create_metadata': + case 'update_metadata': { + const kind = kindOfType(c.type); + if (kind) return kind; + break; + } + default: + break; + } + return verbLabels[c.verb] ?? DEFAULT_CHANGE_VERB_LABELS[c.verb] ?? c.verb; + }, + [t, verbLabels], + ); +} + +/** + * objectui#12894 — the change card v2's body (the StudioRailCopilot artboard): + * 「修改方案 · N 项」, one row per change — the + / ✎ / - symbol, the kind, + * `object · field` (the producer sends machine names only), and in small type + * the field type and the row's `details` (label · default · required) — then + * the posture note. When the change will be saved as a draft (`posture.mode: + * 'draft'`) the note is the producer's `summary`, drawn in amber; otherwise the + * summary reads as plain text. + */ +function ChangePlanBody({ + changes, + verbLabels, +}: { + changes: ProposedChanges; + verbLabels: Record; +}) { + const { t } = useObjectTranslation(); + const kindOf = useChangeKindLabel(verbLabels); + const count = changes.changes.length; + return ( + <> + + + {t('chatbot.changes.planTitle', { count, defaultValue: 'Change plan · {{count}} changes' })} + +
+ {changes.changes.map((c, idx) => { + const face = changeFaceOf(c.verb); + const target = c.field + ? c.object + ? `${c.object} · ${c.field}` + : c.field + : (c.name ?? c.object ?? ''); + const params = [c.type, c.details].filter(Boolean).join(' · '); + return ( +
+ + + {verbLabels[c.verb] ?? DEFAULT_CHANGE_VERB_LABELS[c.verb] ?? c.verb} + +
+ + + {kindOf(c)} + {' '} + + {target} + + + {params ? ( + + {params} + + ) : null} +
+
+ ); + })} +
+ {changes.summary ? ( + changes.posture?.mode === 'draft' ? ( +
+
+ ) : ( +

+ {changes.summary} +

+ ) + ) : null} + + ); +} + +/** + * objectui#12894 — the applied card of a change the copilot saved as a draft + * (cloud#2924, the StudioRailCopilot artboard): `appliedCard.title` beside a + * tick, its `lines`, then 查看变更并发布 (the host's review of the package's + * pending drafts) and 撤销. Without a review door the inline Publish button + * stays in its place, as the drafted badge drew it before. + * + * 撤销 runs a door that discards every pending draft of the package. When + * `draftState.others` says other drafts are pending, the button says so; the + * confirm the parent opens names the counts before anything runs. + */ +function AppliedDraftCard({ + card, + others, + discard, + onReview, + onPublish, + publishLabel, + onDiscard, + onDiscardConfirm, + onDiscardCancel, +}: { + card: NonNullable; + others?: number; + discard?: DraftDiscardState; + onReview?: () => void; + onPublish?: () => void; + publishLabel: string; + onDiscard?: () => void; + onDiscardConfirm?: () => void; + onDiscardCancel: () => void; +}) { + const { t } = useObjectTranslation(); + const done = discard?.phase === 'done'; + const running = discard?.phase === 'running'; + return ( +
+ + + + {card.title} + + {card.lines.length > 0 ? ( +
    + {card.lines.map((line, idx) => ( +
  • + {line} +
  • + ))} +
+ ) : null} + {done ? ( + + + {t('chatbot.changes.undone', { defaultValue: 'Undone' })} + + ) : discard?.phase === 'confirm' ? ( +
+ + {discard.others !== undefined && discard.total !== undefined + ? t('chatbot.changes.undoConfirm', { + count: discard.others, + total: discard.total, + defaultValue: + 'Undo discards all {{total}} unpublished drafts of this package, including {{count}} that are not part of this change.', + }) + : t('chatbot.changes.undoConfirmUnknown', { + defaultValue: 'Undo discards every unpublished draft of this package, not only this change.', + })} + +
+ + +
+
+ ) : ( + <> + {onReview || onPublish || onDiscard ? ( +
+ {onReview ? ( + + ) : onPublish ? ( + + ) : null} + {onDiscard ? ( + + ) : null} +
+ ) : null} + {discard?.phase === 'failed' ? ( + + {t('chatbot.changes.undoFailed', { defaultValue: 'Undo failed' })} + {discard.error ? {discard.error} : null} + + ) : null} + + )} +
+ ); +} + /** * True when this tool is the build agent's "propose a plan" step * (`propose_blueprint`). Matched by name so we can recognise it even when its @@ -1541,6 +1923,8 @@ const ChatbotEnhanced = React.forwardRef( onReviewDraft, toolReviewLabel = (n) => `Review ${n} change${n === 1 ? '' : 's'}`, onPublishDrafts, + onReviewDrafts, + onDiscardDrafts, onOpenBuiltApp, openBuiltAppLabel = 'Open app', onDesignBuiltApp, @@ -1625,6 +2009,9 @@ const ChatbotEnhanced = React.forwardRef( const planAwaitingLabel = tt('chatbot.plan.awaitingConfirm', 'Awaiting confirmation'); const planObjectsLabel = tt('chatbot.plan.objectsAndFields', 'Objects and fields'); const planViewsLabel = tt('chatbot.plan.views', 'Views'); + // objectui#12894 — the change card's primary action when the envelope says + // the change will be saved as a draft (`primaryAction: 'save_draft'`). + const saveDraftLabel = tt('chatbot.changes.saveDraft', 'Save as draft'); // Resolve localizable strings once. Precedence is host `labels` prop -> // locale pack -> the English default this component always shipped, so a @@ -1816,6 +2203,85 @@ const ChatbotEnhanced = React.forwardRef( [onPublishDrafts, messages, refreshPendingCount], ); + // objectui#12894 — 撤销 on the applied card of a change saved as a draft + // (cloud#2924). The door the host wires (`discard-drafts`) discards EVERY + // pending draft of the package, so the card asks before it runs whenever it + // cannot show that only this change is pending. Per card, keyed by the + // proposal's toolCallId; UI-owned like `confirmPendingIds`. + const [discardByToolCall, setDiscardByToolCall] = React.useState< + ReadonlyMap + >(() => new Map()); + const setDiscardState = React.useCallback( + (toolCallId: string, state: DraftDiscardState | undefined) => { + setDiscardByToolCall((prev) => { + const next = new Map(prev); + if (state) next.set(toolCallId, state); + else next.delete(toolCallId); + return next; + }); + }, + [], + ); + const runDiscardDrafts = React.useCallback( + async (toolCallId: string, packageId: string) => { + if (!onDiscardDrafts) return; + setDiscardState(toolCallId, { phase: 'running' }); + try { + const res = await onDiscardDrafts(packageId); + const outcome = res && typeof res === 'object' ? res : undefined; + const ok = outcome ? outcome.ok !== false : res !== false; + setDiscardState( + toolCallId, + ok ? { phase: 'done' } : { phase: 'failed', ...(outcome?.error ? { error: outcome.error } : {}) }, + ); + } catch (e) { + const message = e instanceof Error ? e.message : typeof e === 'string' ? e : ''; + setDiscardState(toolCallId, { phase: 'failed', ...(message ? { error: message } : {}) }); + } + // Every card bound to this package reads the post-discard count. + void refreshPendingCount(packageId); + }, + [onDiscardDrafts, setDiscardState, refreshPendingCount], + ); + // The click on 撤销: run at once only when the drafts pending are this + // change's alone. The snapshot is `draftState` (read when the change + // landed); the host's live count, when it gives one, wins over it — a + // later change in the same conversation stages more drafts into the same + // package, and the snapshot on this older card cannot know that. + const requestDiscardDrafts = React.useCallback( + async ( + toolCallId: string, + packageId: string, + draftState: ReplayOutcome['draftState'], + ) => { + if (!onDiscardDrafts) return; + let others = draftState?.others; + let total = draftState?.pending; + if (fetchPendingDraftCount) { + setDiscardState(toolCallId, { phase: 'running' }); + try { + const live = await fetchPendingDraftCount(packageId); + if (typeof live === 'number' && Number.isInteger(live) && live >= 0) { + total = live; + others = draftState ? Math.max(0, live - (draftState.pending - draftState.others)) : undefined; + } + } catch { + // Unreadable: the snapshot stands. + } + } + if (others === 0) { + await runDiscardDrafts(toolCallId, packageId); + return; + } + setDiscardState(toolCallId, { + phase: 'confirm', + ...(others !== undefined ? { others } : {}), + ...(total !== undefined ? { total } : {}), + }); + }, + [onDiscardDrafts, fetchPendingDraftCount, runDiscardDrafts, setDiscardState], + ); + // Auto-publish "magic moment": when the environment enables autoPublishDrafts // and a WHOLE-APP build finishes (the backend marks it `autoPublishable`), // fire the same publish-drafts call the manual button uses — objects go live @@ -3163,24 +3629,35 @@ const ChatbotEnhanced = React.forwardRef( className="flex flex-col gap-2 border-t bg-muted/20 px-3 py-2.5" data-testid="proposed-changes" > - - - {changesTitleLabel} - - {tool.proposedChanges.summary ? ( -

{tool.proposedChanges.summary}

- ) : null} -
- {tool.proposedChanges.changes.map((c, idx) => ( -
- - {formatChangeRow(c, changeVerbLabels)} + {isChangePlanV2(tool.proposedChanges) ? ( + // objectui#12894 — change card v2 (the StudioRailCopilot + // artboard), drawn when the envelope carries cloud#2924's + // posture: 「修改方案 · N 项」, the posture note, one row per + // change. An envelope without it draws the card below, as + // before. + + ) : ( + <> + + + {changesTitleLabel} + + {tool.proposedChanges.summary ? ( +

{tool.proposedChanges.summary}

+ ) : null} +
+ {tool.proposedChanges.changes.map((c, idx) => ( +
+ + {formatChangeRow(c, changeVerbLabels)} +
+ ))}
- ))} -
+ + )} {(() => { // objectui#5695 — after 确认修改, the card carries the replay's // verdict instead of going dark. Terminal states render on @@ -3218,6 +3695,38 @@ const ChatbotEnhanced = React.forwardRef(
); } + if (outcome?.kind === 'drafted' && outcome.appliedCard) { + // objectui#12894 — the applied card of a change saved as a + // draft (cloud#2924): its title and lines as the producer + // wrote them, 查看变更并发布 and 撤销. + const packageId = outcome.packageId; + const discard = discardByToolCall.get(tool.toolCallId); + return ( + onReviewDrafts(packageId) : undefined} + onPublish={ + !onReviewDrafts && onPublishDrafts && packageId + ? () => void handlePublishDrafts(packageId) + : undefined + } + publishLabel={publishDraftsLabel} + onDiscard={ + onDiscardDrafts && packageId + ? () => void requestDiscardDrafts(tool.toolCallId, packageId, outcome.draftState) + : undefined + } + onDiscardConfirm={ + onDiscardDrafts && packageId + ? () => void runDiscardDrafts(tool.toolCallId, packageId) + : undefined + } + onDiscardCancel={() => setDiscardState(tool.toolCallId, undefined)} + /> + ); + } if (outcome?.kind === 'drafted') { return (
@@ -3318,7 +3827,15 @@ const ChatbotEnhanced = React.forwardRef( data-testid="proposed-changes-confirm" > - {changesConfirmLabel} + {/* objectui#12894 — the envelope's primary action wins + over the host's label for `save_draft`, and only + then (cloud#2924): the change will be saved as a + draft, so the button says 存为草稿. The message it + sends is unchanged — it is the approval the + cloud confirm gate reads. */} + {tool.proposedChanges?.primaryAction === 'save_draft' + ? saveDraftLabel + : changesConfirmLabel}