@datalayer/agent-runtimes 1.3.66 → 1.3.68
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/components/teams/A2ATeamGraph.d.ts +22 -3
- package/lib/components/teams/A2ATeamGraph.js +166 -13
- package/lib/components/teams/a2aTeamFlow.d.ts +60 -0
- package/lib/components/teams/a2aTeamFlow.js +76 -0
- package/lib/components/teams/index.d.ts +1 -0
- package/lib/components/teams/index.js +1 -0
- package/lib/components/teams/teamConnections.d.ts +8 -0
- package/lib/components/teams/teamConnections.js +49 -0
- package/lib/components/teams/useA2ATeam.d.ts +5 -1
- package/lib/components/teams/useA2ATeam.js +26 -4
- package/lib/examples/AgentA2ATeamExample.d.ts +4 -2
- package/lib/examples/AgentA2ATeamExample.js +6 -2
- package/lib/examples/VoiceChatExample.d.ts +2 -1
- package/lib/examples/VoiceChatExample.js +3 -2
- package/lib/runtimes/browser/a2aPeer.d.ts +28 -0
- package/lib/runtimes/browser/a2aPeer.js +27 -9
- package/package.json +1 -1
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
* who asks for no motion sees the same thing without the movement: the arrow
|
|
10
10
|
* at the end the message goes to, and the word for it on the edge.
|
|
11
11
|
*
|
|
12
|
+
* A member's connections — the MCP servers it reaches, Odoo for Accounting —
|
|
13
|
+
* hang under it, each a node half a member's size with the server's mark
|
|
14
|
+
* (`SpecMark`), linked to it by an edge of its own. That edge is still until
|
|
15
|
+
* the member calls one of the connection's tools: then it flows toward the
|
|
16
|
+
* connection for as long as the call runs ({@link A2ATeamCall}), with the
|
|
17
|
+
* tool's name on it; under reduced motion, the arrow and the words alone.
|
|
18
|
+
*
|
|
12
19
|
* The graph is a picture, not an editor: nothing is dragged, panned or
|
|
13
20
|
* zoomed, and the wheel scrolls the page. Its viewport is worked out from its
|
|
14
21
|
* box, so it fits a phone as it fits a desk.
|
|
@@ -16,9 +23,9 @@
|
|
|
16
23
|
* @module components/teams/A2ATeamGraph
|
|
17
24
|
*/
|
|
18
25
|
import type { JSX } from 'react';
|
|
19
|
-
import { type Viewport } from '@xyflow/react';
|
|
26
|
+
import { type Edge, type EdgeProps, type Viewport } from '@xyflow/react';
|
|
20
27
|
import '@xyflow/react/dist/base.css';
|
|
21
|
-
import type
|
|
28
|
+
import { type A2ATeamCall, type A2ATeamConnection, type A2ATeamFlow } from './a2aTeamFlow';
|
|
22
29
|
import type { A2ATeamPersona } from './useA2ATeam';
|
|
23
30
|
/** One member of the team, as the graph draws it. */
|
|
24
31
|
export type A2ATeamGraphMember = {
|
|
@@ -34,6 +41,8 @@ export type A2ATeamGraphMember = {
|
|
|
34
41
|
onToggle?: () => void;
|
|
35
42
|
/** It was sent away, or called back. */
|
|
36
43
|
onAway?: (away: boolean) => void;
|
|
44
|
+
/** The MCP servers it reaches, drawn under it (`teamConnectionsOf`). */
|
|
45
|
+
connections?: A2ATeamConnection[];
|
|
37
46
|
};
|
|
38
47
|
export type A2ATeamGraphProps = {
|
|
39
48
|
/** The member that asks: drawn on the left. */
|
|
@@ -48,11 +57,21 @@ export type A2ATeamGraphProps = {
|
|
|
48
57
|
label?: string;
|
|
49
58
|
/** The character's size, in pixels at full scale. */
|
|
50
59
|
size?: number;
|
|
60
|
+
/** The tool calls running now, to the members' connections (`useA2ATeam`). */
|
|
61
|
+
calls?: A2ATeamCall[];
|
|
51
62
|
};
|
|
52
63
|
/** The words on the edge for a flow. */
|
|
53
64
|
export declare function flowWords(flow: A2ATeamFlow, entry: string, peer: string): string;
|
|
65
|
+
type CallData = {
|
|
66
|
+
/** The tool being called, in words (`list invoices`); empty while none is. */
|
|
67
|
+
calling: string;
|
|
68
|
+
};
|
|
69
|
+
/** A member's link to a connection: still, or flowing toward it while a call runs. */
|
|
70
|
+
export declare const CallEdge: import("react").NamedExoticComponent<EdgeProps<Edge<CallData>>>;
|
|
71
|
+
/** What a member's call says, on its edge and to a screen reader. */
|
|
72
|
+
export declare function callWords(member: string, connection: A2ATeamConnection, tool: string): string;
|
|
54
73
|
/** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
|
|
55
74
|
export declare function teamViewport(width: number): Viewport;
|
|
56
75
|
/** Draw a team of two over A2A, the link moving with what travels on it. */
|
|
57
|
-
export declare function A2ATeamGraph({ entry, peer, flow, connected, label, size, }: A2ATeamGraphProps): JSX.Element;
|
|
76
|
+
export declare function A2ATeamGraph({ entry, peer, flow, connected, label, size, calls, }: A2ATeamGraphProps): JSX.Element;
|
|
58
77
|
export default A2ATeamGraph;
|
|
@@ -5,11 +5,18 @@ import '@xyflow/react/dist/base.css';
|
|
|
5
5
|
import { Button, Label, Text } from '@primer/react';
|
|
6
6
|
import { Box } from '@datalayer/primer-addons';
|
|
7
7
|
import { AssistantStage } from '../../chat/assistant/AssistantStage';
|
|
8
|
+
import { SpecMark } from '../../chat/marks/SpecMark';
|
|
9
|
+
import { toolWords, } from './a2aTeamFlow';
|
|
8
10
|
/** The graph's own coordinates: the members, and room above them for their balloons. */
|
|
9
11
|
const NODE_WIDTH = 200;
|
|
10
12
|
const BALLOON_ROOM = 120;
|
|
11
13
|
const GAP = 220;
|
|
12
14
|
const WIDTH = NODE_WIDTH * 2 + GAP;
|
|
15
|
+
/** A connection's node: half a member's. */
|
|
16
|
+
const CONNECTION_WIDTH = NODE_WIDTH / 2;
|
|
17
|
+
/** Between a member and its connections. */
|
|
18
|
+
const CONNECTION_GAP = 44;
|
|
19
|
+
const CONNECTIONS_APART = 16;
|
|
13
20
|
/** Where the edge leaves and reaches a member: the middle of its character. */
|
|
14
21
|
const HANDLE = (size) => ({
|
|
15
22
|
opacity: 0,
|
|
@@ -28,6 +35,10 @@ const HANDLE = (size) => ({
|
|
|
28
35
|
* moved. The nodes stay the same objects; what they show is read from here.
|
|
29
36
|
*/
|
|
30
37
|
const Members = createContext({});
|
|
38
|
+
/** The connections being called now, by node id: read by their nodes, as the members are. */
|
|
39
|
+
const Busy = createContext({});
|
|
40
|
+
/** A connection's node id: under its member. */
|
|
41
|
+
const connectionNodeId = (member, connection) => `${member}/${connection}`;
|
|
31
42
|
const MemberNode = memo(function MemberNode({ data, }) {
|
|
32
43
|
const { id, side, size } = data;
|
|
33
44
|
const member = useContext(Members)[id];
|
|
@@ -61,7 +72,35 @@ const MemberNode = memo(function MemberNode({ data, }) {
|
|
|
61
72
|
? { position: 'absolute', left: 'auto' }
|
|
62
73
|
: { position: 'absolute', right: 'auto' }, stageRef: stageRef, onDragStart: () => undefined, open: false, onToggle: member.onToggle ?? (() => undefined), balloon: persona.saying
|
|
63
74
|
? { text: persona.saying, more: persona.saying.endsWith('…') }
|
|
64
|
-
: undefined, insist: persona.insist, onDismiss: away => member.onAway?.(away !== 'none') })) }), _jsxs(Text, { sx: { fontWeight: 600, textAlign: 'center' }, children: [member.emoji ? `${member.emoji} ` : '', member.name] }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', textAlign: 'center' }, "data-member-where": "", children: member.where }), _jsx(Handle, { type: "source", position: Position.Right, style: HANDLE(size), isConnectable: false })] }));
|
|
75
|
+
: undefined, insist: persona.insist, onDismiss: away => member.onAway?.(away !== 'none') })) }), _jsxs(Text, { sx: { fontWeight: 600, textAlign: 'center' }, children: [member.emoji ? `${member.emoji} ` : '', member.name] }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', textAlign: 'center' }, "data-member-where": "", children: member.where }), _jsx(Handle, { type: "source", position: Position.Right, style: HANDLE(size), isConnectable: false }), member.connections?.length ? (_jsx(Handle, { id: "connections", type: "source", position: Position.Bottom, style: { ...HANDLE(size), top: 'auto', bottom: 0 }, isConnectable: false })) : null] }));
|
|
76
|
+
});
|
|
77
|
+
/** A connection of a member's: the server's mark, what it reaches, and how. */
|
|
78
|
+
const ConnectionNode = memo(function ConnectionNode({ id, data, }) {
|
|
79
|
+
const { member, connection: connectionId, size } = data;
|
|
80
|
+
const connection = useContext(Members)[member]?.connections?.find(known => known.id === connectionId);
|
|
81
|
+
const tool = useContext(Busy)[id];
|
|
82
|
+
if (!connection) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return (_jsxs(Box, { className: "nodrag nopan nowheel", sx: {
|
|
86
|
+
width: CONNECTION_WIDTH,
|
|
87
|
+
display: 'flex',
|
|
88
|
+
flexDirection: 'column',
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
gap: '2px',
|
|
91
|
+
cursor: 'default',
|
|
92
|
+
pointerEvents: 'all',
|
|
93
|
+
}, title: connection.name, "data-team-connection": connection.id, "data-connection-busy": tool ? 'true' : 'false', children: [_jsx(Handle, { type: "target", position: Position.Top, style: { ...HANDLE(0), top: 0 }, isConnectable: false }), _jsx(Box, { className: tool ? 'a2a-team-connection-busy' : undefined, sx: {
|
|
94
|
+
width: size,
|
|
95
|
+
height: size,
|
|
96
|
+
display: 'flex',
|
|
97
|
+
alignItems: 'center',
|
|
98
|
+
justifyContent: 'center',
|
|
99
|
+
borderRadius: 2,
|
|
100
|
+
border: '1px solid',
|
|
101
|
+
borderColor: tool ? 'accent.emphasis' : 'border.default',
|
|
102
|
+
bg: 'canvas.default',
|
|
103
|
+
}, children: _jsx(SpecMark, { icon: connection.icon, emoji: connection.emoji, size: Math.round(size * 0.66) }) }), _jsx(Text, { sx: { fontWeight: 600, fontSize: 1, textAlign: 'center' }, children: connection.label }), connection.via && (_jsx(Text, { sx: { fontSize: '10px', color: 'fg.muted', textAlign: 'center' }, "data-connection-via": "", children: connection.via }))] }));
|
|
65
104
|
});
|
|
66
105
|
/** The words on the edge for a flow. */
|
|
67
106
|
export function flowWords(flow, entry, peer) {
|
|
@@ -94,15 +133,42 @@ const LinkEdge = memo(function LinkEdge({ id, sourceX, sourceY, targetX, targetY
|
|
|
94
133
|
textAlign: 'center',
|
|
95
134
|
}, children: _jsx(Label, { variant: data?.connected ? 'accent' : 'secondary', children: data?.label }) }) })] }));
|
|
96
135
|
});
|
|
97
|
-
|
|
98
|
-
const
|
|
136
|
+
/** A member's link to a connection: still, or flowing toward it while a call runs. */
|
|
137
|
+
export const CallEdge = memo(function CallEdge({ id, sourceX, sourceY, targetX, targetY, data, }) {
|
|
138
|
+
const calling = Boolean(data?.calling);
|
|
139
|
+
const path = `M ${sourceX},${sourceY} L ${targetX},${targetY}`;
|
|
140
|
+
const arrow = `M ${targetX},${targetY} L ${targetX - ARROW / 1.6},${targetY - ARROW} L ${targetX + ARROW / 1.6},${targetY - ARROW} Z`;
|
|
141
|
+
return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: path, style: {
|
|
142
|
+
stroke: 'var(--borderColor-default, #d0d7de)',
|
|
143
|
+
strokeWidth: 2,
|
|
144
|
+
opacity: calling ? 0.3 : 0.8,
|
|
145
|
+
} }), calling && (_jsxs("g", { "data-mcp-edge-call": data?.calling, children: [_jsx("path", { d: path, fill: "none", className: "a2a-team-flow a2a-team-call", stroke: "var(--fgColor-accent, #0969da)", strokeWidth: 3, strokeLinecap: "round", strokeDasharray: "6 8" }), _jsx("path", { d: arrow, fill: "var(--fgColor-accent, #0969da)" })] })), calling && (_jsx(EdgeLabelRenderer, { children: _jsx(Box, { className: "nodrag nopan", sx: {
|
|
146
|
+
position: 'absolute',
|
|
147
|
+
// Beside the edge, toward the middle of the graph.
|
|
148
|
+
transform: `translate(calc(-100% - 10px), -50%) translate(${(sourceX + targetX) / 2}px, ${(sourceY + targetY) / 2}px)`,
|
|
149
|
+
pointerEvents: 'none',
|
|
150
|
+
whiteSpace: 'nowrap',
|
|
151
|
+
}, children: _jsx(Label, { variant: "accent", children: data?.calling }) }) }))] }));
|
|
152
|
+
});
|
|
153
|
+
const NODE_TYPES = { member: MemberNode, connection: ConnectionNode };
|
|
154
|
+
const EDGE_TYPES = { a2a: LinkEdge, mcp: CallEdge };
|
|
155
|
+
/** Where a member's connections sit: under it, side by side, centred. */
|
|
156
|
+
function connectionsX(memberX, count) {
|
|
157
|
+
const row = count * CONNECTION_WIDTH + (count - 1) * CONNECTIONS_APART;
|
|
158
|
+
const left = memberX + (NODE_WIDTH - row) / 2;
|
|
159
|
+
return Array.from({ length: count }, (_, at) => left + at * (CONNECTION_WIDTH + CONNECTIONS_APART));
|
|
160
|
+
}
|
|
161
|
+
/** What a member's call says, on its edge and to a screen reader. */
|
|
162
|
+
export function callWords(member, connection, tool) {
|
|
163
|
+
return `${member} calls ${connection.label} · ${toolWords(tool, connection)}`;
|
|
164
|
+
}
|
|
99
165
|
/** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
|
|
100
166
|
export function teamViewport(width) {
|
|
101
167
|
const zoom = Math.min(1, width / WIDTH);
|
|
102
168
|
return { zoom, x: (width - WIDTH * zoom) / 2, y: 0 };
|
|
103
169
|
}
|
|
104
170
|
/** Draw a team of two over A2A, the link moving with what travels on it. */
|
|
105
|
-
export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size = 96, }) {
|
|
171
|
+
export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size = 96, calls = [], }) {
|
|
106
172
|
const box = useRef(null);
|
|
107
173
|
const [width, setWidth] = useState(0);
|
|
108
174
|
useEffect(() => {
|
|
@@ -122,7 +188,21 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
122
188
|
const viewport = useMemo(() => teamViewport(width || WIDTH), [width]);
|
|
123
189
|
// The members' height: the character, its name and where it runs.
|
|
124
190
|
const memberHeight = size + 56;
|
|
125
|
-
const
|
|
191
|
+
const connectionSize = Math.round(size / 2);
|
|
192
|
+
// Its mark, its label and how it is reached (about 45px under the mark),
|
|
193
|
+
// and room under it before the graph's edge.
|
|
194
|
+
const connectionHeight = connectionSize + 58;
|
|
195
|
+
// The connections' ids, member by member: the nodes change only when they do.
|
|
196
|
+
const placed = [entry, peer].map(member => ({
|
|
197
|
+
member: member.id,
|
|
198
|
+
connections: (member.connections ?? []).map(connection => connection.id),
|
|
199
|
+
}));
|
|
200
|
+
const placedKey = JSON.stringify(placed);
|
|
201
|
+
const hasConnections = placed.some(({ connections }) => connections.length);
|
|
202
|
+
const height = Math.ceil((BALLOON_ROOM +
|
|
203
|
+
memberHeight +
|
|
204
|
+
(hasConnections ? CONNECTION_GAP + connectionHeight : 0)) *
|
|
205
|
+
viewport.zoom);
|
|
126
206
|
const nodes = useMemo(() => [
|
|
127
207
|
{
|
|
128
208
|
id: entry.id,
|
|
@@ -147,9 +227,50 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
147
227
|
zIndex: 10,
|
|
148
228
|
data: { id: peer.id, side: 'right', size },
|
|
149
229
|
},
|
|
150
|
-
|
|
230
|
+
...JSON.parse(placedKey).flatMap(({ member, connections }, side) => {
|
|
231
|
+
const xs = connectionsX(side === 0 ? 0 : NODE_WIDTH + GAP, connections.length);
|
|
232
|
+
return connections.map((connection, at) => ({
|
|
233
|
+
id: connectionNodeId(member, connection),
|
|
234
|
+
type: 'connection',
|
|
235
|
+
position: {
|
|
236
|
+
x: xs[at],
|
|
237
|
+
y: BALLOON_ROOM + memberHeight + CONNECTION_GAP,
|
|
238
|
+
},
|
|
239
|
+
width: CONNECTION_WIDTH,
|
|
240
|
+
height: connectionHeight,
|
|
241
|
+
draggable: false,
|
|
242
|
+
selectable: false,
|
|
243
|
+
zIndex: 5,
|
|
244
|
+
data: { member, connection, size: connectionSize },
|
|
245
|
+
}));
|
|
246
|
+
}),
|
|
247
|
+
], [
|
|
248
|
+
entry.id,
|
|
249
|
+
peer.id,
|
|
250
|
+
size,
|
|
251
|
+
memberHeight,
|
|
252
|
+
placedKey,
|
|
253
|
+
connectionSize,
|
|
254
|
+
connectionHeight,
|
|
255
|
+
]);
|
|
151
256
|
const members = useMemo(() => ({ [entry.id]: entry, [peer.id]: peer }), [entry, peer]);
|
|
152
257
|
const words = flowWords(flow, entry.name, peer.name);
|
|
258
|
+
// The tool each connection is answering now, in words, by its node's id.
|
|
259
|
+
const busy = useMemo(() => {
|
|
260
|
+
const byNode = {};
|
|
261
|
+
const said = [];
|
|
262
|
+
for (const call of calls) {
|
|
263
|
+
const member = call.member === entry.id ? entry : peer;
|
|
264
|
+
const connection = member.connections?.find(known => known.id === call.connection);
|
|
265
|
+
if (!connection || member.id !== call.member) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const node = connectionNodeId(member.id, connection.id);
|
|
269
|
+
byNode[node] ??= toolWords(call.tool, connection);
|
|
270
|
+
said.push(callWords(member.name, connection, call.tool));
|
|
271
|
+
}
|
|
272
|
+
return { byNode, said: [...new Set(said)] };
|
|
273
|
+
}, [calls, entry, peer]);
|
|
153
274
|
const edges = useMemo(() => [
|
|
154
275
|
{
|
|
155
276
|
id: `${entry.id}->${peer.id}`,
|
|
@@ -164,8 +285,23 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
164
285
|
label: words ? `${label} · ${words}` : label,
|
|
165
286
|
},
|
|
166
287
|
},
|
|
167
|
-
|
|
168
|
-
|
|
288
|
+
...JSON.parse(placedKey).flatMap(({ member, connections }) => connections.map(connection => {
|
|
289
|
+
const node = connectionNodeId(member, connection);
|
|
290
|
+
const calling = busy.byNode[node] ?? '';
|
|
291
|
+
return {
|
|
292
|
+
id: `${member}->${node}`,
|
|
293
|
+
source: member,
|
|
294
|
+
sourceHandle: 'connections',
|
|
295
|
+
target: node,
|
|
296
|
+
type: 'mcp',
|
|
297
|
+
selectable: false,
|
|
298
|
+
focusable: false,
|
|
299
|
+
data: { calling },
|
|
300
|
+
};
|
|
301
|
+
})),
|
|
302
|
+
], [entry.id, peer.id, flow, connected, label, words, placedKey, busy]);
|
|
303
|
+
const heard = [words, ...busy.said].filter(Boolean).join('. ');
|
|
304
|
+
return (_jsxs(Box, { ref: box, "data-a2a-team-graph": "", "data-a2a-flow": flow, "data-a2a-calls": calls.length, sx: {
|
|
169
305
|
width: '100%',
|
|
170
306
|
height,
|
|
171
307
|
position: 'relative',
|
|
@@ -179,17 +315,32 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
179
315
|
'& .a2a-team-flow-answering': {
|
|
180
316
|
animation: 'a2aTeamFlow 0.6s linear infinite reverse',
|
|
181
317
|
},
|
|
318
|
+
'@keyframes a2aTeamCall': {
|
|
319
|
+
from: { strokeDashoffset: 14 },
|
|
320
|
+
to: { strokeDashoffset: 0 },
|
|
321
|
+
},
|
|
322
|
+
'@keyframes a2aTeamPulse': {
|
|
323
|
+
'0%, 100%': { boxShadow: '0 0 0 0 transparent' },
|
|
324
|
+
'50%': {
|
|
325
|
+
boxShadow: '0 0 0 4px var(--bgColor-accent-muted, #ddf4ff)',
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
'& .a2a-team-call': {
|
|
329
|
+
animation: 'a2aTeamCall 0.5s linear infinite',
|
|
330
|
+
},
|
|
331
|
+
'& .a2a-team-connection-busy': {
|
|
332
|
+
animation: 'a2aTeamPulse 1s ease-in-out infinite',
|
|
333
|
+
},
|
|
182
334
|
// The arrow and the words say which way; the movement is extra.
|
|
183
335
|
'@media (prefers-reduced-motion: reduce)': {
|
|
184
|
-
'& .a2a-team-flow': {
|
|
336
|
+
'& .a2a-team-flow, & .a2a-team-connection-busy': {
|
|
337
|
+
animation: 'none',
|
|
338
|
+
},
|
|
185
339
|
},
|
|
186
340
|
'& .react-flow, & .react-flow__renderer, & .react-flow__viewport': {
|
|
187
341
|
overflow: 'visible',
|
|
188
342
|
},
|
|
189
343
|
'& .react-flow__node': { cursor: 'default' },
|
|
190
|
-
// The library's credit stays, quietly (as the landing's network does).
|
|
191
|
-
'& .react-flow__attribution': { bg: 'transparent', color: 'fg.muted' },
|
|
192
|
-
'& .react-flow__attribution a': { color: 'fg.muted' },
|
|
193
344
|
}, children: [_jsx(Box, { as: "span", "aria-live": "polite", sx: {
|
|
194
345
|
position: 'absolute',
|
|
195
346
|
width: 1,
|
|
@@ -197,6 +348,8 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
197
348
|
overflow: 'hidden',
|
|
198
349
|
clip: 'rect(0 0 0 0)',
|
|
199
350
|
whiteSpace: 'nowrap',
|
|
200
|
-
}, children:
|
|
351
|
+
}, children: heard }), _jsx(Members.Provider, { value: members, children: _jsx(Busy.Provider, { value: busy.byNode, children: _jsx(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: NODE_TYPES, edgeTypes: EDGE_TYPES, viewport: viewport, nodesDraggable: false, nodesConnectable: false, nodesFocusable: false, edgesFocusable: false, elementsSelectable: false, deleteKeyCode: null, selectionKeyCode: null, multiSelectionKeyCode: null, panOnDrag: false, panOnScroll: false, zoomOnScroll: false, zoomOnPinch: false, zoomOnDoubleClick: false, preventScrolling: false, style: { overflow: 'visible', background: 'transparent' },
|
|
352
|
+
// The library is MIT: its credit is not required on the page.
|
|
353
|
+
proOptions: { hideAttribution: true }, "aria-label": `${entry.name} and ${peer.name}, over A2A` }) }) })] }));
|
|
201
354
|
}
|
|
202
355
|
export default A2ATeamGraph;
|
|
@@ -31,3 +31,63 @@ export declare function flowEnds(flow: A2ATeamFlow, entry: string, peer: string)
|
|
|
31
31
|
from: string;
|
|
32
32
|
to: string;
|
|
33
33
|
} | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* A member's connection, as the graph draws it: an MCP server it reaches,
|
|
36
|
+
* with its mark, and the tools that are its (`teamConnectionsOf` reads them
|
|
37
|
+
* from the member's Appspec and the server's spec).
|
|
38
|
+
*/
|
|
39
|
+
export type A2ATeamConnection = {
|
|
40
|
+
/** The MCP server's id: `odoo-accounting`. */
|
|
41
|
+
id: string;
|
|
42
|
+
/** The server's name: `Odoo Accounting`. */
|
|
43
|
+
name: string;
|
|
44
|
+
/** What the node says: the system it reaches, `Odoo`. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** Its mark, `<package>:<name>`, as `SpecMark` draws it. */
|
|
47
|
+
icon?: string;
|
|
48
|
+
emoji?: string;
|
|
49
|
+
/** How it is reached, in small words under the label: `via MCP`. */
|
|
50
|
+
via?: string;
|
|
51
|
+
/** Its tools, by name: `odoo_accounting_list_invoices`. */
|
|
52
|
+
tools: string[];
|
|
53
|
+
/** What its tools' names begin with when its spec lists none: `odoo_accounting_`. */
|
|
54
|
+
prefix?: string;
|
|
55
|
+
};
|
|
56
|
+
/** A tool call a member's connection is answering now. */
|
|
57
|
+
export type A2ATeamCall = {
|
|
58
|
+
/** The member that calls. */
|
|
59
|
+
member: string;
|
|
60
|
+
/** The connection called. */
|
|
61
|
+
connection: string;
|
|
62
|
+
/** The tool, as the runtime names it. */
|
|
63
|
+
tool: string;
|
|
64
|
+
/** The call's id, when the runtime gives one. */
|
|
65
|
+
id?: string;
|
|
66
|
+
/** When it started, in milliseconds. */
|
|
67
|
+
started: number;
|
|
68
|
+
/** Ended, but shown until it has been shown {@link CALL_SHOWN_MS}. */
|
|
69
|
+
ended?: boolean;
|
|
70
|
+
};
|
|
71
|
+
/** The least time a call is shown, in milliseconds: a quick one still reads. */
|
|
72
|
+
export declare const CALL_SHOWN_MS = 900;
|
|
73
|
+
/**
|
|
74
|
+
* The connection a tool is of: one whose tools name it (a toolset's prefix
|
|
75
|
+
* before the name is allowed, as a host may add one), else one whose
|
|
76
|
+
* prefix it begins with.
|
|
77
|
+
*/
|
|
78
|
+
export declare function connectionOfTool(tool: string, connections: A2ATeamConnection[]): A2ATeamConnection | undefined;
|
|
79
|
+
/** A tool's name in a few words, without its connection's prefix: `list invoices`. */
|
|
80
|
+
export declare function toolWords(tool: string, connection?: A2ATeamConnection): string;
|
|
81
|
+
/**
|
|
82
|
+
* The calls running after an event of the peer's (`member`): a call it
|
|
83
|
+
* starts to one of its connections is added; its end marks it ended, and
|
|
84
|
+
* drops it once it has been shown long enough (else `holdMs` says when to
|
|
85
|
+
* look again, with {@link pruneCalls}). A new request, an answer or a
|
|
86
|
+
* failure ends them all.
|
|
87
|
+
*/
|
|
88
|
+
export declare function callsAfter(calls: A2ATeamCall[], event: A2APeerEvent, member: string, connections: A2ATeamConnection[], now: number): {
|
|
89
|
+
calls: A2ATeamCall[];
|
|
90
|
+
holdMs?: number;
|
|
91
|
+
};
|
|
92
|
+
/** The calls without those ended and shown long enough. */
|
|
93
|
+
export declare function pruneCalls(calls: A2ATeamCall[], now: number): A2ATeamCall[];
|
|
@@ -30,3 +30,79 @@ export function flowEnds(flow, entry, peer) {
|
|
|
30
30
|
}
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
|
+
/** The least time a call is shown, in milliseconds: a quick one still reads. */
|
|
34
|
+
export const CALL_SHOWN_MS = 900;
|
|
35
|
+
/**
|
|
36
|
+
* The connection a tool is of: one whose tools name it (a toolset's prefix
|
|
37
|
+
* before the name is allowed, as a host may add one), else one whose
|
|
38
|
+
* prefix it begins with.
|
|
39
|
+
*/
|
|
40
|
+
export function connectionOfTool(tool, connections) {
|
|
41
|
+
return (connections.find(connection => connection.tools.some(name => tool === name ||
|
|
42
|
+
tool.endsWith(`_${name}`) ||
|
|
43
|
+
tool.endsWith(`.${name}`))) ??
|
|
44
|
+
connections.find(connection => connection.prefix && tool.includes(connection.prefix)));
|
|
45
|
+
}
|
|
46
|
+
/** A tool's name in a few words, without its connection's prefix: `list invoices`. */
|
|
47
|
+
export function toolWords(tool, connection) {
|
|
48
|
+
let name = tool;
|
|
49
|
+
const prefix = connection?.prefix;
|
|
50
|
+
if (prefix && name.includes(prefix)) {
|
|
51
|
+
name = name.slice(name.indexOf(prefix) + prefix.length);
|
|
52
|
+
}
|
|
53
|
+
return name.replace(/[_-]+/g, ' ').trim() || tool;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The calls running after an event of the peer's (`member`): a call it
|
|
57
|
+
* starts to one of its connections is added; its end marks it ended, and
|
|
58
|
+
* drops it once it has been shown long enough (else `holdMs` says when to
|
|
59
|
+
* look again, with {@link pruneCalls}). A new request, an answer or a
|
|
60
|
+
* failure ends them all.
|
|
61
|
+
*/
|
|
62
|
+
export function callsAfter(calls, event, member, connections, now) {
|
|
63
|
+
if (event.phase !== 'working') {
|
|
64
|
+
return { calls: [] };
|
|
65
|
+
}
|
|
66
|
+
const step = event.tool;
|
|
67
|
+
if (!step) {
|
|
68
|
+
return { calls };
|
|
69
|
+
}
|
|
70
|
+
const connection = connectionOfTool(step.name, connections);
|
|
71
|
+
if (!connection) {
|
|
72
|
+
return { calls };
|
|
73
|
+
}
|
|
74
|
+
if (!step.ended) {
|
|
75
|
+
return {
|
|
76
|
+
calls: [
|
|
77
|
+
...calls,
|
|
78
|
+
{
|
|
79
|
+
member,
|
|
80
|
+
connection: connection.id,
|
|
81
|
+
tool: step.name,
|
|
82
|
+
...(step.id ? { id: step.id } : {}),
|
|
83
|
+
started: now,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const index = calls.findIndex(call => !call.ended &&
|
|
89
|
+
call.connection === connection.id &&
|
|
90
|
+
(step.id && call.id ? call.id === step.id : call.tool === step.name));
|
|
91
|
+
if (index < 0) {
|
|
92
|
+
return { calls };
|
|
93
|
+
}
|
|
94
|
+
const call = calls[index];
|
|
95
|
+
const left = call.started + CALL_SHOWN_MS - now;
|
|
96
|
+
if (left <= 0) {
|
|
97
|
+
return { calls: calls.filter((_, at) => at !== index) };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
calls: calls.map((c, at) => (at === index ? { ...c, ended: true } : c)),
|
|
101
|
+
holdMs: left,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The calls without those ended and shown long enough. */
|
|
105
|
+
export function pruneCalls(calls, now) {
|
|
106
|
+
const kept = calls.filter(call => !call.ended || call.started + CALL_SHOWN_MS > now);
|
|
107
|
+
return kept.length === calls.length ? calls : kept;
|
|
108
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppSpec } from '../../types/agentspecs';
|
|
2
|
+
import type { A2ATeamConnection } from './a2aTeamFlow';
|
|
3
|
+
/**
|
|
4
|
+
* The connections of an application, each with its server's name and mark
|
|
5
|
+
* and its tools. A connection to a server the catalogue does not have is
|
|
6
|
+
* left out: there is nothing to draw it with.
|
|
7
|
+
*/
|
|
8
|
+
export declare function teamConnectionsOf(app: Pick<AppSpec, 'connections'>): A2ATeamConnection[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* A member's connections, as the team graph draws them, read from its
|
|
7
|
+
* Appspec: each connection names an MCP server, whose spec gives its name,
|
|
8
|
+
* its mark (`icon`, as `SpecMark` draws it) and, from the server's actions,
|
|
9
|
+
* the names of its tools.
|
|
10
|
+
*
|
|
11
|
+
* @module components/teams/teamConnections
|
|
12
|
+
*/
|
|
13
|
+
import { MCP_SERVER_LIBRARY } from '../../specs/mcpServers';
|
|
14
|
+
import { SERVER_ACTIONS } from '../../specs/actions';
|
|
15
|
+
/** A server reference without its version: `odoo-accounting:0.0.1` is `odoo-accounting`. */
|
|
16
|
+
const idOf = (ref) => {
|
|
17
|
+
const at = ref.lastIndexOf(':');
|
|
18
|
+
return at > 0 && ref.slice(at + 1).includes('.') ? ref.slice(0, at) : ref;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The connections of an application, each with its server's name and mark
|
|
22
|
+
* and its tools. A connection to a server the catalogue does not have is
|
|
23
|
+
* left out: there is nothing to draw it with.
|
|
24
|
+
*/
|
|
25
|
+
export function teamConnectionsOf(app) {
|
|
26
|
+
const connections = [];
|
|
27
|
+
for (const connection of app.connections ?? []) {
|
|
28
|
+
const id = idOf(connection.server);
|
|
29
|
+
const server = MCP_SERVER_LIBRARY[id];
|
|
30
|
+
if (!server || connections.some(known => known.id === id)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// A pattern (`odoo_accounting_*`) is not a name a call has.
|
|
34
|
+
const tools = Object.keys(SERVER_ACTIONS[id]?.tools ?? {}).filter(name => !/[*?[]/.test(name));
|
|
35
|
+
connections.push({
|
|
36
|
+
id,
|
|
37
|
+
name: server.name,
|
|
38
|
+
// The system it reaches: the first word of its name ("Odoo Accounting").
|
|
39
|
+
label: server.name.split(/\s+/)[0] || server.name,
|
|
40
|
+
...(server.icon ? { icon: server.icon } : {}),
|
|
41
|
+
...(server.emoji ? { emoji: server.emoji } : {}),
|
|
42
|
+
via: 'via MCP',
|
|
43
|
+
tools,
|
|
44
|
+
// The gateway names a toolset's tools after it: `odoo_accounting_…`.
|
|
45
|
+
prefix: `${id.replace(/-/g, '_')}_`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return connections;
|
|
49
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { AssistantState } from '../../chat/assistant/state';
|
|
|
2
2
|
import { type BrowserModelOptions } from '../../runtimes/browser/model';
|
|
3
3
|
import { type A2APeer } from '../../runtimes/browser/a2aPeer';
|
|
4
4
|
import type { AppSpec } from '../../types/agentspecs';
|
|
5
|
-
import { type A2ATeamFlow } from './a2aTeamFlow';
|
|
5
|
+
import { type A2ATeamCall, type A2ATeamConnection, type A2ATeamFlow } from './a2aTeamFlow';
|
|
6
6
|
/** What one member's character does: its state, its balloon, and whether it was sent away. */
|
|
7
7
|
export type A2ATeamPersona = {
|
|
8
8
|
state: AssistantState;
|
|
@@ -33,6 +33,8 @@ export type UseA2ATeamOptions = {
|
|
|
33
33
|
askTool?: string;
|
|
34
34
|
/** The most steps of one turn. */
|
|
35
35
|
maxSteps?: number;
|
|
36
|
+
/** The peer's connections (`teamConnectionsOf(peerApp)`): the calls to them are kept. */
|
|
37
|
+
peerConnections?: A2ATeamConnection[];
|
|
36
38
|
};
|
|
37
39
|
export type A2ATeam = {
|
|
38
40
|
entryPersona: A2ATeamPersona;
|
|
@@ -46,6 +48,8 @@ export type A2ATeam = {
|
|
|
46
48
|
report: string | null;
|
|
47
49
|
/** Which way the link carries a message now. */
|
|
48
50
|
flow: A2ATeamFlow;
|
|
51
|
+
/** The tool calls the peer's connections are answering now. */
|
|
52
|
+
calls: A2ATeamCall[];
|
|
49
53
|
/** Whether a turn is under way. */
|
|
50
54
|
busy: boolean;
|
|
51
55
|
/** Whether the entry can be asked: its peer is connected. */
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* {@link a2aPeerTool}. What each of them does is kept as a persona — the
|
|
14
14
|
* state its character acts and what its balloon says — and the exchange
|
|
15
15
|
* between them as a log, the peer's last answer as the report, and the way
|
|
16
|
-
* the link carries a message as the flow ({@link flowAfter}).
|
|
16
|
+
* the link carries a message as the flow ({@link flowAfter}). The tools the
|
|
17
|
+
* peer calls on its connections (its MCP servers), told over A2A as it calls
|
|
18
|
+
* them, are kept as the calls running now ({@link callsAfter}).
|
|
17
19
|
*
|
|
18
20
|
* What `A2ATeamGraph` draws, and what a page's composer sends.
|
|
19
21
|
*
|
|
@@ -23,7 +25,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
23
25
|
import { stepCountIs, ToolLoopAgent } from 'ai';
|
|
24
26
|
import { createBrowserModel, } from '../../runtimes/browser/model';
|
|
25
27
|
import { a2aPeerTool, } from '../../runtimes/browser/a2aPeer';
|
|
26
|
-
import { flowAfter } from './a2aTeamFlow';
|
|
28
|
+
import { callsAfter, flowAfter, pruneCalls, } from './a2aTeamFlow';
|
|
29
|
+
const NO_CONNECTIONS = [];
|
|
27
30
|
export const AT_REST = {
|
|
28
31
|
state: 'idle',
|
|
29
32
|
insist: false,
|
|
@@ -36,7 +39,7 @@ export function balloonLine(text, length = 120) {
|
|
|
36
39
|
}
|
|
37
40
|
/** Run a team of two over A2A in the page. */
|
|
38
41
|
export function useA2ATeam(options) {
|
|
39
|
-
const { entry, peerApp, peer, inference, maxSteps = 6 } = options;
|
|
42
|
+
const { entry, peerApp, peer, inference, maxSteps = 6, peerConnections = NO_CONNECTIONS, } = options;
|
|
40
43
|
const askTool = options.askTool ?? `ask_${peerApp.id.replace(/-/g, '_')}`;
|
|
41
44
|
const [entryPersona, setEntryPersona] = useState(() => ({
|
|
42
45
|
...AT_REST,
|
|
@@ -49,6 +52,9 @@ export function useA2ATeam(options) {
|
|
|
49
52
|
const [report, setReport] = useState(null);
|
|
50
53
|
const [exchange, setExchange] = useState([]);
|
|
51
54
|
const [flow, setFlow] = useState('still');
|
|
55
|
+
const [calls, setCalls] = useState([]);
|
|
56
|
+
const callsNow = useRef([]);
|
|
57
|
+
const callTimer = useRef(undefined);
|
|
52
58
|
const [busy, setBusy] = useState(false);
|
|
53
59
|
const history = useRef([]);
|
|
54
60
|
const abort = useRef(null);
|
|
@@ -61,6 +67,18 @@ export function useA2ATeam(options) {
|
|
|
61
67
|
if (next.holdMs) {
|
|
62
68
|
flowTimer.current = setTimeout(() => setFlow('still'), next.holdMs);
|
|
63
69
|
}
|
|
70
|
+
// The calls to the peer's connections: a call's end, when it came
|
|
71
|
+
// quickly, is shown a moment longer.
|
|
72
|
+
const after = callsAfter(callsNow.current, event, peerApp.id, peerConnections, Date.now());
|
|
73
|
+
callsNow.current = after.calls;
|
|
74
|
+
setCalls(after.calls);
|
|
75
|
+
if (after.holdMs) {
|
|
76
|
+
clearTimeout(callTimer.current);
|
|
77
|
+
callTimer.current = setTimeout(() => {
|
|
78
|
+
callsNow.current = pruneCalls(callsNow.current, Date.now());
|
|
79
|
+
setCalls(callsNow.current);
|
|
80
|
+
}, after.holdMs);
|
|
81
|
+
}
|
|
64
82
|
if (event.phase === 'asked') {
|
|
65
83
|
setExchange(prev => [
|
|
66
84
|
...prev,
|
|
@@ -109,7 +127,7 @@ export function useA2ATeam(options) {
|
|
|
109
127
|
insist: true,
|
|
110
128
|
}));
|
|
111
129
|
}
|
|
112
|
-
}, [entry.name, peerApp.name]);
|
|
130
|
+
}, [entry.name, peerApp.id, peerApp.name, peerConnections]);
|
|
113
131
|
const agent = useMemo(() => {
|
|
114
132
|
if (!peer) {
|
|
115
133
|
return null;
|
|
@@ -203,6 +221,8 @@ export function useA2ATeam(options) {
|
|
|
203
221
|
insist: true,
|
|
204
222
|
}));
|
|
205
223
|
setFlow('still');
|
|
224
|
+
callsNow.current = [];
|
|
225
|
+
setCalls([]);
|
|
206
226
|
}
|
|
207
227
|
finally {
|
|
208
228
|
abort.current = null;
|
|
@@ -213,6 +233,7 @@ export function useA2ATeam(options) {
|
|
|
213
233
|
useEffect(() => () => {
|
|
214
234
|
abort.current?.abort();
|
|
215
235
|
clearTimeout(flowTimer.current);
|
|
236
|
+
clearTimeout(callTimer.current);
|
|
216
237
|
}, []);
|
|
217
238
|
const setEntryAway = useCallback((away) => setEntryPersona(prev => ({ ...prev, away })), []);
|
|
218
239
|
const setPeerAway = useCallback((away) => setPeerPersona(prev => ({ ...prev, away })), []);
|
|
@@ -225,6 +246,7 @@ export function useA2ATeam(options) {
|
|
|
225
246
|
exchange,
|
|
226
247
|
report,
|
|
227
248
|
flow,
|
|
249
|
+
calls,
|
|
228
250
|
busy,
|
|
229
251
|
ready: agent !== null,
|
|
230
252
|
send,
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* Accounting the wizard, as their Appspecs say, in a graph (`A2ATeamGraph`)
|
|
12
12
|
* whose one edge is the A2A link. The exchange is shown as it happens: Sales
|
|
13
13
|
* asks — the edge flows toward Accounting — Accounting works and then answers
|
|
14
|
-
* — the edge flows back — each in its own state and balloon.
|
|
15
|
-
*
|
|
14
|
+
* — the edge flows back — each in its own state and balloon. Under
|
|
15
|
+
* Accounting hangs Odoo, its connection (the odoo-accounting MCP server, from
|
|
16
|
+
* its Appspec): the edge to it flows while Accounting calls one of its tools,
|
|
17
|
+
* the tool's name on it. Then the report appears. The page's own state is `useA2ATeam`'s, which the landing's home
|
|
16
18
|
* page runs too.
|
|
17
19
|
*
|
|
18
20
|
* Where Accounting is: `VITE_A2A_ACCOUNTING_URL`, else the examples' local
|
|
@@ -10,12 +10,14 @@ import { useExampleAgentRuntimesUrl } from './utils/useExampleAgentRuntimesUrl';
|
|
|
10
10
|
import { useBrowserInference } from '../hooks/useBrowserInference';
|
|
11
11
|
import { readJwtClaims, tokenLifetimeMs, useAnonymousSessionStore, } from '../runtimes/browser/anonymousToken';
|
|
12
12
|
import { connectA2APeer } from '../runtimes/browser/a2aPeer';
|
|
13
|
-
import { A2ATeamGraph, useA2ATeam } from '../components/teams';
|
|
13
|
+
import { A2ATeamGraph, teamConnectionsOf, useA2ATeam, } from '../components/teams';
|
|
14
14
|
import { ACCOUNTING_APP_0_0_1, SALES_APP_0_0_1 } from '../specs/apps';
|
|
15
15
|
import { SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1 } from '../specs/teams/teams';
|
|
16
16
|
const TEAM = SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1;
|
|
17
17
|
const SALES = SALES_APP_0_0_1;
|
|
18
18
|
const ACCOUNTING = ACCOUNTING_APP_0_0_1;
|
|
19
|
+
/** What Accounting reaches, from its Appspec: Odoo, through the odoo-accounting MCP server. */
|
|
20
|
+
const ACCOUNTING_CONNECTIONS = teamConnectionsOf(ACCOUNTING);
|
|
19
21
|
/** The members of the team, as the team spec says them. */
|
|
20
22
|
function members() {
|
|
21
23
|
const sales = TEAM.agents.find(member => member.id === TEAM.entry);
|
|
@@ -67,6 +69,7 @@ function AgentA2ATeam() {
|
|
|
67
69
|
peer,
|
|
68
70
|
inference,
|
|
69
71
|
askTool: 'ask_accounting',
|
|
72
|
+
peerConnections: ACCOUNTING_CONNECTIONS,
|
|
70
73
|
});
|
|
71
74
|
const send = (text) => {
|
|
72
75
|
setDraft('');
|
|
@@ -106,7 +109,8 @@ function AgentA2ATeam() {
|
|
|
106
109
|
where: accountingWhere,
|
|
107
110
|
persona: team.peerPersona,
|
|
108
111
|
onAway: team.setPeerAway,
|
|
109
|
-
|
|
112
|
+
connections: ACCOUNTING_CONNECTIONS,
|
|
113
|
+
}, flow: team.flow, calls: team.calls, connected: peer !== null, label: peer ? `A2A · ${peer.skill.name}` : 'A2A · not connected' }) }), _jsxs(Box, { sx: { display: 'flex', gap: 3, flexWrap: 'wrap' }, children: [_jsxs(Box, { sx: { flex: '1 1 420px', minWidth: 0 }, children: [_jsxs(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: ["Ask ", SALES.name] }), _jsx(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }, children: (SALES.interface.starters ?? []).map(starter => (_jsx(Button, { size: "small", disabled: !team.ready || team.busy, onClick: () => send(starter.message), children: starter.label }, starter.label))) }), _jsx(Textarea, { ref: composer, block: true, rows: 3, value: draft, placeholder: "Ask for a financial report\u2026", onChange: event => setDraft(event.target.value), onKeyDown: event => {
|
|
110
114
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
111
115
|
event.preventDefault();
|
|
112
116
|
send(draft);
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* agent (http://127.0.0.1:8765);
|
|
14
14
|
* - `?voiceModelsUrl=` — the browser's models, as pinned
|
|
15
15
|
* (`scripts/voice/serve_store.py`, http://127.0.0.1:8770);
|
|
16
|
-
* - `?speechUrl=` —
|
|
16
|
+
* - `?speechUrl=` — Datalayer's speech service, datalayer-speech on r1
|
|
17
|
+
* (https://r1.datalayer.run), asked with the signed-in person's token.
|
|
17
18
|
*/
|
|
18
19
|
import React from 'react';
|
|
19
20
|
declare const VoiceChatExample: React.FC;
|
|
@@ -18,7 +18,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
18
18
|
* agent (http://127.0.0.1:8765);
|
|
19
19
|
* - `?voiceModelsUrl=` — the browser's models, as pinned
|
|
20
20
|
* (`scripts/voice/serve_store.py`, http://127.0.0.1:8770);
|
|
21
|
-
* - `?speechUrl=` —
|
|
21
|
+
* - `?speechUrl=` — Datalayer's speech service, datalayer-speech on r1
|
|
22
|
+
* (https://r1.datalayer.run), asked with the signed-in person's token.
|
|
22
23
|
*/
|
|
23
24
|
import { useMemo, useState } from 'react';
|
|
24
25
|
import { Heading, Link, SegmentedControl, Text } from '@primer/react';
|
|
@@ -34,7 +35,7 @@ function fromAddress(name, otherwise) {
|
|
|
34
35
|
}
|
|
35
36
|
const SERVER = fromAddress('agentRuntimesUrl', 'http://127.0.0.1:8765');
|
|
36
37
|
const MODELS = fromAddress('voiceModelsUrl', 'http://127.0.0.1:8770');
|
|
37
|
-
const SPEECH = fromAddress('speechUrl', '
|
|
38
|
+
const SPEECH = fromAddress('speechUrl', 'https://r1.datalayer.run');
|
|
38
39
|
/** The packages that hear, loaded the first time the microphone is used. */
|
|
39
40
|
const ENGINES = {
|
|
40
41
|
transformers: () => import('@huggingface/transformers'),
|
|
@@ -46,6 +46,19 @@ export type ConnectA2APeerOptions = {
|
|
|
46
46
|
* application has one, named for it.
|
|
47
47
|
*/
|
|
48
48
|
export declare function connectA2APeer(options: ConnectA2APeerOptions): Promise<A2APeer>;
|
|
49
|
+
/**
|
|
50
|
+
* A tool the peer calls while it works, as its runtime tells it: the call,
|
|
51
|
+
* then its end (`ended`), the two sharing the call's id.
|
|
52
|
+
*/
|
|
53
|
+
export type A2APeerToolStep = {
|
|
54
|
+
/** The call's id, when the runtime gives one: its end has the same. */
|
|
55
|
+
id?: string;
|
|
56
|
+
name: string;
|
|
57
|
+
/** Whether this is the call's end rather than its start. */
|
|
58
|
+
ended: boolean;
|
|
59
|
+
/** Why it failed, when it did. */
|
|
60
|
+
error?: string;
|
|
61
|
+
};
|
|
49
62
|
/** What happens to one request, as it happens. */
|
|
50
63
|
export type A2APeerEvent = {
|
|
51
64
|
phase: 'asked';
|
|
@@ -54,6 +67,8 @@ export type A2APeerEvent = {
|
|
|
54
67
|
phase: 'working';
|
|
55
68
|
taskId: string;
|
|
56
69
|
note?: string;
|
|
70
|
+
/** The tool it calls or has finished with, when the step is one. */
|
|
71
|
+
tool?: A2APeerToolStep;
|
|
57
72
|
} | {
|
|
58
73
|
phase: 'answered';
|
|
59
74
|
taskId: string;
|
|
@@ -66,6 +81,19 @@ export type A2APeerEvent = {
|
|
|
66
81
|
export declare function textOf(parts: Part[] | undefined): string;
|
|
67
82
|
/** A user's message of one text part, as the SDK's 1.x types write it. */
|
|
68
83
|
export declare function textMessage(text: string): Message;
|
|
84
|
+
/**
|
|
85
|
+
* What a working status says while the peer works: in a few words, and the
|
|
86
|
+
* tool step when it is one.
|
|
87
|
+
*
|
|
88
|
+
* The runtime's A2A worker publishes each tool call of its agent, and each
|
|
89
|
+
* call's end, as a `working` status whose message has one data part:
|
|
90
|
+
* `{tool_call: {id, name, arguments}}`, then
|
|
91
|
+
* `{tool_result: {id, name, result, error}}`.
|
|
92
|
+
*/
|
|
93
|
+
export declare function stepOf(message: Message | undefined): {
|
|
94
|
+
note?: string;
|
|
95
|
+
tool?: A2APeerToolStep;
|
|
96
|
+
};
|
|
69
97
|
/**
|
|
70
98
|
* Ask a peer once, streaming the task, and return its answer.
|
|
71
99
|
*
|
|
@@ -90,23 +90,41 @@ export function textMessage(text) {
|
|
|
90
90
|
referenceTaskIds: [],
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
-
/**
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* What a working status says while the peer works: in a few words, and the
|
|
95
|
+
* tool step when it is one.
|
|
96
|
+
*
|
|
97
|
+
* The runtime's A2A worker publishes each tool call of its agent, and each
|
|
98
|
+
* call's end, as a `working` status whose message has one data part:
|
|
99
|
+
* `{tool_call: {id, name, arguments}}`, then
|
|
100
|
+
* `{tool_result: {id, name, result, error}}`.
|
|
101
|
+
*/
|
|
102
|
+
export function stepOf(message) {
|
|
95
103
|
for (const part of message?.parts ?? []) {
|
|
96
104
|
const content = part.content;
|
|
97
105
|
if (content?.$case === 'text' && content.value.trim()) {
|
|
98
|
-
return content.value;
|
|
106
|
+
return { note: content.value };
|
|
99
107
|
}
|
|
100
108
|
if (content?.$case === 'data' && content.value) {
|
|
101
|
-
// What the runtime's A2A worker publishes while its agent works.
|
|
102
109
|
const data = content.value;
|
|
103
|
-
const
|
|
104
|
-
if (name) {
|
|
105
|
-
|
|
110
|
+
const told = data.tool_call ?? data.tool_result;
|
|
111
|
+
if (told?.name) {
|
|
112
|
+
const ended = !data.tool_call;
|
|
113
|
+
return {
|
|
114
|
+
note: ended ? `Read ${told.name}` : `Calling ${told.name}`,
|
|
115
|
+
tool: {
|
|
116
|
+
...(told.id ? { id: told.id } : {}),
|
|
117
|
+
name: told.name,
|
|
118
|
+
ended,
|
|
119
|
+
...(data.tool_result?.error
|
|
120
|
+
? { error: String(data.tool_result.error) }
|
|
121
|
+
: {}),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
106
124
|
}
|
|
107
125
|
}
|
|
108
126
|
}
|
|
109
|
-
return
|
|
127
|
+
return {};
|
|
110
128
|
}
|
|
111
129
|
const ENDED_BADLY = {
|
|
112
130
|
[TaskState.TASK_STATE_FAILED]: 'failed',
|
|
@@ -158,7 +176,7 @@ async function streamTask(peer, request, signal, onEvent) {
|
|
|
158
176
|
const status = payload.value.status;
|
|
159
177
|
taskId = payload.value.taskId || taskId;
|
|
160
178
|
if (status?.state === TaskState.TASK_STATE_WORKING) {
|
|
161
|
-
onEvent?.({ phase: 'working', taskId,
|
|
179
|
+
onEvent?.({ phase: 'working', taskId, ...stepOf(status.message) });
|
|
162
180
|
}
|
|
163
181
|
else if (status) {
|
|
164
182
|
ended = status.state;
|