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 }); +}