From b022eb7a4f5fbb8565033b38464ed308c833a0b7 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 10 Jun 2026 07:12:19 +0500 Subject: [PATCH] =?UTF-8?q?feat(chatbot):=20auto-publish=20AI-built=20apps?= =?UTF-8?q?=20in=20chat=20=E2=80=94=20the=20self-use=20magic=20moment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the runtime enables `features.autoPublishAiBuilds`, a whole-app build (apply_blueprint, which the backend marks `autoPublishable`) is published the moment the agent finishes — the user lands on a populated, running app with no manual Publish click. Incremental edits are NOT auto-published: they omit the flag and stay drafts for explicit review, so a destructive change never goes live silently. Design: backend declares lifecycle intent → chat decides → the status panel is the single source of truth for publish state (the model only describes what it built). The draft card flips to a "Published" badge once promoted, instead of leaving a stale Publish button. - mapMessages: lift `autoPublishable` + `failedCount` from the draft envelope - ChatbotEnhanced: gate auto-publish on `autoPublishable`; dedup by toolCallId (so repeat builds into one workspace package each fire once); per-toolCallId "Published" badge (a later edit into an already-published package still shows Publish); onPublishDrafts reports success so the badge reflects reality - runtime-config: consume `features.autoPublishAiBuilds` - AiChatPage / ConsoleFloatingChatbot: pass the flag, return publish success - tests: 17 cases incl. scope (edit not auto-published) + badge regressions Co-Authored-By: Claude Opus 4.8 --- .../app-shell/src/console/ai/AiChatPage.tsx | 8 + .../src/layout/ConsoleFloatingChatbot.tsx | 11 ++ packages/app-shell/src/runtime-config.ts | 11 +- .../plugin-chatbot/src/ChatbotEnhanced.tsx | 145 +++++++++++++++-- .../src/__tests__/ChatbotEnhanced.test.tsx | 151 +++++++++++++++++- packages/plugin-chatbot/src/mapMessages.ts | 20 ++- 6 files changed, 329 insertions(+), 17 deletions(-) diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 06ad3439cb..85ae20cd66 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -45,6 +45,7 @@ import { } from '@object-ui/plugin-chatbot'; import { AppHeader } from '../../layout/AppHeader'; +import { getRuntimeConfig } from '../../runtime-config'; import { useNavigationContext } from '../../context/NavigationContext'; import { sanitizeChatMessagesForCache, @@ -554,13 +555,20 @@ function ChatPane({ const failed = payload?.data?.failedCount ?? payload?.failedCount ?? 0; if (failed) throw new Error(String(failed)); toast.success(t('console.ai.publishOk', { defaultValue: 'Published — objects are now live.' })); + return true; } catch (e) { toast.error(t('console.ai.publishFailed', { defaultValue: 'Publish failed' }), { description: e instanceof Error ? e.message : undefined, }); + return false; } }} publishDraftsLabel={t('console.ai.publishDrafts', { defaultValue: 'Publish' })} + publishedLabel={t('console.ai.published', { defaultValue: 'Published' })} + // Self-use "magic moment": when the plan enables it, publish the drafted + // app automatically the moment the agent finishes — no manual click; the + // user refreshes and sees it live WITH data. Same governed endpoint. + autoPublishDrafts={getRuntimeConfig().features.autoPublishAiBuilds} data-testid="ai-chat-panel" /> diff --git a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx index b4519f2e2c..77ed095e10 100644 --- a/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx +++ b/packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx @@ -41,6 +41,7 @@ import { type HydratedUIMessage, } from '../hooks'; import { useAssistant, requestAssistantReview, type AssistantEditorContext } from '../assistant/assistantBus'; +import { getRuntimeConfig } from '../runtime-config'; /** * Display names for the built-in platform agents. The backend ships English @@ -117,6 +118,7 @@ function buildChatLocale( share: '分享对话', reviewDraft: (n: number) => `查看 ${n} 项变更`, publishDrafts: '发布', + published: '已发布', publishOk: '已发布,对象已生效。', publishFailed: '发布失败', suggestions, @@ -160,6 +162,7 @@ function buildChatLocale( share: 'Share conversation', reviewDraft: (n: number) => `Review ${n} change${n === 1 ? '' : 's'}`, publishDrafts: 'Publish', + published: 'Published', publishOk: 'Published — objects are now live.', publishFailed: 'Publish failed', suggestions, @@ -524,13 +527,21 @@ function ChatbotInner({ payload?.data?.published ?? payload?.published ?? []; const app = published.find((p) => p?.type === 'app' && p?.name); if (app?.name) navigate(`/apps/${encodeURIComponent(app.name)}`); + return true; } catch (e) { toast.error(locale.publishFailed, { description: e instanceof Error ? e.message : undefined, }); + return false; } }} publishDraftsLabel={locale.publishDrafts} + publishedLabel={locale.published} + // Self-use "magic moment": when the plan enables it, auto-publish the + // drafted app the instant the agent finishes — the success path above + // then navigates straight to the live app, so "build" lands the user on + // a populated, running app with no manual Publish click. + autoPublishDrafts={getRuntimeConfig().features.autoPublishAiBuilds} /> {conversationId && ( { installLocal: !!body.features.installLocal, marketplace: body.features.marketplace !== false, aiStudio: body.features.aiStudio !== false, + autoPublishAiBuilds: body.features.autoPublishAiBuilds !== false, } : current.features, branding: body.branding diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index faf85a3529..8aa831d562 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -134,7 +134,19 @@ export interface ChatToolInvocation { * change(s)" affordance that opens the designer's review/diff. Nothing is * live until the human publishes — this is the review entry point. */ - draftReview?: { items: Array<{ type: string; name: string }>; summary?: string; packageId?: string }; + draftReview?: { + items: Array<{ type: string; name: string }>; + summary?: string; + packageId?: string; + /** + * Backend lifecycle intent (from the tool result). `true` for whole-app + * builds (apply_blueprint) — eligible for the auto-publish "magic moment". + * Omitted for incremental edits, which stay drafts for explicit review. + */ + autoPublishable?: boolean; + /** Count of artifacts that failed in a partial build, surfaced not hidden. */ + failedCount?: number; + }; } export interface ChatSource { @@ -307,9 +319,25 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes void; + onPublishDrafts?: (packageId: string) => void | boolean | Promise; /** Label for the publish-drafts button (default "Publish"). */ publishDraftsLabel?: string; + /** Label for the published-state badge that replaces the button (default "Published"). */ + publishedLabel?: string; + /** + * Auto-fire `onPublishDrafts` the moment a turn finishes drafting an app — + * the self-use "magic moment" where the user refreshes and the app is already + * live WITH data, instead of clicking Publish. Server-gated by the plan + * (`features.autoPublishAiBuilds`, env-revertible via + * `OS_AI_AUTOPUBLISH_DISABLED`); the host passes the resolved flag. + * + * Only NEW drafts from the current session fire — drafts already present when + * the chat mounts (e.g. reopening a conversation) are left for the manual + * Publish button, so reopening history never silently publishes. + * + * @default false + */ + autoPublishDrafts?: boolean; /** * Controls how agent internals are exposed. `summary` keeps end-user chat * readable by grouping repeated tool calls and hiding raw args/results. @@ -488,6 +516,8 @@ const ChatbotEnhanced = React.forwardRef( toolReviewLabel = (n) => `Review ${n} change${n === 1 ? '' : 's'}`, onPublishDrafts, publishDraftsLabel = 'Publish', + publishedLabel = 'Published', + autoPublishDrafts = false, processVisibility = 'summary', surface = 'card', ...props @@ -528,6 +558,87 @@ const ChatbotEnhanced = React.forwardRef( [labels], ); + // Draft tool calls this chat has published (auto or via the manual button), + // so each card flips from a "Publish" button to a "Published" state instead + // of leaving a stale, now-meaningless button. Keyed by the draft's + // `toolCallId`, NOT its packageId: publishing a package promotes the drafts + // PENDING AT THAT MOMENT, but a later edit into the same package is a new, + // still-pending draft — it must NOT inherit the earlier build's "Published" + // badge (that would falsely tell the user an unpublished change is live). + const [publishedToolCalls, setPublishedToolCalls] = React.useState>( + () => new Set(), + ); + // Publish a package's drafts and reflect success on exactly the cards that + // were pending for it at publish time. The host's onPublishDrafts returns + // `false` on failure (and surfaces its own error); any other outcome (incl. + // void) counts as success. + const handlePublishDrafts = React.useCallback( + async (packageId: string) => { + if (!onPublishDrafts) return; + // Snapshot the on-screen draft cards this publish will promote, BEFORE + // awaiting — later edits into the same package won't be in this set. + const promoted: string[] = []; + for (const message of messages) { + for (const tool of message.toolInvocations ?? []) { + if (tool.draftReview?.packageId === packageId && tool.toolCallId) { + promoted.push(tool.toolCallId); + } + } + } + const ok = await onPublishDrafts(packageId); + if (ok !== false && promoted.length > 0) { + setPublishedToolCalls((prev) => { + const next = new Set(prev); + for (const id of promoted) next.add(id); + return next; + }); + } + }, + [onPublishDrafts, messages], + ); + + // 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 + // and seed data loads, so the user lands on a populated, running app instead + // of hunting for Publish. Incremental edits are NOT auto-published: they omit + // `autoPublishable` and stay drafts for explicit review (a destructive edit + // must never go live silently). Drafts already on screen when the chat mounts + // are seeded as "seen" so reopening a conversation never republishes prior + // work; only NEW builds fire, each at most once, after streaming completes. + // + // Dedup is keyed by the draft tool's `toolCallId`, NOT its packageId: every + // build is a distinct tool call and several can target the SAME workspace + // package in one session. Keying by packageId would publish it only once and + // silently leave later builds staged. Keyed by toolCallId, each new build + // publishes its package once (publish-drafts only promotes rows still + // pending, so re-publishing a package is safe). + const autoPublishedRef = React.useRef>(new Set()); + const autoPublishSeededRef = React.useRef(false); + React.useEffect(() => { + const builds: Array<{ key: string; packageId: string }> = []; + for (const message of messages) { + for (const tool of message.toolInvocations ?? []) { + const dr = tool.draftReview; + if (dr?.autoPublishable && dr.packageId && tool.toolCallId && dr.items.length > 0) { + builds.push({ key: tool.toolCallId, packageId: dr.packageId }); + } + } + } + if (!autoPublishSeededRef.current) { + autoPublishSeededRef.current = true; + for (const b of builds) autoPublishedRef.current.add(b.key); + return; + } + // Wait for the turn to finish so we publish the complete build once. + if (!autoPublishDrafts || !onPublishDrafts || isLoading) return; + const fresh = builds.filter((b) => !autoPublishedRef.current.has(b.key)); + if (fresh.length === 0) return; + for (const b of fresh) autoPublishedRef.current.add(b.key); + // One publish per distinct package, even if a turn made several build calls. + for (const pkg of [...new Set(fresh.map((b) => b.packageId))]) void handlePublishDrafts(pkg); + }, [messages, isLoading, autoPublishDrafts, onPublishDrafts, handlePublishDrafts]); + const handleSubmit = React.useCallback( (payload: PromptInputMessage) => { const hasText = Boolean(payload.text?.trim()); @@ -667,16 +778,26 @@ const ChatbotEnhanced = React.forwardRef( (onPublishDrafts && tool.draftReview.packageId)) ? (
{onPublishDrafts && tool.draftReview.packageId ? ( - + publishedToolCalls.has(tool.toolCallId) ? ( + // Published (auto or manual): a stable status badge, not a + // stale button. Keeps the card honest about lifecycle state. + + + {publishedLabel} + {tool.draftReview.failedCount + ? ` · ${tool.draftReview.failedCount} need${tool.draftReview.failedCount === 1 ? 's' : ''} attention` + : ''} + + ) : ( + + ) ) : null} {onReviewDraft ? (