@lowdefy/blocks-antd-x 6.0.0 → 6.1.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.
@@ -16,7 +16,7 @@
16
16
  import { useChat } from '@ai-sdk/react';
17
17
  import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
18
18
  import { FileCard, Prompts, Sender } from '@ant-design/x';
19
- import { Button } from 'antd';
19
+ import { Button, Skeleton } from 'antd';
20
20
  import { PaperClipOutlined } from '@ant-design/icons';
21
21
  import { isReserved, setKey, type } from '@lowdefy/helpers';
22
22
  import getLegacyObjectUrl from '@lowdefy/blocks-files/utils/getLegacyObjectUrl.js';
@@ -26,10 +26,11 @@ import { getFileCardType, getFileCardIcon } from './fileCardUtils.js';
26
26
  import DrawerWrapper from './DrawerWrapper.js';
27
27
  import createLowdefyChatTransport from './LowdefyChatTransport.js';
28
28
  import MessageList from './MessageList.js';
29
+ import replayableMessages from './replayableMessages.js';
29
30
  import useAgentEvents, { collectExternalEventIds } from './useAgentEvents.js';
30
31
  import WelcomeScreen from './WelcomeScreen.js';
31
32
  function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageId, properties }) {
32
- const { agentId, urlQuery, sharedState, welcome, messageDisplay, sender, conversationId, feedbackValues: storedFeedbackValues, messages: externalMessages, display, drawer: drawerConfig, suggestions } = properties;
33
+ const { agentId, urlQuery, sharedState, welcome, messageDisplay, sender, conversationId, feedbackValues: storedFeedbackValues, messages: externalMessages, display, drawer: drawerConfig, suggestions, loading } = properties;
33
34
  const senderRef = useRef(null);
34
35
  const finishMetaRef = useRef(null);
35
36
  const fileInputRef = useRef(null);
@@ -76,6 +77,11 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
76
77
  urlQueryKey
77
78
  ]);
78
79
  const bubbleListRef = useRef(null);
80
+ // Call ids of client-side tools still running: their parts sit unanswered until
81
+ // addToolOutput, exactly like a cut-off call, and must survive the dead-call filter.
82
+ const pendingToolCallsRef = useRef(new Set());
83
+ // Read by the filter from mount-time method closures, which would see a stale `status`.
84
+ const statusRef = useRef('ready');
79
85
  const { messages, sendMessage, status, stop, addToolApprovalResponse, addToolOutput, setMessages, regenerate, clearError } = useChat({
80
86
  // Key the Chat instance by conversation: without an id, useChat creates its
81
87
  // Chat once per mount and CAPTURES that transport — the transport rebuilt by
@@ -93,6 +99,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
93
99
  async onToolCall ({ toolCall }) {
94
100
  if (toolCall.dynamic) return;
95
101
  if (toolCall.toolName === 'update-page-state') {
102
+ pendingToolCallsRef.current.add(toolCall.toolCallId);
96
103
  try {
97
104
  const updates = toolCall.input?.updates ?? {};
98
105
  // Allowlist writes against the keys currently exposed via sharedState —
@@ -139,6 +146,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
139
146
  state: 'output-error',
140
147
  errorText: err.message
141
148
  });
149
+ } finally{
150
+ pendingToolCallsRef.current.delete(toolCall.toolCallId);
142
151
  }
143
152
  }
144
153
  },
@@ -175,6 +184,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
175
184
  });
176
185
  }
177
186
  });
187
+ statusRef.current = status;
178
188
  // The control is otherwise write-only: it reports a rating and immediately forgets it,
179
189
  // so the thumb un-highlights on the next render and the message looks unrated. This holds
180
190
  // what was clicked THIS visit; the block still persists nothing itself.
@@ -205,13 +215,29 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
205
215
  effectiveConversationId,
206
216
  setMessages
207
217
  ]);
218
+ // Abort a reply still streaming into the conversation being left. useChat swaps its
219
+ // Chat instance during the render that changes the id, and the `stop` it returns is
220
+ // the NEW instance's — calling it in the effect above would stop an idle chat and
221
+ // leave the old request running, still firing onDataPart / onToolCall / onFinish
222
+ // into the page (and the server still generating) for a conversation no longer shown.
223
+ // A cleanup keyed only on the id closes over the previous render's `stop`, which is
224
+ // the old instance's. stop() on an idle chat is a no-op.
225
+ useEffect(()=>()=>stop(), [
226
+ effectiveConversationId
227
+ ]); // eslint-disable-line react-hooks/exhaustive-deps
208
228
  // Sync external messages when provided — undefined means "not provided" (no sync),
209
229
  // null means "clear messages", array means "load these messages".
210
230
  // Compare by count + last ID to avoid re-syncing on every Lowdefy re-render
211
231
  // (operators like _state create new array references even when data hasn't changed).
232
+ // The conversation id is part of the key: two conversations whose transcripts have the
233
+ // same length and last id (every assistant message an older onFinish hook persisted
234
+ // carries id '', so any two same-length transcripts collide) would otherwise not
235
+ // re-sync on a switch, leaving the new conversation's transcript empty.
236
+ // Seeded with the mount-time id so an undefined `messages` stays "no sync" on mount.
212
237
  const prevExternalRef = useRef({
213
238
  count: 0,
214
- lastId: null
239
+ lastId: null,
240
+ conversationId: effectiveConversationId
215
241
  });
216
242
  // Event-dedup ids of externally loaded messages. Restored history must not
217
243
  // replay onToolCall / onToolResult / onUserMessage / onTitleGenerated side
@@ -222,33 +248,35 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
222
248
  const msgs = externalMessages ?? [];
223
249
  const count = msgs.length;
224
250
  const lastId = count > 0 ? msgs[count - 1]?.id : null;
225
- if (count !== prevExternalRef.current.count || lastId !== prevExternalRef.current.lastId) {
251
+ if (count !== prevExternalRef.current.count || lastId !== prevExternalRef.current.lastId || effectiveConversationId !== prevExternalRef.current.conversationId) {
226
252
  prevExternalRef.current = {
227
253
  count,
228
- lastId
254
+ lastId,
255
+ conversationId: effectiveConversationId
229
256
  };
230
- externalIdsRef.current = collectExternalEventIds(msgs);
231
- setMessages(msgs);
257
+ const replayable = withoutDeadToolCalls(msgs);
258
+ externalIdsRef.current = collectExternalEventIds(replayable);
259
+ setMessages(replayable);
232
260
  }
233
261
  }, [
234
262
  externalMessages,
263
+ effectiveConversationId,
235
264
  setMessages
236
265
  ]);
237
266
  // Register CallMethod methods so YAML actions can control the chat.
238
267
  useEffect(()=>{
239
268
  methods.registerMethod('regenerate', (args)=>{
240
- regenerate(args?.messageId ? {
241
- messageId: args.messageId
242
- } : undefined);
269
+ if (sendingRef.current) return;
270
+ regenerateReply(args?.messageId);
243
271
  });
244
272
  methods.registerMethod('setMessages', (args)=>{
245
- const msgs = args?.messages ?? [];
273
+ const msgs = withoutDeadToolCalls(args?.messages ?? []);
246
274
  externalIdsRef.current = collectExternalEventIds(msgs);
247
275
  setMessages(msgs);
248
276
  });
249
277
  methods.registerMethod('sendMessage', (args)=>{
250
- if (args?.text) {
251
- sendMessage({
278
+ if (args?.text && !sendingRef.current) {
279
+ deliver({
252
280
  text: args.text,
253
281
  ...args.files ? {
254
282
  files: args.files
@@ -335,6 +363,45 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
335
363
  });
336
364
  const isEmpty = messages.length === 0;
337
365
  const isBusy = status === 'streaming' || status === 'submitted';
366
+ // The app is still fetching this conversation's transcript (the `loading` property): a
367
+ // skeleton stands in for the message area and the composer is disabled, so nothing can
368
+ // be sent into a conversation whose history has not synced yet.
369
+ const isLoading = loading === true;
370
+ // A send awaits onBeforeSend while useChat still reports `ready`, so every submit during a
371
+ // slow quota check used to send again. The ref refuses re-entry (state is a render behind a
372
+ // double click); the state is what the Sender shows. Every other way of sending (prompts,
373
+ // suggestions, edit, regenerate, the sendMessage and regenerate methods) checks the ref too,
374
+ // or it would go out first and the composer's message straight after it.
375
+ const sendingRef = useRef(false);
376
+ const [sending, setSending] = useState(false);
377
+ // An unanswered tool call left by a reply cut off this session fails validation for the
378
+ // whole history, as one in a loaded history does. A reply still streaming is not cut off,
379
+ // and a client-side tool still running will add its output.
380
+ function withoutDeadToolCalls(list) {
381
+ if (statusRef.current === 'streaming' || statusRef.current === 'submitted') return list;
382
+ return replayableMessages({
383
+ messages: list,
384
+ liveToolCallIds: pendingToolCallsRef.current
385
+ });
386
+ }
387
+ // Every send goes through here. useChat applies the setter synchronously, so sendMessage
388
+ // reads the filtered list.
389
+ function deliver(message) {
390
+ setMessages((prev)=>withoutDeadToolCalls(prev));
391
+ sendMessage(message);
392
+ }
393
+ function regenerateReply(messageId) {
394
+ let kept = [];
395
+ setMessages((prev)=>{
396
+ kept = withoutDeadToolCalls(prev);
397
+ return kept;
398
+ });
399
+ // The reply asked for may have held nothing but a dead call and be gone; regenerate
400
+ // from the end instead of letting useChat throw on the missing id.
401
+ regenerate(kept.some((m)=>m.id === messageId) ? {
402
+ messageId
403
+ } : undefined);
404
+ }
338
405
  function fileToContentPart(file) {
339
406
  return new Promise((resolve)=>{
340
407
  const reader = new FileReader();
@@ -427,7 +494,18 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
427
494
  return Array.from(e.dataTransfer?.types ?? []).includes('Files');
428
495
  }
429
496
  async function handleSend(text) {
497
+ if (sendingRef.current) return;
430
498
  if (!text.trim() && attachedFiles.length === 0) return;
499
+ sendingRef.current = true;
500
+ setSending(true);
501
+ try {
502
+ await submitMessage(text);
503
+ } finally{
504
+ sendingRef.current = false;
505
+ setSending(false);
506
+ }
507
+ }
508
+ async function submitMessage(text) {
431
509
  const filesMeta = attachedFiles.map((file)=>({
432
510
  name: file.name,
433
511
  size: file.size,
@@ -477,12 +555,12 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
477
555
  parts.push(part);
478
556
  }
479
557
  }
480
- sendMessage({
558
+ deliver({
481
559
  parts
482
560
  });
483
561
  setAttachedFiles([]);
484
562
  } else {
485
- sendMessage({
563
+ deliver({
486
564
  text
487
565
  });
488
566
  }
@@ -503,7 +581,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
503
581
  });
504
582
  }
505
583
  function handlePromptClick(prompt) {
506
- sendMessage({
584
+ if (sendingRef.current) return;
585
+ deliver({
507
586
  text: prompt.label
508
587
  });
509
588
  }
@@ -514,13 +593,14 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
514
593
  setInputValue(typeof text === 'string' ? text : '');
515
594
  }
516
595
  function handleSuggestionClick(suggestion) {
596
+ if (sendingRef.current) return;
517
597
  methods.triggerEvent({
518
598
  name: 'onSuggestionClick',
519
599
  event: {
520
600
  suggestion
521
601
  }
522
602
  });
523
- sendMessage({
603
+ deliver({
524
604
  text: suggestion.label
525
605
  });
526
606
  }
@@ -550,6 +630,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
550
630
  messages
551
631
  ]);
552
632
  const activeSuggestions = agentSuggestions ?? suggestions;
633
+ // Hidden while a composer send awaits onBeforeSend as well as while a reply streams.
634
+ const showSuggestions = !isEmpty && !isLoading && (activeSuggestions?.length ?? 0) > 0 && !isBusy && !sending;
553
635
  // A link in an answer was a plain anchor: a full browser navigation out of the
554
636
  // conversation, with no way for an app to do anything else with it. Default is only
555
637
  // prevented when the app actually handles onLinkClick — otherwise the link keeps
@@ -596,6 +678,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
596
678
  });
597
679
  }
598
680
  function handleRegenerate({ messageId }) {
681
+ if (sendingRef.current) return;
599
682
  methods.triggerEvent({
600
683
  name: 'onRegenerate',
601
684
  event: {
@@ -603,11 +686,10 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
603
686
  messages
604
687
  }
605
688
  });
606
- regenerate({
607
- messageId
608
- });
689
+ regenerateReply(messageId);
609
690
  }
610
691
  function handleEditMessage({ messageId, originalContent, newContent }) {
692
+ if (sendingRef.current) return;
611
693
  methods.triggerEvent({
612
694
  name: 'onEditMessage',
613
695
  event: {
@@ -620,7 +702,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
620
702
  const messageIndex = messages.findIndex((m)=>m.id === messageId);
621
703
  if (messageIndex >= 0) {
622
704
  setMessages((prev)=>prev.slice(0, messageIndex));
623
- sendMessage({
705
+ deliver({
624
706
  text: newContent
625
707
  });
626
708
  }
@@ -654,7 +736,36 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
654
736
  minHeight: 0,
655
737
  padding: '16px 0'
656
738
  }
657
- }, isEmpty && !welcome?.tracks ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
739
+ }, isLoading ? /*#__PURE__*/ React.createElement("div", {
740
+ className: "agent-chat-loading",
741
+ style: {
742
+ display: 'flex',
743
+ flexDirection: 'column',
744
+ gap: 24,
745
+ padding: '0 16px'
746
+ }
747
+ }, /*#__PURE__*/ React.createElement(Skeleton, {
748
+ active: true,
749
+ title: false,
750
+ paragraph: {
751
+ rows: 2,
752
+ width: [
753
+ '60%',
754
+ '40%'
755
+ ]
756
+ }
757
+ }), /*#__PURE__*/ React.createElement(Skeleton, {
758
+ active: true,
759
+ title: false,
760
+ paragraph: {
761
+ rows: 3,
762
+ width: [
763
+ '90%',
764
+ '75%',
765
+ '50%'
766
+ ]
767
+ }
768
+ })) : isEmpty && !welcome?.tracks ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
658
769
  config: welcome,
659
770
  onPromptClick: handlePromptClick
660
771
  }) : /*#__PURE__*/ React.createElement(MessageList, {
@@ -673,7 +784,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
673
784
  onDelete: handleDelete,
674
785
  onEditMessage: handleEditMessage,
675
786
  translate: methods.translate
676
- })), !isEmpty && activeSuggestions && activeSuggestions.length > 0 && !isBusy && /*#__PURE__*/ React.createElement("div", {
787
+ })), showSuggestions && /*#__PURE__*/ React.createElement("div", {
677
788
  style: {
678
789
  padding: '0 16px 8px'
679
790
  }
@@ -757,8 +868,12 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
757
868
  onPasteFile: attachmentsConfig?.enabled ? (files)=>addFiles(files, {
758
869
  pasted: true
759
870
  }) : undefined,
760
- onCancel: handleStop,
761
- loading: isBusy,
871
+ // Not during the onBeforeSend wait: nothing is streaming, so onStop would be false.
872
+ onCancel: isBusy ? handleStop : undefined,
873
+ loading: isBusy || sending,
874
+ disabled: isLoading,
875
+ styles: sender?.styles,
876
+ classNames: sender?.classNames,
762
877
  prefix: attachmentsConfig?.enabled ? /*#__PURE__*/ React.createElement(Button, {
763
878
  type: "text",
764
879
  icon: /*#__PURE__*/ React.createElement(PaperClipOutlined, null),
@@ -154,6 +154,8 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
154
154
  placement: 'end',
155
155
  variant: config?.roles?.user?.variant ?? 'filled',
156
156
  shape: config?.roles?.user?.shape ?? 'round',
157
+ styles: config?.roles?.user?.styles,
158
+ classNames: config?.roles?.user?.classNames,
157
159
  avatar: roleAvatar(config?.roles?.user, /*#__PURE__*/ React.createElement(UserOutlined, null)),
158
160
  header: roleHeader(config?.roles?.user, 'agent.message.userHeader', translate),
159
161
  editable: config?.editableMessages !== false ? {
@@ -200,6 +202,8 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
200
202
  style: {
201
203
  maxWidth: '100%'
202
204
  },
205
+ styles: config?.roles?.assistant?.styles,
206
+ classNames: config?.roles?.assistant?.classNames,
203
207
  avatar: roleAvatar(config?.roles?.assistant, /*#__PURE__*/ React.createElement(RobotOutlined, null)),
204
208
  header: roleHeader(config?.roles?.assistant, 'agent.message.assistantHeader', translate),
205
209
  typing: config?.roles?.assistant?.typing ? config.roles.assistant.typing : {
@@ -0,0 +1,60 @@
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 { type } from '@lowdefy/helpers';
16
+ // A reply cut off mid-stream leaves its tool call with no result, and UIMessage validation
17
+ // rejects the whole history for one such part: every later send fails, and a history stored
18
+ // that way fails on every load. Approval states stay; a pending approval is answerable on resume.
19
+ // A client-side tool the block is still running is in the same state, so its call id is in
20
+ // liveToolCallIds and the part is kept: its output is on the way.
21
+ const UNANSWERED = new Set([
22
+ 'input-streaming',
23
+ 'input-available'
24
+ ]);
25
+ function isDeadToolPart({ part, liveToolCallIds }) {
26
+ const partType = part?.type;
27
+ const isTool = partType === 'dynamic-tool' || type.isString(partType) && partType.startsWith('tool-');
28
+ return isTool && UNANSWERED.has(part.state) && !liveToolCallIds.has(part.toolCallId);
29
+ }
30
+ function replayableMessages({ messages, liveToolCallIds = new Set() }) {
31
+ const list = messages ?? [];
32
+ let changed = false;
33
+ const result = [];
34
+ for (const message of list){
35
+ if (!type.isArray(message?.parts)) {
36
+ result.push(message);
37
+ continue;
38
+ }
39
+ const parts = message.parts.filter((part)=>!isDeadToolPart({
40
+ part,
41
+ liveToolCallIds
42
+ }));
43
+ // Only a step marker left is the empty shell onError already drops.
44
+ if (!parts.some((part)=>part?.type !== 'step-start')) {
45
+ changed = true;
46
+ continue;
47
+ }
48
+ if (parts.length === message.parts.length) {
49
+ result.push(message);
50
+ continue;
51
+ }
52
+ changed = true;
53
+ result.push({
54
+ ...message,
55
+ parts
56
+ });
57
+ }
58
+ return changed ? result : list;
59
+ }
60
+ export default replayableMessages;
@@ -137,6 +137,14 @@
137
137
  "shape": {
138
138
  "type": "string",
139
139
  "enum": ["default", "round", "corner"]
140
+ },
141
+ "styles": {
142
+ "type": "object",
143
+ "description": "Inline styles per bubble part: root, content, body, header, footer, avatar, extra. The way to recolour a bubble: the filled and outlined colours come from the theme's colorPrimary and are not reachable from a stylesheet."
144
+ },
145
+ "classNames": {
146
+ "type": "object",
147
+ "description": "Class names per bubble part, same keys as styles."
140
148
  }
141
149
  }
142
150
  },
@@ -152,6 +160,14 @@
152
160
  "shape": {
153
161
  "type": "string",
154
162
  "enum": ["default", "round", "corner"]
163
+ },
164
+ "styles": {
165
+ "type": "object",
166
+ "description": "Inline styles per bubble part: root, content, body, header, footer, avatar, extra."
167
+ },
168
+ "classNames": {
169
+ "type": "object",
170
+ "description": "Class names per bubble part, same keys as styles."
155
171
  }
156
172
  }
157
173
  }
@@ -188,6 +204,14 @@
188
204
  "default": "enter"
189
205
  },
190
206
  "allowSpeech": { "type": "boolean", "default": false },
207
+ "styles": {
208
+ "type": "object",
209
+ "description": "Inline styles per composer part: root, prefix, input, suffix, footer, switch, content. The way to recolour the composer border, which the theme sets with a precedence no page stylesheet beats."
210
+ },
211
+ "classNames": {
212
+ "type": "object",
213
+ "description": "Class names per composer part, same keys as styles."
214
+ },
191
215
  "attachments": {
192
216
  "type": "object",
193
217
  "description": "File attachments on the composer. When enabled, files can be picked with the paperclip, pasted from the clipboard (e.g. a screenshot) or dragged onto the composer; accept and maxSize apply to all three.",
@@ -243,6 +267,11 @@
243
267
  "title": { "type": "string" }
244
268
  }
245
269
  },
270
+ "loading": {
271
+ "type": "boolean",
272
+ "default": false,
273
+ "description": "The app is still loading this conversation's transcript. While true, a skeleton replaces the message area, suggestions are hidden and the composer is disabled, so nothing can be sent into a conversation whose messages have not synced yet."
274
+ },
246
275
  "suggestions": {
247
276
  "type": "array",
248
277
  "items": {
@@ -119,3 +119,221 @@ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/blocks-antd-x",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy Ant Design X Blocks",
6
6
  "homepage": "https://lowdefy.com",
@@ -41,18 +41,19 @@
41
41
  "@ant-design/icons": "6.1.0",
42
42
  "@ant-design/x": "2.7.0",
43
43
  "@ant-design/x-markdown": "2.7.0",
44
- "@lowdefy/block-utils": "6.0.0",
45
- "@lowdefy/blocks-files": "6.0.0",
46
- "@lowdefy/helpers": "6.0.0",
44
+ "@lowdefy/block-utils": "6.1.0",
45
+ "@lowdefy/blocks-files": "6.1.0",
46
+ "@lowdefy/helpers": "6.1.0",
47
47
  "ai": "6.0.176"
48
48
  },
49
49
  "devDependencies": {
50
- "@lowdefy/block-dev-e2e": "6.0.0",
51
- "@lowdefy/e2e-utils": "6.0.0",
50
+ "@lowdefy/block-dev-e2e": "6.1.0",
51
+ "@lowdefy/e2e-utils": "6.1.0",
52
52
  "@playwright/test": "1.59.1",
53
53
  "@swc/cli": "0.8.1",
54
54
  "@swc/core": "1.15.32",
55
- "copyfiles": "2.4.1"
55
+ "copyfiles": "2.4.1",
56
+ "react-dom": "18.2.0"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "antd": "6.3.1",