From f2f68cf66737e6a20a06ef13c599fdb8e0d0ef95 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 8 Oct 2026 18:47:28 +0800 Subject: [PATCH] feat(flowchat): Add generative UI HTML export Export widgets as standalone HTML with embedded styles and the current appearance, using desktop save dialogs or browser downloads. Report unsupported OpenBitFun actions explicitly in exported pages. Keep image export available after streaming and align header actions. Open source details only through the dedicated button. --- .../GenerativeWidgetToolCard.appearance.ts | 2 +- .../tool-cards/GenerativeWidgetToolCard.scss | 5 + .../GenerativeWidgetToolCard.test.tsx | 130 +++++++++++++++++- .../tool-cards/GenerativeWidgetToolCard.tsx | 121 +++++++++++----- .../file-export/saveHtmlFile.test.ts | 56 ++++++++ .../file-export/saveHtmlFile.ts | 36 +++++ src/web-ui/src/locales/en-US/flow-chat.json | 7 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 7 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 7 +- .../GenerativeWidgetFrame.test.tsx | 38 +++++ .../GenerativeWidgetFrame.tsx | 13 ++ .../widgetHtmlExport.test.ts | 95 +++++++++++++ .../generative-widget/widgetHtmlExport.ts | 74 ++++++++++ 13 files changed, 549 insertions(+), 42 deletions(-) create mode 100644 src/web-ui/src/infrastructure/file-export/saveHtmlFile.test.ts create mode 100644 src/web-ui/src/infrastructure/file-export/saveHtmlFile.ts create mode 100644 src/web-ui/src/tools/generative-widget/widgetHtmlExport.test.ts create mode 100644 src/web-ui/src/tools/generative-widget/widgetHtmlExport.ts diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts index 62d217893c..9b0a729851 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.appearance.ts @@ -3,7 +3,7 @@ export const generativeWidgetToolCardAppearanceDescriptor: AppearanceSurfaceDesc id: 'generative-widget-tool-card', parts: [ { id: 'root' }, { id: 'title' }, { id: 'extra' }, { id: 'status' }, - { id: 'exportAction' }, { id: 'placeholder' }, { id: 'preview' }, + { id: 'exportAction' }, { id: 'htmlExportAction' }, { id: 'placeholder' }, { id: 'preview' }, { id: 'captureRoot' }, { id: 'exportStage' }, ], states: [ diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss index 44f61dba6c..567fea0809 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.scss @@ -23,6 +23,11 @@ gap: var(--openbitfun-control-flow-chat-control-gap); } +.generative-widget-card__export-action { + display: inline-flex; + align-items: center; +} + .generative-widget-card__status { font-size: var(--openbitfun-type-flow-control-font-size); color: var(--openbitfun-color-content-secondary); diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.test.tsx index 4b6512f26d..52c8af529a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.test.tsx @@ -5,13 +5,27 @@ import { createRoot } from 'react-dom/client'; import { afterEach, expect, it, vi } from 'vitest'; import { GenerativeWidgetToolCard } from './GenerativeWidgetToolCard'; import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; +import type { GenerativeWidgetFrameProps } from '@/tools/generative-widget/GenerativeWidgetFrame'; +import { exportGenerativeWidgetHtml } from '@/tools/generative-widget/widgetHtmlExport'; +import { notificationService } from '@/shared/notification-system'; +import { createTab } from '@/shared/utils/tabUtils'; + +const frameState = vi.hoisted(() => ({ props: null as GenerativeWidgetFrameProps | null })); vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal(); const { createTestI18nT } = await import('@/test/i18nTestUtils'); return { ...actual, useTranslation: () => ({ t: createTestI18nT('flow-chat') }) }; }); -vi.mock('@/tools/generative-widget/GenerativeWidgetFrame', () => ({ default: () => null })); +vi.mock('@/infrastructure/i18n', async (importOriginal) => { + const actual = await importOriginal(); + const { createTestI18nT } = await import('@/test/i18nTestUtils'); + return { ...actual, useI18n: () => ({ t: createTestI18nT('flow-chat'), currentLanguage: 'en-US' }) }; +}); +vi.mock('@/tools/generative-widget/widgetHtmlExport', () => ({ exportGenerativeWidgetHtml: vi.fn() })); +vi.mock('@/tools/generative-widget/GenerativeWidgetFrame', () => ({ + default: (props: GenerativeWidgetFrameProps) => { frameState.props = props; return null; }, +})); vi.mock('@/tools/generative-widget/GenerativeWidgetStaticRenderer', () => ({ default: () => null })); vi.mock('@/tools/generative-widget/useGenerativeWidgetPromptMenu', () => ({ useGenerativeWidgetPromptMenu: () => vi.fn() })); vi.mock('@/tools/generative-widget/widgetInteraction', () => ({ handleWidgetBridgeEvent: vi.fn() })); @@ -19,7 +33,119 @@ vi.mock('@/shared/notification-system', () => ({ notificationService: { success: vi.mock('@/shared/utils/tabUtils', () => ({ createTab: vi.fn() })); vi.mock('../utils/captureElementToDownloadsPng', () => ({ captureElementToDownloadsPng: vi.fn() })); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); frameState.props = null; }); + +it('shows image export after the final streamed content is applied without remounting', () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const partialCode = '

Partial

'; + const finalCode = '

Complete

'; + const item: FlowToolItem = { + id: 'streamed-widget', type: 'tool', toolName: 'GenerativeUI', timestamp: 1, + status: 'streaming', isParamsStreaming: true, partialParams: { widget_code: partialCode }, + toolCall: { id: 'streamed-widget', input: {} }, + }; + const render = (value: FlowToolItem) => act(() => root.render( + )); + const rendered = (widgetCode: string) => act(() => frameState.props?.onWidgetEvent?.({ + source: 'openbitfun-widget', type: 'openbitfun-widget:rendered', + widgetId: 'streamed-widget', widgetCode, + })); + const exportAction = () => container.querySelector('[data-openbitfun-part="exportAction"]'); + try { + render(item); + const card = container.querySelector('[data-openbitfun-part="root"]'); + act(() => frameState.props?.onWidgetEvent?.({ source: 'openbitfun-widget', type: 'openbitfun-widget:ready' })); + rendered(partialCode); + expect(exportAction()).toBeNull(); + expect(container.querySelector('[data-openbitfun-part="htmlExportAction"]')).toBeNull(); + render({ ...item, status: 'completed', isParamsStreaming: false, + toolCall: { id: 'streamed-widget', input: { widget_code: finalCode } }, + toolResult: { success: true, result: { widget_code: finalCode } }, + }); + expect(exportAction()).toBeNull(); + expect(container.querySelector('[data-openbitfun-part="htmlExportAction"]')).not.toBeNull(); + // A shell-ready event must not mark unapplied final content as ready. + act(() => frameState.props?.onWidgetEvent?.({ source: 'openbitfun-widget', type: 'openbitfun-widget:ready' })); + expect(exportAction()).toBeNull(); + rendered(partialCode); + expect(exportAction()).toBeNull(); + rendered(finalCode); + expect(exportAction()).not.toBeNull(); + expect(container.querySelector('[data-openbitfun-part="root"]')).toBe(card); + } finally { + act(() => root.unmount()); + container.remove(); + } +}); + +it('opens source details only from the dedicated button, not the header or its title', () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const code = '

Widget content

'; + const item: FlowToolItem = { id: 'details-widget', type: 'tool', toolName: 'GenerativeUI', timestamp: 1, + status: 'completed', toolCall: { id: 'details-widget', input: { widget_code: code, title: 'Chart' } }, + toolResult: { success: true }, + }; + try { + act(() => root.render()); + const header = container.querySelector('[data-openbitfun-component="flow-chat-tool-card"][data-openbitfun-part="surface"]')!; + expect(header.getAttribute('data-openbitfun-interactive')).toBe('false'); + expect(container.querySelector('.generative-widget-card')?.getAttribute('title')).toBeNull(); + act(() => header.click()); + act(() => container.querySelector('[data-openbitfun-part="title"]')!.click()); + expect(createTab).not.toHaveBeenCalled(); + act(() => container.querySelector('button[data-openbitfun-affordance="open-panel-right"]')!.click()); + expect(createTab).toHaveBeenCalledTimes(1); + expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ + type: 'generative-widget', title: 'Chart', + data: expect.objectContaining({ widgetCode: code, + _source: expect.objectContaining({ sessionId: 'session-1', toolCallId: 'details-widget' }), + }), + })); + } finally { + act(() => root.unmount()); + container.remove(); + } +}); + +it('exports completed source without preview readiness and handles cancellation, success and failure', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const code = '

Export me

'; + const item: FlowToolItem = { id: 'html-widget', type: 'tool', toolName: 'GenerativeUI', timestamp: 1, + status: 'completed', toolCall: { id: 'html-widget', input: { widget_code: code, title: 'Chart' } }, + toolResult: { success: true }, + }; + const exportHtml = vi.mocked(exportGenerativeWidgetHtml); + exportHtml.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('Write failed')); + try { + act(() => root.render()); + const button = () => container.querySelector('[data-openbitfun-part="htmlExportAction"] button')!; + await act(async () => button().click()); + expect(exportHtml).toHaveBeenCalledWith({ + widgetCode: code, title: 'Chart', language: 'en-US', dialogTitle: 'Export HTML', + hostUnavailableMessage: 'This action requires OpenBitFun and is unavailable in the exported HTML.', + }); + expect(notificationService.success).not.toHaveBeenCalled(); + expect(notificationService.error).not.toHaveBeenCalled(); + await act(async () => button().click()); + expect(notificationService.success).toHaveBeenCalledWith('HTML exported'); + await act(async () => button().click()); + expect(notificationService.error).toHaveBeenCalledWith('Failed to export HTML'); + expect(button().disabled).toBe(false); + expect(createTab).not.toHaveBeenCalled(); + } finally { + act(() => root.unmount()); + container.remove(); + } +}); it('preserves the live preview disclosure on failure and keeps historical failures compact', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); diff --git a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx index 307992106a..4fd08379b0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.tsx @@ -3,13 +3,14 @@ import { useToolCardDisclosure } from '../timeline/readerState'; import { subscribeOverlayInteraction, Tooltip, Icon, IconButton } from '@openbitfun/ui'; import type { ToolCardProps } from '../types/flow-chat'; import { ProminentToolCard, ProminentToolCardSummary, ToolProcessingDots } from '@openbitfun/ui/flow-chat'; -import { useTranslation } from 'react-i18next'; +import { useI18n } from '@/infrastructure/i18n'; import GenerativeWidgetFrame, { type WidgetContextMenuMessage, type WidgetMessage, } from '@/tools/generative-widget/GenerativeWidgetFrame'; import GenerativeWidgetStaticRenderer from '@/tools/generative-widget/GenerativeWidgetStaticRenderer'; import { handleWidgetBridgeEvent } from '@/tools/generative-widget/widgetInteraction'; +import { exportGenerativeWidgetHtml } from '@/tools/generative-widget/widgetHtmlExport'; import { useGenerativeWidgetPromptMenu } from '@/tools/generative-widget/useGenerativeWidgetPromptMenu'; import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; import { captureElementToDownloadsPng } from '../utils/captureElementToDownloadsPng'; @@ -47,7 +48,7 @@ function parseWidgetResult(raw: unknown): WidgetResult | null { } export const GenerativeWidgetToolCard: React.FC = ({ toolItem, sessionId }) => { - const { t } = useTranslation('flow-chat'); + const { t, currentLanguage } = useI18n('flow-chat'); const { status, toolCall, toolResult, partialParams, isParamsStreaming } = toolItem; const previewRef = useRef(null); const captureRootRef = useRef(null); @@ -59,6 +60,7 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se const [selectionRevision, setSelectionRevision] = useState(0); const [menuSelectionActive, setMenuSelectionActive] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [isExportingHtml, setIsExportingHtml] = useState(false); const [shouldRenderExportClone, setShouldRenderExportClone] = useState(false); const [exportWidth, setExportWidth] = useState(null); const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ @@ -154,16 +156,12 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se }); }, [isClickable, sessionId, title, toolCall?.id, toolItem.id, widgetCode, widgetId]); - const handleCardClick = useCallback( + const handleToggle = useCallback( (e: React.MouseEvent) => { - if (isFailed) { - e.preventDefault(); - applyExpandedState(isCardExpanded, !isCardExpanded, setIsCardExpanded); - return; - } - handleOpenPanel(); + e.preventDefault(); + applyExpandedState(isCardExpanded, !isCardExpanded, setIsCardExpanded); }, - [applyExpandedState, handleOpenPanel, isCardExpanded, isFailed, setIsCardExpanded], + [applyExpandedState, isCardExpanded, setIsCardExpanded], ); const handleWidgetEvent = useCallback((event: WidgetMessage) => { @@ -178,7 +176,10 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se return; } if (event.type === 'openbitfun-widget:ready') { - setReadyCode(widgetCode); + return; + } + if (event.type === 'openbitfun-widget:rendered') { + setReadyCode(event.widgetCode); return; } if ( @@ -188,7 +189,7 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se return; } handleWidgetBridgeEvent(event, 'tool-card'); - }, [hideMenu, openPromptMenu, widgetCode]); + }, [hideMenu, openPromptMenu]); useEffect(() => { if (!menuSelectionActive) { @@ -241,6 +242,28 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se [isClickable, readyCode, widgetCode, isExporting, t], ); + const handleExportHtml = useCallback(async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!isClickable || isExportingHtml) return; + setIsExportingHtml(true); + try { + const saved = await exportGenerativeWidgetHtml({ + widgetCode, + title, + language: currentLanguage, + dialogTitle: t('toolCards.generativeWidget.exportHtml'), + hostUnavailableMessage: t('toolCards.generativeWidget.exportHtmlHostUnavailable'), + }); + if (saved) notificationService.success(t('toolCards.generativeWidget.exportHtmlSuccess')); + } catch (error) { + log.error('Generative UI export HTML failed', error); + notificationService.error(t('toolCards.generativeWidget.exportHtmlFailed')); + } finally { + setIsExportingHtml(false); + } + }, [currentLanguage, isClickable, isExportingHtml, t, title, widgetCode]); + const statusText = status === 'cancelled' ? t('toolCards.default.cancelled') : status === 'rejected' ? t('toolCards.default.rejected') : isFailed ? t('toolCards.default.failed') @@ -252,6 +275,9 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se const summary = ( } action={t('toolCards.generativeUI.action')} content={{title}} @@ -266,26 +292,51 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se )} - actions={isClickable && readyCode === widgetCode ? ( - - + {readyCode === widgetCode && ( + + + } + /> + + + )} + - } - /> - - + + } + /> + + + ) : undefined} statusIcon={isLoading ? : null} /> @@ -324,18 +375,16 @@ export const GenerativeWidgetToolCard: React.FC = ({ toolItem, se onPointerDownCapture={() => setRetainInstance(true)} onFocusCapture={() => setRetainInstance(true)} data-tool-card-id={toolItem.id ?? toolCall?.id ?? ''} data-openbitfun-component="generative-widget-tool-card" data-openbitfun-part="root" data-openbitfun-state={isFailed ? 'failed' : undefined}> {shouldRenderExportClone && hasRenderableWidget && ( diff --git a/src/web-ui/src/infrastructure/file-export/saveHtmlFile.test.ts b/src/web-ui/src/infrastructure/file-export/saveHtmlFile.test.ts new file mode 100644 index 0000000000..71a69358c5 --- /dev/null +++ b/src/web-ui/src/infrastructure/file-export/saveHtmlFile.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { safeHtmlFileName, saveHtmlFile } from './saveHtmlFile'; + +const mocks = vi.hoisted(() => ({ save: vi.fn(), writeFile: vi.fn(), download: vi.fn() })); +vi.mock('@tauri-apps/plugin-dialog', () => ({ save: mocks.save })); +vi.mock('@tauri-apps/plugin-fs', () => ({ writeFile: mocks.writeFile })); +vi.mock('@/shared/utils/browserDownload', () => ({ downloadTextFileInBrowser: mocks.download })); + +const options = { html: '

你好

', title: 'Chart / 比较', dialogTitle: 'Export HTML' }; +beforeEach(() => vi.resetAllMocks()); +afterEach(() => { Reflect.deleteProperty(window, '__TAURI__'); }); + +describe('HTML file export on the viewing device', () => { + it('uses portable filenames, including reserved Windows names and empty titles', () => { + expect(safeHtmlFileName(' Chart / 比较.htm ')).toBe('Chart - 比较.html'); + expect(safeHtmlFileName('CON')).toBe('_CON.html'); + expect(safeHtmlFileName('A... ')).toBe('A.html'); + expect(safeHtmlFileName('.html')).toBe('OpenBitFun.html'); + }); + + it('downloads in the browser without invoking a desktop filesystem or dialog', async () => { + expect(await saveHtmlFile(options)).toBe(true); + expect(mocks.download).toHaveBeenCalledWith('Chart - 比较.html', options.html, 'text/html;charset=utf-8'); + expect(mocks.save).not.toHaveBeenCalled(); + expect(mocks.writeFile).not.toHaveBeenCalled(); + }); + + it('saves UTF-8 through the controller desktop plugins', async () => { + Reflect.set(window, '__TAURI__', {}); + mocks.save.mockResolvedValue('E:/tmp/chart.html'); + expect(await saveHtmlFile(options)).toBe(true); + expect(mocks.save).toHaveBeenCalledWith({ + title: 'Export HTML', defaultPath: 'Chart - 比较.html', + filters: [{ name: 'HTML', extensions: ['html', 'htm'] }], + }); + expect(mocks.writeFile).toHaveBeenCalledWith('E:/tmp/chart.html', new TextEncoder().encode(options.html)); + expect(mocks.download).not.toHaveBeenCalled(); + }); + + it('treats a cancelled save dialog as cancellation without writing a file', async () => { + Reflect.set(window, '__TAURI__', {}); + mocks.save.mockResolvedValue(null); + expect(await saveHtmlFile(options)).toBe(false); + expect(mocks.writeFile).not.toHaveBeenCalled(); + }); + + it('propagates save failures without falling back to another destination', async () => { + Reflect.set(window, '__TAURI__', {}); + mocks.save.mockResolvedValue('E:/tmp/chart.html'); + mocks.writeFile.mockRejectedValue(new Error('Write failed')); + await expect(saveHtmlFile(options)).rejects.toThrow('Write failed'); + expect(mocks.download).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/infrastructure/file-export/saveHtmlFile.ts b/src/web-ui/src/infrastructure/file-export/saveHtmlFile.ts new file mode 100644 index 0000000000..4739a3522e --- /dev/null +++ b/src/web-ui/src/infrastructure/file-export/saveHtmlFile.ts @@ -0,0 +1,36 @@ +import { downloadTextFileInBrowser } from '@/shared/utils/browserDownload'; + +export function safeHtmlFileName(title: string): string { + let stem = title.trim().replace(/\.html?$/i, '') + .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-') + .slice(0, 96).replace(/[.\s]+$/g, '').trim(); + if (!stem) stem = 'OpenBitFun'; + if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(stem)) stem = `_${stem}`; + return `${stem}.html`; +} + +/** Save on the viewing device, independently of workspace or peer transport. */ +export async function saveHtmlFile(options: { + html: string; + title: string; + dialogTitle: string; +}): Promise { + const fileName = safeHtmlFileName(options.title); + if (!('__TAURI__' in window)) { + downloadTextFileInBrowser(fileName, options.html, 'text/html;charset=utf-8'); + return true; + } + + const [{ save }, { writeFile }] = await Promise.all([ + import('@tauri-apps/plugin-dialog'), + import('@tauri-apps/plugin-fs'), + ]); + const path = await save({ + title: options.dialogTitle, + defaultPath: fileName, + filters: [{ name: 'HTML', extensions: ['html', 'htm'] }], + }); + if (!path) return false; + await writeFile(path, new TextEncoder().encode(options.html)); + return true; +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 9e8598d65a..6d98b3e537 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2002,7 +2002,12 @@ "finished": "Generation ended" }, "generativeWidget": { - "exportFileNamePrefix": "OpenBitFun-Widget" + "exportFileNamePrefix": "OpenBitFun-Widget", + "exportHtml": "Export HTML", + "exportingHtml": "Exporting HTML...", + "exportHtmlSuccess": "HTML exported", + "exportHtmlFailed": "Failed to export HTML", + "exportHtmlHostUnavailable": "This action requires OpenBitFun and is unavailable in the exported HTML." }, "initMiniApp": { "title": "Initialize mini app", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 93de6d2808..1b437b922a 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2002,7 +2002,12 @@ "finished": "生成结束" }, "generativeWidget": { - "exportFileNamePrefix": "OpenBitFun组件" + "exportFileNamePrefix": "OpenBitFun组件", + "exportHtml": "导出 HTML", + "exportingHtml": "正在导出 HTML…", + "exportHtmlSuccess": "HTML 已导出", + "exportHtmlFailed": "导出 HTML 失败", + "exportHtmlHostUnavailable": "此操作需要 OpenBitFun,无法在导出的 HTML 中使用。" }, "initMiniApp": { "title": "初始化小应用", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index b328aa1fd7..d1028045fb 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2002,7 +2002,12 @@ "finished": "產生結束" }, "generativeWidget": { - "exportFileNamePrefix": "OpenBitFun組件" + "exportFileNamePrefix": "OpenBitFun組件", + "exportHtml": "匯出 HTML", + "exportingHtml": "正在匯出 HTML…", + "exportHtmlSuccess": "HTML 已匯出", + "exportHtmlFailed": "匯出 HTML 失敗", + "exportHtmlHostUnavailable": "此操作需要 OpenBitFun,無法在匯出的 HTML 中使用。" }, "initMiniApp": { "title": "初始化小應用", diff --git a/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.test.tsx b/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.test.tsx index 230e147d45..7252eea436 100644 --- a/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.test.tsx +++ b/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; import GenerativeWidgetFrame, { GENERATIVE_WIDGET_SHELL_HTML } from './GenerativeWidgetFrame'; import { widgetAppearanceAdapter } from '@/infrastructure/appearance/adapters/WidgetAppearanceAdapter'; @@ -41,6 +42,43 @@ describe('GenerativeWidgetFrame shell', () => { expect(values).toEqual(['13px']); }); + it('acknowledges each applied code version and runs completed scripts only once', () => { + const postMessage = vi.fn(); + const dom = new JSDOM(GENERATIVE_WIDGET_SHELL_HTML, { + runScripts: 'dangerously', + beforeParse(window) { + window.postMessage = postMessage; + window.requestAnimationFrame = () => 0; + }, + }); + const streamedCode = '

Partial

'; + const completedCode = '

Complete

'; + const update = (html: string, runScripts: boolean) => { + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { + data: { type: 'openbitfun-widget:update', widgetId: 'widget_1', html, runScripts }, + })); + }; + try { + expect(postMessage.mock.calls.map(([message]) => message.type)).toEqual(['openbitfun-widget:ready']); + update(streamedCode, false); + expect(dom.window.document.querySelector('#root')?.textContent).toBe('Partial'); + expect(postMessage).toHaveBeenLastCalledWith({ + source: 'openbitfun-widget', type: 'openbitfun-widget:rendered', + widgetId: 'widget_1', widgetCode: streamedCode, + }, '*'); + update(completedCode, true); + expect(dom.window.document.querySelector('#root > p')?.textContent).toBe('Complete'); + expect(postMessage).toHaveBeenLastCalledWith({ + source: 'openbitfun-widget', type: 'openbitfun-widget:rendered', + widgetId: 'widget_1', widgetCode: completedCode, + }, '*'); + update(completedCode, true); + expect(Reflect.get(dom.window, 'executionCount')).toBe(1); + } finally { + dom.window.close(); + } + }); + it('writes the widget shell into about:blank instead of relying on srcdoc', async () => { await act(async () => { root.render( diff --git a/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.tsx b/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.tsx index 7e5b0795a0..abcae4acf1 100644 --- a/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.tsx +++ b/src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.tsx @@ -27,6 +27,12 @@ export type WidgetMessage = type: 'openbitfun-widget:ready'; widgetId?: string; } + | { + source: 'openbitfun-widget'; + type: 'openbitfun-widget:rendered'; + widgetId?: string; + widgetCode: string; + } | { source: 'openbitfun-widget'; type: 'openbitfun-widget:open-file'; @@ -860,6 +866,13 @@ ${createWidgetAppearanceFallbackCss()} currentWidgetId = data.widgetId || currentWidgetId || ''; applyAppearance(data.appearance); setContent(String(data.html || ''), Boolean(data.runScripts)); + // Acknowledge the applied content, not only the initial empty shell. + sendMessage({ + source: 'openbitfun-widget', + type: 'openbitfun-widget:rendered', + widgetId: currentWidgetId, + widgetCode: String(data.html || '') + }); }); window.addEventListener('load', scheduleResize); diff --git a/src/web-ui/src/tools/generative-widget/widgetHtmlExport.test.ts b/src/web-ui/src/tools/generative-widget/widgetHtmlExport.test.ts new file mode 100644 index 0000000000..c5ab9416dc --- /dev/null +++ b/src/web-ui/src/tools/generative-widget/widgetHtmlExport.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { JSDOM } from 'jsdom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { widgetAppearanceAdapter } from '@/infrastructure/appearance/adapters/WidgetAppearanceAdapter'; +import { createGenerativeWidgetHtml, exportGenerativeWidgetHtml } from './widgetHtmlExport'; + +const saveHtmlFile = vi.hoisted(() => vi.fn()); +vi.mock('@/infrastructure/file-export/saveHtmlFile', () => ({ saveHtmlFile })); +const appearance = { id: 'test.widget', mode: 'dark', vars: { + '--openbitfun-color-content-primary': '#eeeeee', + '--openbitfun-color-surface-canvas': '#111111', +} }; +const options = { + title: 'Chart & 比较', language: 'zh-CN', + widgetCode: '

Widget

', hostUnavailableMessage: 'Requires OpenBitFun', appearance, +}; + +beforeEach(() => saveHtmlFile.mockReset()); + +describe('standalone generative widget HTML', () => { + it('embeds the theme and shared styles, escapes the title and permits page scrolling', () => { + const html = createGenerativeWidgetHtml(options); + const doc = new DOMParser().parseFromString(html, 'text/html'); + expect(html).toMatch(/^/); + expect(doc.title).toBe(options.title); + expect(doc.documentElement.lang).toBe('zh-CN'); + expect(doc.documentElement.style.getPropertyValue('--openbitfun-color-content-primary')).toBe('#eeeeee'); + expect(doc.documentElement.style.colorScheme).toBe('dark'); + expect(doc.head.querySelectorAll('script')).toHaveLength(0); + expect(doc.head.textContent).toContain('.openbitfun-card'); + expect(doc.head.textContent).toContain('overflow-y: auto'); + expect(doc.getElementById('root')?.innerHTML).toBe('

Widget

'); + expect(html).not.toContain('openbitfun-widget:update'); + expect(html).not.toContain('morphdom'); + }); + + it('runs widget scripts and local interactions independently of the application', () => { + const html = createGenerativeWidgetHtml({ ...options, widgetCode: ` + + + ` }); + const postMessage = vi.fn(); + const dom = new JSDOM(html, { runScripts: 'dangerously', beforeParse(window) { window.postMessage = postMessage; } }); + try { + dom.window.document.querySelector('#increment')!.click(); + expect(dom.window.document.getElementById('increment')?.textContent).toBe('1'); + expect(postMessage).not.toHaveBeenCalled(); + } finally { dom.window.close(); } + }); + + it('reports host-only actions clearly and escapes localized bridge text', () => { + const message = 'Unavailable '; + const html = createGenerativeWidgetHtml({ ...options, hostUnavailableMessage: message, + widgetCode: '', + }); + const alert = vi.fn(); + const dom = new JSDOM(html, { runScripts: 'dangerously', beforeParse(window) { window.alert = alert; } }); + try { + dom.window.document.querySelector('[data-file-path]')!.click(); + const sendPrompt = Reflect.get(dom.window, 'sendPrompt') as (text: string) => void; + sendPrompt('Follow up'); + const bridge = Reflect.get(dom.window, 'openbitfunWidget') as { send: (data: unknown) => void }; + bridge.send({ action: 'test' }); + expect(alert).toHaveBeenCalledTimes(3); + expect(alert).toHaveBeenLastCalledWith(message); + expect(Reflect.get(dom.window, 'injected')).toBeUndefined(); + } finally { dom.window.close(); } + }); + + it('preserves SVG and external references without claiming to bundle network resources', () => { + const html = createGenerativeWidgetHtml({ ...options, appearance: null, widgetCode: + '', + }); + const doc = new DOMParser().parseFromString(html, 'text/html'); + expect(doc.querySelector('circle')?.namespaceURI).toBe('http://www.w3.org/2000/svg'); + expect(doc.querySelector('#root script')?.getAttribute('src')).toBe('https://example.com/chart.js'); + }); + + it('captures the current appearance when exporting and preserves cancellation', async () => { + widgetAppearanceAdapter.apply(appearance, undefined, + { revision: 1, appearanceId: 'test', mode: 'dark', globals: {}, assets: {} }); + saveHtmlFile.mockResolvedValue(false); + expect(await exportGenerativeWidgetHtml({ ...options, dialogTitle: 'Export HTML' })).toBe(false); + const saved = saveHtmlFile.mock.calls[0][0]; + expect(saved.title).toBe(options.title); + expect(saved.dialogTitle).toBe('Export HTML'); + const doc = new DOMParser().parseFromString(saved.html, 'text/html'); + expect(doc.documentElement.style.getPropertyValue('--openbitfun-color-surface-canvas')).toBe('#111111'); + }); +}); diff --git a/src/web-ui/src/tools/generative-widget/widgetHtmlExport.ts b/src/web-ui/src/tools/generative-widget/widgetHtmlExport.ts new file mode 100644 index 0000000000..42a2b3c17a --- /dev/null +++ b/src/web-ui/src/tools/generative-widget/widgetHtmlExport.ts @@ -0,0 +1,74 @@ +import { saveHtmlFile } from '@/infrastructure/file-export/saveHtmlFile'; +import { GENERATIVE_WIDGET_SHELL_HTML } from './GenerativeWidgetFrame'; +import { readWidgetAppearancePayload, type WidgetAppearancePayload } from './appearancePayload'; + +interface WidgetHtmlOptions { + widgetCode: string; + title: string; + language: string; + hostUnavailableMessage: string; +} + +export function createGenerativeWidgetHtml({ + widgetCode, title, language, hostUnavailableMessage, appearance, +}: WidgetHtmlOptions & { appearance: WidgetAppearancePayload | null }): string { + // DOMParser keeps scripts inert while constructing the export. Remove only the + // host runtime before inserting the widget's own markup and scripts. + const doc = new DOMParser().parseFromString(GENERATIVE_WIDGET_SHELL_HTML, 'text/html'); + doc.querySelectorAll('script').forEach(script => script.remove()); + doc.title = title; + doc.documentElement.lang = language; + if (appearance) { + doc.documentElement.setAttribute('data-openbitfun-appearance', appearance.id); + doc.documentElement.setAttribute('data-openbitfun-appearance-mode', appearance.mode); + Object.entries(appearance.vars).forEach(([name, value]) => { + doc.documentElement.style.setProperty(name, value); + }); + if (appearance.mode === 'light' || appearance.mode === 'dark') { + doc.documentElement.style.colorScheme = appearance.mode; + } + } + + const style = doc.createElement('style'); + style.textContent = ` + html, body { min-height: 100%; overflow-y: auto; } + html { background: var(--openbitfun-color-surface-panel); } + body { + background: var(--openbitfun-color-surface-canvas); + font-family: var(--openbitfun-type-body-sm-font-family); + } + `; + doc.head.appendChild(style); + + const bridge = doc.createElement('script'); + const message = JSON.stringify(hostUnavailableMessage).replace(/\n${doc.documentElement.outerHTML}`; +} + +export async function exportGenerativeWidgetHtml(options: WidgetHtmlOptions & { + dialogTitle: string; +}): Promise { + const html = createGenerativeWidgetHtml({ + ...options, + appearance: readWidgetAppearancePayload(), + }); + return saveHtmlFile({ html, title: options.title, dialogTitle: options.dialogTitle }); +}