@lowdefy/blocks-antd-x 5.6.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.
- package/dist/blocks/AgentChat/AgentChat.js +231 -68
- package/dist/blocks/AgentChat/LowdefyChatTransport.js +3 -1
- package/dist/blocks/AgentChat/MessageList.js +32 -1
- package/dist/blocks/AgentChat/WelcomeScreen.js +88 -1
- package/dist/blocks/AgentChat/replayableMessages.js +60 -0
- package/dist/blocks/AgentChat/schema.json +38 -1
- package/dist/blocks/AgentChat/tests/AgentChat.e2e.yaml +339 -0
- package/dist/blocks/AgentChat/tests/AgentChatTarget.e2e.yaml +25 -0
- package/dist/blocks/AgentConversations/AgentConversations.js +1 -4
- package/package.json +12 -10
- package/dist/blocks/AgentConversations/style.less +0 -1
|
@@ -16,27 +16,36 @@
|
|
|
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
|
+
import getLegacyObjectUrl from '@lowdefy/blocks-files/utils/getLegacyObjectUrl.js';
|
|
23
|
+
import getUploadPolicy from '@lowdefy/blocks-files/utils/getUploadPolicy.js';
|
|
24
|
+
import uploadFile from '@lowdefy/blocks-files/utils/uploadFile.js';
|
|
22
25
|
import { getFileCardType, getFileCardIcon } from './fileCardUtils.js';
|
|
23
26
|
import DrawerWrapper from './DrawerWrapper.js';
|
|
24
27
|
import createLowdefyChatTransport from './LowdefyChatTransport.js';
|
|
25
28
|
import MessageList from './MessageList.js';
|
|
29
|
+
import replayableMessages from './replayableMessages.js';
|
|
26
30
|
import useAgentEvents, { collectExternalEventIds } from './useAgentEvents.js';
|
|
27
31
|
import WelcomeScreen from './WelcomeScreen.js';
|
|
28
32
|
function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageId, properties }) {
|
|
29
|
-
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;
|
|
30
34
|
const senderRef = useRef(null);
|
|
31
35
|
const finishMetaRef = useRef(null);
|
|
32
36
|
const fileInputRef = useRef(null);
|
|
33
37
|
const [attachedFiles, setAttachedFiles] = useState([]);
|
|
34
38
|
const [dragOver, setDragOver] = useState(false);
|
|
39
|
+
// Controlled composer value, so a starter prompt (or the setInput method) can
|
|
40
|
+
// fill the box, and a send can leave typed text in place when it is rejected.
|
|
41
|
+
const [inputValue, setInputValue] = useState('');
|
|
35
42
|
// Mirror the operator-evaluated sharedState object into a ref so transport.body()
|
|
36
43
|
// sees the freshest value at send time without re-constructing the transport.
|
|
37
44
|
const sharedStateRef = useRef(null);
|
|
38
45
|
sharedStateRef.current = type.isObject(sharedState) && Object.keys(sharedState).length > 0 ? sharedState : null;
|
|
39
46
|
const attachmentsConfig = sender?.attachments;
|
|
47
|
+
const uploadPolicyRequestId = attachmentsConfig?.uploadPolicyRequestId ?? attachmentsConfig?.s3PostPolicyRequestId;
|
|
48
|
+
const downloadPolicyRequestId = attachmentsConfig?.downloadPolicyRequestId;
|
|
40
49
|
const switchConfigs = sender?.switches ?? [];
|
|
41
50
|
const [headerOpen, setHeaderOpen] = useState(sender?.header?.open ?? true);
|
|
42
51
|
const [switchState, setSwitchState] = useState(()=>{
|
|
@@ -68,6 +77,11 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
68
77
|
urlQueryKey
|
|
69
78
|
]);
|
|
70
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');
|
|
71
85
|
const { messages, sendMessage, status, stop, addToolApprovalResponse, addToolOutput, setMessages, regenerate, clearError } = useChat({
|
|
72
86
|
// Key the Chat instance by conversation: without an id, useChat creates its
|
|
73
87
|
// Chat once per mount and CAPTURES that transport — the transport rebuilt by
|
|
@@ -85,6 +99,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
85
99
|
async onToolCall ({ toolCall }) {
|
|
86
100
|
if (toolCall.dynamic) return;
|
|
87
101
|
if (toolCall.toolName === 'update-page-state') {
|
|
102
|
+
pendingToolCallsRef.current.add(toolCall.toolCallId);
|
|
88
103
|
try {
|
|
89
104
|
const updates = toolCall.input?.updates ?? {};
|
|
90
105
|
// Allowlist writes against the keys currently exposed via sharedState —
|
|
@@ -131,10 +146,19 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
131
146
|
state: 'output-error',
|
|
132
147
|
errorText: err.message
|
|
133
148
|
});
|
|
149
|
+
} finally{
|
|
150
|
+
pendingToolCallsRef.current.delete(toolCall.toolCallId);
|
|
134
151
|
}
|
|
135
152
|
}
|
|
136
153
|
},
|
|
137
154
|
onError: (error)=>{
|
|
155
|
+
// A request that fails after the stream's `start` chunk leaves the
|
|
156
|
+
// assistant shell the SDK pushed on that chunk — id set, parts: [] — in
|
|
157
|
+
// the message list. Left there, every later send fails UIMessage
|
|
158
|
+
// validation ("Message must contain at least one part") and the
|
|
159
|
+
// conversation is dead until a reload. Drop empty shells so the user can
|
|
160
|
+
// simply try again; there is nothing in them to show or to send.
|
|
161
|
+
setMessages((prev)=>prev.filter((m)=>m.role !== 'assistant' || (m.parts?.length ?? 0) > 0));
|
|
138
162
|
methods.triggerEvent({
|
|
139
163
|
name: 'onError',
|
|
140
164
|
event: {
|
|
@@ -160,6 +184,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
160
184
|
});
|
|
161
185
|
}
|
|
162
186
|
});
|
|
187
|
+
statusRef.current = status;
|
|
163
188
|
// The control is otherwise write-only: it reports a rating and immediately forgets it,
|
|
164
189
|
// so the thumb un-highlights on the next render and the message looks unrated. This holds
|
|
165
190
|
// what was clicked THIS visit; the block still persists nothing itself.
|
|
@@ -190,13 +215,29 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
190
215
|
effectiveConversationId,
|
|
191
216
|
setMessages
|
|
192
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
|
|
193
228
|
// Sync external messages when provided — undefined means "not provided" (no sync),
|
|
194
229
|
// null means "clear messages", array means "load these messages".
|
|
195
230
|
// Compare by count + last ID to avoid re-syncing on every Lowdefy re-render
|
|
196
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.
|
|
197
237
|
const prevExternalRef = useRef({
|
|
198
238
|
count: 0,
|
|
199
|
-
lastId: null
|
|
239
|
+
lastId: null,
|
|
240
|
+
conversationId: effectiveConversationId
|
|
200
241
|
});
|
|
201
242
|
// Event-dedup ids of externally loaded messages. Restored history must not
|
|
202
243
|
// replay onToolCall / onToolResult / onUserMessage / onTitleGenerated side
|
|
@@ -207,36 +248,38 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
207
248
|
const msgs = externalMessages ?? [];
|
|
208
249
|
const count = msgs.length;
|
|
209
250
|
const lastId = count > 0 ? msgs[count - 1]?.id : null;
|
|
210
|
-
if (count !== prevExternalRef.current.count || lastId !== prevExternalRef.current.lastId) {
|
|
251
|
+
if (count !== prevExternalRef.current.count || lastId !== prevExternalRef.current.lastId || effectiveConversationId !== prevExternalRef.current.conversationId) {
|
|
211
252
|
prevExternalRef.current = {
|
|
212
253
|
count,
|
|
213
|
-
lastId
|
|
254
|
+
lastId,
|
|
255
|
+
conversationId: effectiveConversationId
|
|
214
256
|
};
|
|
215
|
-
|
|
216
|
-
|
|
257
|
+
const replayable = withoutDeadToolCalls(msgs);
|
|
258
|
+
externalIdsRef.current = collectExternalEventIds(replayable);
|
|
259
|
+
setMessages(replayable);
|
|
217
260
|
}
|
|
218
261
|
}, [
|
|
219
262
|
externalMessages,
|
|
263
|
+
effectiveConversationId,
|
|
220
264
|
setMessages
|
|
221
265
|
]);
|
|
222
266
|
// Register CallMethod methods so YAML actions can control the chat.
|
|
223
267
|
useEffect(()=>{
|
|
224
268
|
methods.registerMethod('regenerate', (args)=>{
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
} : undefined);
|
|
269
|
+
if (sendingRef.current) return;
|
|
270
|
+
regenerateReply(args?.messageId);
|
|
228
271
|
});
|
|
229
272
|
methods.registerMethod('setMessages', (args)=>{
|
|
230
|
-
const msgs = args?.messages ?? [];
|
|
273
|
+
const msgs = withoutDeadToolCalls(args?.messages ?? []);
|
|
231
274
|
externalIdsRef.current = collectExternalEventIds(msgs);
|
|
232
275
|
setMessages(msgs);
|
|
233
276
|
});
|
|
234
277
|
methods.registerMethod('sendMessage', (args)=>{
|
|
235
|
-
if (args?.text) {
|
|
236
|
-
|
|
278
|
+
if (args?.text && !sendingRef.current) {
|
|
279
|
+
deliver({
|
|
237
280
|
text: args.text,
|
|
238
281
|
...args.files ? {
|
|
239
|
-
|
|
282
|
+
files: args.files
|
|
240
283
|
} : {},
|
|
241
284
|
...args.metadata ? {
|
|
242
285
|
metadata: args.metadata
|
|
@@ -244,6 +287,9 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
244
287
|
});
|
|
245
288
|
}
|
|
246
289
|
});
|
|
290
|
+
methods.registerMethod('setInput', (args)=>{
|
|
291
|
+
setInputValue(typeof args?.text === 'string' ? args.text : '');
|
|
292
|
+
});
|
|
247
293
|
methods.registerMethod('clearMessages', ()=>{
|
|
248
294
|
setMessages([]);
|
|
249
295
|
});
|
|
@@ -275,19 +321,36 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
275
321
|
}
|
|
276
322
|
]
|
|
277
323
|
});
|
|
278
|
-
if (
|
|
324
|
+
if (uploadPolicyRequestId) {
|
|
325
|
+
if (attachmentsConfig?.s3PostPolicyRequestId) {
|
|
326
|
+
console.warn('AgentChat attachments property "s3PostPolicyRequestId" is deprecated. Use "uploadPolicyRequestId" instead.');
|
|
327
|
+
}
|
|
279
328
|
methods.registerEvent({
|
|
280
|
-
name: '
|
|
329
|
+
name: '__getUploadPolicy',
|
|
281
330
|
actions: [
|
|
282
331
|
{
|
|
283
|
-
id: '
|
|
332
|
+
id: '__getUploadPolicy',
|
|
284
333
|
type: 'Request',
|
|
285
334
|
params: [
|
|
286
|
-
|
|
335
|
+
uploadPolicyRequestId
|
|
287
336
|
]
|
|
288
337
|
}
|
|
289
338
|
]
|
|
290
339
|
});
|
|
340
|
+
if (downloadPolicyRequestId) {
|
|
341
|
+
methods.registerEvent({
|
|
342
|
+
name: '__getDownloadPolicy',
|
|
343
|
+
actions: [
|
|
344
|
+
{
|
|
345
|
+
id: '__getDownloadPolicy',
|
|
346
|
+
type: 'Request',
|
|
347
|
+
params: [
|
|
348
|
+
downloadPolicyRequestId
|
|
349
|
+
]
|
|
350
|
+
}
|
|
351
|
+
]
|
|
352
|
+
});
|
|
353
|
+
}
|
|
291
354
|
}
|
|
292
355
|
}, []);
|
|
293
356
|
useAgentEvents({
|
|
@@ -300,6 +363,45 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
300
363
|
});
|
|
301
364
|
const isEmpty = messages.length === 0;
|
|
302
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
|
+
}
|
|
303
405
|
function fileToContentPart(file) {
|
|
304
406
|
return new Promise((resolve)=>{
|
|
305
407
|
const reader = new FileReader();
|
|
@@ -314,42 +416,41 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
314
416
|
reader.readAsDataURL(file);
|
|
315
417
|
});
|
|
316
418
|
}
|
|
317
|
-
async function
|
|
318
|
-
const { name,
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
file: {
|
|
323
|
-
name,
|
|
324
|
-
size,
|
|
325
|
-
type: fileType
|
|
326
|
-
}
|
|
327
|
-
}
|
|
419
|
+
async function uploadFileToStorage(file) {
|
|
420
|
+
const { name, type: fileType } = file;
|
|
421
|
+
const descriptor = await getUploadPolicy({
|
|
422
|
+
methods,
|
|
423
|
+
file
|
|
328
424
|
});
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const { url, fields = {} } = s3PostPolicyResponse.responses.__getS3PostPolicy.response[0];
|
|
333
|
-
const { key } = fields;
|
|
334
|
-
const formData = new FormData();
|
|
335
|
-
Object.keys(fields).forEach((field)=>{
|
|
336
|
-
formData.append(field, fields[field]);
|
|
425
|
+
await uploadFile({
|
|
426
|
+
descriptor,
|
|
427
|
+
file
|
|
337
428
|
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
429
|
+
// The model provider fetches this URL, so resolve it through the download
|
|
430
|
+
// request when configured (stable public URL or signed URL). Otherwise
|
|
431
|
+
// fall back to the legacy unsigned object URL from the descriptor.
|
|
432
|
+
let objectUrl;
|
|
433
|
+
if (downloadPolicyRequestId) {
|
|
434
|
+
const response = await methods.triggerEvent({
|
|
435
|
+
name: '__getDownloadPolicy',
|
|
436
|
+
event: {
|
|
437
|
+
file: {
|
|
438
|
+
bucket: descriptor.bucket,
|
|
439
|
+
key: descriptor.key,
|
|
440
|
+
name,
|
|
441
|
+
type: fileType
|
|
442
|
+
}
|
|
346
443
|
}
|
|
347
444
|
});
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
445
|
+
if (response.success !== true) {
|
|
446
|
+
throw new Error('Download policy request failed.');
|
|
447
|
+
}
|
|
448
|
+
objectUrl = response.responses.__getDownloadPolicy.response[0];
|
|
449
|
+
} else {
|
|
450
|
+
objectUrl = getLegacyObjectUrl({
|
|
451
|
+
descriptor
|
|
452
|
+
});
|
|
453
|
+
}
|
|
353
454
|
return {
|
|
354
455
|
url: objectUrl,
|
|
355
456
|
mediaType: fileType,
|
|
@@ -393,7 +494,18 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
393
494
|
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
394
495
|
}
|
|
395
496
|
async function handleSend(text) {
|
|
497
|
+
if (sendingRef.current) return;
|
|
396
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) {
|
|
397
509
|
const filesMeta = attachedFiles.map((file)=>({
|
|
398
510
|
name: file.name,
|
|
399
511
|
size: file.size,
|
|
@@ -417,9 +529,9 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
417
529
|
text
|
|
418
530
|
}
|
|
419
531
|
];
|
|
420
|
-
if (
|
|
532
|
+
if (uploadPolicyRequestId) {
|
|
421
533
|
try {
|
|
422
|
-
const uploadResults = await Promise.all(attachedFiles.map((file)=>
|
|
534
|
+
const uploadResults = await Promise.all(attachedFiles.map((file)=>uploadFileToStorage(file)));
|
|
423
535
|
for (const result of uploadResults){
|
|
424
536
|
parts.push({
|
|
425
537
|
type: 'file',
|
|
@@ -432,7 +544,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
432
544
|
methods.triggerEvent({
|
|
433
545
|
name: 'onError',
|
|
434
546
|
event: {
|
|
435
|
-
message: error.message ?? 'File upload
|
|
547
|
+
message: error.message ?? 'File upload failed.'
|
|
436
548
|
}
|
|
437
549
|
});
|
|
438
550
|
return;
|
|
@@ -443,16 +555,21 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
443
555
|
parts.push(part);
|
|
444
556
|
}
|
|
445
557
|
}
|
|
446
|
-
|
|
558
|
+
deliver({
|
|
447
559
|
parts
|
|
448
560
|
});
|
|
449
561
|
setAttachedFiles([]);
|
|
450
562
|
} else {
|
|
451
|
-
|
|
563
|
+
deliver({
|
|
452
564
|
text
|
|
453
565
|
});
|
|
454
566
|
}
|
|
455
|
-
|
|
567
|
+
// Empty the composer here, after the sends and downstream of both the
|
|
568
|
+
// onBeforeSend cancellation return and the upload await, so a send that was
|
|
569
|
+
// rejected or failed to upload leaves the user's text in the box. Resetting
|
|
570
|
+
// from the Sender's onSubmit handler instead would silently lose typed input
|
|
571
|
+
// on every rejected send.
|
|
572
|
+
setInputValue('');
|
|
456
573
|
}
|
|
457
574
|
function handleStop() {
|
|
458
575
|
stop();
|
|
@@ -464,18 +581,26 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
464
581
|
});
|
|
465
582
|
}
|
|
466
583
|
function handlePromptClick(prompt) {
|
|
467
|
-
|
|
584
|
+
if (sendingRef.current) return;
|
|
585
|
+
deliver({
|
|
468
586
|
text: prompt.label
|
|
469
587
|
});
|
|
470
588
|
}
|
|
589
|
+
// A two-track welcome starter fills the composer instead of sending, so a
|
|
590
|
+
// generic shipped default is an editable first draft rather than a message the
|
|
591
|
+
// user never meant to send. Reuses the controlled Sender value.
|
|
592
|
+
function handleWelcomePromptFill(text) {
|
|
593
|
+
setInputValue(typeof text === 'string' ? text : '');
|
|
594
|
+
}
|
|
471
595
|
function handleSuggestionClick(suggestion) {
|
|
596
|
+
if (sendingRef.current) return;
|
|
472
597
|
methods.triggerEvent({
|
|
473
598
|
name: 'onSuggestionClick',
|
|
474
599
|
event: {
|
|
475
600
|
suggestion
|
|
476
601
|
}
|
|
477
602
|
});
|
|
478
|
-
|
|
603
|
+
deliver({
|
|
479
604
|
text: suggestion.label
|
|
480
605
|
});
|
|
481
606
|
}
|
|
@@ -505,6 +630,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
505
630
|
messages
|
|
506
631
|
]);
|
|
507
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;
|
|
508
635
|
// A link in an answer was a plain anchor: a full browser navigation out of the
|
|
509
636
|
// conversation, with no way for an app to do anything else with it. Default is only
|
|
510
637
|
// prevented when the app actually handles onLinkClick — otherwise the link keeps
|
|
@@ -551,6 +678,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
551
678
|
});
|
|
552
679
|
}
|
|
553
680
|
function handleRegenerate({ messageId }) {
|
|
681
|
+
if (sendingRef.current) return;
|
|
554
682
|
methods.triggerEvent({
|
|
555
683
|
name: 'onRegenerate',
|
|
556
684
|
event: {
|
|
@@ -558,11 +686,10 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
558
686
|
messages
|
|
559
687
|
}
|
|
560
688
|
});
|
|
561
|
-
|
|
562
|
-
messageId
|
|
563
|
-
});
|
|
689
|
+
regenerateReply(messageId);
|
|
564
690
|
}
|
|
565
691
|
function handleEditMessage({ messageId, originalContent, newContent }) {
|
|
692
|
+
if (sendingRef.current) return;
|
|
566
693
|
methods.triggerEvent({
|
|
567
694
|
name: 'onEditMessage',
|
|
568
695
|
event: {
|
|
@@ -575,7 +702,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
575
702
|
const messageIndex = messages.findIndex((m)=>m.id === messageId);
|
|
576
703
|
if (messageIndex >= 0) {
|
|
577
704
|
setMessages((prev)=>prev.slice(0, messageIndex));
|
|
578
|
-
|
|
705
|
+
deliver({
|
|
579
706
|
text: newContent
|
|
580
707
|
});
|
|
581
708
|
}
|
|
@@ -609,7 +736,36 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
609
736
|
minHeight: 0,
|
|
610
737
|
padding: '16px 0'
|
|
611
738
|
}
|
|
612
|
-
},
|
|
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, {
|
|
613
769
|
config: welcome,
|
|
614
770
|
onPromptClick: handlePromptClick
|
|
615
771
|
}) : /*#__PURE__*/ React.createElement(MessageList, {
|
|
@@ -617,6 +773,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
617
773
|
messages: messages,
|
|
618
774
|
isStreaming: isBusy,
|
|
619
775
|
config: messageDisplay,
|
|
776
|
+
welcome: welcome,
|
|
777
|
+
onWelcomePromptFill: handleWelcomePromptFill,
|
|
620
778
|
addToolApprovalResponse: addToolApprovalResponse,
|
|
621
779
|
onFeedback: handleFeedback,
|
|
622
780
|
onLinkClick: handleLinkClick,
|
|
@@ -626,7 +784,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
626
784
|
onDelete: handleDelete,
|
|
627
785
|
onEditMessage: handleEditMessage,
|
|
628
786
|
translate: methods.translate
|
|
629
|
-
})),
|
|
787
|
+
})), showSuggestions && /*#__PURE__*/ React.createElement("div", {
|
|
630
788
|
style: {
|
|
631
789
|
padding: '0 16px 8px'
|
|
632
790
|
}
|
|
@@ -699,6 +857,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
699
857
|
}
|
|
700
858
|
}), /*#__PURE__*/ React.createElement(Sender, {
|
|
701
859
|
ref: senderRef,
|
|
860
|
+
value: inputValue,
|
|
861
|
+
onChange: setInputValue,
|
|
702
862
|
placeholder: sender?.placeholder ?? methods.translate('agent.sender.placeholder'),
|
|
703
863
|
submitType: sender?.submitType ?? 'enter',
|
|
704
864
|
allowSpeech: sender?.allowSpeech ?? false,
|
|
@@ -708,8 +868,12 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
708
868
|
onPasteFile: attachmentsConfig?.enabled ? (files)=>addFiles(files, {
|
|
709
869
|
pasted: true
|
|
710
870
|
}) : undefined,
|
|
711
|
-
|
|
712
|
-
|
|
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,
|
|
713
877
|
prefix: attachmentsConfig?.enabled ? /*#__PURE__*/ React.createElement(Button, {
|
|
714
878
|
type: "text",
|
|
715
879
|
icon: /*#__PURE__*/ React.createElement(PaperClipOutlined, null),
|
|
@@ -741,7 +905,6 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
|
|
|
741
905
|
}
|
|
742
906
|
AgentChat.meta = {
|
|
743
907
|
category: 'display',
|
|
744
|
-
icons: []
|
|
745
|
-
styles: []
|
|
908
|
+
icons: []
|
|
746
909
|
};
|
|
747
910
|
export default AgentChat;
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import { DefaultChatTransport } from 'ai';
|
|
16
16
|
function createLowdefyChatTransport({ pageId, agentId, conversationId, urlQuery, sharedStateRef }) {
|
|
17
|
-
|
|
17
|
+
// The server route takes the last path segment as the agentId — module-scoped
|
|
18
|
+
// agent ids contain '/', so encode the agentId into a single segment.
|
|
19
|
+
const base = `/api/agent/${pageId}/${encodeURIComponent(agentId)}`;
|
|
18
20
|
const api = conversationId ? `${base}?conversationId=${encodeURIComponent(conversationId)}` : base;
|
|
19
21
|
return new DefaultChatTransport({
|
|
20
22
|
api,
|
|
@@ -21,6 +21,7 @@ import { hasVisibleContent } from './messageParts.js';
|
|
|
21
21
|
import MessageBubble from './MessageBubble.js';
|
|
22
22
|
import ThinkingIndicator from './ThinkingIndicator.js';
|
|
23
23
|
import useThinkingMessage from './useThinkingMessage.js';
|
|
24
|
+
import WelcomeScreen from './WelcomeScreen.js';
|
|
24
25
|
function roleAvatar(roleConfig, fallbackIcon) {
|
|
25
26
|
if (roleConfig?.avatar) {
|
|
26
27
|
return /*#__PURE__*/ React.createElement(Avatar, {
|
|
@@ -55,7 +56,7 @@ function ThinkingBubbleContent({ bubbleId, config }) {
|
|
|
55
56
|
label: label
|
|
56
57
|
});
|
|
57
58
|
}
|
|
58
|
-
const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, addToolApprovalResponse, onFeedback, onLinkClick, Link, feedbackValues, onRegenerate, onDelete, onEditMessage, translate }, ref) {
|
|
59
|
+
const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, welcome, onWelcomePromptFill, addToolApprovalResponse, onFeedback, onLinkClick, Link, feedbackValues, onRegenerate, onDelete, onEditMessage, translate }, ref) {
|
|
59
60
|
// Build a lookup map for message parts.
|
|
60
61
|
// Bubble.List's contentRender callback only receives (content, info) where info.key is the
|
|
61
62
|
// item key — it does not receive the full message object with its parts array. This map
|
|
@@ -119,14 +120,42 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
|
|
|
119
120
|
streaming: true
|
|
120
121
|
});
|
|
121
122
|
}
|
|
123
|
+
// The welcome renders as a leading item so it scrolls up with the transcript
|
|
124
|
+
// and stays reachable by scrolling back, rather than being swapped out on the
|
|
125
|
+
// first send. With no messages yet it is simply the only item in the list.
|
|
126
|
+
if (welcome?.tracks) {
|
|
127
|
+
items.unshift({
|
|
128
|
+
key: '__welcome__',
|
|
129
|
+
role: 'welcome'
|
|
130
|
+
});
|
|
131
|
+
}
|
|
122
132
|
return /*#__PURE__*/ React.createElement(Bubble.List, {
|
|
123
133
|
ref: ref,
|
|
124
134
|
items: items,
|
|
125
135
|
role: {
|
|
136
|
+
welcome: {
|
|
137
|
+
placement: 'start',
|
|
138
|
+
variant: 'borderless',
|
|
139
|
+
style: {
|
|
140
|
+
maxWidth: '100%'
|
|
141
|
+
},
|
|
142
|
+
styles: {
|
|
143
|
+
content: {
|
|
144
|
+
width: '100%'
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
contentRender: ()=>/*#__PURE__*/ React.createElement(WelcomeScreen, {
|
|
148
|
+
config: welcome,
|
|
149
|
+
onFill: onWelcomePromptFill,
|
|
150
|
+
inFlow: true
|
|
151
|
+
})
|
|
152
|
+
},
|
|
126
153
|
user: {
|
|
127
154
|
placement: 'end',
|
|
128
155
|
variant: config?.roles?.user?.variant ?? 'filled',
|
|
129
156
|
shape: config?.roles?.user?.shape ?? 'round',
|
|
157
|
+
styles: config?.roles?.user?.styles,
|
|
158
|
+
classNames: config?.roles?.user?.classNames,
|
|
130
159
|
avatar: roleAvatar(config?.roles?.user, /*#__PURE__*/ React.createElement(UserOutlined, null)),
|
|
131
160
|
header: roleHeader(config?.roles?.user, 'agent.message.userHeader', translate),
|
|
132
161
|
editable: config?.editableMessages !== false ? {
|
|
@@ -173,6 +202,8 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
|
|
|
173
202
|
style: {
|
|
174
203
|
maxWidth: '100%'
|
|
175
204
|
},
|
|
205
|
+
styles: config?.roles?.assistant?.styles,
|
|
206
|
+
classNames: config?.roles?.assistant?.classNames,
|
|
176
207
|
avatar: roleAvatar(config?.roles?.assistant, /*#__PURE__*/ React.createElement(RobotOutlined, null)),
|
|
177
208
|
header: roleHeader(config?.roles?.assistant, 'agent.message.assistantHeader', translate),
|
|
178
209
|
typing: config?.roles?.assistant?.typing ? config.roles.assistant.typing : {
|
|
@@ -14,8 +14,95 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import React from 'react';
|
|
16
16
|
import { Welcome, Prompts } from '@ant-design/x';
|
|
17
|
-
|
|
17
|
+
import { Button } from 'antd';
|
|
18
|
+
// One track: a bordered card holding a heading and a column of starter chips.
|
|
19
|
+
// A chip fills the composer (onFill) rather than sending — a near-miss starter
|
|
20
|
+
// becomes an editable first draft, not a message the user never meant to send.
|
|
21
|
+
function TrackCard({ track, onFill }) {
|
|
22
|
+
const prompts = track.prompts ?? [];
|
|
23
|
+
return /*#__PURE__*/ React.createElement("div", {
|
|
24
|
+
style: {
|
|
25
|
+
flex: '1 1 240px',
|
|
26
|
+
border: '1px solid var(--ant-color-border)',
|
|
27
|
+
borderRadius: 'var(--ant-border-radius-lg)',
|
|
28
|
+
padding: 16,
|
|
29
|
+
backgroundColor: 'var(--ant-color-bg-container)',
|
|
30
|
+
display: 'flex',
|
|
31
|
+
flexDirection: 'column',
|
|
32
|
+
gap: 8
|
|
33
|
+
}
|
|
34
|
+
}, track.label && /*#__PURE__*/ React.createElement("h5", {
|
|
35
|
+
style: {
|
|
36
|
+
margin: '0 0 4px',
|
|
37
|
+
fontSize: 'var(--ant-font-size-heading-5)',
|
|
38
|
+
fontWeight: 'var(--ant-font-weight-strong, 600)',
|
|
39
|
+
lineHeight: 'var(--ant-line-height-heading-5)'
|
|
40
|
+
}
|
|
41
|
+
}, track.label), prompts.map((prompt, index)=>{
|
|
42
|
+
const text = typeof prompt === 'string' ? prompt : prompt.label;
|
|
43
|
+
return /*#__PURE__*/ React.createElement(Button, {
|
|
44
|
+
key: index,
|
|
45
|
+
block: true,
|
|
46
|
+
// antd buttons are nowrap and size to their longest line; height auto
|
|
47
|
+
// plus whiteSpace normal lets a sentence-length starter wrap left.
|
|
48
|
+
style: {
|
|
49
|
+
height: 'auto',
|
|
50
|
+
whiteSpace: 'normal',
|
|
51
|
+
textAlign: 'left',
|
|
52
|
+
paddingTop: 6,
|
|
53
|
+
paddingBottom: 6
|
|
54
|
+
},
|
|
55
|
+
onClick: ()=>onFill?.(text)
|
|
56
|
+
}, text);
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
// The chat empty state. Two modes:
|
|
60
|
+
// - `config.tracks` present → a two-track teaching panel (ask a question /
|
|
61
|
+
// build a report), each track a card of fill-the-composer starters. When
|
|
62
|
+
// `inFlow` it renders top-aligned as a leading item inside the message
|
|
63
|
+
// scroll area, so it scrolls up with the conversation and stays reachable
|
|
64
|
+
// by scrolling back rather than being swapped out on the first send.
|
|
65
|
+
// - otherwise → the flat Welcome + Prompts row, centred, sending on click.
|
|
66
|
+
function WelcomeScreen({ config, onPromptClick, onFill, inFlow }) {
|
|
18
67
|
if (!config) return null;
|
|
68
|
+
if (config.tracks) {
|
|
69
|
+
return /*#__PURE__*/ React.createElement("div", {
|
|
70
|
+
style: inFlow ? {
|
|
71
|
+
display: 'flex',
|
|
72
|
+
flexDirection: 'column',
|
|
73
|
+
gap: 16,
|
|
74
|
+
width: '100%'
|
|
75
|
+
} : {
|
|
76
|
+
flex: 1,
|
|
77
|
+
display: 'flex',
|
|
78
|
+
flexDirection: 'column',
|
|
79
|
+
alignItems: 'center',
|
|
80
|
+
justifyContent: 'center',
|
|
81
|
+
gap: 16
|
|
82
|
+
}
|
|
83
|
+
}, config.title && /*#__PURE__*/ React.createElement("h3", {
|
|
84
|
+
style: {
|
|
85
|
+
margin: 0,
|
|
86
|
+
fontSize: 'var(--ant-font-size-heading-3)',
|
|
87
|
+
fontWeight: 'var(--ant-font-weight-strong, 600)',
|
|
88
|
+
lineHeight: 'var(--ant-line-height-heading-3)'
|
|
89
|
+
}
|
|
90
|
+
}, config.title), config.description && /*#__PURE__*/ React.createElement("div", {
|
|
91
|
+
style: {
|
|
92
|
+
color: 'var(--ant-color-text-secondary)'
|
|
93
|
+
}
|
|
94
|
+
}, config.description), /*#__PURE__*/ React.createElement("div", {
|
|
95
|
+
style: {
|
|
96
|
+
display: 'flex',
|
|
97
|
+
gap: 12,
|
|
98
|
+
flexWrap: 'wrap'
|
|
99
|
+
}
|
|
100
|
+
}, config.tracks.map((track, index)=>/*#__PURE__*/ React.createElement(TrackCard, {
|
|
101
|
+
key: index,
|
|
102
|
+
track: track,
|
|
103
|
+
onFill: onFill
|
|
104
|
+
}))));
|
|
105
|
+
}
|
|
19
106
|
const promptItems = (config.prompts ?? []).map((prompt, index)=>({
|
|
20
107
|
key: prompt.key ?? `prompt-${index}`,
|
|
21
108
|
label: prompt.label,
|
|
@@ -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.",
|
|
@@ -201,9 +225,17 @@
|
|
|
201
225
|
"type": "number",
|
|
202
226
|
"description": "Maximum file size in bytes. Larger files are silently skipped."
|
|
203
227
|
},
|
|
228
|
+
"uploadPolicyRequestId": {
|
|
229
|
+
"type": "string",
|
|
230
|
+
"description": "Id of an upload-policy request (e.g. AwsS3PresignedPostPolicy, GcsSignedPostPolicy, AzureBlobUploadSas). When set, files are uploaded to storage and their URLs are sent to the LLM instead of base64 data."
|
|
231
|
+
},
|
|
232
|
+
"downloadPolicyRequestId": {
|
|
233
|
+
"type": "string",
|
|
234
|
+
"description": "Id of a download request used to resolve the URL of an uploaded attachment. The URL is fetched by the model provider, so it should remain reachable — use public: true on the request for public buckets, or a long expiry. When unset, a legacy unsigned object URL is constructed from the upload response (S3-shaped, deprecated)."
|
|
235
|
+
},
|
|
204
236
|
"s3PostPolicyRequestId": {
|
|
205
237
|
"type": "string",
|
|
206
|
-
"description": "
|
|
238
|
+
"description": "Deprecated — use uploadPolicyRequestId instead."
|
|
207
239
|
}
|
|
208
240
|
}
|
|
209
241
|
},
|
|
@@ -235,6 +267,11 @@
|
|
|
235
267
|
"title": { "type": "string" }
|
|
236
268
|
}
|
|
237
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
|
+
},
|
|
238
275
|
"suggestions": {
|
|
239
276
|
"type": "array",
|
|
240
277
|
"items": {
|
|
@@ -0,0 +1,339 @@
|
|
|
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-chat
|
|
16
|
+
type: PageHeaderMenu
|
|
17
|
+
properties:
|
|
18
|
+
title: AgentChat
|
|
19
|
+
blocks:
|
|
20
|
+
# Both chats are driven from the messages property, so nothing here calls the agent.
|
|
21
|
+
- id: chat_wired
|
|
22
|
+
type: AgentChat
|
|
23
|
+
properties:
|
|
24
|
+
agentId: test_agent
|
|
25
|
+
height: 300
|
|
26
|
+
conversationId: wired
|
|
27
|
+
messageDisplay:
|
|
28
|
+
actions:
|
|
29
|
+
- feedback
|
|
30
|
+
messages:
|
|
31
|
+
- id: w1
|
|
32
|
+
role: assistant
|
|
33
|
+
parts:
|
|
34
|
+
- type: text
|
|
35
|
+
text: |
|
|
36
|
+
[in-app link](/agent-chat-target) and [external link](https://example.com)
|
|
37
|
+
and [**bold** link](/agent-chat-target?tab=two)
|
|
38
|
+
events:
|
|
39
|
+
onFeedback:
|
|
40
|
+
- id: record_rating
|
|
41
|
+
type: SetState
|
|
42
|
+
params:
|
|
43
|
+
last_rating:
|
|
44
|
+
_event: rating
|
|
45
|
+
onLinkClick:
|
|
46
|
+
- id: record_link_click
|
|
47
|
+
type: SetState
|
|
48
|
+
params:
|
|
49
|
+
last_href:
|
|
50
|
+
_event: href
|
|
51
|
+
last_text:
|
|
52
|
+
_event: text
|
|
53
|
+
click_count:
|
|
54
|
+
_sum:
|
|
55
|
+
- _if_none:
|
|
56
|
+
- _state: click_count
|
|
57
|
+
- 0
|
|
58
|
+
- 1
|
|
59
|
+
|
|
60
|
+
# Seeded from feedbackValues rather than from a click: what a reload or a conversation
|
|
61
|
+
# switch looks like to an app that stored the rating itself.
|
|
62
|
+
- id: chat_seeded
|
|
63
|
+
type: AgentChat
|
|
64
|
+
properties:
|
|
65
|
+
agentId: test_agent
|
|
66
|
+
height: 300
|
|
67
|
+
conversationId: seeded
|
|
68
|
+
messageDisplay:
|
|
69
|
+
actions:
|
|
70
|
+
- feedback
|
|
71
|
+
feedbackValues:
|
|
72
|
+
s1: like
|
|
73
|
+
messages:
|
|
74
|
+
- id: s1
|
|
75
|
+
role: assistant
|
|
76
|
+
parts:
|
|
77
|
+
- type: text
|
|
78
|
+
text: A rated answer.
|
|
79
|
+
events:
|
|
80
|
+
onFeedback:
|
|
81
|
+
- id: record_seeded_rating
|
|
82
|
+
type: SetState
|
|
83
|
+
params:
|
|
84
|
+
seeded_rating:
|
|
85
|
+
_event: rating
|
|
86
|
+
|
|
87
|
+
- id: wired_readout
|
|
88
|
+
type: Html
|
|
89
|
+
properties:
|
|
90
|
+
html:
|
|
91
|
+
_nunjucks:
|
|
92
|
+
template: |
|
|
93
|
+
<span id="readout_href">{{ href }}</span>
|
|
94
|
+
<span id="readout_text">{{ text }}</span>
|
|
95
|
+
<span id="readout_count">{{ count or 0 }}</span>
|
|
96
|
+
<span id="readout_rating">{{ rating }}</span>
|
|
97
|
+
<span id="readout_seeded_rating">{{ seeded_rating }}</span>
|
|
98
|
+
on:
|
|
99
|
+
href:
|
|
100
|
+
_state: last_href
|
|
101
|
+
text:
|
|
102
|
+
_state: last_text
|
|
103
|
+
count:
|
|
104
|
+
_state: click_count
|
|
105
|
+
rating:
|
|
106
|
+
_state: last_rating
|
|
107
|
+
seeded_rating:
|
|
108
|
+
_state: seeded_rating
|
|
109
|
+
|
|
110
|
+
- id: chat_unwired
|
|
111
|
+
type: AgentChat
|
|
112
|
+
properties:
|
|
113
|
+
agentId: test_agent
|
|
114
|
+
height: 300
|
|
115
|
+
conversationId: unwired
|
|
116
|
+
messages:
|
|
117
|
+
- id: u1
|
|
118
|
+
role: assistant
|
|
119
|
+
parts:
|
|
120
|
+
- type: text
|
|
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.
|
|
@@ -0,0 +1,25 @@
|
|
|
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-chat-target
|
|
16
|
+
type: PageHeaderMenu
|
|
17
|
+
properties:
|
|
18
|
+
title: Link target
|
|
19
|
+
blocks:
|
|
20
|
+
- id: target_marker
|
|
21
|
+
type: Html
|
|
22
|
+
properties:
|
|
23
|
+
# No id on the span: Lowdefy renders the block id into the DOM, so repeating it here
|
|
24
|
+
# gives two matching elements and a strict-mode violation.
|
|
25
|
+
html: <span>arrived</span>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lowdefy/blocks-antd-x",
|
|
3
|
-
"version": "
|
|
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,17 +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": "
|
|
45
|
-
"@lowdefy/
|
|
44
|
+
"@lowdefy/block-utils": "6.1.0",
|
|
45
|
+
"@lowdefy/blocks-files": "6.1.0",
|
|
46
|
+
"@lowdefy/helpers": "6.1.0",
|
|
46
47
|
"ai": "6.0.176"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@lowdefy/block-dev-e2e": "
|
|
50
|
-
"@lowdefy/e2e-utils": "
|
|
51
|
-
"@playwright/test": "1.
|
|
52
|
-
"@swc/cli": "0.8.
|
|
53
|
-
"@swc/core": "1.15.
|
|
54
|
-
"copyfiles": "2.4.1"
|
|
50
|
+
"@lowdefy/block-dev-e2e": "6.1.0",
|
|
51
|
+
"@lowdefy/e2e-utils": "6.1.0",
|
|
52
|
+
"@playwright/test": "1.59.1",
|
|
53
|
+
"@swc/cli": "0.8.1",
|
|
54
|
+
"@swc/core": "1.15.32",
|
|
55
|
+
"copyfiles": "2.4.1",
|
|
56
|
+
"react-dom": "18.2.0"
|
|
55
57
|
},
|
|
56
58
|
"peerDependencies": {
|
|
57
59
|
"antd": "6.3.1",
|
|
@@ -63,7 +65,7 @@
|
|
|
63
65
|
"scripts": {
|
|
64
66
|
"build": "swc src --out-dir dist --config-file ../../../../.swcrc --cli-config-file ../../../../.swc-cli.json && pnpm copyfiles",
|
|
65
67
|
"clean": "rm -rf dist",
|
|
66
|
-
"copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.
|
|
68
|
+
"copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.snap\"",
|
|
67
69
|
"e2e": "playwright test --config e2e/playwright.config.js",
|
|
68
70
|
"e2e:ui": "playwright test --config e2e/playwright.config.js --ui"
|
|
69
71
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// AgentConversations styles — currently uses inline styles and Ant Design X defaults.
|