@lowdefy/blocks-antd-x 6.0.0 → 6.2.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.
- package/dist/blocks/AgentChat/AgentChat.js +140 -25
- package/dist/blocks/AgentChat/MessageList.js +4 -0
- package/dist/blocks/AgentChat/replayableMessages.js +60 -0
- package/dist/blocks/AgentChat/schema.json +29 -0
- package/dist/blocks/AgentChat/tests/AgentChat.e2e.yaml +218 -0
- package/package.json +8 -7
|
@@ -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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
+
deliver({
|
|
481
559
|
parts
|
|
482
560
|
});
|
|
483
561
|
setAttachedFiles([]);
|
|
484
562
|
} else {
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
})),
|
|
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
|
-
|
|
761
|
-
|
|
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.
|
|
3
|
+
"version": "6.2.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.
|
|
45
|
-
"@lowdefy/blocks-files": "6.
|
|
46
|
-
"@lowdefy/helpers": "6.
|
|
44
|
+
"@lowdefy/block-utils": "6.2.0",
|
|
45
|
+
"@lowdefy/blocks-files": "6.2.0",
|
|
46
|
+
"@lowdefy/helpers": "6.2.0",
|
|
47
47
|
"ai": "6.0.176"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@lowdefy/block-dev-e2e": "6.
|
|
51
|
-
"@lowdefy/e2e-utils": "6.
|
|
50
|
+
"@lowdefy/block-dev-e2e": "6.2.0",
|
|
51
|
+
"@lowdefy/e2e-utils": "6.2.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",
|