@datalayer/agent-runtimes 1.3.83 → 1.3.84
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/chat/base/ChatBase.js +16 -0
- package/lib/chat/base/loopElement.d.ts +53 -0
- package/lib/chat/base/loopElement.js +78 -0
- package/lib/chat/base/loopWindow.d.ts +31 -0
- package/lib/chat/base/loopWindow.js +74 -0
- package/lib/components/a2ui/datalayer/FileUpload.d.ts +10 -2
- package/lib/components/a2ui/datalayer/FileUpload.js +10 -7
- package/lib/components/a2ui/datalayer/Form.d.ts +2 -0
- package/lib/components/a2ui/datalayer/Form.js +9 -2
- package/lib/components/a2ui/datalayer/formWidgets.d.ts +19 -0
- package/lib/components/a2ui/datalayer/formWidgets.js +51 -0
- package/lib/loop/apps/AppComposer.d.ts +39 -4
- package/lib/loop/apps/AppComposer.js +191 -19
- package/lib/loop/apps/AppRenderer.d.ts +20 -3
- package/lib/loop/apps/AppRenderer.js +58 -27
- package/lib/loop/apps/appspec.d.ts +2 -0
- package/lib/loop/apps/appspec.js +190 -11
- package/lib/loop/apps/checks.d.ts +6 -0
- package/lib/loop/apps/checks.js +170 -1
- package/lib/loop/apps/composer.d.ts +21 -10
- package/lib/loop/apps/composer.js +41 -8
- package/lib/loop/apps/language.d.ts +68 -0
- package/lib/loop/apps/language.js +275 -0
- package/lib/loop/apps/settingsInputs.d.ts +29 -0
- package/lib/loop/apps/settingsInputs.js +147 -0
- package/lib/loop/apps/uploads.d.ts +43 -0
- package/lib/loop/apps/uploads.js +66 -0
- package/lib/loop/core/index.d.ts +14 -1
- package/lib/loop/embed/AppEmbed.d.ts +14 -0
- package/lib/loop/embed/AppEmbed.js +64 -2
- package/lib/loop/embed/element.js +23 -0
- package/lib/loop/embed/embedSession.d.ts +14 -0
- package/lib/loop/embed/embedSession.js +34 -0
- package/lib/loop/embed/hostBridge.d.ts +22 -1
- package/lib/loop/plugins/app-elements/AppElements.d.ts +27 -0
- package/lib/loop/plugins/app-elements/AppElements.js +76 -0
- package/lib/loop/plugins/app-elements/index.d.ts +20 -0
- package/lib/loop/plugins/app-elements/index.js +43 -0
- package/lib/loop/plugins/app-page/appPageModel.js +2 -0
- package/lib/loop/plugins/chat/ChatView.js +10 -2
- package/lib/protocols/BrowserAgentAdapter.d.ts +8 -6
- package/lib/protocols/BrowserAgentAdapter.js +13 -10
- package/lib/runtimes/browser/protocol.d.ts +1 -1
- package/lib/specs/apps.js +149 -0
- package/lib/specs/appspecSchema.js +439 -2
- package/lib/specs/uiPlugins.js +6 -1
- package/lib/types/agentspecs.d.ts +130 -1
- package/package.json +1 -1
- package/scripts/codegen/generate_apps.py +16 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/** The largest file a person may send an application, in megabytes. */
|
|
6
|
+
export const MAX_UPLOAD_MB = 25;
|
|
7
|
+
/** What a kind takes when it does not say, in megabytes. */
|
|
8
|
+
export const DEFAULT_UPLOAD_MB = 10;
|
|
9
|
+
/** How many files go with one message when it does not say. */
|
|
10
|
+
export const DEFAULT_MAX_FILES = 5;
|
|
11
|
+
/** A media type, a family of them, or an extension, lowercase. */
|
|
12
|
+
export const UPLOAD_KIND = /^(?:[a-z0-9][a-z0-9.+-]*\/(?:\*|[a-z0-9][a-z0-9.+-]*)|\.[a-z0-9][a-z0-9_+-]*)$/;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a kind of file takes a file: an extension (`.csv`) by its name, a
|
|
15
|
+
* media type (`application/pdf`) or a family (`image/*`) by its type.
|
|
16
|
+
*/
|
|
17
|
+
export function kindTakes(kind, file) {
|
|
18
|
+
const wanted = kind.trim().toLowerCase();
|
|
19
|
+
if (wanted.startsWith('.')) {
|
|
20
|
+
return file.name.toLowerCase().endsWith(wanted);
|
|
21
|
+
}
|
|
22
|
+
const type = (file.type || '').split(';', 1)[0].trim().toLowerCase();
|
|
23
|
+
if (wanted.endsWith('/*')) {
|
|
24
|
+
return type.startsWith(wanted.slice(0, -1));
|
|
25
|
+
}
|
|
26
|
+
return type === wanted;
|
|
27
|
+
}
|
|
28
|
+
/** Whether any of `kinds` takes a file; any file when there are none. */
|
|
29
|
+
export const acceptsFile = (kinds, file) => !kinds?.length || kinds.some(kind => kindTakes(kind, file));
|
|
30
|
+
const megabytes = (size) => `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
31
|
+
/** The `accept` of a file input for what the application takes. */
|
|
32
|
+
export const uploadsAccept = (uploads) => uploads.kinds.map(kind => kind.type).join(',');
|
|
33
|
+
/**
|
|
34
|
+
* Why files sent without being asked are refused, in a sentence — the
|
|
35
|
+
* runtime's — or `null` when the application takes every one of them.
|
|
36
|
+
*/
|
|
37
|
+
export function uploadsRefusal(app, files) {
|
|
38
|
+
if (files.length === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const uploads = app.interface.uploads;
|
|
42
|
+
if (!uploads) {
|
|
43
|
+
return `${app.name} takes no file sent with a message: its Appspec names none it takes (interface.uploads).`;
|
|
44
|
+
}
|
|
45
|
+
if (files.length > uploads.maxFiles) {
|
|
46
|
+
return `${files.length} files were sent at once: ${app.name} takes at most ${uploads.maxFiles}.`;
|
|
47
|
+
}
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const kind = uploads.kinds.find(candidate => kindTakes(candidate.type, file));
|
|
50
|
+
if (!kind) {
|
|
51
|
+
const taken = uploads.kinds.map(candidate => candidate.type).join(', ');
|
|
52
|
+
return `${file.name} is not a kind of file ${app.name} takes: it takes ${taken}.`;
|
|
53
|
+
}
|
|
54
|
+
if (file.size > kind.maxMb * 1024 * 1024) {
|
|
55
|
+
return `${file.name} is ${megabytes(file.size)}: ${app.name} takes ${kind.type} of at most ${kind.maxMb} MB.`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/** What the composer says it takes, in a sentence. */
|
|
61
|
+
export function uploadsInWords(uploads) {
|
|
62
|
+
const kinds = uploads.kinds
|
|
63
|
+
.map(kind => `${kind.type} up to ${kind.maxMb} MB`)
|
|
64
|
+
.join(', ');
|
|
65
|
+
return `Takes ${kinds}; ${uploads.maxFiles} at most at once.`;
|
|
66
|
+
}
|
package/lib/loop/core/index.d.ts
CHANGED
|
@@ -519,7 +519,7 @@ export type AgentBlueprintContribution = {
|
|
|
519
519
|
* P-19) tell a run turned in the page, and the model it runs on — as the
|
|
520
520
|
* runtime's session API applies them. Unsaid for an agent with no modes.
|
|
521
521
|
*/
|
|
522
|
-
modeEffect?: (chosen: Record<string, string
|
|
522
|
+
modeEffect?: (chosen: Record<string, string>, profile?: string) => AppModeEffect;
|
|
523
523
|
};
|
|
524
524
|
export declare const LoopAgentBlueprint: import("@datalayer/reactor").ContributionPoint<AgentBlueprintContribution>;
|
|
525
525
|
/** One conversation opener a plugin offers on the empty chat. */
|
|
@@ -529,6 +529,12 @@ export type ChatSuggestionItem = {
|
|
|
529
529
|
/** What is submitted; the chip's words when absent. */
|
|
530
530
|
message?: string;
|
|
531
531
|
emoji?: string;
|
|
532
|
+
/**
|
|
533
|
+
* The heading it is offered under: openers of one group together, under
|
|
534
|
+
* its name, those without one first — an application's starter's category
|
|
535
|
+
* (LOOP P-20).
|
|
536
|
+
*/
|
|
537
|
+
group?: string;
|
|
532
538
|
};
|
|
533
539
|
/**
|
|
534
540
|
* Conversation openers, contributed ahead of the spec's own.
|
|
@@ -649,6 +655,13 @@ export type LoopChatExtrasValue = {
|
|
|
649
655
|
* offering a first question to somebody who has already asked one.
|
|
650
656
|
*/
|
|
651
657
|
hideOpeners?: boolean;
|
|
658
|
+
/**
|
|
659
|
+
* Openers in place of the contributed ones, live: what an application
|
|
660
|
+
* offers with the profile picked in its composer (LOOP P-20) — the empty
|
|
661
|
+
* chat offers these, and changes them when the profile changes, without
|
|
662
|
+
* rebuilding the reactor. `hiddenOpeners` still applies.
|
|
663
|
+
*/
|
|
664
|
+
openers?: ChatSuggestionItem[];
|
|
652
665
|
/**
|
|
653
666
|
* Told what the agent has used, each time the chat learns it.
|
|
654
667
|
*
|
|
@@ -91,6 +91,20 @@ export declare function useEmbedSession({ app, serverUrl, embedToken, resume, }:
|
|
|
91
91
|
embedToken?: string;
|
|
92
92
|
resume?: boolean;
|
|
93
93
|
}): EmbedSession;
|
|
94
|
+
/**
|
|
95
|
+
* Window messages from the page (LOOP P-25): on the host's own server, the
|
|
96
|
+
* page is handed how to post one to the application's session
|
|
97
|
+
* (`host.onWindowPort`) — the session the chat goes on with, its uid known
|
|
98
|
+
* from its first message — and what the code answers is raised on the page
|
|
99
|
+
* as `window-message` events. The session returned is the one to draw: its
|
|
100
|
+
* thread told when it starts.
|
|
101
|
+
*/
|
|
102
|
+
export declare function useWindowPort({ serverUrl, embedToken, session, host, }: {
|
|
103
|
+
serverUrl?: string;
|
|
104
|
+
embedToken?: string;
|
|
105
|
+
session: EmbedSession;
|
|
106
|
+
host?: AppEmbedHost;
|
|
107
|
+
}): EmbedSession;
|
|
94
108
|
/**
|
|
95
109
|
* The page and the application talking (LOOP D-10): the tools of the host
|
|
96
110
|
* the Appspec names, given to its agent through a chat-extras plugin made
|
|
@@ -52,7 +52,8 @@ import { floatingViewOf } from './embedConfig';
|
|
|
52
52
|
import { createChatExtrasPlugin } from '../plugins/chat-extras';
|
|
53
53
|
import { hostFrontendTools } from './hostBridge';
|
|
54
54
|
import { embedThemeOverrides, embedThemeStyles } from './embedTheme';
|
|
55
|
-
import { embedSessionKey, reattachSession, sessionGoneSentence, sessionKeeper, visitOfToken, } from './embedSession';
|
|
55
|
+
import { WINDOW_NOT_YET, embedSessionKey, postWindowMessage, reattachSession, sessionGoneSentence, sessionKeeper, visitOfToken, } from './embedSession';
|
|
56
|
+
import { onLoopWindowMessage } from '../../chat/base/loopWindow';
|
|
56
57
|
/**
|
|
57
58
|
* The visit's session, kept and picked up again (LOOP D-13): on the host's
|
|
58
59
|
* server with an embed token that names its visit, the session the chat
|
|
@@ -120,6 +121,55 @@ export function useEmbedSession({ app, serverUrl, embedToken, resume = true, })
|
|
|
120
121
|
...(found.said ? { said: found.said } : {}),
|
|
121
122
|
};
|
|
122
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Window messages from the page (LOOP P-25): on the host's own server, the
|
|
126
|
+
* page is handed how to post one to the application's session
|
|
127
|
+
* (`host.onWindowPort`) — the session the chat goes on with, its uid known
|
|
128
|
+
* from its first message — and what the code answers is raised on the page
|
|
129
|
+
* as `window-message` events. The session returned is the one to draw: its
|
|
130
|
+
* thread told when it starts.
|
|
131
|
+
*/
|
|
132
|
+
export function useWindowPort({ serverUrl, embedToken, session, host, }) {
|
|
133
|
+
const hostRef = useRef(host);
|
|
134
|
+
hostRef.current = host;
|
|
135
|
+
const given = session.thread;
|
|
136
|
+
const uid = useRef(given?.messages.length ? given.id : undefined);
|
|
137
|
+
// A thread of its own where the embed keeps none, so that its uid is known.
|
|
138
|
+
const own = useMemo(() => ({ id: generateMessageId(), messages: [] }), []);
|
|
139
|
+
const base = given ?? (serverUrl && session.ready ? own : undefined);
|
|
140
|
+
const thread = useMemo(() => base && {
|
|
141
|
+
...base,
|
|
142
|
+
onStarted: (id) => {
|
|
143
|
+
uid.current = id;
|
|
144
|
+
base.onStarted?.(id);
|
|
145
|
+
},
|
|
146
|
+
}, [base]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const port = host?.onWindowPort;
|
|
149
|
+
if (!serverUrl || !port) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
port(async (data) => {
|
|
153
|
+
if (!uid.current) {
|
|
154
|
+
throw new Error(WINDOW_NOT_YET);
|
|
155
|
+
}
|
|
156
|
+
const said = await postWindowMessage({
|
|
157
|
+
serverUrl,
|
|
158
|
+
uid: uid.current,
|
|
159
|
+
...(embedToken ? { token: embedToken } : {}),
|
|
160
|
+
data,
|
|
161
|
+
});
|
|
162
|
+
for (const answer of said) {
|
|
163
|
+
hostRef.current?.onEvent?.({
|
|
164
|
+
type: 'window-message',
|
|
165
|
+
detail: { data: answer },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return () => port(null);
|
|
170
|
+
}, [serverUrl, embedToken, host]);
|
|
171
|
+
return thread ? { ...session, thread } : session;
|
|
172
|
+
}
|
|
123
173
|
/** What the embed says once of its session, above the conversation. */
|
|
124
174
|
function SessionSaid({ said }) {
|
|
125
175
|
return said ? (_jsx(Text, { as: "p", role: "status", sx: { px: 3, py: 2, m: 0, fontSize: 1 }, children: said })) : null;
|
|
@@ -146,6 +196,11 @@ export function useHostBridge(app, host) {
|
|
|
146
196
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
197
|
}, [handle, key]);
|
|
148
198
|
const plugins = useMemo(() => (bridge ? [handle.plugin] : NO_HOST_PLUGINS), [bridge, handle]);
|
|
199
|
+
// What its code tells the page during a turn (LOOP P-25), raised on it.
|
|
200
|
+
useEffect(() => onLoopWindowMessage(data => hostRef.current?.onEvent?.({
|
|
201
|
+
type: 'window-message',
|
|
202
|
+
detail: { data },
|
|
203
|
+
})), []);
|
|
149
204
|
const said = useRef({ answering: false });
|
|
150
205
|
const before = useRef('idle');
|
|
151
206
|
const told = useRef('');
|
|
@@ -278,7 +333,14 @@ export function EmbedThemed({ accent, colorMode, font, variant, children, style,
|
|
|
278
333
|
}
|
|
279
334
|
export function AppEmbed({ app, mode, accent, colorMode, font, serverUrl, instance, embedToken, height = 640, ownPortal = false, plugins = NO_PLUGINS, host, resume = true, }) {
|
|
280
335
|
const bridge = useHostBridge(app, host);
|
|
281
|
-
const
|
|
336
|
+
const kept = useEmbedSession({ app, serverUrl, embedToken, resume });
|
|
337
|
+
// Window messages from the page, on the host's own server (P-25).
|
|
338
|
+
const session = useWindowPort({
|
|
339
|
+
serverUrl,
|
|
340
|
+
embedToken,
|
|
341
|
+
session: kept,
|
|
342
|
+
host,
|
|
343
|
+
});
|
|
282
344
|
const system = useSystemMode();
|
|
283
345
|
// The theme the application names (T-30), `loop` when none; its mode
|
|
284
346
|
// the host's, else the application's, else the visitor's system's.
|
|
@@ -38,6 +38,11 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
38
38
|
* when it called a function of the page, beside `decision` and
|
|
39
39
|
* `token-expired`.
|
|
40
40
|
*
|
|
41
|
+
* Window messages (LOOP P-25), on the host's own server: the element raises
|
|
42
|
+
* `window-message` (`detail.data`) when the application's code tells the page
|
|
43
|
+
* something outside the conversation (`session.send_window_message`), and
|
|
44
|
+
* `element.postWindowMessage(data)` hands the page's to its `@app.window`.
|
|
45
|
+
*
|
|
41
46
|
* On the host's server (`server`) with the visit's embed token, the
|
|
42
47
|
* session the application opens is kept in the page's `localStorage`, under
|
|
43
48
|
* the application and the visit its token names, and picked up again after
|
|
@@ -54,6 +59,7 @@ import { coreStore } from '@datalayer/core/lib/state/substates/CoreState';
|
|
|
54
59
|
import { parseAppspec } from '../apps/appspec';
|
|
55
60
|
import { readAppspecYaml } from '../apps/yaml';
|
|
56
61
|
import { AppEmbed, embedAssistantCharacter } from './AppEmbed';
|
|
62
|
+
import { WINDOW_NOT_YET } from './embedSession';
|
|
57
63
|
import { EMBED_HOST_VARIABLES, EMBED_OBSERVED_ATTRIBUTES, EMBED_TAG, EmbedAttributeError, embedLookOf, inlineHeightOf, resumeOf, } from './embedConfig';
|
|
58
64
|
import { embedShadowCss } from './embedTheme';
|
|
59
65
|
/** What the framed run page and the element say to each other. */
|
|
@@ -193,10 +199,15 @@ export function defineDatalayerAppElement(options = {}) {
|
|
|
193
199
|
passed = {};
|
|
194
200
|
/** One object, its functions replaced in place: the host below holds it. */
|
|
195
201
|
offered = {};
|
|
202
|
+
/** How a window message reaches the application's session, once it can (P-25). */
|
|
203
|
+
windowPort = null;
|
|
196
204
|
/** What the page gives the application, read at each call (D-10). */
|
|
197
205
|
host = {
|
|
198
206
|
context: () => this.passed,
|
|
199
207
|
functions: this.offered,
|
|
208
|
+
onWindowPort: post => {
|
|
209
|
+
this.windowPort = post;
|
|
210
|
+
},
|
|
200
211
|
onEvent: event => this.dispatchEvent(new CustomEvent(event.type, {
|
|
201
212
|
detail: event.detail,
|
|
202
213
|
bubbles: true,
|
|
@@ -210,6 +221,18 @@ export function defineDatalayerAppElement(options = {}) {
|
|
|
210
221
|
set context(value) {
|
|
211
222
|
this.passed = { ...(value ?? {}) };
|
|
212
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Post a window message to the application (LOOP P-25): its code's
|
|
226
|
+
* `@app.window` reads it, and what it answers is raised as
|
|
227
|
+
* `window-message` events. Refused, in a sentence, before its
|
|
228
|
+
* conversation has started or off the host's own server.
|
|
229
|
+
*/
|
|
230
|
+
postWindowMessage(data) {
|
|
231
|
+
const post = this.windowPort;
|
|
232
|
+
return post
|
|
233
|
+
? post(data)
|
|
234
|
+
: Promise.reject(new Error(`datalayer-app: ${WINDOW_NOT_YET}`));
|
|
235
|
+
}
|
|
213
236
|
/** The page's functions the application may call, by name (D-10). */
|
|
214
237
|
get functions() {
|
|
215
238
|
return this.offered;
|
|
@@ -75,5 +75,19 @@ export declare function reattachSession({ serverUrl, uid, token, fetcher, }: {
|
|
|
75
75
|
token: string;
|
|
76
76
|
fetcher?: typeof fetch;
|
|
77
77
|
}): Promise<Reattached>;
|
|
78
|
+
/** Said when a window message is posted before the conversation has started. */
|
|
79
|
+
export declare const WINDOW_NOT_YET = "The application takes a window message once its conversation has started, on its own server (the \"server\" attribute).";
|
|
80
|
+
/**
|
|
81
|
+
* Post a window message to a session (LOOP P-25): its code's `@app.window`
|
|
82
|
+
* runs on it. Returns what the code told the page back; refused — the
|
|
83
|
+
* runtime's sentence — when the session or its code takes none.
|
|
84
|
+
*/
|
|
85
|
+
export declare function postWindowMessage({ serverUrl, uid, token, data, fetcher, }: {
|
|
86
|
+
serverUrl: string;
|
|
87
|
+
uid: string;
|
|
88
|
+
token?: string;
|
|
89
|
+
data: unknown;
|
|
90
|
+
fetcher?: typeof fetch;
|
|
91
|
+
}): Promise<unknown[]>;
|
|
78
92
|
/** What the page says, once, when the conversation it kept is gone. */
|
|
79
93
|
export declare const sessionGoneSentence: (name: string) => string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
+
import { windowTurnOf } from '../../chat/base/loopWindow';
|
|
5
6
|
/** Where a visit's session is kept in the host's storage: `<prefix>:<app>:<visit>`. */
|
|
6
7
|
export const EMBED_SESSION_KEY_PREFIX = 'datalayer-app:session';
|
|
7
8
|
/**
|
|
@@ -132,5 +133,38 @@ export async function reattachSession({ serverUrl, uid, token, fetcher = fetch,
|
|
|
132
133
|
return { kind: 'gone', uid };
|
|
133
134
|
}
|
|
134
135
|
}
|
|
136
|
+
/** Said when a window message is posted before the conversation has started. */
|
|
137
|
+
export const WINDOW_NOT_YET = 'The application takes a window message once its conversation has started, on its own server (the "server" attribute).';
|
|
138
|
+
/**
|
|
139
|
+
* Post a window message to a session (LOOP P-25): its code's `@app.window`
|
|
140
|
+
* runs on it. Returns what the code told the page back; refused — the
|
|
141
|
+
* runtime's sentence — when the session or its code takes none.
|
|
142
|
+
*/
|
|
143
|
+
export async function postWindowMessage({ serverUrl, uid, token, data, fetcher = fetch, }) {
|
|
144
|
+
const response = await fetcher(`${serverUrl.replace(/\/+$/, '')}/api/v1/apps/sessions/${encodeURIComponent(uid)}/window`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: {
|
|
147
|
+
'content-type': 'application/json',
|
|
148
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({ data }),
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
let detail = '';
|
|
154
|
+
try {
|
|
155
|
+
const body = (await response.json());
|
|
156
|
+
detail = typeof body.detail === 'string' ? body.detail : '';
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// No sentence: the status says it.
|
|
160
|
+
}
|
|
161
|
+
throw new Error(detail || `The window message was refused (${response.status}).`);
|
|
162
|
+
}
|
|
163
|
+
const turn = windowTurnOf(await response.text());
|
|
164
|
+
if (turn.error) {
|
|
165
|
+
throw new Error(turn.error);
|
|
166
|
+
}
|
|
167
|
+
return turn.said;
|
|
168
|
+
}
|
|
135
169
|
/** What the page says, once, when the conversation it kept is gone. */
|
|
136
170
|
export const sessionGoneSentence = (name) => `Your earlier conversation with ${name} has ended, so this is a new one.`;
|
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
* its Appspec names (`deployment.embedded.host.context`).
|
|
7
7
|
*
|
|
8
8
|
* Out: what the application does, as events — `message` when it has said
|
|
9
|
-
* something, `action` when it called a function of the page
|
|
9
|
+
* something, `action` when it called a function of the page,
|
|
10
|
+
* `window-message` when its code told the page something outside the
|
|
11
|
+
* conversation (LOOP P-25, `session.send_window_message`) — beside the
|
|
10
12
|
* `decision` and `token-expired` the element raises already.
|
|
11
13
|
*
|
|
14
|
+
* Window messages, in (P-25): the page posts one with
|
|
15
|
+
* `element.postWindowMessage(data)`; its code's `@app.window` reads it, on
|
|
16
|
+
* the host's own server (`server`), and what it answers comes back as
|
|
17
|
+
* `window-message` events.
|
|
18
|
+
*
|
|
12
19
|
* Offered: the functions of the page its Appspec names
|
|
13
20
|
* (`deployment.embedded.host.functions`), each called by its agent as the
|
|
14
21
|
* tool `host_<name>`. Every one of these tools is decided in the page by
|
|
@@ -42,7 +49,15 @@ export type HostEvent = {
|
|
|
42
49
|
result?: unknown;
|
|
43
50
|
error?: string;
|
|
44
51
|
};
|
|
52
|
+
} | {
|
|
53
|
+
type: 'window-message';
|
|
54
|
+
/** What its code told the page (LOOP P-25): anything JSON writes. */
|
|
55
|
+
detail: {
|
|
56
|
+
data: unknown;
|
|
57
|
+
};
|
|
45
58
|
};
|
|
59
|
+
/** Posts a window message to the application's session (LOOP P-25). */
|
|
60
|
+
export type WindowPost = (data: unknown) => Promise<void>;
|
|
46
61
|
/** A function of the page, as the page gives it. */
|
|
47
62
|
export type HostFunction = (args: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
48
63
|
/** What the page gives an embedded application (LOOP D-10). */
|
|
@@ -58,6 +73,12 @@ export type AppEmbedHost = {
|
|
|
58
73
|
* may be made; the browser's `confirm` when the page gives none.
|
|
59
74
|
*/
|
|
60
75
|
ask?: (question: HostQuestion) => boolean | Promise<boolean>;
|
|
76
|
+
/**
|
|
77
|
+
* Handed how a window message is posted to the application's session once
|
|
78
|
+
* the embed can (LOOP P-25) — on the host's own server, its conversation
|
|
79
|
+
* started — and `null` when it no longer can.
|
|
80
|
+
*/
|
|
81
|
+
onWindowPort?: (post: WindowPost | null) => void;
|
|
61
82
|
};
|
|
62
83
|
/** What the person is asked before a call of the page is made. */
|
|
63
84
|
export type HostQuestion = {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The elements an application's code opened beside the conversation (LOOP
|
|
3
|
+
* P-18): a side panel on the trailing edge, and a page of its own over the
|
|
4
|
+
* workspace — each drawn from the same A2UI surface an answer is drawn from
|
|
5
|
+
* (`InlineSurface`), its buttons sent to the application's actions as an
|
|
6
|
+
* answer's are (`answerAction`), and closed with its own button.
|
|
7
|
+
*
|
|
8
|
+
* Drawn from the workspace's root slot, positioned against the workspace:
|
|
9
|
+
* nothing is drawn, and nothing is laid out, while nothing is open.
|
|
10
|
+
*
|
|
11
|
+
* @module loop/plugins/app-elements/AppElements
|
|
12
|
+
*/
|
|
13
|
+
import type { JSX } from 'react';
|
|
14
|
+
import { type LoopElement } from '../../../chat/base/loopElement';
|
|
15
|
+
import type { LoopWorkspaceContext } from '../../core';
|
|
16
|
+
/** How wide the side panel is, at most. */
|
|
17
|
+
export declare const APP_PANEL_WIDTH = 380;
|
|
18
|
+
/** What is open now: the panels in the order they opened, and the last page. */
|
|
19
|
+
export declare function elementsByPlace(open: readonly LoopElement[]): {
|
|
20
|
+
panels: LoopElement[];
|
|
21
|
+
page: LoopElement | null;
|
|
22
|
+
};
|
|
23
|
+
/** Every element open in a side panel or on a page, drawn where it belongs. */
|
|
24
|
+
export declare function AppElements({ workspace, }: {
|
|
25
|
+
workspace?: LoopWorkspaceContext;
|
|
26
|
+
}): JSX.Element | null;
|
|
27
|
+
export default AppElements;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
3
|
+
import { IconButton, Text } from '@primer/react';
|
|
4
|
+
import { XIcon } from '@primer/octicons-react';
|
|
5
|
+
import { Box } from '@datalayer/primer-addons';
|
|
6
|
+
import { closeLoopElement, onLoopElements, openLoopElements, } from '../../../chat/base/loopElement';
|
|
7
|
+
import { InlineSurface, SURFACE_CATALOG_ID, } from '../a2ui-surface/InlineSurface';
|
|
8
|
+
import { answerAction, readA2uiToolResult } from '../a2ui-surface/toolResult';
|
|
9
|
+
/** How wide the side panel is, at most. */
|
|
10
|
+
export const APP_PANEL_WIDTH = 380;
|
|
11
|
+
/** What is open now: the panels in the order they opened, and the last page. */
|
|
12
|
+
export function elementsByPlace(open) {
|
|
13
|
+
const pages = open.filter(element => element.where === 'page');
|
|
14
|
+
return {
|
|
15
|
+
panels: open.filter(element => element.where === 'panel'),
|
|
16
|
+
page: pages.length > 0 ? pages[pages.length - 1] : null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function ElementBody({ element, send, }) {
|
|
20
|
+
const parsed = useMemo(() => readA2uiToolResult(element.shows, SURFACE_CATALOG_ID), [element.shows]);
|
|
21
|
+
const onAction = (action) => {
|
|
22
|
+
// A button of what its code shows: its action goes to the application,
|
|
23
|
+
// as a button of an answer does (LOOP P-04).
|
|
24
|
+
const pressed = answerAction(action);
|
|
25
|
+
if (pressed)
|
|
26
|
+
send?.(pressed.message, pressed.forwardedProps);
|
|
27
|
+
};
|
|
28
|
+
if (!parsed?.messages || parsed.messages.length === 0) {
|
|
29
|
+
return _jsx(Text, { sx: { color: 'fg.muted' }, children: "Nothing to show." });
|
|
30
|
+
}
|
|
31
|
+
return (_jsx(InlineSurface
|
|
32
|
+
// Drawn again whole when the code changes it.
|
|
33
|
+
, { messages: parsed.messages, onAction: onAction }, JSON.stringify(element.shows)));
|
|
34
|
+
}
|
|
35
|
+
function ElementHeader({ element }) {
|
|
36
|
+
return (_jsxs(Box, { sx: {
|
|
37
|
+
display: 'flex',
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
justifyContent: 'space-between',
|
|
40
|
+
gap: 2,
|
|
41
|
+
mb: 2,
|
|
42
|
+
}, children: [_jsx(Text, { as: "h2", sx: { fontSize: 2, fontWeight: 'bold', m: 0 }, children: element.title }), _jsx(IconButton, { icon: XIcon, variant: "invisible", size: "small", "aria-label": `Close ${element.title}`, onClick: () => closeLoopElement(element.id) })] }));
|
|
43
|
+
}
|
|
44
|
+
/** Every element open in a side panel or on a page, drawn where it belongs. */
|
|
45
|
+
export function AppElements({ workspace, }) {
|
|
46
|
+
const open = useSyncExternalStore(onLoopElements, openLoopElements, openLoopElements);
|
|
47
|
+
const { panels, page } = elementsByPlace(open);
|
|
48
|
+
const send = workspace?.viewControls.send;
|
|
49
|
+
if (panels.length === 0 && page === null)
|
|
50
|
+
return null;
|
|
51
|
+
return (_jsxs(_Fragment, { children: [panels.length > 0 ? (_jsx(Box, { as: "aside", "aria-label": "Side panel", sx: {
|
|
52
|
+
position: 'absolute',
|
|
53
|
+
top: 0,
|
|
54
|
+
right: 0,
|
|
55
|
+
bottom: 0,
|
|
56
|
+
width: `min(${APP_PANEL_WIDTH}px, 100%)`,
|
|
57
|
+
zIndex: 1,
|
|
58
|
+
overflowY: 'auto',
|
|
59
|
+
borderLeft: '1px solid',
|
|
60
|
+
borderColor: 'border.default',
|
|
61
|
+
bg: 'canvas.subtle',
|
|
62
|
+
boxShadow: 'shadow.medium',
|
|
63
|
+
p: 3,
|
|
64
|
+
display: 'flex',
|
|
65
|
+
flexDirection: 'column',
|
|
66
|
+
gap: 3,
|
|
67
|
+
}, children: panels.map(element => (_jsxs(Box, { as: "section", "aria-label": element.title, children: [_jsx(ElementHeader, { element: element }), _jsx(ElementBody, { element: element, send: send })] }, element.id))) })) : null, page !== null ? (_jsxs(Box, { role: "dialog", "aria-label": page.title, sx: {
|
|
68
|
+
position: 'absolute',
|
|
69
|
+
inset: 0,
|
|
70
|
+
zIndex: 2,
|
|
71
|
+
overflowY: 'auto',
|
|
72
|
+
bg: 'canvas.default',
|
|
73
|
+
p: [3, 4],
|
|
74
|
+
}, children: [_jsx(ElementHeader, { element: page }), _jsx(ElementBody, { element: page, send: send })] })) : null] }));
|
|
75
|
+
}
|
|
76
|
+
export default AppElements;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `app-elements` — what an application's code opens beside the
|
|
3
|
+
* conversation (LOOP P-18): `session.show(..., where="panel")` a side panel,
|
|
4
|
+
* `where="page"` a page of its own over the workspace, each closed by the
|
|
5
|
+
* code (`session.close`) or by the person. Inline, an element is a message,
|
|
6
|
+
* which the chat draws (P-04); this plugin draws the other two.
|
|
7
|
+
*
|
|
8
|
+
* The chat keeps what is open (`chat/base/loopElement`), from the session
|
|
9
|
+
* API's `loop.element` events; this plugin draws it from the workspace's
|
|
10
|
+
* root slot, positioned against the workspace, so that nothing is laid out
|
|
11
|
+
* while nothing is open.
|
|
12
|
+
*
|
|
13
|
+
* @module loop/plugins/app-elements
|
|
14
|
+
*/
|
|
15
|
+
import { type ReactorPlugin } from '@datalayer/reactor';
|
|
16
|
+
export declare const APP_ELEMENTS_PLUGIN_NAME = "@datalayer/loop-plugin-app-elements";
|
|
17
|
+
/** The plugin that draws an application's side panel and pages. */
|
|
18
|
+
export declare const AppElementsPlugin: ReactorPlugin<Record<string, never>, unknown, unknown>;
|
|
19
|
+
export { AppElements, APP_PANEL_WIDTH, elementsByPlace } from './AppElements';
|
|
20
|
+
export default AppElementsPlugin;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* `app-elements` — what an application's code opens beside the
|
|
7
|
+
* conversation (LOOP P-18): `session.show(..., where="panel")` a side panel,
|
|
8
|
+
* `where="page"` a page of its own over the workspace, each closed by the
|
|
9
|
+
* code (`session.close`) or by the person. Inline, an element is a message,
|
|
10
|
+
* which the chat draws (P-04); this plugin draws the other two.
|
|
11
|
+
*
|
|
12
|
+
* The chat keeps what is open (`chat/base/loopElement`), from the session
|
|
13
|
+
* API's `loop.element` events; this plugin draws it from the workspace's
|
|
14
|
+
* root slot, positioned against the workspace, so that nothing is laid out
|
|
15
|
+
* while nothing is open.
|
|
16
|
+
*
|
|
17
|
+
* @module loop/plugins/app-elements
|
|
18
|
+
*/
|
|
19
|
+
import { definePlugin } from '@datalayer/reactor';
|
|
20
|
+
import { clearLoopElements } from '../../../chat/base/loopElement';
|
|
21
|
+
import { LoopSlots } from '../../core';
|
|
22
|
+
import { AppElements } from './AppElements';
|
|
23
|
+
export const APP_ELEMENTS_PLUGIN_NAME = '@datalayer/loop-plugin-app-elements';
|
|
24
|
+
/** The plugin that draws an application's side panel and pages. */
|
|
25
|
+
export const AppElementsPlugin = definePlugin({
|
|
26
|
+
name: APP_ELEMENTS_PLUGIN_NAME,
|
|
27
|
+
displayName: 'Side panel and pages',
|
|
28
|
+
description: 'What an application opens beside the conversation: a side panel, or a page of its own.',
|
|
29
|
+
octicon: 'sidebar-expand',
|
|
30
|
+
build: () => ({
|
|
31
|
+
components: [
|
|
32
|
+
{
|
|
33
|
+
id: 'app-elements',
|
|
34
|
+
slot: LoopSlots.root,
|
|
35
|
+
Component: AppElements,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
// A workspace that goes takes what it had open with it.
|
|
40
|
+
register: () => clearLoopElements,
|
|
41
|
+
});
|
|
42
|
+
export { AppElements, APP_PANEL_WIDTH, elementsByPlace } from './AppElements';
|
|
43
|
+
export default AppElementsPlugin;
|
|
@@ -492,6 +492,8 @@ function settingsForm(app) {
|
|
|
492
492
|
id: 'settings',
|
|
493
493
|
component: 'Form',
|
|
494
494
|
schema: app.interface.settings,
|
|
495
|
+
// Drawn with the inputs its uiSchema names (P-20).
|
|
496
|
+
...(app.interface.settingsUi ? { ui: app.interface.settingsUi } : {}),
|
|
495
497
|
values: { path: '/inputs' },
|
|
496
498
|
},
|
|
497
499
|
]
|
|
@@ -372,11 +372,12 @@ export default function ChatView({ workspace }) {
|
|
|
372
372
|
* itself (`modeEffect`, `BrowserAgentAdapter`).
|
|
373
373
|
*/
|
|
374
374
|
const sendNow = useCallback((message, given) => {
|
|
375
|
-
const
|
|
375
|
+
const forwardedProps = runForwardedProps(runPropsRef.current.map(entry => entry.value), given);
|
|
376
|
+
// The page's files, or those attached in the composer (LOOP P-21).
|
|
377
|
+
const loop = forwardedProps?.loop;
|
|
376
378
|
if (inPageRef.current && loop?.files && loop.files.length > 0) {
|
|
377
379
|
return 'A file is given to an application running on a runtime: run it on Datalayer or on your machine to give it one.';
|
|
378
380
|
}
|
|
379
|
-
const forwardedProps = runForwardedProps(runPropsRef.current.map(entry => entry.value), given);
|
|
380
381
|
// A new turn: whatever the panel showed is gone, this message is it.
|
|
381
382
|
turnFeedRef.current?.begin(message, controlsRef.current?.thread());
|
|
382
383
|
const send = controlsRef.current?.send;
|
|
@@ -752,6 +753,7 @@ export default function ChatView({ workspace }) {
|
|
|
752
753
|
const suggestionEntries = useContributions(LoopChatSuggestion);
|
|
753
754
|
const hiddenOpeners = chatExtras.hiddenOpeners;
|
|
754
755
|
const hideOpeners = chatExtras.hideOpeners;
|
|
756
|
+
const liveOpeners = chatExtras.openers;
|
|
755
757
|
const suggestions = useMemo(() => {
|
|
756
758
|
if (hideOpeners) {
|
|
757
759
|
return [];
|
|
@@ -760,6 +762,11 @@ export default function ChatView({ workspace }) {
|
|
|
760
762
|
// the two-group merge below uses to tell openers apart.
|
|
761
763
|
const hidden = new Set(hiddenOpeners ?? []);
|
|
762
764
|
const offered = (items) => hidden.size === 0 ? items : items.filter(item => !hidden.has(item.text));
|
|
765
|
+
// What a plugin offers live (an application's profile's starters,
|
|
766
|
+
// LOOP P-20) wins over what was contributed when the reactor was built.
|
|
767
|
+
if (liveOpeners) {
|
|
768
|
+
return offered(liveOpeners);
|
|
769
|
+
}
|
|
763
770
|
const contributed = suggestionEntries.flatMap(entry => entry.value.suggestions);
|
|
764
771
|
if (contributed.length > 0) {
|
|
765
772
|
return offered(contributed);
|
|
@@ -788,6 +795,7 @@ export default function ChatView({ workspace }) {
|
|
|
788
795
|
agentId,
|
|
789
796
|
hiddenOpeners,
|
|
790
797
|
hideOpeners,
|
|
798
|
+
liveOpeners,
|
|
791
799
|
]);
|
|
792
800
|
/*
|
|
793
801
|
* The same openers in the shape the chat's empty state asks for.
|
|
@@ -70,7 +70,7 @@ export interface BrowserAgentAdapterConfig extends ProtocolAdapterConfig, Fronte
|
|
|
70
70
|
* rules. Unsaid for an agent with no modes, which refuses a run sent in
|
|
71
71
|
* some.
|
|
72
72
|
*/
|
|
73
|
-
modeEffect?: (chosen: Record<string, string
|
|
73
|
+
modeEffect?: (chosen: Record<string, string>, profile?: string) => AppModeEffect;
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
76
|
* An agent loop running in the page, speaking the protocol adapter interface.
|
|
@@ -120,12 +120,14 @@ export declare class BrowserAgentAdapter extends BaseProtocolAdapter {
|
|
|
120
120
|
*/
|
|
121
121
|
private resolveModel;
|
|
122
122
|
/**
|
|
123
|
-
* What the modes a run is sent in
|
|
124
|
-
*
|
|
123
|
+
* What the modes a run is sent in, and the profile its conversation is
|
|
124
|
+
* with, tell it, and the model it runs on (LOOP P-19, P-20) — the
|
|
125
|
+
* runtime's session API applies the same, for that run only.
|
|
125
126
|
*
|
|
126
|
-
* Refused, in a sentence, rather than run without them: modes
|
|
127
|
-
* agent that has none, modes that are not the
|
|
128
|
-
*
|
|
127
|
+
* Refused, in a sentence, rather than run without them: modes or a profile
|
|
128
|
+
* sent to an agent that has none, modes or a profile that are not the
|
|
129
|
+
* application's, and a model chosen for an agent whose model is the host's
|
|
130
|
+
* own.
|
|
129
131
|
*/
|
|
130
132
|
private runModes;
|
|
131
133
|
/**
|