@datalayer/agent-runtimes 1.3.29 → 1.3.30
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/lib/AgentNode.js +14 -33
- package/lib/chat/base/ChatBase.js +41 -32
- package/lib/chat/base/modelChoice.d.ts +13 -0
- package/lib/chat/base/modelChoice.js +13 -0
- package/lib/loop/apps/AppRenderer.d.ts +33 -1
- package/lib/loop/apps/AppRenderer.js +96 -29
- package/lib/loop/apps/agent.d.ts +15 -0
- package/lib/loop/apps/agent.js +42 -0
- package/lib/loop/apps/index.d.ts +1 -0
- package/lib/loop/apps/index.js +1 -0
- package/lib/loop/core/index.d.ts +8 -0
- package/lib/loop/embed/AppEmbed.d.ts +42 -0
- package/lib/loop/embed/AppEmbed.js +205 -0
- package/lib/loop/embed/datalayer-app.d.ts +1 -0
- package/lib/loop/embed/datalayer-app.js +16 -0
- package/lib/loop/embed/element.d.ts +57 -0
- package/lib/loop/embed/element.js +405 -0
- package/lib/loop/embed/embedConfig.d.ts +120 -0
- package/lib/loop/embed/embedConfig.js +148 -0
- package/lib/loop/embed/embedTheme.d.ts +24 -0
- package/lib/loop/embed/embedTheme.js +83 -0
- package/lib/loop/index.d.ts +6 -2
- package/lib/loop/index.js +8 -2
- package/lib/loop/plugins/a2ui-surface/InlineSurface.d.ts +21 -3
- package/lib/loop/plugins/a2ui-surface/InlineSurface.js +34 -6
- package/lib/loop/plugins/agent-capacity/index.d.ts +4 -0
- package/lib/loop/plugins/agent-capacity/index.js +3 -1
- package/lib/loop/plugins/agents/switchable.d.ts +7 -0
- package/lib/loop/plugins/agents/switchable.js +91 -88
- package/lib/loop/plugins/app-page/AppPage.d.ts +16 -0
- package/lib/loop/plugins/app-page/AppPage.js +53 -0
- package/lib/loop/plugins/app-page/appPage.d.ts +180 -0
- package/lib/loop/plugins/app-page/appPage.js +443 -0
- package/lib/loop/plugins/app-page/index.d.ts +8 -0
- package/lib/loop/plugins/app-page/index.js +34 -0
- package/lib/loop/plugins/chat/ChatView.js +47 -25
- package/lib/loop/plugins/page-layout/SplitLayout.d.ts +31 -0
- package/lib/loop/plugins/page-layout/SplitLayout.js +129 -0
- package/lib/loop/plugins/page-layout/index.d.ts +13 -0
- package/lib/loop/plugins/page-layout/index.js +10 -1
- package/lib/loop/presets.d.ts +12 -0
- package/lib/loop/presets.js +5 -3
- package/lib/specs/actions.js +8 -0
- package/lib/specs/agents/agents.js +4 -0
- package/lib/specs/apps.d.ts +10 -1
- package/lib/specs/apps.js +1792 -178
- package/lib/specs/appspecSchema.js +1 -1
- package/lib/types/agentspecs.d.ts +11 -0
- package/lib/types/config.d.ts +12 -0
- package/package.json +2 -1
- package/scripts/codegen/generate_agents.py +50 -2
- package/scripts/codegen/generate_apps.py +36 -6
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
4
|
+
* Distributed under the terms of the Modified BSD License.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* An application in another product's page, as a React component (LOOP
|
|
8
|
+
* D-07, D-09, D-11): what the `<datalayer-app>` element draws, and what a
|
|
9
|
+
* host that uses React mounts itself.
|
|
10
|
+
*
|
|
11
|
+
* Four modes, as the Appspec says (`deployment.embedded.mode`) or the host:
|
|
12
|
+
*
|
|
13
|
+
* - **inline** — the application where the component is: `AppRenderer`, its
|
|
14
|
+
* page beside its conversation when it has one;
|
|
15
|
+
* - **bubble** — a round button in the corner that opens the conversation in
|
|
16
|
+
* a popup, the copilot pattern;
|
|
17
|
+
* - **panel** — the conversation at the right edge of the page, at full
|
|
18
|
+
* height;
|
|
19
|
+
* - **assistant** — the application's character on the page, speaking in a
|
|
20
|
+
* balloon (§6.9), the one its Appspec names (`interface.assistant`).
|
|
21
|
+
*
|
|
22
|
+
* The three floating modes are `ChatFloating`'s, given the application's
|
|
23
|
+
* name, welcome, starters and accent, and its agent's endpoint — created
|
|
24
|
+
* with the application's spec in its payload, so the runtime enforces its
|
|
25
|
+
* rules there as it does inline.
|
|
26
|
+
*
|
|
27
|
+
* In the `loop` theme with the application's accent (T-12, T-05), which the
|
|
28
|
+
* host may override with the face and the mode (D-11).
|
|
29
|
+
*
|
|
30
|
+
* @module loop/embed/AppEmbed
|
|
31
|
+
*/
|
|
32
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
33
|
+
import { registerPortalRoot } from '@primer/react';
|
|
34
|
+
import { DatalayerThemeProvider, loopTheme } from '@datalayer/primer-addons';
|
|
35
|
+
import { useIAMStore } from '@datalayer/core/lib/state/substates/IAMState';
|
|
36
|
+
import { ChatFloating } from '../../chat/ChatFloating';
|
|
37
|
+
import { useAgentRuntimes } from '../../hooks/useAgentRuntimes';
|
|
38
|
+
import { AppRenderer, DATALAYER_BOOTSTRAP_AGENTSPEC, appDatalayerCreatePayload, defineAppPlugin, } from '../apps/AppRenderer';
|
|
39
|
+
import { LoopAgentBlueprint } from '../core';
|
|
40
|
+
import { ensureServerAgent } from '../plugins/agents/switchable';
|
|
41
|
+
import { floatingViewOf } from './embedConfig';
|
|
42
|
+
import { embedThemeStyles } from './embedTheme';
|
|
43
|
+
/** What the application's agent is created with on an agent-runtimes server. */
|
|
44
|
+
function serverCreatePayload(app) {
|
|
45
|
+
const blueprint = (defineAppPlugin(app).contributes ?? []).find((item) => item.point === LoopAgentBlueprint);
|
|
46
|
+
return blueprint?.value.createPayload ?? { name: app.id };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The application's agent on the host's agent-runtimes server: created
|
|
50
|
+
* there with the application's spec unless it is there already, as the
|
|
51
|
+
* workspace's Local target does.
|
|
52
|
+
*/
|
|
53
|
+
function useServerAgent(app, serverUrl) {
|
|
54
|
+
const [state, setState] = useState({
|
|
55
|
+
waiting: 'Starting…',
|
|
56
|
+
});
|
|
57
|
+
const source = JSON.stringify(app);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
const base = serverUrl.replace(/\/+$/, '');
|
|
61
|
+
setState({ waiting: 'Starting…' });
|
|
62
|
+
ensureServerAgent(base, app.id, serverCreatePayload(app)).then(() => {
|
|
63
|
+
if (!cancelled) {
|
|
64
|
+
setState({
|
|
65
|
+
protocol: {
|
|
66
|
+
type: 'ag-ui',
|
|
67
|
+
endpoint: `${base}/api/v1/ag-ui/${app.id}/`,
|
|
68
|
+
agentId: app.id,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}, (error) => {
|
|
73
|
+
if (!cancelled) {
|
|
74
|
+
setState({
|
|
75
|
+
error: error instanceof Error ? error.message : String(error),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
};
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [serverUrl, source]);
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The application's agent on a Datalayer runtime: allocated with a plain
|
|
88
|
+
* agentspec, the agent created there with the application's spec — the
|
|
89
|
+
* runtime `AppRenderer` launches inline (R-03, R-05).
|
|
90
|
+
*/
|
|
91
|
+
function useDatalayerAgent(app, instance) {
|
|
92
|
+
const token = useIAMStore(state => state.token);
|
|
93
|
+
const source = JSON.stringify(app);
|
|
94
|
+
const agentConfig = useMemo(() => ({
|
|
95
|
+
name: app.id,
|
|
96
|
+
protocol: 'ag-ui',
|
|
97
|
+
agentSpecId: DATALAYER_BOOTSTRAP_AGENTSPEC,
|
|
98
|
+
createPayload: appDatalayerCreatePayload(app, instance),
|
|
99
|
+
}),
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
+
[source, instance?.appUid, instance?.deploymentUid, instance?.version]);
|
|
102
|
+
const { runtime, error } = useAgentRuntimes({
|
|
103
|
+
agentSpecId: DATALAYER_BOOTSTRAP_AGENTSPEC,
|
|
104
|
+
agentConfig,
|
|
105
|
+
variant: 'cloud-pydanticai',
|
|
106
|
+
// Only for a visitor Datalayer knows: a runtime is somebody's to pay for.
|
|
107
|
+
autoStart: Boolean(token),
|
|
108
|
+
autoCreateAgent: Boolean(token),
|
|
109
|
+
});
|
|
110
|
+
if (!token) {
|
|
111
|
+
return {
|
|
112
|
+
error: `${app.name} runs on Datalayer, which needs you signed in. An application embedded for visitors who are not needs its embed token (LOOP D-14), or an agent-runtimes server of the host's own (the "server" attribute).`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (error) {
|
|
116
|
+
return { error };
|
|
117
|
+
}
|
|
118
|
+
if (!runtime?.agentBaseUrl) {
|
|
119
|
+
return { waiting: 'Starting…' };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
protocol: {
|
|
123
|
+
type: 'ag-ui',
|
|
124
|
+
endpoint: `${runtime.agentBaseUrl}/api/v1/ag-ui/${app.id}/`,
|
|
125
|
+
agentId: app.id,
|
|
126
|
+
authToken: token,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** The application's face, as the floating chat's button and header draw it. */
|
|
131
|
+
function Face({ emoji }) {
|
|
132
|
+
return (_jsx("span", { "aria-hidden": true, style: { fontSize: 18, lineHeight: 1 }, children: emoji }));
|
|
133
|
+
}
|
|
134
|
+
/** The bubble, the panel and the assistant: `ChatFloating` in that mode. */
|
|
135
|
+
function FloatingChat({ app, view, colorMode, agent, }) {
|
|
136
|
+
const welcome = app.interface.welcome || app.description;
|
|
137
|
+
return (_jsx(ChatFloating
|
|
138
|
+
// A new agent is a new conversation.
|
|
139
|
+
, { defaultViewMode: view, assistantCharacter: app.interface.assistant ?? 'paperclip', protocol: agent.protocol, authToken: agent.protocol?.authToken, title: app.name, description: welcome, brandIcon: _jsx(Face, { emoji: app.emoji }), buttonIcon: _jsx(Face, { emoji: app.emoji }), buttonTooltip: `Talk to ${app.name}`, suggestions: app.interface.starters.map(starter => ({
|
|
140
|
+
title: starter.label,
|
|
141
|
+
message: starter.message,
|
|
142
|
+
})), themeVariant: "loop", colorMode: colorMode, showTokenUsage: false, showSettingsButton: false, showPoweredBy: false, useStore: false, launching: !agent.protocol && !agent.error, launchingMessage: agent.waiting, disabled: Boolean(agent.error), disableReason: agent.error }, agent.protocol?.endpoint ?? 'waiting'));
|
|
143
|
+
}
|
|
144
|
+
/** Floating, its agent on the host's agent-runtimes server. */
|
|
145
|
+
function ServerFloating({ serverUrl, ...props }) {
|
|
146
|
+
return (_jsx(FloatingChat, { ...props, agent: useServerAgent(props.app, serverUrl) }));
|
|
147
|
+
}
|
|
148
|
+
/** Floating, its agent on a Datalayer runtime. */
|
|
149
|
+
function DatalayerFloating({ instance, ...props }) {
|
|
150
|
+
return (_jsx(FloatingChat, { ...props, agent: useDatalayerAgent(props.app, instance) }));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Overlays — menus, dialogs — drawn inside the embed's own root, under the
|
|
154
|
+
* theme's element, so that they wear its theme: what the element asks for,
|
|
155
|
+
* since Primer's own portal root is in the host's `<body>`, outside the
|
|
156
|
+
* shadow root and its styles.
|
|
157
|
+
*/
|
|
158
|
+
function OwnPortalRoot() {
|
|
159
|
+
const ref = useRef(null);
|
|
160
|
+
useLayoutEffect(() => {
|
|
161
|
+
if (ref.current) {
|
|
162
|
+
registerPortalRoot(ref.current);
|
|
163
|
+
}
|
|
164
|
+
}, []);
|
|
165
|
+
return _jsx("div", { ref: ref, className: "datalayer-app-portal" });
|
|
166
|
+
}
|
|
167
|
+
/** The visitor's system mode, followed while it changes. */
|
|
168
|
+
function useSystemMode() {
|
|
169
|
+
const query = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
170
|
+
? window.matchMedia('(prefers-color-scheme: dark)')
|
|
171
|
+
: null;
|
|
172
|
+
const [dark, setDark] = useState(Boolean(query?.matches));
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!query) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const follow = () => setDark(query.matches);
|
|
178
|
+
query.addEventListener?.('change', follow);
|
|
179
|
+
return () => query.removeEventListener?.('change', follow);
|
|
180
|
+
}, [query]);
|
|
181
|
+
return dark ? 'dark' : 'light';
|
|
182
|
+
}
|
|
183
|
+
/** The `loop` theme, with the application's (or the host's) accent and face. */
|
|
184
|
+
export function EmbedThemed({ accent, colorMode, font, children, style, }) {
|
|
185
|
+
const themeStyles = useMemo(() => embedThemeStyles({ accent, font }), [accent, font]);
|
|
186
|
+
return (
|
|
187
|
+
/*
|
|
188
|
+
* Marked as themed above the provider, so that the provider counts
|
|
189
|
+
* itself nested and leaves the host's <body> and Primer's portal root
|
|
190
|
+
* alone: it would otherwise write the theme's tokens, colour and face on
|
|
191
|
+
* the host's page (T-13). A wrapper of no box.
|
|
192
|
+
*/
|
|
193
|
+
_jsx("div", { "data-color-mode": colorMode, style: { display: 'contents' }, children: _jsx(DatalayerThemeProvider, { colorMode: colorMode, theme: loopTheme, themeStyles: themeStyles, baseStyles: style, children: children }) }));
|
|
194
|
+
}
|
|
195
|
+
export function AppEmbed({ app, mode, accent, colorMode = 'auto', font, serverUrl, instance, height = 640, ownPortal = false, }) {
|
|
196
|
+
const system = useSystemMode();
|
|
197
|
+
const resolvedMode = colorMode === 'auto' ? system : colorMode;
|
|
198
|
+
const shownAs = mode ?? app.deployment.embedded?.mode ?? 'inline';
|
|
199
|
+
const view = floatingViewOf(shownAs);
|
|
200
|
+
return (_jsxs(EmbedThemed, { accent: accent ?? app.interface.accent, colorMode: resolvedMode, font: font, style: view
|
|
201
|
+
? // Floating: nothing in the page's flow, nothing painted behind.
|
|
202
|
+
{ backgroundColor: 'transparent' }
|
|
203
|
+
: { height, display: 'flex', flexDirection: 'column' }, children: [ownPortal ? _jsx(OwnPortalRoot, {}) : null, view && serverUrl ? (_jsx(ServerFloating, { app: app, view: view, serverUrl: serverUrl, colorMode: resolvedMode })) : view ? (_jsx(DatalayerFloating, { app: app, view: view, instance: instance, colorMode: resolvedMode })) : (_jsx(AppRenderer, { app: app, target: serverUrl ? 'local' : 'datalayer', ...(serverUrl ? { serverUrl } : {}), instance: instance }))] }));
|
|
204
|
+
}
|
|
205
|
+
export default AppEmbed;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The one script a host loads (LOOP D-08): it defines `<datalayer-app>` and
|
|
7
|
+
* nothing else. Built on its own by `npm run build:embed`
|
|
8
|
+
* (`vite.embed.config.ts`) into `dist-embed/datalayer-app.js`, with the
|
|
9
|
+
* stylesheet the element links into its shadow root beside it
|
|
10
|
+
* (`dist-embed/datalayer-app.css`); served from a Datalayer origin at
|
|
11
|
+
* `/embed/`.
|
|
12
|
+
*
|
|
13
|
+
* @module loop/embed/datalayer-app
|
|
14
|
+
*/
|
|
15
|
+
import { defineDatalayerAppElement } from './element';
|
|
16
|
+
defineDatalayerAppElement();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AppSpec } from '../../types/agentspecs';
|
|
2
|
+
import { type ParsedAppspec } from '../apps/appspec';
|
|
3
|
+
/** What the framed run page and the element say to each other. */
|
|
4
|
+
export declare const EMBED_MESSAGES: {
|
|
5
|
+
readonly size: "datalayer-app:size";
|
|
6
|
+
readonly decision: "datalayer-app:decision";
|
|
7
|
+
/** To the frame: an embed token, the first or a new one. */
|
|
8
|
+
readonly token: "datalayer-app:token";
|
|
9
|
+
/** From the frame: the token lapsed; a new one is needed. */
|
|
10
|
+
readonly tokenExpired: "datalayer-app:token-expired";
|
|
11
|
+
};
|
|
12
|
+
/** Where an application comes from, once read. */
|
|
13
|
+
export type EmbedSource = {
|
|
14
|
+
kind: 'spec';
|
|
15
|
+
app: AppSpec;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'frame';
|
|
18
|
+
src: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'said';
|
|
21
|
+
text: string;
|
|
22
|
+
expired?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** An Appspec from its text, YAML or JSON; refused in a sentence when it is neither. */
|
|
25
|
+
export declare function appspecOfText(text: string): AppSpec;
|
|
26
|
+
/**
|
|
27
|
+
* An application the embed can run, or a sentence saying why not: one with
|
|
28
|
+
* an id, and an agent to answer — what the spec tolerates in a draft, an
|
|
29
|
+
* embed cannot run.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runnable({ app, problems }: ParsedAppspec): AppSpec;
|
|
32
|
+
/** The run page an application on Datalayer is framed from. */
|
|
33
|
+
export declare function frameSrcOf(origin: string, app: string, token?: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* An application on Datalayer, read with its embed token: what the space
|
|
36
|
+
* keeps of it (`model_s`, the stored definition), whose `spec` is the
|
|
37
|
+
* Appspec. A decision is framed; so is an application that has no spec.
|
|
38
|
+
*/
|
|
39
|
+
export declare function readEmbeddedApp({ api, origin, app, token, fetcher, }: {
|
|
40
|
+
api: string;
|
|
41
|
+
origin: string;
|
|
42
|
+
app: string;
|
|
43
|
+
token: string;
|
|
44
|
+
fetcher?: typeof fetch;
|
|
45
|
+
}): Promise<EmbedSource>;
|
|
46
|
+
export type DatalayerAppElementOptions = {
|
|
47
|
+
/** The element's name; `datalayer-app` by default. */
|
|
48
|
+
tag?: string;
|
|
49
|
+
/** Where the script was loaded from: the default origin, and where its stylesheet is. */
|
|
50
|
+
scriptUrl?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Define the element, once. Returns its class, or `undefined` where there is
|
|
54
|
+
* no `customElements` (a server render) — a second call returns the class
|
|
55
|
+
* already defined.
|
|
56
|
+
*/
|
|
57
|
+
export declare function defineDatalayerAppElement(options?: DatalayerAppElementOptions): CustomElementConstructor | undefined;
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
4
|
+
* Distributed under the terms of the Modified BSD License.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* `<datalayer-app>`: an application in another product's page, as one
|
|
8
|
+
* script tag and one element (LOOP D-08), in the mode its Appspec says
|
|
9
|
+
* (D-07), its theme isolated from the host's and overridable in the three
|
|
10
|
+
* ways that matter (T-13, D-11).
|
|
11
|
+
*
|
|
12
|
+
* <script src="https://datalayer.app/embed/datalayer-app.js" async></script>
|
|
13
|
+
* <datalayer-app app="01M…" origin="https://datalayer.app"></datalayer-app>
|
|
14
|
+
*
|
|
15
|
+
* What it shows comes from one of three places, first found first:
|
|
16
|
+
*
|
|
17
|
+
* 1. an Appspec written in the element, as YAML or JSON
|
|
18
|
+
* (`<script type="application/yaml">…</script>`);
|
|
19
|
+
* 2. an Appspec at an address (`spec="https://…/support-desk.yaml"`);
|
|
20
|
+
* 3. an application on Datalayer (`app`), read with its embed token
|
|
21
|
+
* (`token`). A decision, or an application given without a token, is
|
|
22
|
+
* framed as before: Datalayer's run page in an iframe, sized to it, its
|
|
23
|
+
* `decision` and `token-expired` events raised on the element.
|
|
24
|
+
*
|
|
25
|
+
* Every other application is drawn here, in the element's shadow root, by
|
|
26
|
+
* `AppEmbed` — inline, or floating over the page as a bubble, a panel or the
|
|
27
|
+
* assistant. The bundle's stylesheet is linked inside that root, the
|
|
28
|
+
* styled-components rules are written into it, and Primer's overlays are
|
|
29
|
+
* drawn in it, so nothing of the application's styles reaches the page and
|
|
30
|
+
* nothing of the page's reaches the application.
|
|
31
|
+
*
|
|
32
|
+
* @module loop/embed/element
|
|
33
|
+
*/
|
|
34
|
+
import { createRoot } from 'react-dom/client';
|
|
35
|
+
import { StyleSheetManager } from 'styled-components';
|
|
36
|
+
import { loopThemeStyles } from '@datalayer/primer-addons';
|
|
37
|
+
import { coreStore } from '@datalayer/core/lib/state/substates/CoreState';
|
|
38
|
+
import { parseAppspec } from '../apps/appspec';
|
|
39
|
+
import { readAppspecYaml } from '../apps/yaml';
|
|
40
|
+
import { AppEmbed } from './AppEmbed';
|
|
41
|
+
import { EMBED_HOST_VARIABLES, EMBED_OBSERVED_ATTRIBUTES, EMBED_TAG, EmbedAttributeError, embedLookOf, inlineHeightOf, } from './embedConfig';
|
|
42
|
+
import { embedShadowCss } from './embedTheme';
|
|
43
|
+
/** What the framed run page and the element say to each other. */
|
|
44
|
+
export const EMBED_MESSAGES = {
|
|
45
|
+
size: 'datalayer-app:size',
|
|
46
|
+
decision: 'datalayer-app:decision',
|
|
47
|
+
/** To the frame: an embed token, the first or a new one. */
|
|
48
|
+
token: 'datalayer-app:token',
|
|
49
|
+
/** From the frame: the token lapsed; a new one is needed. */
|
|
50
|
+
tokenExpired: 'datalayer-app:token-expired',
|
|
51
|
+
};
|
|
52
|
+
/** An Appspec from its text, YAML or JSON; refused in a sentence when it is neither. */
|
|
53
|
+
export function appspecOfText(text) {
|
|
54
|
+
// Written in a page, a document is indented with its markup: the
|
|
55
|
+
// indentation every line shares is the page's, not the document's.
|
|
56
|
+
const lines = text.replace(/\t/g, ' ').split('\n');
|
|
57
|
+
const indent = Math.min(...lines
|
|
58
|
+
.filter(line => line.trim())
|
|
59
|
+
.map(line => line.length - line.trimStart().length));
|
|
60
|
+
const trimmed = lines
|
|
61
|
+
.map(line => line.slice(Number.isFinite(indent) ? indent : 0))
|
|
62
|
+
.join('\n')
|
|
63
|
+
.trim();
|
|
64
|
+
if (!trimmed) {
|
|
65
|
+
throw new EmbedAttributeError('datalayer-app: the Appspec is empty.');
|
|
66
|
+
}
|
|
67
|
+
let parsed;
|
|
68
|
+
if (trimmed.startsWith('{')) {
|
|
69
|
+
let document;
|
|
70
|
+
try {
|
|
71
|
+
document = JSON.parse(trimmed);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new EmbedAttributeError(`datalayer-app: the Appspec is not JSON (${error.message}).`);
|
|
75
|
+
}
|
|
76
|
+
parsed = parseAppspec(document);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
parsed = readAppspecYaml(trimmed);
|
|
80
|
+
}
|
|
81
|
+
return runnable(parsed);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* An application the embed can run, or a sentence saying why not: one with
|
|
85
|
+
* an id, and an agent to answer — what the spec tolerates in a draft, an
|
|
86
|
+
* embed cannot run.
|
|
87
|
+
*/
|
|
88
|
+
export function runnable({ app, problems }) {
|
|
89
|
+
if (!app.id) {
|
|
90
|
+
throw new EmbedAttributeError(`datalayer-app: the Appspec names no application${problems.length ? ` (${problems[0]})` : ''}.`);
|
|
91
|
+
}
|
|
92
|
+
if (app.kind !== 'decision' && !app.agent) {
|
|
93
|
+
throw new EmbedAttributeError(`datalayer-app: “${app.name || app.id}” names no agent to answer; an embedded application is run by an agent (a team is not supported yet).`);
|
|
94
|
+
}
|
|
95
|
+
return app;
|
|
96
|
+
}
|
|
97
|
+
/** The run page an application on Datalayer is framed from. */
|
|
98
|
+
export function frameSrcOf(origin, app, token = '') {
|
|
99
|
+
// The token in the fragment, which no server is sent: it reaches the page
|
|
100
|
+
// and no access log.
|
|
101
|
+
return `${origin.replace(/\/+$/, '')}/public/apps/${encodeURIComponent(app)}/run?embed=1${token ? `#token=${encodeURIComponent(token)}` : ''}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* An application on Datalayer, read with its embed token: what the space
|
|
105
|
+
* keeps of it (`model_s`, the stored definition), whose `spec` is the
|
|
106
|
+
* Appspec. A decision is framed; so is an application that has no spec.
|
|
107
|
+
*/
|
|
108
|
+
export async function readEmbeddedApp({ api, origin, app, token, fetcher = fetch, }) {
|
|
109
|
+
const response = await fetcher(`${api.replace(/\/+$/, '')}/api/spacer/v1/apps/${encodeURIComponent(app)}/embedded`, { headers: { Authorization: `Bearer ${token}` } });
|
|
110
|
+
if (response.status === 401) {
|
|
111
|
+
return {
|
|
112
|
+
kind: 'said',
|
|
113
|
+
text: 'datalayer-app: the embed token expired or is not for this application.',
|
|
114
|
+
expired: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
return {
|
|
119
|
+
kind: 'said',
|
|
120
|
+
text: `datalayer-app: the application could not be read (${response.status}).`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const body = (await response.json());
|
|
124
|
+
let definition;
|
|
125
|
+
try {
|
|
126
|
+
definition = JSON.parse(String(body.app?.model_s ?? '{}'));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
definition = {};
|
|
130
|
+
}
|
|
131
|
+
if (!definition.spec) {
|
|
132
|
+
return { kind: 'frame', src: frameSrcOf(origin, app, token) };
|
|
133
|
+
}
|
|
134
|
+
const spec = runnable(parseAppspec(definition.spec));
|
|
135
|
+
return spec.kind === 'decision'
|
|
136
|
+
? { kind: 'frame', src: frameSrcOf(origin, app, token) }
|
|
137
|
+
: { kind: 'spec', app: spec };
|
|
138
|
+
}
|
|
139
|
+
/** The script that is running, when it is a script tag. */
|
|
140
|
+
function currentScriptUrl() {
|
|
141
|
+
try {
|
|
142
|
+
const script = document.currentScript;
|
|
143
|
+
return script?.src ? new URL(script.src, location.href).href : '';
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Define the element, once. Returns its class, or `undefined` where there is
|
|
151
|
+
* no `customElements` (a server render) — a second call returns the class
|
|
152
|
+
* already defined.
|
|
153
|
+
*/
|
|
154
|
+
export function defineDatalayerAppElement(options = {}) {
|
|
155
|
+
if (typeof window === 'undefined' || !('customElements' in window)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const tag = options.tag ?? EMBED_TAG;
|
|
159
|
+
const existing = customElements.get(tag);
|
|
160
|
+
if (existing) {
|
|
161
|
+
return existing;
|
|
162
|
+
}
|
|
163
|
+
const scriptUrl = options.scriptUrl ?? currentScriptUrl();
|
|
164
|
+
const scriptOrigin = scriptUrl ? new URL(scriptUrl).origin : '';
|
|
165
|
+
// The bundle's stylesheet, beside its script.
|
|
166
|
+
const stylesheetUrl = scriptUrl
|
|
167
|
+
? scriptUrl.replace(/\.js(\?.*)?$/, '.css')
|
|
168
|
+
: '';
|
|
169
|
+
class DatalayerAppElement extends HTMLElement {
|
|
170
|
+
static get observedAttributes() {
|
|
171
|
+
return EMBED_OBSERVED_ATTRIBUTES;
|
|
172
|
+
}
|
|
173
|
+
root = null;
|
|
174
|
+
frame = null;
|
|
175
|
+
source = null;
|
|
176
|
+
loading = 0;
|
|
177
|
+
constructor() {
|
|
178
|
+
super();
|
|
179
|
+
this.attachShadow({ mode: 'open' });
|
|
180
|
+
this.onMessage = this.onMessage.bind(this);
|
|
181
|
+
}
|
|
182
|
+
/** An embed token, the attribute's. Set it to hand a new one over. */
|
|
183
|
+
get token() {
|
|
184
|
+
return this.getAttribute('token') ?? '';
|
|
185
|
+
}
|
|
186
|
+
set token(value) {
|
|
187
|
+
if (value) {
|
|
188
|
+
this.setAttribute('token', String(value));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
this.removeAttribute('token');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
connectedCallback() {
|
|
195
|
+
window.addEventListener('message', this.onMessage);
|
|
196
|
+
void this.load();
|
|
197
|
+
}
|
|
198
|
+
disconnectedCallback() {
|
|
199
|
+
window.removeEventListener('message', this.onMessage);
|
|
200
|
+
this.unmount();
|
|
201
|
+
}
|
|
202
|
+
attributeChangedCallback(name, before, after) {
|
|
203
|
+
if (!this.isConnected || before === after) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// A new token is handed to the framed page that is already there,
|
|
207
|
+
// which keeps what the person entered.
|
|
208
|
+
if (name === 'token' && this.frame?.contentWindow && after) {
|
|
209
|
+
this.frame.contentWindow.postMessage({ type: EMBED_MESSAGES.token, token: after }, this.origin());
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// How it looks is drawn again; what it is, read again.
|
|
213
|
+
if (['mode', 'accent', 'theme', 'font', 'height'].includes(name)) {
|
|
214
|
+
this.draw();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
void this.load();
|
|
218
|
+
}
|
|
219
|
+
origin() {
|
|
220
|
+
return (this.getAttribute('origin') ||
|
|
221
|
+
scriptOrigin ||
|
|
222
|
+
location.origin).replace(/\/+$/, '');
|
|
223
|
+
}
|
|
224
|
+
inputs() {
|
|
225
|
+
const attributes = {};
|
|
226
|
+
for (const name of EMBED_OBSERVED_ATTRIBUTES) {
|
|
227
|
+
attributes[name] = this.getAttribute(name);
|
|
228
|
+
}
|
|
229
|
+
const computed = getComputedStyle(this);
|
|
230
|
+
const variables = {};
|
|
231
|
+
for (const [key, property] of Object.entries(EMBED_HOST_VARIABLES)) {
|
|
232
|
+
const value = computed.getPropertyValue(property).trim();
|
|
233
|
+
if (value) {
|
|
234
|
+
variables[key] = value.replace(/^["']|["']$/g, '');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { attributes, variables };
|
|
238
|
+
}
|
|
239
|
+
/** Where the application comes from: the element's own Appspec, an address, or Datalayer. */
|
|
240
|
+
async read() {
|
|
241
|
+
const inline = this.querySelector('script[type="application/yaml"], script[type="application/json"], script[type="text/yaml"]');
|
|
242
|
+
if (inline?.textContent?.trim()) {
|
|
243
|
+
return { kind: 'spec', app: appspecOfText(inline.textContent) };
|
|
244
|
+
}
|
|
245
|
+
const spec = this.getAttribute('spec');
|
|
246
|
+
if (spec) {
|
|
247
|
+
const response = await fetch(new URL(spec, location.href).href);
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
return {
|
|
250
|
+
kind: 'said',
|
|
251
|
+
text: `datalayer-app: the Appspec at ${spec} could not be read (${response.status}).`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { kind: 'spec', app: appspecOfText(await response.text()) };
|
|
255
|
+
}
|
|
256
|
+
const app = this.getAttribute('app');
|
|
257
|
+
if (!app) {
|
|
258
|
+
return {
|
|
259
|
+
kind: 'said',
|
|
260
|
+
text: 'datalayer-app: the "app" attribute names the application to show, or "spec" the address of its Appspec.',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const token = this.token;
|
|
264
|
+
if (!token) {
|
|
265
|
+
// A public application: Datalayer's run page, framed.
|
|
266
|
+
return { kind: 'frame', src: frameSrcOf(this.origin(), app) };
|
|
267
|
+
}
|
|
268
|
+
return readEmbeddedApp({
|
|
269
|
+
api: this.getAttribute('api') ||
|
|
270
|
+
coreStore.getState().configuration?.spacerUrl ||
|
|
271
|
+
'https://prod1.datalayer.run',
|
|
272
|
+
origin: this.origin(),
|
|
273
|
+
app,
|
|
274
|
+
token,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async load() {
|
|
278
|
+
const turn = ++this.loading;
|
|
279
|
+
let source;
|
|
280
|
+
try {
|
|
281
|
+
source = await this.read();
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
source = {
|
|
285
|
+
kind: 'said',
|
|
286
|
+
text: error instanceof Error
|
|
287
|
+
? error.message
|
|
288
|
+
: `datalayer-app: ${String(error)}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// A later read wins over an earlier one still in flight.
|
|
292
|
+
if (turn !== this.loading || !this.isConnected) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this.source = source;
|
|
296
|
+
this.draw();
|
|
297
|
+
if (source.kind === 'said' && source.expired) {
|
|
298
|
+
this.dispatchEvent(new CustomEvent('token-expired', {
|
|
299
|
+
detail: { app: this.getAttribute('app') ?? '' },
|
|
300
|
+
bubbles: true,
|
|
301
|
+
composed: true,
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
unmount() {
|
|
306
|
+
this.root?.unmount();
|
|
307
|
+
this.root = null;
|
|
308
|
+
this.frame = null;
|
|
309
|
+
}
|
|
310
|
+
/** The shadow root's skeleton: the bundle's stylesheet, the element's own, and what goes in it. */
|
|
311
|
+
skeleton(themeCss) {
|
|
312
|
+
const shadow = this.shadowRoot;
|
|
313
|
+
this.unmount();
|
|
314
|
+
shadow.replaceChildren();
|
|
315
|
+
if (stylesheetUrl) {
|
|
316
|
+
const link = document.createElement('link');
|
|
317
|
+
link.rel = 'stylesheet';
|
|
318
|
+
link.href = stylesheetUrl;
|
|
319
|
+
shadow.appendChild(link);
|
|
320
|
+
}
|
|
321
|
+
const style = document.createElement('style');
|
|
322
|
+
style.textContent = embedShadowCss(themeCss);
|
|
323
|
+
shadow.appendChild(style);
|
|
324
|
+
return shadow;
|
|
325
|
+
}
|
|
326
|
+
say(text) {
|
|
327
|
+
this.setAttribute('data-embed-mode', 'inline');
|
|
328
|
+
const shadow = this.skeleton();
|
|
329
|
+
const note = document.createElement('p');
|
|
330
|
+
note.className = 'datalayer-app-said';
|
|
331
|
+
note.setAttribute('role', 'status');
|
|
332
|
+
note.textContent = text;
|
|
333
|
+
shadow.appendChild(note);
|
|
334
|
+
}
|
|
335
|
+
draw() {
|
|
336
|
+
const source = this.source;
|
|
337
|
+
if (!source) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (source.kind === 'said') {
|
|
341
|
+
this.say(source.text);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (source.kind === 'frame') {
|
|
345
|
+
this.setAttribute('data-embed-mode', 'inline');
|
|
346
|
+
const shadow = this.skeleton();
|
|
347
|
+
const frame = document.createElement('iframe');
|
|
348
|
+
frame.className = 'datalayer-app-frame';
|
|
349
|
+
frame.src = source.src;
|
|
350
|
+
frame.title = 'Datalayer app';
|
|
351
|
+
frame.style.height = `${inlineHeightOf(this.getAttribute('height'))}px`;
|
|
352
|
+
frame.setAttribute('allow', 'clipboard-write');
|
|
353
|
+
shadow.appendChild(frame);
|
|
354
|
+
this.frame = frame;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
let look;
|
|
358
|
+
try {
|
|
359
|
+
look = embedLookOf(this.inputs(), source.app);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
this.say(error.message);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.setAttribute('data-embed-mode', look.mode);
|
|
366
|
+
const shadow = this.skeleton(loopThemeStyles.css);
|
|
367
|
+
const mount = document.createElement('div');
|
|
368
|
+
mount.className = 'datalayer-app-root';
|
|
369
|
+
shadow.appendChild(mount);
|
|
370
|
+
this.root = createRoot(mount);
|
|
371
|
+
const server = this.getAttribute('server') || undefined;
|
|
372
|
+
this.root.render(_jsx(StyleSheetManager, { target: shadow, children: _jsx(AppEmbed, { app: source.app, mode: look.mode, accent: look.accent, colorMode: look.colorMode, font: look.font, serverUrl: server, height: inlineHeightOf(this.getAttribute('height')), ownPortal: true }) }));
|
|
373
|
+
}
|
|
374
|
+
onMessage(event) {
|
|
375
|
+
if (!this.frame || event.source !== this.frame.contentWindow) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (event.origin !== this.origin()) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const data = (event.data ?? {});
|
|
382
|
+
if (data.type === EMBED_MESSAGES.size &&
|
|
383
|
+
typeof data.height === 'number' &&
|
|
384
|
+
data.height > 0) {
|
|
385
|
+
this.frame.style.height = `${Math.ceil(data.height)}px`;
|
|
386
|
+
}
|
|
387
|
+
else if (data.type === EMBED_MESSAGES.decision) {
|
|
388
|
+
this.dispatchEvent(new CustomEvent('decision', {
|
|
389
|
+
detail: data.record,
|
|
390
|
+
bubbles: true,
|
|
391
|
+
composed: true,
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
else if (data.type === EMBED_MESSAGES.tokenExpired) {
|
|
395
|
+
this.dispatchEvent(new CustomEvent('token-expired', {
|
|
396
|
+
detail: { app: this.getAttribute('app') ?? '' },
|
|
397
|
+
bubbles: true,
|
|
398
|
+
composed: true,
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
customElements.define(tag, DatalayerAppElement);
|
|
404
|
+
return DatalayerAppElement;
|
|
405
|
+
}
|