@datalayer/agent-runtimes 1.3.11 → 1.3.13
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/README.md +1 -2
- package/lib/AgentNode.js +3 -3
- package/lib/api/runtimes/healthz.js +4 -2
- package/lib/api/spacer/healthz.js +4 -2
- package/lib/chat/base/ChatBase.js +39 -25
- package/lib/chat/base/modelChoice.d.ts +62 -0
- package/lib/chat/base/modelChoice.js +56 -0
- package/lib/chat/messages/ChatMessageList.js +13 -5
- package/lib/chat/messages/FormSubmissionMessage.d.ts +17 -0
- package/lib/chat/messages/FormSubmissionMessage.js +49 -0
- package/lib/chat/messages/formSubmission.d.ts +41 -0
- package/lib/chat/messages/formSubmission.js +90 -0
- package/lib/chat/prompt/menus/ModelSelector.js +11 -4
- package/lib/chat/styles/streamdownStyles.js +40 -0
- package/lib/client/mixins/RuntimesMixin.js +1 -1
- package/lib/client/mixins/SpacerMixin.js +3 -3
- package/lib/components/a2ui/A2uiMarkdownProvider.d.ts +11 -0
- package/lib/components/a2ui/A2uiMarkdownProvider.js +13 -0
- package/lib/components/a2ui/index.d.ts +1 -0
- package/lib/components/a2ui/index.js +1 -0
- package/lib/examples/A2UiAgentExample.js +36 -223
- package/lib/examples/A2UiJupyterOutputExample.js +11 -1
- package/lib/examples/A2UiViewerExample.d.ts +9 -2
- package/lib/examples/A2UiViewerExample.js +33 -3
- package/lib/examples/main.js +37 -35
- package/lib/examples/utils/a2uiMarkdownProvider.d.ts +1 -7
- package/lib/examples/utils/a2uiMarkdownProvider.js +6 -8
- package/lib/loop/core/index.d.ts +33 -2
- package/lib/loop/core/index.js +29 -2
- package/lib/loop/embed/LoopEmbed.js +1 -0
- package/lib/loop/index.d.ts +1 -1
- package/lib/loop/index.js +4 -1
- package/lib/loop/plugins/a2ui-surface/InlineSurface.d.ts +22 -0
- package/lib/loop/plugins/a2ui-surface/InlineSurface.js +64 -0
- package/lib/loop/plugins/a2ui-surface/SurfaceToolResult.d.ts +38 -0
- package/lib/loop/plugins/a2ui-surface/SurfaceToolResult.js +64 -0
- package/lib/loop/plugins/a2ui-surface/index.d.ts +34 -0
- package/lib/loop/plugins/a2ui-surface/index.js +91 -0
- package/lib/loop/plugins/a2ui-surface/toolResult.d.ts +52 -0
- package/lib/loop/plugins/a2ui-surface/toolResult.js +106 -0
- package/lib/loop/plugins/chat/ChatView.js +81 -10
- package/lib/loop/plugins/chat/index.d.ts +14 -0
- package/lib/loop/plugins/chat/index.js +1 -0
- package/lib/loop/plugins/document/index.js +4 -0
- package/lib/loop/plugins/notebook/index.js +4 -0
- package/lib/loop/plugins/shell/useEditorPreviews.js +7 -0
- package/lib/loop/presets.d.ts +5 -0
- package/lib/loop/presets.js +7 -1
- package/lib/models/environments.generated.d.ts +7 -1
- package/lib/models/environments.generated.js +1 -1
- package/lib/specs/agents/agents.d.ts +1 -0
- package/lib/specs/agents/agents.js +112 -0
- package/lib/specs/models.js +3 -3
- package/lib/tools/adapters/copilotkit/CopilotKitToolAdapter.js +1 -1
- package/package.json +10 -4
- package/patches/@primer+react+37.31.0.patch +390 -0
- package/patches/eslint-plugin-react+7.37.5.patch +65 -0
- package/scripts/apply-patches.sh +15 -1
- package/scripts/codegen/compose.py +4 -4
- package/scripts/codegen/generate_agents.py +1 -2
- package/scripts/codegen/generate_frontend_tools.py +1 -1
- package/scripts/codegen/generate_models.py +33 -8
package/lib/loop/core/index.js
CHANGED
|
@@ -71,13 +71,27 @@ export const IDLE_SANDBOX_TARGET_SIGNAL = {
|
|
|
71
71
|
export function loopSurfaceId(sessionId) {
|
|
72
72
|
return `loop-${sessionId || 'default'}`;
|
|
73
73
|
}
|
|
74
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Create a prompt channel. The shell owns one per workspace.
|
|
76
|
+
*
|
|
77
|
+
* The shell's composer is on screen before the view that answers it: the
|
|
78
|
+
* editor and its sandbox are megabytes and take a moment, and the prompt is
|
|
79
|
+
* ready to type into immediately. A message sent in that moment used to be
|
|
80
|
+
* dropped — it appeared in the conversation and was never answered, and the
|
|
81
|
+
* only way to find out was to send it again — so an unheard message is held
|
|
82
|
+
* and handed to the first view that subscribes, in the order it was sent.
|
|
83
|
+
*/
|
|
75
84
|
export function createPromptChannel() {
|
|
76
85
|
const listeners = new Set();
|
|
86
|
+
/** Said before anything was listening, waiting for the first view. */
|
|
87
|
+
const waiting = [];
|
|
77
88
|
return {
|
|
78
89
|
submit(message) {
|
|
79
90
|
if (listeners.size === 0) {
|
|
80
|
-
|
|
91
|
+
waiting.push(message);
|
|
92
|
+
// Heard, as far as the sender is concerned: it is going to be
|
|
93
|
+
// answered, so the composer clears and no failure is reported.
|
|
94
|
+
return true;
|
|
81
95
|
}
|
|
82
96
|
for (const listener of [...listeners]) {
|
|
83
97
|
listener(message);
|
|
@@ -86,6 +100,19 @@ export function createPromptChannel() {
|
|
|
86
100
|
},
|
|
87
101
|
subscribe(listener) {
|
|
88
102
|
listeners.add(listener);
|
|
103
|
+
if (waiting.length > 0) {
|
|
104
|
+
const held = waiting.splice(0, waiting.length);
|
|
105
|
+
/*
|
|
106
|
+
* Next tick, not now: `subscribe` is called from an effect as the
|
|
107
|
+
* view mounts, and a listener that started answering inside it would
|
|
108
|
+
* be setting state on a component in the middle of mounting.
|
|
109
|
+
*/
|
|
110
|
+
queueMicrotask(() => {
|
|
111
|
+
for (const message of held) {
|
|
112
|
+
listener(message);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
89
116
|
return () => {
|
|
90
117
|
listeners.delete(listener);
|
|
91
118
|
};
|
|
@@ -66,6 +66,7 @@ export function LoopEmbed({ agentId = '', showHeader = false, chatHeaderActions,
|
|
|
66
66
|
preset.defaultEditor,
|
|
67
67
|
preset.showViewSelector,
|
|
68
68
|
preset.hideChatHeader,
|
|
69
|
+
preset.chatHeaderButtons,
|
|
69
70
|
preset.promptPlacement,
|
|
70
71
|
preset.autoFocusPrompt,
|
|
71
72
|
preset.fullScreenTopOffset,
|
package/lib/loop/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { ChatHeaderPlugin, CHAT_HEADER_PLUGIN_NAME, } from './plugins/chat-heade
|
|
|
16
16
|
export { InputPromptPlugin, INPUT_PROMPT_PLUGIN_NAME, type InputPromptPluginConfig, } from './plugins/input-prompt';
|
|
17
17
|
export { createChatExtrasPlugin, CHAT_EXTRAS_PLUGIN_NAME, type ChatExtrasHandle, } from './plugins/chat-extras';
|
|
18
18
|
export { LoopChatExtras, LoopChatLayout, LoopChatTurn, LoopOpeningDocument, LoopOpeningNotebook, LoopPromptPanel, type ChatLayoutContribution, type ChatLayoutParts, type ChatTurnContribution, type ChatTurnSnapshot, type ChatTurnStatus, type LoopChatExtrasValue, type OpeningDocumentContribution, type OpeningNotebookContribution, type PromptPanelContribution, } from './core';
|
|
19
|
-
export { LoopPageLayoutPlugin, LOOP_PAGE_LAYOUT_PLUGIN_NAME, openConversationPanel, pageLayoutSheet, pageLayoutPanelOpen, type LoopPageLayoutConfig, } from './plugins/page-layout';
|
|
19
|
+
export { LoopPageLayoutPlugin, LOOP_PAGE_LAYOUT_PLUGIN_NAME, openConversationPanel, pageLayoutSheet, pageLayoutPanelOpen, setPageLayoutChatMode, type LoopPageLayoutConfig, } from './plugins/page-layout';
|
|
20
20
|
export { useOptionalTeamSelection } from './plugins/agents/useTeamSelection';
|
|
21
21
|
export type { TeamMember, TeamSelection } from './plugins/agents/team';
|
|
22
22
|
export { ChatViewPlugin, CHAT_VIEW_PLUGIN_NAME } from './plugins/chat-view';
|
package/lib/loop/index.js
CHANGED
|
@@ -26,7 +26,10 @@ export { createChatExtrasPlugin, CHAT_EXTRAS_PLUGIN_NAME, } from './plugins/chat
|
|
|
26
26
|
// read back: the chat extras, the opening notebook, and who the team is
|
|
27
27
|
// talking to. A page composing panels around a `LoopEmbed` needs all three.
|
|
28
28
|
export { LoopChatExtras, LoopChatLayout, LoopChatTurn, LoopOpeningDocument, LoopOpeningNotebook, LoopPromptPanel, } from './core';
|
|
29
|
-
export { LoopPageLayoutPlugin, LOOP_PAGE_LAYOUT_PLUGIN_NAME, openConversationPanel, pageLayoutSheet, pageLayoutPanelOpen,
|
|
29
|
+
export { LoopPageLayoutPlugin, LOOP_PAGE_LAYOUT_PLUGIN_NAME, openConversationPanel, pageLayoutSheet, pageLayoutPanelOpen,
|
|
30
|
+
// How a host moves the composer: `sidebar` stands it in the conversation
|
|
31
|
+
// panel, under what was said, and opens the panel to show it.
|
|
32
|
+
setPageLayoutChatMode, } from './plugins/page-layout';
|
|
30
33
|
export { useOptionalTeamSelection } from './plugins/agents/useTeamSelection';
|
|
31
34
|
export { ChatViewPlugin, CHAT_VIEW_PLUGIN_NAME } from './plugins/chat-view';
|
|
32
35
|
export { NotebookViewPlugin, NOTEBOOK_VIEW_PLUGIN_NAME, } from './plugins/notebook-view';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generated A2UI surface, drawn inline where the agent's answer lands.
|
|
3
|
+
*
|
|
4
|
+
* Its own processor, so the surface can be filled in and submitted here
|
|
5
|
+
* whatever else is showing it. The basic catalogue draws from inherited
|
|
6
|
+
* `--a2ui-*` custom properties, so the surface follows the workspace's theme
|
|
7
|
+
* by inheriting rather than by being told — the same rule the Surface view
|
|
8
|
+
* follows, and what keeps a generated form from being the one light card in
|
|
9
|
+
* a dark workspace.
|
|
10
|
+
*
|
|
11
|
+
* @module loop/plugins/a2ui-surface/InlineSurface
|
|
12
|
+
*/
|
|
13
|
+
import type { JSX } from 'react';
|
|
14
|
+
import { type A2uiClientAction, type A2uiMessage } from '@a2ui/web_core/v0_9';
|
|
15
|
+
/** The catalogue a surface is rewritten to before it is drawn. */
|
|
16
|
+
export declare const SURFACE_CATALOG_ID: string;
|
|
17
|
+
export declare function InlineSurface({ messages, onAction, validationError, }: {
|
|
18
|
+
messages: A2uiMessage[];
|
|
19
|
+
onAction: (action: A2uiClientAction) => void;
|
|
20
|
+
validationError?: string | null;
|
|
21
|
+
}): JSX.Element | null;
|
|
22
|
+
export default InlineSurface;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { Box } from '@datalayer/primer-addons';
|
|
4
|
+
import { basicCatalog, } from '@a2ui/react/v0_9';
|
|
5
|
+
import { MessageProcessor, } from '@a2ui/web_core/v0_9';
|
|
6
|
+
import { A2UI_RENDER_SCOPE_SX, A2uiMarkdownProvider, A2uiSurfaceComposed, } from '../../../components/a2ui';
|
|
7
|
+
/** The catalogue a surface is rewritten to before it is drawn. */
|
|
8
|
+
export const SURFACE_CATALOG_ID = basicCatalog.id;
|
|
9
|
+
const INHERIT_THEME = {
|
|
10
|
+
['--a2ui-color-surface']: 'var(--bgColor-muted)',
|
|
11
|
+
['--a2ui-color-on-surface']: 'var(--fgColor-default)',
|
|
12
|
+
['--a2ui-color-primary']: 'var(--fgColor-accent)',
|
|
13
|
+
['--a2ui-color-outline']: 'var(--borderColor-default)',
|
|
14
|
+
};
|
|
15
|
+
export function InlineSurface({ messages, onAction, validationError, }) {
|
|
16
|
+
// Reached through a ref: the processor is built once, and the handler it
|
|
17
|
+
// was built with must not go stale when the host's does not.
|
|
18
|
+
const onActionRef = useRef(onAction);
|
|
19
|
+
onActionRef.current = onAction;
|
|
20
|
+
const processor = useMemo(() => new MessageProcessor([basicCatalog], action => onActionRef.current(action)), []);
|
|
21
|
+
const [surfaces, setSurfaces] = useState([]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const created = processor.onSurfaceCreated(surface => setSurfaces(previous => [...previous, surface]));
|
|
24
|
+
const deleted = processor.onSurfaceDeleted(id => setSurfaces(previous => previous.filter(surface => surface.id !== id)));
|
|
25
|
+
return () => {
|
|
26
|
+
created.unsubscribe();
|
|
27
|
+
deleted.unsubscribe();
|
|
28
|
+
};
|
|
29
|
+
}, [processor]);
|
|
30
|
+
// Once: the messages are one tool result, processed when it arrives.
|
|
31
|
+
const processedRef = useRef(false);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (processedRef.current) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
processedRef.current = true;
|
|
37
|
+
processor.processMessages(messages);
|
|
38
|
+
}, [messages, processor]);
|
|
39
|
+
if (surfaces.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return (_jsx(A2uiMarkdownProvider, { children: _jsxs(Box, { style: INHERIT_THEME, sx: {
|
|
43
|
+
...A2UI_RENDER_SCOPE_SX,
|
|
44
|
+
display: 'flex',
|
|
45
|
+
flexDirection: 'column',
|
|
46
|
+
gap: 2,
|
|
47
|
+
}, children: [validationError ? (_jsx(Box, { role: "alert", sx: {
|
|
48
|
+
px: 3,
|
|
49
|
+
py: 2,
|
|
50
|
+
borderRadius: 2,
|
|
51
|
+
bg: 'danger.subtle',
|
|
52
|
+
border: '1px solid',
|
|
53
|
+
borderColor: 'danger.muted',
|
|
54
|
+
color: 'danger.fg',
|
|
55
|
+
fontSize: 1,
|
|
56
|
+
}, children: validationError })) : null, surfaces.map(surface => (_jsx(Box, { sx: {
|
|
57
|
+
border: '1px solid',
|
|
58
|
+
borderColor: 'border.default',
|
|
59
|
+
borderRadius: 2,
|
|
60
|
+
p: 3,
|
|
61
|
+
bg: 'canvas.default',
|
|
62
|
+
}, children: _jsx(A2uiSurfaceComposed, { surface: surface }) }, surface.id)))] }) }));
|
|
63
|
+
}
|
|
64
|
+
export default InlineSurface;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `render_a2ui_surface` result in the transcript: the surface, and what
|
|
3
|
+
* pressing its Submit does.
|
|
4
|
+
*
|
|
5
|
+
* The values are checked against the rules the tool sent with the surface —
|
|
6
|
+
* a missing required field is said under the form — and, when they pass,
|
|
7
|
+
* handed back to the agent as the reader's next turn, so the confirmation
|
|
8
|
+
* arrives in the conversation where the form was asked for. A host that
|
|
9
|
+
* wants to know is told too (`onRendered`, `onSubmitted`): the A2UI Agent
|
|
10
|
+
* example mirrors the surface on a canvas beside the chat.
|
|
11
|
+
*
|
|
12
|
+
* @module loop/plugins/a2ui-surface/SurfaceToolResult
|
|
13
|
+
*/
|
|
14
|
+
import type { JSX } from 'react';
|
|
15
|
+
import type { A2uiMessage } from '@a2ui/web_core/v0_9';
|
|
16
|
+
import type { ToolCallRenderContext } from '../../../types/chat';
|
|
17
|
+
import { type A2uiToolResult } from './toolResult';
|
|
18
|
+
export type SurfaceRendered = {
|
|
19
|
+
toolCallId: string;
|
|
20
|
+
surfaceId?: string;
|
|
21
|
+
title: string;
|
|
22
|
+
messages: A2uiMessage[];
|
|
23
|
+
result: A2uiToolResult;
|
|
24
|
+
};
|
|
25
|
+
export type SurfaceSubmitted = {
|
|
26
|
+
surfaceId: string;
|
|
27
|
+
title: string;
|
|
28
|
+
values: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
export type SurfaceToolResultProps = {
|
|
31
|
+
context: ToolCallRenderContext;
|
|
32
|
+
/** Say something as the reader; `false` when the chat cannot take it. */
|
|
33
|
+
send: (message: string) => boolean;
|
|
34
|
+
onRendered?: (rendered: SurfaceRendered) => void;
|
|
35
|
+
onSubmitted?: (submitted: SurfaceSubmitted) => void;
|
|
36
|
+
};
|
|
37
|
+
export declare function SurfaceToolResult({ context, send, onRendered, onSubmitted, }: SurfaceToolResultProps): JSX.Element;
|
|
38
|
+
export default SurfaceToolResult;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { Spinner, Text } from '@primer/react';
|
|
4
|
+
import { Box } from '@datalayer/primer-addons';
|
|
5
|
+
import { encodeFormSubmission } from '../../../chat/messages/formSubmission';
|
|
6
|
+
import { InlineSurface, SURFACE_CATALOG_ID } from './InlineSurface';
|
|
7
|
+
import { readA2uiToolResult, validateA2uiSubmission, } from './toolResult';
|
|
8
|
+
export function SurfaceToolResult({ context, send, onRendered, onSubmitted, }) {
|
|
9
|
+
const parsed = useMemo(() => context.status === 'complete'
|
|
10
|
+
? readA2uiToolResult(context.result, SURFACE_CATALOG_ID)
|
|
11
|
+
: null, [context.status, context.result]);
|
|
12
|
+
const title = parsed?.title ?? 'the form';
|
|
13
|
+
const [validationError, setValidationError] = useState(null);
|
|
14
|
+
// Told once per result, when it is there to be told about.
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (parsed?.messages && parsed.messages.length > 0) {
|
|
17
|
+
onRendered?.({
|
|
18
|
+
toolCallId: context.toolCallId,
|
|
19
|
+
surfaceId: parsed.surfaceId,
|
|
20
|
+
title,
|
|
21
|
+
messages: parsed.messages,
|
|
22
|
+
result: parsed,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// The result is what changes; the callback's identity is the host's.
|
|
26
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
27
|
+
}, [parsed, context.toolCallId]);
|
|
28
|
+
const handleAction = useCallback((action) => {
|
|
29
|
+
if (action.name !== 'submit_a2ui_form') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const values = (action.context ?? {});
|
|
33
|
+
const errors = validateA2uiSubmission(parsed?.fieldRules ?? [], values);
|
|
34
|
+
if (errors.length > 0) {
|
|
35
|
+
setValidationError(errors.join(' '));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setValidationError(null);
|
|
39
|
+
onSubmitted?.({ surfaceId: action.surfaceId, title, values });
|
|
40
|
+
const sent = send(encodeFormSubmission({
|
|
41
|
+
title,
|
|
42
|
+
surfaceId: action.surfaceId,
|
|
43
|
+
values,
|
|
44
|
+
}));
|
|
45
|
+
if (!sent) {
|
|
46
|
+
setValidationError('The chat is not ready to take the submission yet.');
|
|
47
|
+
}
|
|
48
|
+
}, [parsed, title, send, onSubmitted]);
|
|
49
|
+
if (!parsed?.messages || parsed.messages.length === 0) {
|
|
50
|
+
return (_jsxs(Box, { sx: {
|
|
51
|
+
border: '1px solid',
|
|
52
|
+
borderColor: 'border.default',
|
|
53
|
+
borderRadius: 2,
|
|
54
|
+
px: 3,
|
|
55
|
+
py: 2,
|
|
56
|
+
bg: 'canvas.default',
|
|
57
|
+
display: 'flex',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
gap: 2,
|
|
60
|
+
}, children: [_jsx(Spinner, { size: "small" }), _jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Rendering A2UI surface..." })] }));
|
|
61
|
+
}
|
|
62
|
+
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(Text, { sx: { fontWeight: 'bold' }, children: title }), _jsx(InlineSurface, { messages: parsed.messages, onAction: handleAction, validationError: validationError })] }));
|
|
63
|
+
}
|
|
64
|
+
export default SurfaceToolResult;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactorPlugin } from '@datalayer/reactor';
|
|
2
|
+
import { type SurfaceRendered, type SurfaceSubmitted } from './SurfaceToolResult';
|
|
3
|
+
export declare const A2UI_SURFACE_PLUGIN_NAME = "@datalayer/loop-plugin-a2ui-surface";
|
|
4
|
+
/** The renderer name a spec binds a tool to. */
|
|
5
|
+
export declare const A2UI_SURFACE_RENDERER = "a2ui-surface";
|
|
6
|
+
/**
|
|
7
|
+
* Every tool some spec binds to this renderer.
|
|
8
|
+
*
|
|
9
|
+
* Read from the specs rather than from the workspace's current agent: a
|
|
10
|
+
* tool result arrives named by its tool, and a name bound in any spec is a
|
|
11
|
+
* surface wherever it turns up.
|
|
12
|
+
*/
|
|
13
|
+
export declare function surfaceToolNames(): Set<string>;
|
|
14
|
+
export type A2uiSurfacePluginOptions = {
|
|
15
|
+
/** Told when a surface has been drawn from a tool result. */
|
|
16
|
+
onRendered?: (rendered: SurfaceRendered) => void;
|
|
17
|
+
/** Told when a submission passed its checks and went to the agent. */
|
|
18
|
+
onSubmitted?: (submitted: SurfaceSubmitted) => void;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* An instance of the plugin, with a host's callbacks.
|
|
22
|
+
*
|
|
23
|
+
* The chat reaches a workspace's `send` only through slot props, so the
|
|
24
|
+
* plugin keeps a silent component in the chat's title-bar slot — it draws
|
|
25
|
+
* nothing and holds the `send` of the workspace it is in. The slot is asked
|
|
26
|
+
* whether anyone contributed before it is drawn, so this costs the header
|
|
27
|
+
* no space.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createA2uiSurfacePlugin(options?: A2uiSurfacePluginOptions): ReactorPlugin<Record<string, never>, unknown, unknown>;
|
|
30
|
+
/** The instance the preset mounts: no host callbacks. */
|
|
31
|
+
export declare const A2uiSurfacePlugin: ReactorPlugin<Record<string, never>, unknown, unknown>;
|
|
32
|
+
export type { SurfaceRendered, SurfaceSubmitted } from './SurfaceToolResult';
|
|
33
|
+
export { readA2uiToolResult, validateA2uiField, validateA2uiSubmission, type A2uiFieldRule, type A2uiToolResult, } from './toolResult';
|
|
34
|
+
export default A2uiSurfacePlugin;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { contribution, definePlugin, signal } from '@datalayer/reactor';
|
|
4
|
+
import { AGENTSPECS } from '../../../specs/agents';
|
|
5
|
+
import { LoopChatExtras, LoopSlots, } from '../../core';
|
|
6
|
+
import { SurfaceToolResult, } from './SurfaceToolResult';
|
|
7
|
+
export const A2UI_SURFACE_PLUGIN_NAME = '@datalayer/loop-plugin-a2ui-surface';
|
|
8
|
+
/** The renderer name a spec binds a tool to. */
|
|
9
|
+
export const A2UI_SURFACE_RENDERER = 'a2ui-surface';
|
|
10
|
+
/**
|
|
11
|
+
* Every tool some spec binds to this renderer.
|
|
12
|
+
*
|
|
13
|
+
* Read from the specs rather than from the workspace's current agent: a
|
|
14
|
+
* tool result arrives named by its tool, and a name bound in any spec is a
|
|
15
|
+
* surface wherever it turns up.
|
|
16
|
+
*/
|
|
17
|
+
export function surfaceToolNames() {
|
|
18
|
+
const names = new Set();
|
|
19
|
+
for (const spec of Object.values(AGENTSPECS)) {
|
|
20
|
+
for (const binding of spec.frontendRenderTools ?? []) {
|
|
21
|
+
if (binding.renderer === A2UI_SURFACE_RENDERER) {
|
|
22
|
+
names.add(binding.tool);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return names;
|
|
27
|
+
}
|
|
28
|
+
let counter = 0;
|
|
29
|
+
/**
|
|
30
|
+
* An instance of the plugin, with a host's callbacks.
|
|
31
|
+
*
|
|
32
|
+
* The chat reaches a workspace's `send` only through slot props, so the
|
|
33
|
+
* plugin keeps a silent component in the chat's title-bar slot — it draws
|
|
34
|
+
* nothing and holds the `send` of the workspace it is in. The slot is asked
|
|
35
|
+
* whether anyone contributed before it is drawn, so this costs the header
|
|
36
|
+
* no space.
|
|
37
|
+
*/
|
|
38
|
+
export function createA2uiSurfacePlugin(options = {}) {
|
|
39
|
+
const id = `a2ui-surface-${(counter += 1)}`;
|
|
40
|
+
let currentSend;
|
|
41
|
+
const send = (message) => {
|
|
42
|
+
if (!currentSend) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
currentSend(message);
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
function Capture({ workspace, }) {
|
|
49
|
+
const workspaceSend = workspace?.viewControls.send;
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
currentSend = workspaceSend;
|
|
52
|
+
return () => {
|
|
53
|
+
if (currentSend === workspaceSend) {
|
|
54
|
+
currentSend = undefined;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}, [workspaceSend]);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const tools = surfaceToolNames();
|
|
61
|
+
// A failed call is the chat's to draw: its default card says what went
|
|
62
|
+
// wrong, and a spinner that never ends would say nothing at all. So is
|
|
63
|
+
// every tool that is not a surface: this renderer is the chat's only one,
|
|
64
|
+
// and returning nothing for a tool it does not own hid every other tool
|
|
65
|
+
// row — the notebook reads, the cell runs — from the transcript.
|
|
66
|
+
const renderToolResult = (context) => tools.has(context.toolName) && context.status !== 'error' ? (_jsx(SurfaceToolResult, { context: context, send: send, onRendered: options.onRendered, onSubmitted: options.onSubmitted }, context.toolCallId)) : (context.defaultUI);
|
|
67
|
+
const plugin = definePlugin({
|
|
68
|
+
name: counter === 1
|
|
69
|
+
? A2UI_SURFACE_PLUGIN_NAME
|
|
70
|
+
: `${A2UI_SURFACE_PLUGIN_NAME}-${id}`,
|
|
71
|
+
displayName: 'A2UI surfaces',
|
|
72
|
+
description: 'Generated surfaces drawn in the transcript, and submitted back to the agent.',
|
|
73
|
+
octicon: 'browser',
|
|
74
|
+
emoji: '\u{1FA9F}',
|
|
75
|
+
contributes: [
|
|
76
|
+
contribution(LoopChatExtras, { id, extras: signal({ renderToolResult }) }, { id }),
|
|
77
|
+
],
|
|
78
|
+
build() {
|
|
79
|
+
return {
|
|
80
|
+
components: [
|
|
81
|
+
{ slot: LoopSlots.chatHeader, id, Component: Capture },
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return plugin;
|
|
87
|
+
}
|
|
88
|
+
/** The instance the preset mounts: no host callbacks. */
|
|
89
|
+
export const A2uiSurfacePlugin = createA2uiSurfacePlugin();
|
|
90
|
+
export { readA2uiToolResult, validateA2uiField, validateA2uiSubmission, } from './toolResult';
|
|
91
|
+
export default A2uiSurfacePlugin;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the `render_a2ui_surface` tool answers, read and checked.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no React, no catalogue instance. What is here is what a test wants
|
|
5
|
+
* to reach without a chat around it — the shape of a result, the rules a
|
|
6
|
+
* surface ships for its own fields, and the verdict on a submission.
|
|
7
|
+
*
|
|
8
|
+
* @module loop/plugins/a2ui-surface/toolResult
|
|
9
|
+
*/
|
|
10
|
+
import type { A2uiMessage } from '@a2ui/web_core/v0_9';
|
|
11
|
+
export type A2uiRequiredField = {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Per-field validation descriptor emitted by the backend tool. The rules
|
|
17
|
+
* gate a submission: required, email format, pattern, length, slider range.
|
|
18
|
+
*/
|
|
19
|
+
export type A2uiFieldRule = {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
type?: string;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
format?: 'email';
|
|
25
|
+
pattern?: string;
|
|
26
|
+
minLength?: number;
|
|
27
|
+
min?: number;
|
|
28
|
+
max?: number;
|
|
29
|
+
};
|
|
30
|
+
export type A2uiToolResult = {
|
|
31
|
+
surfaceId?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
messages?: A2uiMessage[];
|
|
34
|
+
requiredFields?: A2uiRequiredField[];
|
|
35
|
+
fieldRules?: A2uiFieldRule[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Read a tool result — an object, or the JSON of one — as A2UI messages,
|
|
39
|
+
* with the catalogue id rewritten to `catalogId` so the frontend catalogue
|
|
40
|
+
* accepts what a backend named by its own id.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readA2uiToolResult(result: unknown, catalogId: string): A2uiToolResult | null;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a submitted value counts as "not provided". Handles the shapes the
|
|
45
|
+
* basic catalogue emits: strings (text/email), arrays (choice), booleans
|
|
46
|
+
* (checkbox).
|
|
47
|
+
*/
|
|
48
|
+
export declare function isEmptyA2uiValue(value: unknown): boolean;
|
|
49
|
+
/** One rule's complaint about one value, or `null` when it is acceptable. */
|
|
50
|
+
export declare function validateA2uiField(rule: A2uiFieldRule, value: unknown): string | null;
|
|
51
|
+
/** Every rule's complaint about a submission; empty when it passes. */
|
|
52
|
+
export declare function validateA2uiSubmission(rules: A2uiFieldRule[], values: Record<string, unknown>): string[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Read a tool result — an object, or the JSON of one — as A2UI messages,
|
|
7
|
+
* with the catalogue id rewritten to `catalogId` so the frontend catalogue
|
|
8
|
+
* accepts what a backend named by its own id.
|
|
9
|
+
*/
|
|
10
|
+
export function readA2uiToolResult(result, catalogId) {
|
|
11
|
+
if (!result) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
let obj = result;
|
|
15
|
+
if (typeof result === 'string') {
|
|
16
|
+
try {
|
|
17
|
+
obj = JSON.parse(result);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (typeof obj !== 'object' ||
|
|
24
|
+
obj === null ||
|
|
25
|
+
!Array.isArray(obj.messages)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const parsed = obj;
|
|
29
|
+
const messages = (parsed.messages ?? []).map(message => {
|
|
30
|
+
const payload = message;
|
|
31
|
+
if (payload.createSurface &&
|
|
32
|
+
payload.createSurface.catalogId !== catalogId) {
|
|
33
|
+
return {
|
|
34
|
+
...payload,
|
|
35
|
+
createSurface: { ...payload.createSurface, catalogId },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return message;
|
|
39
|
+
});
|
|
40
|
+
return { ...parsed, messages };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether a submitted value counts as "not provided". Handles the shapes the
|
|
44
|
+
* basic catalogue emits: strings (text/email), arrays (choice), booleans
|
|
45
|
+
* (checkbox).
|
|
46
|
+
*/
|
|
47
|
+
export function isEmptyA2uiValue(value) {
|
|
48
|
+
if (value === null || value === undefined) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (typeof value === 'string') {
|
|
52
|
+
return value.trim() === '';
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
return value.length === 0;
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === 'boolean') {
|
|
58
|
+
return value === false;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/** Permissive on purpose: `local@domain.tld` with no spaces. */
|
|
63
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
64
|
+
/** One rule's complaint about one value, or `null` when it is acceptable. */
|
|
65
|
+
export function validateA2uiField(rule, value) {
|
|
66
|
+
if (isEmptyA2uiValue(value)) {
|
|
67
|
+
return rule.required ? `${rule.label} is required.` : null;
|
|
68
|
+
}
|
|
69
|
+
if (rule.format === 'email' &&
|
|
70
|
+
typeof value === 'string' &&
|
|
71
|
+
!EMAIL_PATTERN.test(value.trim())) {
|
|
72
|
+
return `${rule.label} must be a valid email address.`;
|
|
73
|
+
}
|
|
74
|
+
if (rule.pattern && typeof value === 'string') {
|
|
75
|
+
let matches;
|
|
76
|
+
try {
|
|
77
|
+
matches = new RegExp(rule.pattern).test(value.trim());
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
matches = true; // A malformed pattern blocks nothing.
|
|
81
|
+
}
|
|
82
|
+
if (!matches) {
|
|
83
|
+
return `${rule.label} is not in the expected format.`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (rule.minLength !== undefined &&
|
|
87
|
+
typeof value === 'string' &&
|
|
88
|
+
value.trim().length < rule.minLength) {
|
|
89
|
+
return `${rule.label} must be at least ${rule.minLength} characters.`;
|
|
90
|
+
}
|
|
91
|
+
if (rule.type === 'slider' && typeof value === 'number') {
|
|
92
|
+
if (rule.min !== undefined && value < rule.min) {
|
|
93
|
+
return `${rule.label} must be at least ${rule.min}.`;
|
|
94
|
+
}
|
|
95
|
+
if (rule.max !== undefined && value > rule.max) {
|
|
96
|
+
return `${rule.label} must be at most ${rule.max}.`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/** Every rule's complaint about a submission; empty when it passes. */
|
|
102
|
+
export function validateA2uiSubmission(rules, values) {
|
|
103
|
+
return rules
|
|
104
|
+
.map(rule => validateA2uiField(rule, values[rule.id]))
|
|
105
|
+
.filter((message) => message !== null);
|
|
106
|
+
}
|