@ulearning/ai-generative-frame 0.0.0-stage → 1.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +233 -2
- package/dist/assistant-ui/entry.d.ts +34 -0
- package/dist/assistant-ui/entry.d.ts.map +1 -0
- package/dist/assistant-ui/entry.js +50 -0
- package/dist/assistant-ui/entry.js.map +1 -0
- package/dist/assistant-ui/extension.d.ts +18 -0
- package/dist/assistant-ui/extension.d.ts.map +1 -0
- package/dist/assistant-ui/extension.js +0 -0
- package/dist/assistant-ui/history.d.ts +15 -0
- package/dist/assistant-ui/history.d.ts.map +1 -0
- package/dist/assistant-ui/history.js +39 -0
- package/dist/assistant-ui/history.js.map +1 -0
- package/dist/assistant-ui/modelContext.d.ts +45 -0
- package/dist/assistant-ui/modelContext.d.ts.map +1 -0
- package/dist/assistant-ui/modelContext.js +88 -0
- package/dist/assistant-ui/modelContext.js.map +1 -0
- package/dist/assistant-ui/snapshotSaver.d.ts +11 -0
- package/dist/assistant-ui/snapshotSaver.d.ts.map +1 -0
- package/dist/assistant-ui/snapshotSaver.js +83 -0
- package/dist/assistant-ui/snapshotSaver.js.map +1 -0
- package/dist/assistant-ui/tool-calls.d.ts +21 -0
- package/dist/assistant-ui/tool-calls.d.ts.map +1 -0
- package/dist/assistant-ui/tool-calls.js +65 -0
- package/dist/assistant-ui/tool-calls.js.map +1 -0
- package/dist/assistant-ui/toolkit.d.ts +96 -0
- package/dist/assistant-ui/toolkit.d.ts.map +1 -0
- package/dist/assistant-ui/toolkit.js +456 -0
- package/dist/assistant-ui/toolkit.js.map +1 -0
- package/dist/assistant-ui/useSharedContext.d.ts +14 -0
- package/dist/assistant-ui/useSharedContext.d.ts.map +1 -0
- package/dist/assistant-ui/useSharedContext.js +27 -0
- package/dist/assistant-ui/useSharedContext.js.map +1 -0
- package/dist/assistant-ui.d.ts +3 -0
- package/dist/assistant-ui.d.ts.map +1 -0
- package/dist/assistant-ui.js +1 -0
- package/dist/bootstrap.d.ts +23 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +78 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/csp.d.ts +25 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +78 -0
- package/dist/csp.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/json-schema.d.ts +70 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +163 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/preview.d.ts +30 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +60 -0
- package/dist/preview.js.map +1 -0
- package/dist/prompts/guidance.d.ts +41 -0
- package/dist/prompts/guidance.d.ts.map +1 -0
- package/dist/prompts/guidance.js +268 -0
- package/dist/prompts/guidance.js.map +1 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +1 -0
- package/dist/protocol.d.ts +114 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +49 -0
- package/dist/protocol.js.map +1 -0
- package/dist/repair/repair.d.ts +72 -0
- package/dist/repair/repair.d.ts.map +1 -0
- package/dist/repair/repair.js +103 -0
- package/dist/repair/repair.js.map +1 -0
- package/dist/repair.d.ts +2 -0
- package/dist/repair.d.ts.map +1 -0
- package/dist/repair.js +1 -0
- package/dist/rpc.d.ts +60 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +127 -0
- package/dist/rpc.js.map +1 -0
- package/dist/runtime/diagnostics.d.ts +17 -0
- package/dist/runtime/diagnostics.d.ts.map +1 -0
- package/dist/runtime/diagnostics.js +128 -0
- package/dist/runtime/diagnostics.js.map +1 -0
- package/dist/runtime/generated.d.ts +2 -0
- package/dist/runtime/generated.d.ts.map +1 -0
- package/dist/runtime/generated.js +5 -0
- package/dist/runtime/generated.js.map +1 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +264 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/morph.d.ts +51 -0
- package/dist/runtime/morph.d.ts.map +1 -0
- package/dist/runtime/morph.js +251 -0
- package/dist/runtime/morph.js.map +1 -0
- package/dist/runtime/screenshot.d.ts +23 -0
- package/dist/runtime/screenshot.d.ts.map +1 -0
- package/dist/runtime/screenshot.js +188 -0
- package/dist/runtime/screenshot.js.map +1 -0
- package/dist/runtime/size.d.ts +10 -0
- package/dist/runtime/size.d.ts.map +1 -0
- package/dist/runtime/size.js +21 -0
- package/dist/runtime/size.js.map +1 -0
- package/dist/runtime/storage.d.ts +4 -0
- package/dist/runtime/storage.d.ts.map +1 -0
- package/dist/runtime/storage.js +38 -0
- package/dist/runtime/storage.js.map +1 -0
- package/dist/spec/actions.d.ts +40 -0
- package/dist/spec/actions.d.ts.map +1 -0
- package/dist/spec/actions.js +53 -0
- package/dist/spec/actions.js.map +1 -0
- package/dist/spec/assistant-ui/thread.d.ts +66 -0
- package/dist/spec/assistant-ui/thread.d.ts.map +1 -0
- package/dist/spec/assistant-ui/thread.js +252 -0
- package/dist/spec/assistant-ui/thread.js.map +1 -0
- package/dist/spec/assistant-ui/toolkit.d.ts +33 -0
- package/dist/spec/assistant-ui/toolkit.d.ts.map +1 -0
- package/dist/spec/assistant-ui/toolkit.js +142 -0
- package/dist/spec/assistant-ui/toolkit.js.map +1 -0
- package/dist/spec/assistant-ui/web.d.ts +6 -0
- package/dist/spec/assistant-ui/web.d.ts.map +1 -0
- package/dist/spec/assistant-ui/web.js +48 -0
- package/dist/spec/assistant-ui/web.js.map +1 -0
- package/dist/spec/assistant-ui.d.ts +3 -0
- package/dist/spec/assistant-ui.d.ts.map +1 -0
- package/dist/spec/assistant-ui.js +1 -0
- package/dist/spec/calc.d.ts +8 -0
- package/dist/spec/calc.d.ts.map +1 -0
- package/dist/spec/calc.js +498 -0
- package/dist/spec/calc.js.map +1 -0
- package/dist/spec/catalog.d.ts +52 -0
- package/dist/spec/catalog.d.ts.map +1 -0
- package/dist/spec/catalog.js +104 -0
- package/dist/spec/catalog.js.map +1 -0
- package/dist/spec/expressions.d.ts +45 -0
- package/dist/spec/expressions.d.ts.map +1 -0
- package/dist/spec/expressions.js +199 -0
- package/dist/spec/expressions.js.map +1 -0
- package/dist/spec/patch.d.ts +20 -0
- package/dist/spec/patch.d.ts.map +1 -0
- package/dist/spec/patch.js +77 -0
- package/dist/spec/patch.js.map +1 -0
- package/dist/spec/pending.d.ts +15 -0
- package/dist/spec/pending.d.ts.map +1 -0
- package/dist/spec/pending.js +33 -0
- package/dist/spec/pending.js.map +1 -0
- package/dist/spec/pointer.d.ts +17 -0
- package/dist/spec/pointer.d.ts.map +1 -0
- package/dist/spec/pointer.js +88 -0
- package/dist/spec/pointer.js.map +1 -0
- package/dist/spec/prompt.d.ts +29 -0
- package/dist/spec/prompt.d.ts.map +1 -0
- package/dist/spec/prompt.js +145 -0
- package/dist/spec/prompt.js.map +1 -0
- package/dist/spec/render-spec.d.ts +50 -0
- package/dist/spec/render-spec.d.ts.map +1 -0
- package/dist/spec/render-spec.js +104 -0
- package/dist/spec/render-spec.js.map +1 -0
- package/dist/spec/resolve.d.ts +39 -0
- package/dist/spec/resolve.d.ts.map +1 -0
- package/dist/spec/resolve.js +51 -0
- package/dist/spec/resolve.js.map +1 -0
- package/dist/spec/resolved.d.ts +3 -0
- package/dist/spec/resolved.d.ts.map +1 -0
- package/dist/spec/resolved.js +36 -0
- package/dist/spec/resolved.js.map +1 -0
- package/dist/spec/state.d.ts +34 -0
- package/dist/spec/state.d.ts.map +1 -0
- package/dist/spec/state.js +111 -0
- package/dist/spec/state.js.map +1 -0
- package/dist/spec/stream.d.ts +50 -0
- package/dist/spec/stream.d.ts.map +1 -0
- package/dist/spec/stream.js +168 -0
- package/dist/spec/stream.js.map +1 -0
- package/dist/spec/tools.d.ts +3 -0
- package/dist/spec/tools.d.ts.map +1 -0
- package/dist/spec/tools.js +3 -0
- package/dist/spec/tree.d.ts +21 -0
- package/dist/spec/tree.d.ts.map +1 -0
- package/dist/spec/tree.js +27 -0
- package/dist/spec/tree.js.map +1 -0
- package/dist/spec/types.d.ts +66 -0
- package/dist/spec/types.d.ts.map +1 -0
- package/dist/spec/types.js +9 -0
- package/dist/spec/types.js.map +1 -0
- package/dist/spec/validate.d.ts +32 -0
- package/dist/spec/validate.d.ts.map +1 -0
- package/dist/spec/validate.js +264 -0
- package/dist/spec/validate.js.map +1 -0
- package/dist/spec/vue/SpecRenderer.d.ts +43 -0
- package/dist/spec/vue/SpecRenderer.d.ts.map +1 -0
- package/dist/spec/vue/SpecRenderer.js +357 -0
- package/dist/spec/vue/SpecRenderer.js.map +1 -0
- package/dist/spec/vue/useSpecStream.d.ts +19 -0
- package/dist/spec/vue/useSpecStream.d.ts.map +1 -0
- package/dist/spec/vue/useSpecStream.js +115 -0
- package/dist/spec/vue/useSpecStream.js.map +1 -0
- package/dist/spec/vue.d.ts +3 -0
- package/dist/spec/vue.d.ts.map +1 -0
- package/dist/spec/vue.js +3 -0
- package/dist/spec.d.ts +12 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +8 -0
- package/dist/theme.d.ts +38 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +370 -0
- package/dist/theme.js.map +1 -0
- package/dist/tools/define.d.ts +43 -0
- package/dist/tools/define.d.ts.map +1 -0
- package/dist/tools/define.js +31 -0
- package/dist/tools/define.js.map +1 -0
- package/dist/tools/edits.d.ts +19 -0
- package/dist/tools/edits.d.ts.map +1 -0
- package/dist/tools/edits.js +55 -0
- package/dist/tools/edits.js.map +1 -0
- package/dist/tools/registry.d.ts +19 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +33 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/tools.d.ts +88 -0
- package/dist/tools/tools.d.ts.map +1 -0
- package/dist/tools/tools.js +235 -0
- package/dist/tools/tools.js.map +1 -0
- package/dist/tools.d.ts +5 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +5 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/dist/vue/Widget.d.ts +16 -0
- package/dist/vue/Widget.d.ts.map +1 -0
- package/dist/vue/Widget.js +58 -0
- package/dist/vue/Widget.js.map +1 -0
- package/dist/vue/sync.d.ts +19 -0
- package/dist/vue/sync.d.ts.map +1 -0
- package/dist/vue/sync.js +31 -0
- package/dist/vue/sync.js.map +1 -0
- package/dist/vue/themeStore.d.ts +23 -0
- package/dist/vue/themeStore.d.ts.map +1 -0
- package/dist/vue/themeStore.js +115 -0
- package/dist/vue/themeStore.js.map +1 -0
- package/dist/vue/useThemeTokens.d.ts +6 -0
- package/dist/vue/useThemeTokens.d.ts.map +1 -0
- package/dist/vue/useThemeTokens.js +49 -0
- package/dist/vue/useThemeTokens.js.map +1 -0
- package/dist/vue/useWidget.d.ts +10 -0
- package/dist/vue/useWidget.d.ts.map +1 -0
- package/dist/vue/useWidget.js +81 -0
- package/dist/vue/useWidget.js.map +1 -0
- package/dist/vue.d.ts +5 -0
- package/dist/vue.d.ts.map +1 -0
- package/dist/vue.js +4 -0
- package/dist/widget.d.ts +129 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +655 -0
- package/dist/widget.js.map +1 -0
- package/package.json +125 -3
- package/src/assistant-ui/entry.ts +124 -0
- package/src/assistant-ui/extension.ts +19 -0
- package/src/assistant-ui/history.ts +63 -0
- package/src/assistant-ui/modelContext.ts +162 -0
- package/src/assistant-ui/snapshotSaver.ts +116 -0
- package/src/assistant-ui/tool-calls.ts +115 -0
- package/src/assistant-ui/toolkit.tsx +747 -0
- package/src/assistant-ui/useSharedContext.ts +40 -0
- package/src/assistant-ui.ts +14 -0
- package/src/bootstrap.ts +112 -0
- package/src/csp.ts +96 -0
- package/src/index.ts +44 -0
- package/src/json-schema.ts +353 -0
- package/src/preview.ts +120 -0
- package/src/prompts/guidance.ts +369 -0
- package/src/prompts.ts +8 -0
- package/src/protocol.ts +141 -0
- package/src/repair/repair.ts +202 -0
- package/src/repair.ts +11 -0
- package/src/rpc.ts +228 -0
- package/src/runtime/diagnostics.ts +165 -0
- package/src/runtime/generated.ts +2 -0
- package/src/runtime/index.ts +343 -0
- package/src/runtime/morph.ts +352 -0
- package/src/runtime/screenshot.ts +288 -0
- package/src/runtime/size.ts +17 -0
- package/src/runtime/storage.ts +49 -0
- package/src/spec/actions.ts +111 -0
- package/src/spec/assistant-ui/thread.ts +381 -0
- package/src/spec/assistant-ui/toolkit.tsx +295 -0
- package/src/spec/assistant-ui/web.tsx +63 -0
- package/src/spec/assistant-ui.ts +2 -0
- package/src/spec/calc.ts +598 -0
- package/src/spec/catalog.ts +196 -0
- package/src/spec/expressions.ts +301 -0
- package/src/spec/patch.ts +112 -0
- package/src/spec/pending.ts +36 -0
- package/src/spec/pointer.ts +170 -0
- package/src/spec/prompt.ts +243 -0
- package/src/spec/render-spec.ts +144 -0
- package/src/spec/resolve.ts +123 -0
- package/src/spec/resolved.ts +53 -0
- package/src/spec/state.ts +193 -0
- package/src/spec/stream.ts +230 -0
- package/src/spec/tools.ts +17 -0
- package/src/spec/tree.ts +37 -0
- package/src/spec/types.ts +51 -0
- package/src/spec/validate.ts +429 -0
- package/src/spec/vue/SpecRenderer.tsx +544 -0
- package/src/spec/vue/__tests__/harness.ts +50 -0
- package/src/spec/vue/useSpecStream.ts +162 -0
- package/src/spec/vue.ts +13 -0
- package/src/spec.ts +55 -0
- package/src/theme.ts +423 -0
- package/src/tools/define.ts +65 -0
- package/src/tools/edits.ts +73 -0
- package/src/tools/registry.ts +44 -0
- package/src/tools/tools.ts +361 -0
- package/src/tools.ts +32 -0
- package/src/version.ts +4 -0
- package/src/vue/Widget.tsx +74 -0
- package/src/vue/sync.ts +36 -0
- package/src/vue/themeStore.ts +157 -0
- package/src/vue/useThemeTokens.ts +71 -0
- package/src/vue/useWidget.ts +117 -0
- package/src/vue.ts +8 -0
- package/src/widget.ts +1080 -0
package/src/widget.ts
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SafeContentFrame,
|
|
3
|
+
type RenderedFrame,
|
|
4
|
+
} from "@ulearning/ai-safe-content-frame";
|
|
5
|
+
import { buildBootstrapHtml, themeContext } from "./bootstrap";
|
|
6
|
+
import type { CspOptions } from "./csp";
|
|
7
|
+
import {
|
|
8
|
+
GENFRAME_PROTOCOL_VERSION,
|
|
9
|
+
INIT_MESSAGE,
|
|
10
|
+
METHODS,
|
|
11
|
+
READY_MESSAGE,
|
|
12
|
+
type ClearStorageResult,
|
|
13
|
+
type ConsoleEntry,
|
|
14
|
+
type DisplayMode,
|
|
15
|
+
type EndResult,
|
|
16
|
+
type FrameInspection,
|
|
17
|
+
type HostContext,
|
|
18
|
+
type InitMessage,
|
|
19
|
+
type McpAppInitializeParams,
|
|
20
|
+
type ScreenshotOptions,
|
|
21
|
+
type WidgetError,
|
|
22
|
+
type WidgetSize,
|
|
23
|
+
} from "./protocol";
|
|
24
|
+
import {
|
|
25
|
+
createRpcPeer,
|
|
26
|
+
isJsonRpcMessage,
|
|
27
|
+
RPC_ERROR,
|
|
28
|
+
RpcError,
|
|
29
|
+
type RpcPeer,
|
|
30
|
+
} from "./rpc";
|
|
31
|
+
import { DEFAULT_LIGHT_TOKENS, type ThemeTokens } from "./theme";
|
|
32
|
+
import { VERSION } from "./version";
|
|
33
|
+
|
|
34
|
+
export type UiMessageParams = {
|
|
35
|
+
role?: string;
|
|
36
|
+
content?: { type: string; text?: string }[];
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ToolCallRequest = {
|
|
41
|
+
name: string;
|
|
42
|
+
arguments?: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type WidgetHandlers = {
|
|
46
|
+
/** A widget called `sendPrompt(text)`, possibly without a user gesture; let the user review it before it is sent as their message. */
|
|
47
|
+
onPrompt?: (text: string) => unknown;
|
|
48
|
+
/** Raw MCP Apps `ui/message`. Takes precedence over `onPrompt`. */
|
|
49
|
+
onMessage?: (params: UiMessageParams) => unknown;
|
|
50
|
+
/** Defaults to opening an `http(s)` URL in a new tab with `noopener`. */
|
|
51
|
+
onOpenLink?: (url: string) => unknown;
|
|
52
|
+
onCallTool?: (call: ToolCallRequest) => unknown;
|
|
53
|
+
onInitialized?: (params: McpAppInitializeParams) => void;
|
|
54
|
+
onRequestDisplayMode?: (request: { mode: DisplayMode }) => unknown;
|
|
55
|
+
onUpdateModelContext?: (params: unknown) => unknown;
|
|
56
|
+
/** A widget called `genframe.copyText(text)` or `navigator.clipboard.writeText(text)`. The request alone does not show the user asked for the copy, so confirm with them, such as through a Copy control the host renders, before writing the clipboard. Without it, copying fails in the widget. */
|
|
57
|
+
onCopyText?: (text: string) => unknown;
|
|
58
|
+
onWidgetState?: (state: unknown) => void;
|
|
59
|
+
onResize?: (size: WidgetSize) => void;
|
|
60
|
+
onError?: (error: WidgetError) => void;
|
|
61
|
+
onLog?: (entry: ConsoleEntry) => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type CreateWidgetOptions = WidgetHandlers & {
|
|
65
|
+
container: HTMLElement;
|
|
66
|
+
/** Scopes the frame origin, see `SafeContentFrame`. Defaults to `"generative-frame"`. */
|
|
67
|
+
product?: string;
|
|
68
|
+
/** A preconfigured `SafeContentFrame`, for example with `useShadowDom`. */
|
|
69
|
+
frame?: SafeContentFrame;
|
|
70
|
+
/**
|
|
71
|
+
* Loads the Safe Content Frame shim from a domain you host instead of
|
|
72
|
+
* the default service. Widgets are isolated from your app and from each
|
|
73
|
+
* other only when the domain is a public suffix on the Public Suffix List.
|
|
74
|
+
*/
|
|
75
|
+
unsafeShimDomain?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Renders in a `sandbox="allow-scripts"` frame with an opaque (`null`)
|
|
78
|
+
* origin instead of Safe Content Frame: no shim domain, and no storage or
|
|
79
|
+
* cookies. Cannot be combined with `id`, `frame`, or `unsafeShimDomain`.
|
|
80
|
+
*/
|
|
81
|
+
opaqueOrigin?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Gives the widget a stable origin, so its localStorage, IndexedDB, and
|
|
84
|
+
* cookies persist across reloads for this id on this host origin. Choose
|
|
85
|
+
* it on the host (never from model output); widgets with the same id share
|
|
86
|
+
* storage. Without an id, every frame gets a fresh origin.
|
|
87
|
+
*/
|
|
88
|
+
id?: string;
|
|
89
|
+
csp?: CspOptions | string;
|
|
90
|
+
tokens?: ThemeTokens;
|
|
91
|
+
/** Extra host context fields (locale, display mode, …) merged over the theme. */
|
|
92
|
+
context?: HostContext;
|
|
93
|
+
animate?: boolean;
|
|
94
|
+
css?: string;
|
|
95
|
+
/** The iframe never grows past this height; taller content scrolls inside it. */
|
|
96
|
+
maxHeight?: number;
|
|
97
|
+
minHeight?: number;
|
|
98
|
+
readyTimeoutMs?: number;
|
|
99
|
+
initialState?: unknown;
|
|
100
|
+
/** Complete code to render immediately, as `write(code)` then `end()`. */
|
|
101
|
+
code?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type WidgetInspection = FrameInspection & { code: string };
|
|
105
|
+
|
|
106
|
+
export type Screenshot = { dataUrl: string; width: number; height: number };
|
|
107
|
+
|
|
108
|
+
export type WidgetEventMap = {
|
|
109
|
+
ready: undefined;
|
|
110
|
+
resize: WidgetSize;
|
|
111
|
+
error: WidgetError;
|
|
112
|
+
log: ConsoleEntry;
|
|
113
|
+
end: EndResult;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type WidgetHandle = {
|
|
117
|
+
/** Resolves once the frame runtime is connected; rejects if it never connects. */
|
|
118
|
+
readonly ready: Promise<void>;
|
|
119
|
+
/** All code written so far. */
|
|
120
|
+
readonly code: string;
|
|
121
|
+
readonly ended: boolean;
|
|
122
|
+
readonly iframe: HTMLIFrameElement | undefined;
|
|
123
|
+
/** Appends streamed code. Rendering is coalesced to animation frames. */
|
|
124
|
+
write(chunk: string): void;
|
|
125
|
+
/** Marks the code complete and runs held scripts in order. */
|
|
126
|
+
end(): Promise<EndResult>;
|
|
127
|
+
/** Renders new complete code, morphing in place or remounting when scripts already ran. A newer call removes a remount still in progress, whose promise rejects. */
|
|
128
|
+
replace(code: string): Promise<EndResult>;
|
|
129
|
+
setTheme(tokens: ThemeTokens): void;
|
|
130
|
+
setContext(context: HostContext): void;
|
|
131
|
+
notifyToolInput(
|
|
132
|
+
args: Record<string, unknown>,
|
|
133
|
+
options?: { partial?: boolean },
|
|
134
|
+
): void;
|
|
135
|
+
notifyToolResult(result: unknown): void;
|
|
136
|
+
screenshot(options?: ScreenshotOptions): Promise<Screenshot>;
|
|
137
|
+
inspect(): Promise<WidgetInspection>;
|
|
138
|
+
on<K extends keyof WidgetEventMap>(
|
|
139
|
+
event: K,
|
|
140
|
+
listener: (payload: WidgetEventMap[K]) => void,
|
|
141
|
+
): () => void;
|
|
142
|
+
dispose(): void;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const DEFAULT_PRODUCT = "generative-frame";
|
|
146
|
+
|
|
147
|
+
/** The Safe Content Frame salt behind a widget id's stable origin. */
|
|
148
|
+
export const widgetStorageSalt = (id: string) => `genframe:v1:${id}`;
|
|
149
|
+
|
|
150
|
+
type FrameRenderer = {
|
|
151
|
+
renderHtml(html: string, container: HTMLElement): Promise<RenderedFrame>;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* An opaque origin cannot be named as a `postMessage` target, so messages to
|
|
156
|
+
* the frame use `"*"`. A sandboxed frame keeps its opaque origin and window
|
|
157
|
+
* when it navigates, so after a navigated document loads, `origin` stops
|
|
158
|
+
* matching and nothing is posted to the frame. Loads are counted from the
|
|
159
|
+
* bootstrap's first ready message, so an extra initial `about:blank` load in
|
|
160
|
+
* some browser cannot cut off the bootstrap itself. A navigated document can
|
|
161
|
+
* still post before its own `load`; that grants it nothing the widget code
|
|
162
|
+
* could not already do.
|
|
163
|
+
*/
|
|
164
|
+
const opaqueFrame: FrameRenderer = {
|
|
165
|
+
async renderHtml(html, container) {
|
|
166
|
+
const iframe = container.ownerDocument.createElement("iframe");
|
|
167
|
+
iframe.setAttribute("sandbox", "allow-scripts");
|
|
168
|
+
iframe.style.cssText = "border:none;width:100%;height:100%";
|
|
169
|
+
iframe.srcdoc = html;
|
|
170
|
+
let ready = false;
|
|
171
|
+
let loads = 0;
|
|
172
|
+
const onMessage = (event: MessageEvent) => {
|
|
173
|
+
if (
|
|
174
|
+
event.source === iframe.contentWindow &&
|
|
175
|
+
(event.data as { type?: unknown } | null)?.type === READY_MESSAGE
|
|
176
|
+
) {
|
|
177
|
+
ready = true;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
window.addEventListener("message", onMessage);
|
|
181
|
+
iframe.addEventListener("load", () => {
|
|
182
|
+
if (ready) loads++;
|
|
183
|
+
});
|
|
184
|
+
container.appendChild(iframe);
|
|
185
|
+
return {
|
|
186
|
+
iframe,
|
|
187
|
+
get origin() {
|
|
188
|
+
return loads > 1 ? "" : "null";
|
|
189
|
+
},
|
|
190
|
+
sendMessage: (data, transfer) => {
|
|
191
|
+
if (loads > 1) return;
|
|
192
|
+
iframe.contentWindow?.postMessage(data, "*", transfer);
|
|
193
|
+
},
|
|
194
|
+
fullyLoadedPromiseWithTimeout: async () => {},
|
|
195
|
+
dispose: () => {
|
|
196
|
+
window.removeEventListener("message", onMessage);
|
|
197
|
+
iframe.remove();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const resolveFrame = (
|
|
204
|
+
options: Pick<
|
|
205
|
+
CreateWidgetOptions,
|
|
206
|
+
"frame" | "id" | "product" | "unsafeShimDomain" | "opaqueOrigin"
|
|
207
|
+
>,
|
|
208
|
+
): FrameRenderer => {
|
|
209
|
+
if (options.opaqueOrigin) {
|
|
210
|
+
if (
|
|
211
|
+
options.frame ||
|
|
212
|
+
options.id !== undefined ||
|
|
213
|
+
options.unsafeShimDomain !== undefined
|
|
214
|
+
) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
"`opaqueOrigin` renders without Safe Content Frame, so it cannot be combined with `frame`, `id`, or `unsafeShimDomain`.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return opaqueFrame;
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
options.frame &&
|
|
223
|
+
(options.id !== undefined || options.unsafeShimDomain !== undefined)
|
|
224
|
+
) {
|
|
225
|
+
throw new TypeError(
|
|
226
|
+
"`frame` cannot be combined with `id` or `unsafeShimDomain`; configure the `SafeContentFrame` instead, with `salt: widgetStorageSalt(id)` and `unsafeShimDomain` as needed.",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return (
|
|
230
|
+
options.frame ??
|
|
231
|
+
new SafeContentFrame(options.product ?? DEFAULT_PRODUCT, {
|
|
232
|
+
...(options.id !== undefined
|
|
233
|
+
? { salt: widgetStorageSalt(options.id) }
|
|
234
|
+
: {}),
|
|
235
|
+
...(options.unsafeShimDomain !== undefined
|
|
236
|
+
? { unsafeShimDomain: options.unsafeShimDomain }
|
|
237
|
+
: {}),
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** Lets `clearWidgetStorage` send a request no public method exposes. */
|
|
243
|
+
const internalRequests = new WeakMap<
|
|
244
|
+
WidgetHandle,
|
|
245
|
+
<T>(method: string, params?: unknown) => Promise<T>
|
|
246
|
+
>();
|
|
247
|
+
const DEFAULT_READY_TIMEOUT_MS = 15_000;
|
|
248
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
249
|
+
const SCRIPT_TAG = /<script[\s>]/i;
|
|
250
|
+
const REVEAL_MS = 120;
|
|
251
|
+
|
|
252
|
+
const prefersReducedMotion = () =>
|
|
253
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
254
|
+
|
|
255
|
+
const promptText = (params: UiMessageParams) => {
|
|
256
|
+
const content = params.content ?? [];
|
|
257
|
+
return Object.keys(content)
|
|
258
|
+
.filter((key) => {
|
|
259
|
+
const index = Number(key);
|
|
260
|
+
return (
|
|
261
|
+
Number.isInteger(index) &&
|
|
262
|
+
index >= 0 &&
|
|
263
|
+
index < content.length &&
|
|
264
|
+
String(index) === key
|
|
265
|
+
);
|
|
266
|
+
})
|
|
267
|
+
.map((key) => content[Number(key)])
|
|
268
|
+
.filter(
|
|
269
|
+
(part): part is { type: string; text: string } =>
|
|
270
|
+
typeof part === "object" &&
|
|
271
|
+
part !== null &&
|
|
272
|
+
part.type === "text" &&
|
|
273
|
+
typeof part.text === "string",
|
|
274
|
+
)
|
|
275
|
+
.map((part) => part.text)
|
|
276
|
+
.join("\n");
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const VALID_DISPLAY_MODES = [
|
|
280
|
+
"inline",
|
|
281
|
+
"fullscreen",
|
|
282
|
+
"pip",
|
|
283
|
+
] as const satisfies readonly DisplayMode[];
|
|
284
|
+
|
|
285
|
+
const isDisplayMode = (value: unknown): value is DisplayMode =>
|
|
286
|
+
VALID_DISPLAY_MODES.some((mode) => mode === value);
|
|
287
|
+
|
|
288
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
289
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
290
|
+
|
|
291
|
+
/** How many characters a widget's state may take as JSON. A shared object counts once for each place it appears, as it does once serialized, so checking and saving a state stay bounded however the widget shares objects. */
|
|
292
|
+
const MAX_STATE_LENGTH = 1_000_000;
|
|
293
|
+
|
|
294
|
+
/** The length of `text` once JSON quotes and escapes it. */
|
|
295
|
+
const jsonStringLength = (text: string) => {
|
|
296
|
+
let length = text.length + 2;
|
|
297
|
+
for (let index = 0; index < text.length; index++) {
|
|
298
|
+
const code = text.charCodeAt(index);
|
|
299
|
+
if (code === 0x22 || code === 0x5c) length += 1;
|
|
300
|
+
else if (code < 0x20)
|
|
301
|
+
length +=
|
|
302
|
+
code === 0x08 ||
|
|
303
|
+
code === 0x09 ||
|
|
304
|
+
code === 0x0a ||
|
|
305
|
+
code === 0x0c ||
|
|
306
|
+
code === 0x0d
|
|
307
|
+
? 1
|
|
308
|
+
: 5;
|
|
309
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
310
|
+
const next = text.charCodeAt(index + 1);
|
|
311
|
+
if (next >= 0xdc00 && next <= 0xdfff) index++;
|
|
312
|
+
else length += 5;
|
|
313
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) length += 5;
|
|
314
|
+
}
|
|
315
|
+
return length;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const isPlainJson = (state: unknown): boolean => {
|
|
319
|
+
let budget = MAX_STATE_LENGTH;
|
|
320
|
+
const spend = (length: number) => (budget -= length) >= 0;
|
|
321
|
+
const spendText = (text: string) =>
|
|
322
|
+
text.length + 2 <= budget && spend(jsonStringLength(text));
|
|
323
|
+
const ancestors = new Set<object>();
|
|
324
|
+
const check = (value: unknown): boolean => {
|
|
325
|
+
if (value === null || typeof value === "boolean") return spend(5);
|
|
326
|
+
if (typeof value === "string") return spendText(value);
|
|
327
|
+
if (typeof value === "number")
|
|
328
|
+
return Number.isFinite(value) && spend(String(value).length);
|
|
329
|
+
if (typeof value !== "object" || ancestors.has(value) || !spend(2))
|
|
330
|
+
return false;
|
|
331
|
+
|
|
332
|
+
const array = Array.isArray(value);
|
|
333
|
+
const prototype = Object.getPrototypeOf(value);
|
|
334
|
+
if (
|
|
335
|
+
array
|
|
336
|
+
? prototype !== Array.prototype
|
|
337
|
+
: prototype !== Object.prototype && prototype !== null
|
|
338
|
+
)
|
|
339
|
+
return false;
|
|
340
|
+
|
|
341
|
+
ancestors.add(value);
|
|
342
|
+
const keys = Reflect.ownKeys(value);
|
|
343
|
+
if (array && keys.length !== value.length + 1) return false;
|
|
344
|
+
if (array) {
|
|
345
|
+
for (let index = 0; index < value.length; index++) {
|
|
346
|
+
const property = Object.getOwnPropertyDescriptor(value, String(index));
|
|
347
|
+
if (
|
|
348
|
+
!property ||
|
|
349
|
+
!("value" in property) ||
|
|
350
|
+
!spend(1) ||
|
|
351
|
+
!check(property.value)
|
|
352
|
+
)
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
for (const key of keys) {
|
|
357
|
+
if (typeof key !== "string") return false;
|
|
358
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
359
|
+
if (
|
|
360
|
+
!property ||
|
|
361
|
+
!("value" in property) ||
|
|
362
|
+
!property.enumerable ||
|
|
363
|
+
!spendText(key) ||
|
|
364
|
+
!spend(2) ||
|
|
365
|
+
!check(property.value)
|
|
366
|
+
)
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
ancestors.delete(value);
|
|
371
|
+
return true;
|
|
372
|
+
};
|
|
373
|
+
return check(state);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const isAppInfo = (
|
|
377
|
+
value: unknown,
|
|
378
|
+
): value is NonNullable<McpAppInitializeParams["appInfo"]> =>
|
|
379
|
+
isRecord(value) &&
|
|
380
|
+
typeof value["name"] === "string" &&
|
|
381
|
+
typeof value["version"] === "string";
|
|
382
|
+
|
|
383
|
+
const getAppCapabilities = (
|
|
384
|
+
value: unknown,
|
|
385
|
+
): McpAppInitializeParams["appCapabilities"] => {
|
|
386
|
+
if (!isRecord(value)) return undefined;
|
|
387
|
+
const { availableDisplayModes, ...rest } = value;
|
|
388
|
+
return {
|
|
389
|
+
...rest,
|
|
390
|
+
...(Array.isArray(availableDisplayModes)
|
|
391
|
+
? {
|
|
392
|
+
availableDisplayModes: Object.values(availableDisplayModes).filter(
|
|
393
|
+
isDisplayMode,
|
|
394
|
+
),
|
|
395
|
+
}
|
|
396
|
+
: {}),
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const getInitializeParams = (params: unknown): McpAppInitializeParams => {
|
|
401
|
+
if (!isRecord(params)) return {};
|
|
402
|
+
const appCapabilities = getAppCapabilities(params["appCapabilities"]);
|
|
403
|
+
return {
|
|
404
|
+
...(isAppInfo(params["appInfo"]) ? { appInfo: params["appInfo"] } : {}),
|
|
405
|
+
...(appCapabilities ? { appCapabilities } : {}),
|
|
406
|
+
...(typeof params["protocolVersion"] === "string"
|
|
407
|
+
? { protocolVersion: params["protocolVersion"] }
|
|
408
|
+
: {}),
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const defaultOpenLink = (url: string) => {
|
|
413
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
type Session = {
|
|
417
|
+
slot: HTMLElement;
|
|
418
|
+
ready: Promise<void>;
|
|
419
|
+
peer: () => RpcPeer;
|
|
420
|
+
iframe: () => HTMLIFrameElement | undefined;
|
|
421
|
+
/** Shows the iframe once the runtime has themed and laid out its document. */
|
|
422
|
+
reveal(): void;
|
|
423
|
+
/** Delivers the frame's MCP Apps initialization if it is the shown frame. */
|
|
424
|
+
deliverInitialized(): void;
|
|
425
|
+
dispose(): void;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Mounts a widget frame in `container`. The frame loads a bootstrap document
|
|
430
|
+
* once through Safe Content Frame; code then streams in over a private
|
|
431
|
+
* `MessagePort`, so every chunk renders without reloading the frame.
|
|
432
|
+
*/
|
|
433
|
+
export function createWidget(options: CreateWidgetOptions): WidgetHandle {
|
|
434
|
+
const { container } = options;
|
|
435
|
+
const frame = resolveFrame(options);
|
|
436
|
+
const minHeight = options.minHeight ?? 0;
|
|
437
|
+
const maxHeight = options.maxHeight ?? Number.POSITIVE_INFINITY;
|
|
438
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
439
|
+
|
|
440
|
+
let tokens = options.tokens ?? DEFAULT_LIGHT_TOKENS;
|
|
441
|
+
let context: HostContext = {
|
|
442
|
+
...themeContext(tokens),
|
|
443
|
+
platform: "web",
|
|
444
|
+
...(Number.isFinite(maxHeight)
|
|
445
|
+
? { containerDimensions: { maxHeight } }
|
|
446
|
+
: {}),
|
|
447
|
+
...options.context,
|
|
448
|
+
};
|
|
449
|
+
let code = "";
|
|
450
|
+
let widgetState: unknown = options.initialState ?? null;
|
|
451
|
+
let ended = false;
|
|
452
|
+
let disposed = false;
|
|
453
|
+
let lastToolInput:
|
|
454
|
+
| { args: Record<string, unknown>; partial: boolean }
|
|
455
|
+
| undefined;
|
|
456
|
+
let lastToolResult: { result: unknown } | undefined;
|
|
457
|
+
const listeners = new Map<
|
|
458
|
+
keyof WidgetEventMap,
|
|
459
|
+
Set<(payload: never) => void>
|
|
460
|
+
>();
|
|
461
|
+
|
|
462
|
+
const emit = <K extends keyof WidgetEventMap>(
|
|
463
|
+
event: K,
|
|
464
|
+
payload: WidgetEventMap[K],
|
|
465
|
+
) => {
|
|
466
|
+
for (const listener of listeners.get(event) ?? []) {
|
|
467
|
+
try {
|
|
468
|
+
(listener as (payload: WidgetEventMap[K]) => void)(payload);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error(error);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const reportError = (error: WidgetError) => {
|
|
476
|
+
options.onError?.(error);
|
|
477
|
+
emit("error", error);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const onRequest = async (method: string, params: unknown) => {
|
|
481
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
482
|
+
switch (method) {
|
|
483
|
+
case METHODS.initialize:
|
|
484
|
+
return {
|
|
485
|
+
protocolVersion:
|
|
486
|
+
typeof p["protocolVersion"] === "string"
|
|
487
|
+
? p["protocolVersion"]
|
|
488
|
+
: GENFRAME_PROTOCOL_VERSION,
|
|
489
|
+
hostInfo: { name: "generative-frame", version: VERSION },
|
|
490
|
+
hostCapabilities: {
|
|
491
|
+
openLinks: {},
|
|
492
|
+
...(options.onCallTool ? { serverTools: {} } : {}),
|
|
493
|
+
...(options.onMessage || options.onPrompt
|
|
494
|
+
? { message: { text: {} } }
|
|
495
|
+
: {}),
|
|
496
|
+
...(options.onUpdateModelContext
|
|
497
|
+
? { updateModelContext: { text: {}, structuredContent: {} } }
|
|
498
|
+
: {}),
|
|
499
|
+
logging: {},
|
|
500
|
+
},
|
|
501
|
+
hostContext: context,
|
|
502
|
+
};
|
|
503
|
+
case METHODS.message: {
|
|
504
|
+
if (options.onMessage) return options.onMessage(p as UiMessageParams);
|
|
505
|
+
if (options.onPrompt) {
|
|
506
|
+
await options.onPrompt(promptText(p as UiMessageParams));
|
|
507
|
+
return {};
|
|
508
|
+
}
|
|
509
|
+
throw new RpcError(
|
|
510
|
+
RPC_ERROR.methodNotFound,
|
|
511
|
+
"This host does not accept messages",
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
case METHODS.openLink: {
|
|
515
|
+
const url = p["url"];
|
|
516
|
+
let protocol = "";
|
|
517
|
+
try {
|
|
518
|
+
protocol = typeof url === "string" ? new URL(url).protocol : "";
|
|
519
|
+
} catch {}
|
|
520
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
521
|
+
throw new RpcError(
|
|
522
|
+
RPC_ERROR.invalidParams,
|
|
523
|
+
"ui/open-link requires an http(s) URL",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
await (options.onOpenLink ?? defaultOpenLink)(url as string);
|
|
527
|
+
return {};
|
|
528
|
+
}
|
|
529
|
+
case METHODS.callTool: {
|
|
530
|
+
if (!options.onCallTool) {
|
|
531
|
+
throw new RpcError(
|
|
532
|
+
RPC_ERROR.methodNotFound,
|
|
533
|
+
"This host does not expose tools",
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (typeof p["name"] !== "string") {
|
|
537
|
+
throw new RpcError(
|
|
538
|
+
RPC_ERROR.invalidParams,
|
|
539
|
+
"tools/call requires a string 'name'",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const args = p["arguments"];
|
|
543
|
+
if (
|
|
544
|
+
args !== undefined &&
|
|
545
|
+
(typeof args !== "object" || args === null || Array.isArray(args))
|
|
546
|
+
) {
|
|
547
|
+
throw new RpcError(
|
|
548
|
+
RPC_ERROR.invalidParams,
|
|
549
|
+
"tools/call 'arguments' must be an object",
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return options.onCallTool({
|
|
553
|
+
name: p["name"],
|
|
554
|
+
...(args !== undefined
|
|
555
|
+
? { arguments: args as Record<string, unknown> }
|
|
556
|
+
: {}),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
case METHODS.requestDisplayMode: {
|
|
560
|
+
const mode = p["mode"];
|
|
561
|
+
if (!isDisplayMode(mode)) {
|
|
562
|
+
throw new RpcError(
|
|
563
|
+
RPC_ERROR.invalidParams,
|
|
564
|
+
"ui/request-display-mode requires a valid 'mode'",
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (!options.onRequestDisplayMode)
|
|
568
|
+
return { mode: context.displayMode ?? "inline" };
|
|
569
|
+
return (await options.onRequestDisplayMode({ mode })) ?? { mode };
|
|
570
|
+
}
|
|
571
|
+
case METHODS.copyText: {
|
|
572
|
+
if (!options.onCopyText) {
|
|
573
|
+
throw new RpcError(
|
|
574
|
+
RPC_ERROR.methodNotFound,
|
|
575
|
+
"This host does not copy text",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (typeof p["text"] !== "string") {
|
|
579
|
+
throw new RpcError(
|
|
580
|
+
RPC_ERROR.invalidParams,
|
|
581
|
+
"genframe/copy-text requires a string 'text'",
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
await options.onCopyText(p["text"]);
|
|
585
|
+
return {};
|
|
586
|
+
}
|
|
587
|
+
case METHODS.updateModelContext:
|
|
588
|
+
if (!options.onUpdateModelContext) {
|
|
589
|
+
throw new RpcError(
|
|
590
|
+
RPC_ERROR.methodNotFound,
|
|
591
|
+
"This host does not accept model context",
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
return options.onUpdateModelContext(params);
|
|
595
|
+
default:
|
|
596
|
+
throw new RpcError(
|
|
597
|
+
RPC_ERROR.methodNotFound,
|
|
598
|
+
`Unknown method: ${method}`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
let activeSession: Session | undefined;
|
|
604
|
+
let pendingSession: Session | undefined;
|
|
605
|
+
let pendingWidgetState: unknown;
|
|
606
|
+
let hasPendingWidgetState = false;
|
|
607
|
+
let heldWidgetState: unknown;
|
|
608
|
+
let hasHeldWidgetState = false;
|
|
609
|
+
|
|
610
|
+
const applySize = (session: Session, size: WidgetSize) => {
|
|
611
|
+
const iframe = session.iframe();
|
|
612
|
+
if (iframe) {
|
|
613
|
+
const height = Math.min(Math.max(size.height, minHeight), maxHeight);
|
|
614
|
+
iframe.style.height = `${height}px`;
|
|
615
|
+
session.reveal();
|
|
616
|
+
}
|
|
617
|
+
if (session === activeSession) {
|
|
618
|
+
options.onResize?.(size);
|
|
619
|
+
emit("resize", size);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const createSession = (hidden: boolean): Session => {
|
|
624
|
+
const slot = container.ownerDocument.createElement("div");
|
|
625
|
+
slot.dataset["generativeFrame"] = "";
|
|
626
|
+
// Safe Content Frame appends the iframe at once, and its shim paints an
|
|
627
|
+
// opaque canvas before the runtime themes the document, so the slot stays
|
|
628
|
+
// transparent (but laid out) until the runtime's first size report.
|
|
629
|
+
slot.style.cssText = hidden
|
|
630
|
+
? "display:block;height:0;overflow:hidden;visibility:hidden;pointer-events:none;opacity:0"
|
|
631
|
+
: "display:block;opacity:0";
|
|
632
|
+
container.appendChild(slot);
|
|
633
|
+
|
|
634
|
+
let rendered: RenderedFrame | undefined;
|
|
635
|
+
let peer: RpcPeer | undefined;
|
|
636
|
+
let initSent = false;
|
|
637
|
+
let initializeParams: McpAppInitializeParams = {};
|
|
638
|
+
let pendingInitialized: McpAppInitializeParams | undefined;
|
|
639
|
+
let sessionDisposed = false;
|
|
640
|
+
let revealed = false;
|
|
641
|
+
let resolveReady!: () => void;
|
|
642
|
+
let rejectReady!: (error: unknown) => void;
|
|
643
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
644
|
+
resolveReady = resolve;
|
|
645
|
+
rejectReady = reject;
|
|
646
|
+
});
|
|
647
|
+
void ready.catch(() => {});
|
|
648
|
+
|
|
649
|
+
const session: Session = {
|
|
650
|
+
slot,
|
|
651
|
+
ready,
|
|
652
|
+
peer: () => {
|
|
653
|
+
if (!peer) throw new Error("Widget frame is not connected");
|
|
654
|
+
return peer;
|
|
655
|
+
},
|
|
656
|
+
iframe: () => rendered?.iframe,
|
|
657
|
+
reveal() {
|
|
658
|
+
if (revealed) return;
|
|
659
|
+
revealed = true;
|
|
660
|
+
if (!prefersReducedMotion()) {
|
|
661
|
+
slot.style.transition = `opacity ${REVEAL_MS}ms ease-out`;
|
|
662
|
+
}
|
|
663
|
+
slot.style.opacity = "1";
|
|
664
|
+
},
|
|
665
|
+
deliverInitialized() {
|
|
666
|
+
if (session !== activeSession || !pendingInitialized) return;
|
|
667
|
+
const params = pendingInitialized;
|
|
668
|
+
pendingInitialized = undefined;
|
|
669
|
+
try {
|
|
670
|
+
options.onInitialized?.(params);
|
|
671
|
+
} catch {
|
|
672
|
+
// A host handler error must not fail a remount that already swapped in.
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
dispose() {
|
|
676
|
+
if (sessionDisposed) return;
|
|
677
|
+
sessionDisposed = true;
|
|
678
|
+
clearTimeout(timer);
|
|
679
|
+
window.removeEventListener("message", onWindowMessage);
|
|
680
|
+
peer?.dispose();
|
|
681
|
+
rendered?.dispose();
|
|
682
|
+
slot.remove();
|
|
683
|
+
rejectReady(new Error("Widget disposed"));
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const fail = (message: string) => {
|
|
688
|
+
if (sessionDisposed) return;
|
|
689
|
+
rejectReady(new Error(message));
|
|
690
|
+
reportError({ kind: "error", message });
|
|
691
|
+
};
|
|
692
|
+
const timer = setTimeout(
|
|
693
|
+
() => fail(`Widget frame did not connect within ${readyTimeoutMs}ms`),
|
|
694
|
+
readyTimeoutMs,
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
const sendInit = () => {
|
|
698
|
+
if (initSent || !rendered) return;
|
|
699
|
+
initSent = true;
|
|
700
|
+
const channel = new MessageChannel();
|
|
701
|
+
peer = createRpcPeer(channel.port1, {
|
|
702
|
+
onRequest: (method, params) => {
|
|
703
|
+
if (method === METHODS.initialize) {
|
|
704
|
+
initializeParams = getInitializeParams(params);
|
|
705
|
+
}
|
|
706
|
+
return onRequest(method, params);
|
|
707
|
+
},
|
|
708
|
+
onNotification: (method, params) => {
|
|
709
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
710
|
+
switch (method) {
|
|
711
|
+
case METHODS.ready:
|
|
712
|
+
clearTimeout(timer);
|
|
713
|
+
resolveReady();
|
|
714
|
+
return;
|
|
715
|
+
case METHODS.initialized:
|
|
716
|
+
pendingInitialized = initializeParams;
|
|
717
|
+
session.deliverInitialized();
|
|
718
|
+
return;
|
|
719
|
+
case METHODS.sizeChanged:
|
|
720
|
+
if (
|
|
721
|
+
typeof p["height"] === "number" &&
|
|
722
|
+
typeof p["width"] === "number"
|
|
723
|
+
) {
|
|
724
|
+
applySize(session, { width: p["width"], height: p["height"] });
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
case METHODS.error:
|
|
728
|
+
if (session === activeSession) reportError(p as WidgetError);
|
|
729
|
+
return;
|
|
730
|
+
case METHODS.log:
|
|
731
|
+
if (session === activeSession) {
|
|
732
|
+
options.onLog?.(p as ConsoleEntry);
|
|
733
|
+
emit("log", p as ConsoleEntry);
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
case METHODS.widgetState:
|
|
737
|
+
if (session !== pendingSession && session !== activeSession)
|
|
738
|
+
return;
|
|
739
|
+
try {
|
|
740
|
+
if (!isPlainJson(p["state"])) return;
|
|
741
|
+
} catch {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (session === pendingSession) {
|
|
745
|
+
pendingWidgetState = p["state"];
|
|
746
|
+
hasPendingWidgetState = true;
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (pendingSession) {
|
|
750
|
+
heldWidgetState = p["state"];
|
|
751
|
+
hasHeldWidgetState = true;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
widgetState = p["state"];
|
|
755
|
+
options.onWidgetState?.(widgetState);
|
|
756
|
+
return;
|
|
757
|
+
default:
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
const init: InitMessage = {
|
|
763
|
+
type: INIT_MESSAGE,
|
|
764
|
+
context,
|
|
765
|
+
state: widgetState,
|
|
766
|
+
};
|
|
767
|
+
rendered.sendMessage(init, [channel.port2]);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// Widgets built on the MCP Apps SDK talk to `window.parent` rather than the port.
|
|
771
|
+
function onWindowMessage(event: MessageEvent) {
|
|
772
|
+
if (!rendered) return;
|
|
773
|
+
if (
|
|
774
|
+
event.source !== rendered.iframe.contentWindow ||
|
|
775
|
+
event.origin !== rendered.origin
|
|
776
|
+
) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const data: unknown = event.data;
|
|
780
|
+
if ((data as { type?: unknown } | null)?.type === READY_MESSAGE) {
|
|
781
|
+
sendInit();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (peer && isJsonRpcMessage(data)) {
|
|
785
|
+
const target = rendered;
|
|
786
|
+
peer.receive(data, (message) => target.sendMessage(message));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
window.addEventListener("message", onWindowMessage);
|
|
790
|
+
|
|
791
|
+
const html = buildBootstrapHtml({
|
|
792
|
+
hostOrigin: window.location.origin,
|
|
793
|
+
tokens,
|
|
794
|
+
...(options.csp !== undefined ? { csp: options.csp } : {}),
|
|
795
|
+
...(options.animate !== undefined ? { animate: options.animate } : {}),
|
|
796
|
+
...(options.css !== undefined ? { css: options.css } : {}),
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
frame.renderHtml(html, slot).then(
|
|
800
|
+
(result) => {
|
|
801
|
+
if (sessionDisposed) {
|
|
802
|
+
result.dispose();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
rendered = result;
|
|
806
|
+
const style = result.iframe.style;
|
|
807
|
+
style.display = "block";
|
|
808
|
+
style.height = `${minHeight}px`;
|
|
809
|
+
style.colorScheme = tokens.colorScheme;
|
|
810
|
+
result.iframe.setAttribute("title", "Generated widget");
|
|
811
|
+
result
|
|
812
|
+
.fullyLoadedPromiseWithTimeout(readyTimeoutMs)
|
|
813
|
+
.catch((error: unknown) =>
|
|
814
|
+
fail(error instanceof Error ? error.message : String(error)),
|
|
815
|
+
);
|
|
816
|
+
},
|
|
817
|
+
(error: unknown) =>
|
|
818
|
+
fail(error instanceof Error ? error.message : String(error)),
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
return session;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
activeSession = createSession(false);
|
|
825
|
+
|
|
826
|
+
type Op = { kind: "write"; chunk: string } | { kind: "run"; run: () => void };
|
|
827
|
+
let queue: Op[] | undefined = [];
|
|
828
|
+
activeSession.ready.then(
|
|
829
|
+
() => {
|
|
830
|
+
const ops = queue ?? [];
|
|
831
|
+
queue = undefined;
|
|
832
|
+
emit("ready", undefined);
|
|
833
|
+
for (const op of ops) {
|
|
834
|
+
if (op.kind === "write")
|
|
835
|
+
activeSession?.peer().notify(METHODS.write, { chunk: op.chunk });
|
|
836
|
+
else op.run();
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
() => {},
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
const enqueue = (run: () => void) => {
|
|
843
|
+
if (queue) queue.push({ kind: "run", run });
|
|
844
|
+
else run();
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const request = <T>(method: string, params?: unknown) =>
|
|
848
|
+
new Promise<T>((resolve, reject) => {
|
|
849
|
+
const session = activeSession;
|
|
850
|
+
if (!session || disposed) {
|
|
851
|
+
reject(new Error("Widget disposed"));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
session.ready.then(
|
|
855
|
+
() =>
|
|
856
|
+
enqueue(() => {
|
|
857
|
+
session
|
|
858
|
+
.peer()
|
|
859
|
+
.request(method, params, { timeoutMs: REQUEST_TIMEOUT_MS })
|
|
860
|
+
.then((value) => resolve(value as T), reject);
|
|
861
|
+
}),
|
|
862
|
+
reject,
|
|
863
|
+
);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const notify = (method: string, params: unknown) => {
|
|
867
|
+
const session = activeSession;
|
|
868
|
+
if (!session || disposed) return;
|
|
869
|
+
enqueue(() => {
|
|
870
|
+
try {
|
|
871
|
+
session.peer().notify(method, params);
|
|
872
|
+
} catch {}
|
|
873
|
+
});
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const replayState = (session: Session) => {
|
|
877
|
+
const peer = session.peer();
|
|
878
|
+
if (lastToolInput) {
|
|
879
|
+
peer.notify(
|
|
880
|
+
lastToolInput.partial ? METHODS.toolInputPartial : METHODS.toolInput,
|
|
881
|
+
{ arguments: lastToolInput.args },
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
if (lastToolResult) peer.notify(METHODS.toolResult, lastToolResult.result);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const remount = async (next: string): Promise<EndResult> => {
|
|
888
|
+
const previous = activeSession;
|
|
889
|
+
// The newest replacement wins, so one still booting retires now rather than reaching the host until it settles.
|
|
890
|
+
pendingSession?.dispose();
|
|
891
|
+
const session = createSession(true);
|
|
892
|
+
pendingSession = session;
|
|
893
|
+
hasPendingWidgetState = false;
|
|
894
|
+
let result: EndResult;
|
|
895
|
+
try {
|
|
896
|
+
await session.ready;
|
|
897
|
+
if (disposed || pendingSession !== session)
|
|
898
|
+
throw new Error("Widget replacement superseded");
|
|
899
|
+
replayState(session);
|
|
900
|
+
result = (await session
|
|
901
|
+
.peer()
|
|
902
|
+
.request(
|
|
903
|
+
METHODS.replace,
|
|
904
|
+
{ code: next },
|
|
905
|
+
{ timeoutMs: REQUEST_TIMEOUT_MS },
|
|
906
|
+
)) as EndResult;
|
|
907
|
+
if (disposed || pendingSession !== session)
|
|
908
|
+
throw new Error("Widget replacement superseded");
|
|
909
|
+
} catch (error) {
|
|
910
|
+
const superseded = !disposed && pendingSession !== session;
|
|
911
|
+
if (pendingSession === session) {
|
|
912
|
+
pendingSession = undefined;
|
|
913
|
+
hasPendingWidgetState = false;
|
|
914
|
+
if (hasHeldWidgetState && !disposed) {
|
|
915
|
+
hasHeldWidgetState = false;
|
|
916
|
+
widgetState = heldWidgetState;
|
|
917
|
+
try {
|
|
918
|
+
options.onWidgetState?.(widgetState);
|
|
919
|
+
} catch {}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
session.dispose();
|
|
923
|
+
if (disposed) throw new Error("Widget disposed");
|
|
924
|
+
throw superseded ? new Error("Widget replacement superseded") : error;
|
|
925
|
+
}
|
|
926
|
+
pendingSession = undefined;
|
|
927
|
+
hasHeldWidgetState = false;
|
|
928
|
+
activeSession = session;
|
|
929
|
+
if (hasPendingWidgetState) widgetState = pendingWidgetState;
|
|
930
|
+
// The new frame has rendered completely, so it swaps in without a fade.
|
|
931
|
+
session.slot.style.cssText = "display:block";
|
|
932
|
+
previous?.dispose();
|
|
933
|
+
if (hasPendingWidgetState) {
|
|
934
|
+
hasPendingWidgetState = false;
|
|
935
|
+
try {
|
|
936
|
+
options.onWidgetState?.(widgetState);
|
|
937
|
+
} catch {}
|
|
938
|
+
}
|
|
939
|
+
session.deliverInitialized();
|
|
940
|
+
applySize(session, result.size);
|
|
941
|
+
return result;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const handle: WidgetHandle = {
|
|
945
|
+
ready: activeSession.ready,
|
|
946
|
+
get code() {
|
|
947
|
+
return code;
|
|
948
|
+
},
|
|
949
|
+
get ended() {
|
|
950
|
+
return ended;
|
|
951
|
+
},
|
|
952
|
+
get iframe() {
|
|
953
|
+
return activeSession?.iframe();
|
|
954
|
+
},
|
|
955
|
+
write(chunk) {
|
|
956
|
+
if (disposed || !chunk) return;
|
|
957
|
+
if (ended) throw new Error("Cannot write after end(); use replace()");
|
|
958
|
+
code += chunk;
|
|
959
|
+
const last = queue?.[queue.length - 1];
|
|
960
|
+
if (queue) {
|
|
961
|
+
if (last?.kind === "write") last.chunk += chunk;
|
|
962
|
+
else queue.push({ kind: "write", chunk });
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
activeSession?.peer().notify(METHODS.write, { chunk });
|
|
966
|
+
},
|
|
967
|
+
async end() {
|
|
968
|
+
ended = true;
|
|
969
|
+
const result = await request<EndResult>(METHODS.end);
|
|
970
|
+
emit("end", result);
|
|
971
|
+
return result;
|
|
972
|
+
},
|
|
973
|
+
async replace(next) {
|
|
974
|
+
// While a remount is pending, the frame on screen is still the one whose scripts ran.
|
|
975
|
+
const scriptsRan =
|
|
976
|
+
pendingSession !== undefined || (ended && SCRIPT_TAG.test(code));
|
|
977
|
+
code = next;
|
|
978
|
+
ended = true;
|
|
979
|
+
const result = scriptsRan
|
|
980
|
+
? await remount(next)
|
|
981
|
+
: await request<EndResult>(METHODS.replace, { code: next });
|
|
982
|
+
emit("end", result);
|
|
983
|
+
return result;
|
|
984
|
+
},
|
|
985
|
+
setTheme(next) {
|
|
986
|
+
tokens = next;
|
|
987
|
+
context = { ...context, ...themeContext(next) };
|
|
988
|
+
notify(METHODS.hostContextChanged, themeContext(next));
|
|
989
|
+
// An iframe whose color-scheme differs from its document's paints an
|
|
990
|
+
// opaque canvas, so the element follows only after the frame switched.
|
|
991
|
+
const session = activeSession;
|
|
992
|
+
const followTheme = () => {
|
|
993
|
+
const iframe = session?.iframe();
|
|
994
|
+
if (iframe && tokens === next)
|
|
995
|
+
iframe.style.colorScheme = next.colorScheme;
|
|
996
|
+
};
|
|
997
|
+
request(METHODS.inspect).then(followTheme, followTheme);
|
|
998
|
+
},
|
|
999
|
+
setContext(partial) {
|
|
1000
|
+
context = { ...context, ...partial };
|
|
1001
|
+
notify(METHODS.hostContextChanged, partial);
|
|
1002
|
+
},
|
|
1003
|
+
notifyToolInput(args, notifyOptions) {
|
|
1004
|
+
const partial = notifyOptions?.partial ?? false;
|
|
1005
|
+
lastToolInput = { args, partial };
|
|
1006
|
+
notify(partial ? METHODS.toolInputPartial : METHODS.toolInput, {
|
|
1007
|
+
arguments: args,
|
|
1008
|
+
});
|
|
1009
|
+
},
|
|
1010
|
+
notifyToolResult(result) {
|
|
1011
|
+
lastToolResult = { result };
|
|
1012
|
+
notify(METHODS.toolResult, result);
|
|
1013
|
+
},
|
|
1014
|
+
screenshot(screenshotOptions) {
|
|
1015
|
+
return request<Screenshot>(METHODS.screenshot, screenshotOptions ?? {});
|
|
1016
|
+
},
|
|
1017
|
+
async inspect() {
|
|
1018
|
+
const inspection = await request<FrameInspection>(METHODS.inspect);
|
|
1019
|
+
return { ...inspection, code };
|
|
1020
|
+
},
|
|
1021
|
+
on(event, listener) {
|
|
1022
|
+
let set = listeners.get(event);
|
|
1023
|
+
if (!set) {
|
|
1024
|
+
set = new Set();
|
|
1025
|
+
listeners.set(event, set);
|
|
1026
|
+
}
|
|
1027
|
+
set.add(listener as (payload: never) => void);
|
|
1028
|
+
return () => set.delete(listener as (payload: never) => void);
|
|
1029
|
+
},
|
|
1030
|
+
dispose() {
|
|
1031
|
+
if (disposed) return;
|
|
1032
|
+
disposed = true;
|
|
1033
|
+
queue = undefined;
|
|
1034
|
+
pendingSession?.dispose();
|
|
1035
|
+
pendingSession = undefined;
|
|
1036
|
+
activeSession?.dispose();
|
|
1037
|
+
activeSession = undefined;
|
|
1038
|
+
listeners.clear();
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
internalRequests.set(handle, request);
|
|
1043
|
+
|
|
1044
|
+
if (options.code !== undefined) {
|
|
1045
|
+
handle.write(options.code);
|
|
1046
|
+
void handle.end().catch(() => {});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return handle;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Clears what widgets with this `id` stored (localStorage, sessionStorage,
|
|
1054
|
+
* IndexedDB, Cache Storage, and cookies) by loading a hidden frame on the
|
|
1055
|
+
* same origin. Resolves with the storage kinds that were cleared.
|
|
1056
|
+
*/
|
|
1057
|
+
export async function clearWidgetStorage(
|
|
1058
|
+
id: string,
|
|
1059
|
+
options: Pick<
|
|
1060
|
+
CreateWidgetOptions,
|
|
1061
|
+
"product" | "unsafeShimDomain" | "readyTimeoutMs"
|
|
1062
|
+
> = {},
|
|
1063
|
+
): Promise<ClearStorageResult> {
|
|
1064
|
+
const container = document.createElement("div");
|
|
1065
|
+
container.setAttribute("aria-hidden", "true");
|
|
1066
|
+
container.style.cssText =
|
|
1067
|
+
"position:fixed;left:0;top:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;";
|
|
1068
|
+
document.body.appendChild(container);
|
|
1069
|
+
let widget: WidgetHandle | undefined;
|
|
1070
|
+
try {
|
|
1071
|
+
widget = createWidget({ ...options, container, id });
|
|
1072
|
+
await widget.ready;
|
|
1073
|
+
return await internalRequests.get(widget)!<ClearStorageResult>(
|
|
1074
|
+
METHODS.clearStorage,
|
|
1075
|
+
);
|
|
1076
|
+
} finally {
|
|
1077
|
+
widget?.dispose();
|
|
1078
|
+
container.remove();
|
|
1079
|
+
}
|
|
1080
|
+
}
|