@lowdefy/blocks-antd-x 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -119,3 +119,240 @@ blocks:
119
119
  parts:
120
120
  - type: text
121
121
  text: '[in-app link](/agent-chat-target) and [external link](https://example.com)'
122
+
123
+ # A history stored after a reply was cut off mid-stream: c2's tool call never got its result,
124
+ # c3 is nothing but that, and c4 is a call that completed and must be left alone.
125
+ - id: chat_cutoff
126
+ type: AgentChat
127
+ properties:
128
+ agentId: test_agent
129
+ height: 300
130
+ conversationId: cutoff
131
+ messages:
132
+ - id: c1
133
+ role: user
134
+ parts:
135
+ - type: text
136
+ text: What is in this repo?
137
+ - id: c2
138
+ role: assistant
139
+ parts:
140
+ - type: step-start
141
+ - type: text
142
+ text: Let me look.
143
+ - type: tool-search
144
+ toolCallId: tc1
145
+ state: input-available
146
+ input: {}
147
+ - id: c3
148
+ role: assistant
149
+ parts:
150
+ - type: step-start
151
+ - type: tool-fetch
152
+ toolCallId: tc2
153
+ state: input-available
154
+ input: {}
155
+ - id: c4
156
+ role: assistant
157
+ parts:
158
+ - type: tool-lookup
159
+ toolCallId: tc3
160
+ state: output-available
161
+ input: {}
162
+ output:
163
+ ok: true
164
+ - type: text
165
+ text: Done looking.
166
+
167
+ # onBeforeSend that takes as long as a quota request might, counts how often it ran, then
168
+ # refuses the send so nothing ever calls the agent.
169
+ - id: chat_guarded
170
+ type: AgentChat
171
+ properties:
172
+ agentId: test_agent
173
+ height: 300
174
+ conversationId: guarded
175
+ messages:
176
+ - id: g1
177
+ role: assistant
178
+ parts:
179
+ - type: text
180
+ text: Ask me something.
181
+ events:
182
+ onBeforeSend:
183
+ - id: count_before_send
184
+ type: SetState
185
+ params:
186
+ before_send_count:
187
+ _sum:
188
+ - _if_none:
189
+ - _state: before_send_count
190
+ - 0
191
+ - 1
192
+ - id: slow_check
193
+ type: Wait
194
+ params:
195
+ ms: 1500
196
+ - id: refuse
197
+ type: Throw
198
+ params:
199
+ throw: true
200
+ message: Refused by the test.
201
+
202
+ - id: guarded_readout
203
+ type: Html
204
+ properties:
205
+ html:
206
+ _nunjucks:
207
+ template: '<span id="readout_before_send">{{ n or 0 }}</span>'
208
+ on:
209
+ n:
210
+ _state: before_send_count
211
+
212
+ - id: chat_styled
213
+ type: AgentChat
214
+ properties:
215
+ agentId: test_agent
216
+ height: 300
217
+ conversationId: styled
218
+ messageDisplay:
219
+ roles:
220
+ assistant:
221
+ styles:
222
+ content:
223
+ backgroundColor: 'rgb(1, 2, 3)'
224
+ user:
225
+ classNames:
226
+ content: styled-user-content
227
+ sender:
228
+ styles:
229
+ root:
230
+ borderColor: 'rgb(4, 5, 6)'
231
+ messages:
232
+ - id: y1
233
+ role: user
234
+ parts:
235
+ - type: text
236
+ text: Styled question
237
+ - id: y2
238
+ role: assistant
239
+ parts:
240
+ - type: text
241
+ text: Styled answer
242
+
243
+ # Two conversations with the SAME message count and the same last id (''), the shape
244
+ # every persisted transcript had before assistant messages were given ids. Switching
245
+ # between them has to re-sync the transcript from `messages`.
246
+ - id: switch_to_a
247
+ type: Button
248
+ properties:
249
+ title: Switch to A
250
+ events:
251
+ onClick:
252
+ - id: set_a
253
+ type: SetState
254
+ params:
255
+ switch_id: conv-a
256
+ - id: switch_to_b
257
+ type: Button
258
+ properties:
259
+ title: Switch to B
260
+ events:
261
+ onClick:
262
+ - id: set_b
263
+ type: SetState
264
+ params:
265
+ switch_id: conv-b
266
+ - id: chat_switch
267
+ type: AgentChat
268
+ properties:
269
+ agentId: test_agent
270
+ height: 300
271
+ conversationId:
272
+ _if_none:
273
+ - _state: switch_id
274
+ - conv-a
275
+ messages:
276
+ _if:
277
+ test:
278
+ _eq:
279
+ - _if_none:
280
+ - _state: switch_id
281
+ - conv-a
282
+ - conv-b
283
+ then:
284
+ - id: b-user
285
+ role: user
286
+ parts:
287
+ - type: text
288
+ text: Question in B
289
+ - id: ''
290
+ role: assistant
291
+ parts:
292
+ - type: text
293
+ text: Answer from conversation B.
294
+ else:
295
+ - id: a-user
296
+ role: user
297
+ parts:
298
+ - type: text
299
+ text: Question in A
300
+ - id: ''
301
+ role: assistant
302
+ parts:
303
+ - type: text
304
+ text: Answer from conversation A.
305
+
306
+ # The loading property: a skeleton in place of the transcript, composer disabled.
307
+ - id: toggle_loading
308
+ type: Button
309
+ properties:
310
+ title: Toggle loading
311
+ events:
312
+ onClick:
313
+ - id: flip_loading
314
+ type: SetState
315
+ params:
316
+ chat_loading:
317
+ _not:
318
+ _eq:
319
+ - _state: chat_loading
320
+ - true
321
+ - id: chat_loading
322
+ type: AgentChat
323
+ properties:
324
+ agentId: test_agent
325
+ height: 300
326
+ conversationId: loading
327
+ loading:
328
+ _eq:
329
+ - _state: chat_loading
330
+ - true
331
+ suggestions:
332
+ - key: s
333
+ label: A suggestion
334
+ messages:
335
+ - id: l1
336
+ role: assistant
337
+ parts:
338
+ - type: text
339
+ text: Loaded answer.
340
+
341
+ # Both post to the agent route, which the spec intercepts to read the request body.
342
+ - id: chat_shared_read_only
343
+ type: AgentChat
344
+ properties:
345
+ agentId: test_agent
346
+ height: 200
347
+ conversationId: shared-read-only
348
+ sharedStateReadOnly: true
349
+ sharedState:
350
+ space: PEL
351
+ - id: chat_shared_writable
352
+ type: AgentChat
353
+ properties:
354
+ agentId: test_agent
355
+ height: 200
356
+ conversationId: shared-writable
357
+ sharedState:
358
+ space: PEL
@@ -14,9 +14,24 @@
14
14
  limitations under the License.
15
15
  */ import React from 'react';
16
16
  import { Conversations } from '@ant-design/x';
17
- import { Button } from 'antd';
17
+ import { Button, Spin } from 'antd';
18
18
  function AgentConversations({ blockId, methods, properties }) {
19
- const { items, activeKey, menu, creation, groupable, width } = properties;
19
+ const { items, activeKey, loadingKey, menu, creation, groupable, width } = properties;
20
+ // Conversations has no loading state of its own, so a loading row swaps its icon slot
21
+ // for a spinner. aria-busy and data-loading reach the <li> through the item's DOM attrs.
22
+ const conversationItems = (items ?? []).map((item)=>{
23
+ if (item.loading !== true && item.key !== loadingKey) {
24
+ return item;
25
+ }
26
+ return {
27
+ ...item,
28
+ icon: /*#__PURE__*/ React.createElement(Spin, {
29
+ size: "small"
30
+ }),
31
+ 'aria-busy': true,
32
+ 'data-loading': true
33
+ };
34
+ });
20
35
  function handleNew() {
21
36
  methods.triggerEvent({
22
37
  name: 'onNew',
@@ -64,7 +79,7 @@ function AgentConversations({ blockId, methods, properties }) {
64
79
  block: true,
65
80
  onClick: handleNew
66
81
  }, "+ New Chat")), /*#__PURE__*/ React.createElement(Conversations, {
67
- items: items ?? [],
82
+ items: conversationItems,
68
83
  activeKey: activeKey,
69
84
  onActiveChange: (key)=>{
70
85
  methods.triggerEvent({
@@ -26,11 +26,15 @@
26
26
  properties: {
27
27
  items: {
28
28
  type: 'array',
29
- description: 'Conversation list. Each item: { key, label, icon?, disabled?, timestamp?, group? }.'
29
+ description: 'Conversation list. Each item: { key, label, icon?, disabled?, loading?, timestamp?, group? }.'
30
30
  },
31
31
  activeKey: {
32
32
  type: 'string',
33
33
  description: 'Currently selected conversation key.'
34
+ },
35
+ loadingKey: {
36
+ type: 'string',
37
+ description: 'Key of a conversation that is still loading. That row shows a spinner in place of its icon.'
34
38
  }
35
39
  }
36
40
  }
@@ -10,6 +10,10 @@
10
10
  "label": { "type": "string" },
11
11
  "icon": {},
12
12
  "disabled": { "type": "boolean" },
13
+ "loading": {
14
+ "type": "boolean",
15
+ "description": "Show a spinner in place of this row's icon."
16
+ },
13
17
  "timestamp": { "type": "number" },
14
18
  "group": { "type": "string" }
15
19
  }
@@ -17,6 +21,10 @@
17
21
  "default": []
18
22
  },
19
23
  "activeKey": { "type": "string" },
24
+ "loadingKey": {
25
+ "type": "string",
26
+ "description": "Key of a conversation that is still loading, for example while its transcript is read after a select. That row shows a spinner in place of its icon."
27
+ },
20
28
  "menu": {
21
29
  "type": "array",
22
30
  "items": {
@@ -0,0 +1,69 @@
1
+ # Copyright 2020-2026 Lowdefy, Inc
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ id: agent-conversations
16
+ type: PageHeaderMenu
17
+ properties:
18
+ title: AgentConversations
19
+ events:
20
+ onInit:
21
+ - id: init
22
+ type: SetState
23
+ params:
24
+ active: conv-1
25
+ blocks:
26
+ # Selecting a row marks it as loading until finish_loading clears loadingKey, standing in
27
+ # for an app that reads the transcript on select.
28
+ - id: conversations
29
+ type: AgentConversations
30
+ properties:
31
+ activeKey:
32
+ _state: active
33
+ loadingKey:
34
+ _state: loading
35
+ items:
36
+ - key: conv-1
37
+ label: First conversation
38
+ - key: conv-2
39
+ label: Second conversation
40
+ - key: conv-3
41
+ label: Generating a title
42
+ loading: true
43
+ events:
44
+ onSelect:
45
+ - id: select
46
+ type: SetState
47
+ params:
48
+ active:
49
+ _event: key
50
+ loading:
51
+ _event: key
52
+ - id: finish_loading
53
+ type: Button
54
+ properties:
55
+ title: Finish loading
56
+ events:
57
+ onClick:
58
+ - id: clear_loading
59
+ type: SetState
60
+ params:
61
+ loading: null
62
+ - id: readout_active
63
+ type: Html
64
+ properties:
65
+ html:
66
+ _string.concat:
67
+ - _if_none:
68
+ - _state: active
69
+ - ''
@@ -0,0 +1,230 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
16
+ import { theme } from 'antd';
17
+ import { type } from '@lowdefy/helpers';
18
+ import { withBlockDefaults } from '@lowdefy/block-utils';
19
+ import answerScores from './answerScores.js';
20
+ import nextIntentState from './nextIntentState.js';
21
+ import css from './IntentInput.module.css';
22
+ const IDLE = {
23
+ intent: null,
24
+ challenger: undefined,
25
+ mode: 'idle',
26
+ choices: []
27
+ };
28
+ const CLASSIFY_DELAY = 150;
29
+ const SETTLE_DELAY = 450;
30
+ // A text input that becomes what the user means. The app classifies the text on
31
+ // onClassify (typically a Decide request) and passes the answer back; each intent
32
+ // has a slot of the same name. The block only decides which slot to show and how
33
+ // sure to look, and animates between them.
34
+ function IntentInput({ blockId, classNames = {}, components: { Icon }, content, methods, properties, styles = {}, value }) {
35
+ const { token } = theme.useToken();
36
+ const intents = properties.intents ?? {};
37
+ const confidence = {
38
+ preview: properties.confidence?.preview ?? 0.3,
39
+ show: properties.confidence?.show ?? 0.6,
40
+ switch: properties.confidence?.switch ?? 0.85
41
+ };
42
+ const text = value?.text ?? '';
43
+ // Keep the last answer while the next one loads, so the slot does not drop out
44
+ // between keystrokes.
45
+ const heldAnswer = useRef(null);
46
+ if (!type.isNone(properties.answer)) heldAnswer.current = properties.answer;
47
+ const scores = answerScores({
48
+ answer: heldAnswer.current,
49
+ intents
50
+ });
51
+ const [state, setState] = useState(IDLE);
52
+ const [pinned, setPinned] = useState(null);
53
+ // A round runs each time the latest classification finishes, not when the scores
54
+ // change: an answer that repeats the last one (the same text typed again after a
55
+ // clear) still counts, and a classification a newer one replaced does not.
56
+ const [round, setRound] = useState(0);
57
+ const latestClassify = useRef(0);
58
+ useEffect(()=>{
59
+ latestClassify.current += 1;
60
+ if (!text) {
61
+ setState(IDLE);
62
+ setPinned(null);
63
+ return undefined;
64
+ }
65
+ const classify = latestClassify.current;
66
+ const timer = setTimeout(async ()=>{
67
+ const result = await methods.triggerEvent({
68
+ name: 'onClassify',
69
+ event: {
70
+ text
71
+ }
72
+ });
73
+ if (classify === latestClassify.current && result.success) setRound((n)=>n + 1);
74
+ }, CLASSIFY_DELAY);
75
+ return ()=>clearTimeout(timer);
76
+ }, [
77
+ text
78
+ ]);
79
+ useEffect(()=>{
80
+ if (!text) return;
81
+ setState((current)=>nextIntentState({
82
+ state: current,
83
+ scores,
84
+ confidence
85
+ }));
86
+ }, [
87
+ round
88
+ ]);
89
+ // Typing restarts the pause, so a challenger only wins on a pause while the user
90
+ // is not typing.
91
+ useEffect(()=>{
92
+ if (state.challenger === undefined) return undefined;
93
+ const timer = setTimeout(()=>setState((current)=>nextIntentState({
94
+ state: current,
95
+ scores,
96
+ confidence
97
+ })), SETTLE_DELAY);
98
+ return ()=>clearTimeout(timer);
99
+ }, [
100
+ state.challenger,
101
+ round,
102
+ text
103
+ ]);
104
+ // A picked choice holds until the scores commit to a different intent on their own.
105
+ useEffect(()=>{
106
+ if (pinned && state.mode === 'committed' && state.intent !== pinned) setPinned(null);
107
+ }, [
108
+ state
109
+ ]);
110
+ const intent = pinned ?? state.intent;
111
+ const mode = pinned ? 'committed' : state.mode;
112
+ // onIntentChange fires when the shown intent or mode changes, not on mount.
113
+ const shown = useRef({
114
+ intent: null,
115
+ mode: 'idle'
116
+ });
117
+ useEffect(()=>{
118
+ if (value?.intent !== intent || value?.mode !== mode) methods.setValue({
119
+ text,
120
+ intent,
121
+ mode
122
+ });
123
+ if (shown.current.intent === intent && shown.current.mode === mode) return;
124
+ shown.current = {
125
+ intent,
126
+ mode
127
+ };
128
+ methods.triggerEvent({
129
+ name: 'onIntentChange',
130
+ event: {
131
+ intent,
132
+ mode
133
+ }
134
+ });
135
+ }, [
136
+ intent,
137
+ mode
138
+ ]);
139
+ const innerRef = useRef(null);
140
+ const [height, setHeight] = useState(0);
141
+ useLayoutEffect(()=>{
142
+ const observer = new ResizeObserver(([entry])=>setHeight(entry.target.offsetHeight));
143
+ observer.observe(innerRef.current);
144
+ return ()=>observer.disconnect();
145
+ }, []);
146
+ const accentOf = (key)=>intents[key]?.color ?? token.colorPrimary;
147
+ const labelOf = (key)=>intents[key]?.label ?? key;
148
+ return /*#__PURE__*/ React.createElement("div", {
149
+ id: blockId,
150
+ "data-mode": mode,
151
+ className: `${css.root} ${classNames.element ?? ''}`,
152
+ style: {
153
+ '--accent': intent ? accentOf(intent) : token.colorPrimary,
154
+ '--border': token.colorBorderSecondary,
155
+ '--bg': token.colorBgContainer,
156
+ '--text': token.colorText,
157
+ '--muted': token.colorTextTertiary,
158
+ fontFamily: token.fontFamily,
159
+ ...styles.element
160
+ }
161
+ }, /*#__PURE__*/ React.createElement("div", {
162
+ className: css.field
163
+ }, /*#__PURE__*/ React.createElement("input", {
164
+ id: `${blockId}_input`,
165
+ className: css.input,
166
+ autoFocus: properties.autoFocus,
167
+ autoComplete: "off",
168
+ placeholder: properties.placeholder,
169
+ value: text,
170
+ onChange: (event)=>{
171
+ methods.setValue({
172
+ text: event.target.value,
173
+ intent,
174
+ mode
175
+ });
176
+ methods.triggerEvent({
177
+ name: 'onChange',
178
+ event: {
179
+ text: event.target.value
180
+ }
181
+ });
182
+ },
183
+ onKeyDown: (event)=>{
184
+ // Enter while an IME is composing picks a candidate; it must not submit.
185
+ if (event.key !== 'Enter' || event.nativeEvent.isComposing) return;
186
+ methods.triggerEvent({
187
+ name: 'onSubmit',
188
+ event: {
189
+ text,
190
+ intent,
191
+ mode
192
+ }
193
+ });
194
+ }
195
+ }), intent && /*#__PURE__*/ React.createElement("span", {
196
+ key: intent,
197
+ className: css.badge
198
+ }, intents[intent]?.icon && /*#__PURE__*/ React.createElement(Icon, {
199
+ blockId: `${blockId}_badge_icon`,
200
+ properties: intents[intent].icon
201
+ }), labelOf(intent), mode !== 'committed' && '?')), /*#__PURE__*/ React.createElement("div", {
202
+ className: css.morph,
203
+ style: {
204
+ height
205
+ }
206
+ }, /*#__PURE__*/ React.createElement("div", {
207
+ ref: innerRef,
208
+ className: css.inner
209
+ }, mode === 'choose' && /*#__PURE__*/ React.createElement("div", {
210
+ className: css.choices
211
+ }, /*#__PURE__*/ React.createElement("span", {
212
+ className: css.choicesLabel
213
+ }, "Did you mean"), state.choices.map((key)=>/*#__PURE__*/ React.createElement("button", {
214
+ key: key,
215
+ type: "button",
216
+ className: css.choice,
217
+ style: {
218
+ '--accent': accentOf(key)
219
+ },
220
+ onClick: ()=>setPinned(key)
221
+ }, intents[key]?.icon && /*#__PURE__*/ React.createElement(Icon, {
222
+ blockId: `${blockId}_choice_${key}_icon`,
223
+ properties: intents[key].icon
224
+ }), labelOf(key)))), intent && content[intent] && /*#__PURE__*/ React.createElement("div", {
225
+ key: intent,
226
+ className: css.slot,
227
+ "data-preview": mode !== 'committed'
228
+ }, content[intent]()))));
229
+ }
230
+ export default withBlockDefaults(IntentInput);