@lowdefy/blocks-antd-x 5.5.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blocks/AgentChat/AgentChat.js +235 -58
- package/dist/blocks/AgentChat/LowdefyChatTransport.js +3 -1
- package/dist/blocks/AgentChat/MessageBubble.js +62 -9
- package/dist/blocks/AgentChat/MessageList.js +31 -1
- package/dist/blocks/AgentChat/WelcomeScreen.js +88 -1
- package/dist/blocks/AgentChat/meta.js +3 -2
- package/dist/blocks/AgentChat/schema.json +22 -3
- package/dist/blocks/AgentChat/tests/AgentChat.e2e.yaml +121 -0
- package/dist/blocks/AgentChat/tests/AgentChatTarget.e2e.yaml +25 -0
- package/dist/blocks/AgentConversations/AgentConversations.js +1 -4
- package/package.json +12 -6
- package/dist/blocks/AgentConversations/style.less +0 -1
|
@@ -12,30 +12,39 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/ import React, { useRef, useMemo, useEffect, useState } from 'react';
|
|
15
|
+
*/ import React, { useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
|
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
19
|
import { Button } from 'antd';
|
|
20
20
|
import { PaperClipOutlined } from '@ant-design/icons';
|
|
21
|
-
import { type } from '@lowdefy/helpers';
|
|
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';
|
|
26
29
|
import useAgentEvents, { collectExternalEventIds } from './useAgentEvents.js';
|
|
27
30
|
import WelcomeScreen from './WelcomeScreen.js';
|
|
28
|
-
function AgentChat({ blockId, components: { Icon }, methods, pageId, properties }) {
|
|
29
|
-
const { agentId, urlQuery, sharedState, welcome, messageDisplay, sender, conversationId, messages: externalMessages, display, drawer: drawerConfig, suggestions } = properties;
|
|
31
|
+
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;
|
|
30
33
|
const senderRef = useRef(null);
|
|
31
34
|
const finishMetaRef = useRef(null);
|
|
32
35
|
const fileInputRef = useRef(null);
|
|
33
36
|
const [attachedFiles, setAttachedFiles] = useState([]);
|
|
37
|
+
const [dragOver, setDragOver] = useState(false);
|
|
38
|
+
// Controlled composer value, so a starter prompt (or the setInput method) can
|
|
39
|
+
// fill the box, and a send can leave typed text in place when it is rejected.
|
|
40
|
+
const [inputValue, setInputValue] = useState('');
|
|
34
41
|
// Mirror the operator-evaluated sharedState object into a ref so transport.body()
|
|
35
42
|
// sees the freshest value at send time without re-constructing the transport.
|
|
36
43
|
const sharedStateRef = useRef(null);
|
|
37
44
|
sharedStateRef.current = type.isObject(sharedState) && Object.keys(sharedState).length > 0 ? sharedState : null;
|
|
38
45
|
const attachmentsConfig = sender?.attachments;
|
|
46
|
+
const uploadPolicyRequestId = attachmentsConfig?.uploadPolicyRequestId ?? attachmentsConfig?.s3PostPolicyRequestId;
|
|
47
|
+
const downloadPolicyRequestId = attachmentsConfig?.downloadPolicyRequestId;
|
|
39
48
|
const switchConfigs = sender?.switches ?? [];
|
|
40
49
|
const [headerOpen, setHeaderOpen] = useState(sender?.header?.open ?? true);
|
|
41
50
|
const [switchState, setSwitchState] = useState(()=>{
|
|
@@ -68,6 +77,16 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
68
77
|
]);
|
|
69
78
|
const bubbleListRef = useRef(null);
|
|
70
79
|
const { messages, sendMessage, status, stop, addToolApprovalResponse, addToolOutput, setMessages, regenerate, clearError } = useChat({
|
|
80
|
+
// Key the Chat instance by conversation: without an id, useChat creates its
|
|
81
|
+
// Chat once per mount and CAPTURES that transport — the transport rebuilt by
|
|
82
|
+
// the useMemo above on a conversationId change is silently ignored, so every
|
|
83
|
+
// send posts under the mount-time conversationId. Continuing a conversation
|
|
84
|
+
// the app selected later then persists the whole transcript under the stale
|
|
85
|
+
// id (a duplicate conversation doc). Keying by id makes useChat swap Chat
|
|
86
|
+
// instances — and adopt the rebuilt transport — when the conversation
|
|
87
|
+
// changes; the clear-on-id-change and external-message-sync effects below
|
|
88
|
+
// compose with the swap unchanged.
|
|
89
|
+
id: effectiveConversationId,
|
|
71
90
|
transport,
|
|
72
91
|
experimental_throttle: 50,
|
|
73
92
|
sendAutomaticallyWhen: (args)=>lastAssistantMessageIsCompleteWithToolCalls(args) || lastAssistantMessageIsCompleteWithApprovalResponses(args),
|
|
@@ -84,8 +103,17 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
84
103
|
const writable = {};
|
|
85
104
|
const ignored = [];
|
|
86
105
|
for (const [key, value] of Object.entries(updates)){
|
|
87
|
-
if (allowedKeys.has(key))
|
|
88
|
-
|
|
106
|
+
if (!allowedKeys.has(key)) {
|
|
107
|
+
ignored.push(key);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// Same policy as a non-allowlisted key: drop it and report it back
|
|
111
|
+
// so the model can self-correct on the next turn.
|
|
112
|
+
if (isReserved(key)) {
|
|
113
|
+
ignored.push(key);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
setKey(writable, key, value);
|
|
89
117
|
}
|
|
90
118
|
if (Object.keys(writable).length > 0) {
|
|
91
119
|
await methods.triggerEvent({
|
|
@@ -115,6 +143,13 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
115
143
|
}
|
|
116
144
|
},
|
|
117
145
|
onError: (error)=>{
|
|
146
|
+
// A request that fails after the stream's `start` chunk leaves the
|
|
147
|
+
// assistant shell the SDK pushed on that chunk — id set, parts: [] — in
|
|
148
|
+
// the message list. Left there, every later send fails UIMessage
|
|
149
|
+
// validation ("Message must contain at least one part") and the
|
|
150
|
+
// conversation is dead until a reload. Drop empty shells so the user can
|
|
151
|
+
// simply try again; there is nothing in them to show or to send.
|
|
152
|
+
setMessages((prev)=>prev.filter((m)=>m.role !== 'assistant' || (m.parts?.length ?? 0) > 0));
|
|
118
153
|
methods.triggerEvent({
|
|
119
154
|
name: 'onError',
|
|
120
155
|
event: {
|
|
@@ -140,6 +175,21 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
140
175
|
});
|
|
141
176
|
}
|
|
142
177
|
});
|
|
178
|
+
// The control is otherwise write-only: it reports a rating and immediately forgets it,
|
|
179
|
+
// so the thumb un-highlights on the next render and the message looks unrated. This holds
|
|
180
|
+
// what was clicked THIS visit; the block still persists nothing itself.
|
|
181
|
+
const [feedbackValues, setFeedbackValues] = useState({});
|
|
182
|
+
// Ratings the app already knows about, keyed by message id — what a reload or a
|
|
183
|
+
// conversation switch would otherwise lose, since the block stores nothing. A click this
|
|
184
|
+
// visit wins over the stored value, so the thumb responds immediately and a rating the
|
|
185
|
+
// user has just withdrawn is not re-lit by a stale prop.
|
|
186
|
+
// Not memoised: the property arrives from operators that rebuild it every render, so a
|
|
187
|
+
// dependency on it would miss every time — the same reason the message sync below
|
|
188
|
+
// compares by count and id rather than by reference.
|
|
189
|
+
const effectiveFeedbackValues = {
|
|
190
|
+
...storedFeedbackValues ?? {},
|
|
191
|
+
...feedbackValues
|
|
192
|
+
};
|
|
143
193
|
// Clear messages when conversationId changes so the new conversation starts clean.
|
|
144
194
|
// Developers load saved messages via the messages property if needed.
|
|
145
195
|
const prevConversationIdRef = useRef(effectiveConversationId);
|
|
@@ -147,6 +197,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
147
197
|
if (effectiveConversationId !== prevConversationIdRef.current) {
|
|
148
198
|
prevConversationIdRef.current = effectiveConversationId;
|
|
149
199
|
setMessages([]);
|
|
200
|
+
// Ratings clicked in the thread being left must not carry over: they are keyed by
|
|
201
|
+
// message id, and the incoming conversation supplies its own through feedbackValues.
|
|
202
|
+
setFeedbackValues({});
|
|
150
203
|
}
|
|
151
204
|
}, [
|
|
152
205
|
effectiveConversationId,
|
|
@@ -198,7 +251,7 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
198
251
|
sendMessage({
|
|
199
252
|
text: args.text,
|
|
200
253
|
...args.files ? {
|
|
201
|
-
|
|
254
|
+
files: args.files
|
|
202
255
|
} : {},
|
|
203
256
|
...args.metadata ? {
|
|
204
257
|
metadata: args.metadata
|
|
@@ -206,6 +259,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
206
259
|
});
|
|
207
260
|
}
|
|
208
261
|
});
|
|
262
|
+
methods.registerMethod('setInput', (args)=>{
|
|
263
|
+
setInputValue(typeof args?.text === 'string' ? args.text : '');
|
|
264
|
+
});
|
|
209
265
|
methods.registerMethod('clearMessages', ()=>{
|
|
210
266
|
setMessages([]);
|
|
211
267
|
});
|
|
@@ -237,19 +293,36 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
237
293
|
}
|
|
238
294
|
]
|
|
239
295
|
});
|
|
240
|
-
if (
|
|
296
|
+
if (uploadPolicyRequestId) {
|
|
297
|
+
if (attachmentsConfig?.s3PostPolicyRequestId) {
|
|
298
|
+
console.warn('AgentChat attachments property "s3PostPolicyRequestId" is deprecated. Use "uploadPolicyRequestId" instead.');
|
|
299
|
+
}
|
|
241
300
|
methods.registerEvent({
|
|
242
|
-
name: '
|
|
301
|
+
name: '__getUploadPolicy',
|
|
243
302
|
actions: [
|
|
244
303
|
{
|
|
245
|
-
id: '
|
|
304
|
+
id: '__getUploadPolicy',
|
|
246
305
|
type: 'Request',
|
|
247
306
|
params: [
|
|
248
|
-
|
|
307
|
+
uploadPolicyRequestId
|
|
249
308
|
]
|
|
250
309
|
}
|
|
251
310
|
]
|
|
252
311
|
});
|
|
312
|
+
if (downloadPolicyRequestId) {
|
|
313
|
+
methods.registerEvent({
|
|
314
|
+
name: '__getDownloadPolicy',
|
|
315
|
+
actions: [
|
|
316
|
+
{
|
|
317
|
+
id: '__getDownloadPolicy',
|
|
318
|
+
type: 'Request',
|
|
319
|
+
params: [
|
|
320
|
+
downloadPolicyRequestId
|
|
321
|
+
]
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
});
|
|
325
|
+
}
|
|
253
326
|
}
|
|
254
327
|
}, []);
|
|
255
328
|
useAgentEvents({
|
|
@@ -276,48 +349,83 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
276
349
|
reader.readAsDataURL(file);
|
|
277
350
|
});
|
|
278
351
|
}
|
|
279
|
-
async function
|
|
280
|
-
const { name,
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
file: {
|
|
285
|
-
name,
|
|
286
|
-
size,
|
|
287
|
-
type: fileType
|
|
288
|
-
}
|
|
289
|
-
}
|
|
352
|
+
async function uploadFileToStorage(file) {
|
|
353
|
+
const { name, type: fileType } = file;
|
|
354
|
+
const descriptor = await getUploadPolicy({
|
|
355
|
+
methods,
|
|
356
|
+
file
|
|
290
357
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const { url, fields = {} } = s3PostPolicyResponse.responses.__getS3PostPolicy.response[0];
|
|
295
|
-
const { key } = fields;
|
|
296
|
-
const formData = new FormData();
|
|
297
|
-
Object.keys(fields).forEach((field)=>{
|
|
298
|
-
formData.append(field, fields[field]);
|
|
358
|
+
await uploadFile({
|
|
359
|
+
descriptor,
|
|
360
|
+
file
|
|
299
361
|
});
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
362
|
+
// The model provider fetches this URL, so resolve it through the download
|
|
363
|
+
// request when configured (stable public URL or signed URL). Otherwise
|
|
364
|
+
// fall back to the legacy unsigned object URL from the descriptor.
|
|
365
|
+
let objectUrl;
|
|
366
|
+
if (downloadPolicyRequestId) {
|
|
367
|
+
const response = await methods.triggerEvent({
|
|
368
|
+
name: '__getDownloadPolicy',
|
|
369
|
+
event: {
|
|
370
|
+
file: {
|
|
371
|
+
bucket: descriptor.bucket,
|
|
372
|
+
key: descriptor.key,
|
|
373
|
+
name,
|
|
374
|
+
type: fileType
|
|
375
|
+
}
|
|
308
376
|
}
|
|
309
377
|
});
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
378
|
+
if (response.success !== true) {
|
|
379
|
+
throw new Error('Download policy request failed.');
|
|
380
|
+
}
|
|
381
|
+
objectUrl = response.responses.__getDownloadPolicy.response[0];
|
|
382
|
+
} else {
|
|
383
|
+
objectUrl = getLegacyObjectUrl({
|
|
384
|
+
descriptor
|
|
385
|
+
});
|
|
386
|
+
}
|
|
315
387
|
return {
|
|
316
388
|
url: objectUrl,
|
|
317
389
|
mediaType: fileType,
|
|
318
390
|
filename: name
|
|
319
391
|
};
|
|
320
392
|
}
|
|
393
|
+
// One intake for every way a file arrives — the paperclip picker, a
|
|
394
|
+
// clipboard paste, a drag-and-drop — so the accept list and size cap apply
|
|
395
|
+
// to all of them, not just the picker's native dialog. Pasted clipboard
|
|
396
|
+
// images all arrive as "image.png" (every browser), and the attached list
|
|
397
|
+
// is keyed by name + size, so those get a unique name.
|
|
398
|
+
function acceptsFile(file) {
|
|
399
|
+
const accept = attachmentsConfig?.accept;
|
|
400
|
+
if (!accept) return true;
|
|
401
|
+
const name = (file.name || '').toLowerCase();
|
|
402
|
+
const mime = (file.type || '').toLowerCase();
|
|
403
|
+
return accept.split(',').map((t)=>t.trim().toLowerCase()).filter(Boolean).some((t)=>{
|
|
404
|
+
if (t.startsWith('.')) return name.endsWith(t);
|
|
405
|
+
if (t.endsWith('/*')) return mime.startsWith(t.slice(0, -1));
|
|
406
|
+
return mime === t;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
function addFiles(incoming, { pasted = false } = {}) {
|
|
410
|
+
const files = Array.from(incoming ?? []).filter((f)=>acceptsFile(f) && !(attachmentsConfig?.maxSize && f.size > attachmentsConfig.maxSize));
|
|
411
|
+
if (files.length === 0) return;
|
|
412
|
+
const named = files.map((f)=>{
|
|
413
|
+
if (!pasted || !/^image\.\w+$/i.test(f.name)) return f;
|
|
414
|
+
const ext = f.name.split('.').pop();
|
|
415
|
+
return new File([
|
|
416
|
+
f
|
|
417
|
+
], `pasted-${Date.now()}.${ext}`, {
|
|
418
|
+
type: f.type
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
setAttachedFiles((prev)=>[
|
|
422
|
+
...prev,
|
|
423
|
+
...named
|
|
424
|
+
]);
|
|
425
|
+
}
|
|
426
|
+
function dragHasFiles(e) {
|
|
427
|
+
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
428
|
+
}
|
|
321
429
|
async function handleSend(text) {
|
|
322
430
|
if (!text.trim() && attachedFiles.length === 0) return;
|
|
323
431
|
const filesMeta = attachedFiles.map((file)=>({
|
|
@@ -343,9 +451,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
343
451
|
text
|
|
344
452
|
}
|
|
345
453
|
];
|
|
346
|
-
if (
|
|
454
|
+
if (uploadPolicyRequestId) {
|
|
347
455
|
try {
|
|
348
|
-
const uploadResults = await Promise.all(attachedFiles.map((file)=>
|
|
456
|
+
const uploadResults = await Promise.all(attachedFiles.map((file)=>uploadFileToStorage(file)));
|
|
349
457
|
for (const result of uploadResults){
|
|
350
458
|
parts.push({
|
|
351
459
|
type: 'file',
|
|
@@ -358,7 +466,7 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
358
466
|
methods.triggerEvent({
|
|
359
467
|
name: 'onError',
|
|
360
468
|
event: {
|
|
361
|
-
message: error.message ?? 'File upload
|
|
469
|
+
message: error.message ?? 'File upload failed.'
|
|
362
470
|
}
|
|
363
471
|
});
|
|
364
472
|
return;
|
|
@@ -378,7 +486,12 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
378
486
|
text
|
|
379
487
|
});
|
|
380
488
|
}
|
|
381
|
-
|
|
489
|
+
// Empty the composer here, after the sends and downstream of both the
|
|
490
|
+
// onBeforeSend cancellation return and the upload await, so a send that was
|
|
491
|
+
// rejected or failed to upload leaves the user's text in the box. Resetting
|
|
492
|
+
// from the Sender's onSubmit handler instead would silently lose typed input
|
|
493
|
+
// on every rejected send.
|
|
494
|
+
setInputValue('');
|
|
382
495
|
}
|
|
383
496
|
function handleStop() {
|
|
384
497
|
stop();
|
|
@@ -394,6 +507,12 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
394
507
|
text: prompt.label
|
|
395
508
|
});
|
|
396
509
|
}
|
|
510
|
+
// A two-track welcome starter fills the composer instead of sending, so a
|
|
511
|
+
// generic shipped default is an editable first draft rather than a message the
|
|
512
|
+
// user never meant to send. Reuses the controlled Sender value.
|
|
513
|
+
function handleWelcomePromptFill(text) {
|
|
514
|
+
setInputValue(typeof text === 'string' ? text : '');
|
|
515
|
+
}
|
|
397
516
|
function handleSuggestionClick(suggestion) {
|
|
398
517
|
methods.triggerEvent({
|
|
399
518
|
name: 'onSuggestionClick',
|
|
@@ -431,7 +550,40 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
431
550
|
messages
|
|
432
551
|
]);
|
|
433
552
|
const activeSuggestions = agentSuggestions ?? suggestions;
|
|
553
|
+
// A link in an answer was a plain anchor: a full browser navigation out of the
|
|
554
|
+
// conversation, with no way for an app to do anything else with it. Default is only
|
|
555
|
+
// prevented when the app actually handles onLinkClick — otherwise the link keeps
|
|
556
|
+
// navigating, so wiring nothing changes nothing.
|
|
557
|
+
//
|
|
558
|
+
// Modified and non-primary clicks are never intercepted: open-in-new-tab is a
|
|
559
|
+
// reasonable thing to do with a citation, and preventing it would be a regression.
|
|
560
|
+
const interceptLinks = Boolean(events?.onLinkClick);
|
|
561
|
+
// Stable identity: MessageBubble memoises its markdown component map on this, and the map
|
|
562
|
+
// is rebuilt on every streaming chunk otherwise — remounting every link in the answer as
|
|
563
|
+
// it streams.
|
|
564
|
+
const handleLinkClick = useCallback(({ href, text, domEvent })=>{
|
|
565
|
+
if (!interceptLinks || domEvent.defaultPrevented || domEvent.button !== 0 || domEvent.metaKey || domEvent.ctrlKey || domEvent.shiftKey || domEvent.altKey) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// Prevented synchronously: Link checks defaultPrevented immediately after calling
|
|
569
|
+
// this, and an external anchor's default fires before any async work could run.
|
|
570
|
+
domEvent.preventDefault();
|
|
571
|
+
methods.triggerEvent({
|
|
572
|
+
name: 'onLinkClick',
|
|
573
|
+
event: {
|
|
574
|
+
href,
|
|
575
|
+
text
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}, [
|
|
579
|
+
interceptLinks,
|
|
580
|
+
methods
|
|
581
|
+
]);
|
|
434
582
|
function handleFeedback({ messageId, rating }) {
|
|
583
|
+
setFeedbackValues((prev)=>({
|
|
584
|
+
...prev,
|
|
585
|
+
[messageId]: rating
|
|
586
|
+
}));
|
|
435
587
|
const message = messages.find((msg)=>msg.id === messageId);
|
|
436
588
|
const messageContent = message?.parts?.filter((part)=>part.type === 'text').map((part)=>part.text).join('') ?? '';
|
|
437
589
|
methods.triggerEvent({
|
|
@@ -502,7 +654,7 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
502
654
|
minHeight: 0,
|
|
503
655
|
padding: '16px 0'
|
|
504
656
|
}
|
|
505
|
-
}, isEmpty ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
|
|
657
|
+
}, isEmpty && !welcome?.tracks ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
|
|
506
658
|
config: welcome,
|
|
507
659
|
onPromptClick: handlePromptClick
|
|
508
660
|
}) : /*#__PURE__*/ React.createElement(MessageList, {
|
|
@@ -510,8 +662,13 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
510
662
|
messages: messages,
|
|
511
663
|
isStreaming: isBusy,
|
|
512
664
|
config: messageDisplay,
|
|
665
|
+
welcome: welcome,
|
|
666
|
+
onWelcomePromptFill: handleWelcomePromptFill,
|
|
513
667
|
addToolApprovalResponse: addToolApprovalResponse,
|
|
514
668
|
onFeedback: handleFeedback,
|
|
669
|
+
onLinkClick: handleLinkClick,
|
|
670
|
+
Link: Link,
|
|
671
|
+
feedbackValues: effectiveFeedbackValues,
|
|
515
672
|
onRegenerate: handleRegenerate,
|
|
516
673
|
onDelete: handleDelete,
|
|
517
674
|
onEditMessage: handleEditMessage,
|
|
@@ -530,8 +687,27 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
530
687
|
wrap: true
|
|
531
688
|
})), /*#__PURE__*/ React.createElement("div", {
|
|
532
689
|
style: {
|
|
533
|
-
padding: '8px 16px 24px'
|
|
534
|
-
|
|
690
|
+
padding: '8px 16px 24px',
|
|
691
|
+
borderRadius: 12,
|
|
692
|
+
outline: dragOver ? '2px dashed currentColor' : 'none',
|
|
693
|
+
outlineOffset: -6,
|
|
694
|
+
transition: 'outline-color 120ms'
|
|
695
|
+
},
|
|
696
|
+
// Drag-in lands in the same intake as the picker and paste. Only
|
|
697
|
+
// react to drags carrying files, so text selections dragged across
|
|
698
|
+
// the composer are left to the textarea.
|
|
699
|
+
onDragOver: attachmentsConfig?.enabled ? (e)=>{
|
|
700
|
+
if (!dragHasFiles(e)) return;
|
|
701
|
+
e.preventDefault();
|
|
702
|
+
if (!dragOver) setDragOver(true);
|
|
703
|
+
} : undefined,
|
|
704
|
+
onDragLeave: attachmentsConfig?.enabled ? ()=>setDragOver(false) : undefined,
|
|
705
|
+
onDrop: attachmentsConfig?.enabled ? (e)=>{
|
|
706
|
+
if (!dragHasFiles(e)) return;
|
|
707
|
+
e.preventDefault();
|
|
708
|
+
setDragOver(false);
|
|
709
|
+
addFiles(e.dataTransfer.files);
|
|
710
|
+
} : undefined
|
|
535
711
|
}, attachmentsConfig?.enabled && attachedFiles.length > 0 && /*#__PURE__*/ React.createElement(FileCard.List, {
|
|
536
712
|
style: {
|
|
537
713
|
marginBottom: 4
|
|
@@ -565,20 +741,22 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
565
741
|
accept: attachmentsConfig.accept,
|
|
566
742
|
multiple: true,
|
|
567
743
|
onChange: (e)=>{
|
|
568
|
-
|
|
569
|
-
const valid = files.filter((f)=>!(attachmentsConfig.maxSize && f.size > attachmentsConfig.maxSize));
|
|
570
|
-
setAttachedFiles((prev)=>[
|
|
571
|
-
...prev,
|
|
572
|
-
...valid
|
|
573
|
-
]);
|
|
744
|
+
addFiles(e.target.files);
|
|
574
745
|
e.target.value = '';
|
|
575
746
|
}
|
|
576
747
|
}), /*#__PURE__*/ React.createElement(Sender, {
|
|
577
748
|
ref: senderRef,
|
|
749
|
+
value: inputValue,
|
|
750
|
+
onChange: setInputValue,
|
|
578
751
|
placeholder: sender?.placeholder ?? methods.translate('agent.sender.placeholder'),
|
|
579
752
|
submitType: sender?.submitType ?? 'enter',
|
|
580
753
|
allowSpeech: sender?.allowSpeech ?? false,
|
|
581
754
|
onSubmit: handleSend,
|
|
755
|
+
// Clipboard paste (a screenshot, a copied file) attaches like the
|
|
756
|
+
// paperclip does.
|
|
757
|
+
onPasteFile: attachmentsConfig?.enabled ? (files)=>addFiles(files, {
|
|
758
|
+
pasted: true
|
|
759
|
+
}) : undefined,
|
|
582
760
|
onCancel: handleStop,
|
|
583
761
|
loading: isBusy,
|
|
584
762
|
prefix: attachmentsConfig?.enabled ? /*#__PURE__*/ React.createElement(Button, {
|
|
@@ -612,7 +790,6 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
612
790
|
}
|
|
613
791
|
AgentChat.meta = {
|
|
614
792
|
category: 'display',
|
|
615
|
-
icons: []
|
|
616
|
-
styles: []
|
|
793
|
+
icons: []
|
|
617
794
|
};
|
|
618
795
|
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,
|
|
@@ -101,6 +101,52 @@ function resolveToolResultMode(toolResultDisplay, toolName) {
|
|
|
101
101
|
}
|
|
102
102
|
return 'summary';
|
|
103
103
|
}
|
|
104
|
+
// Plain link text arrives as a string, but any inline formatting makes it an element tree —
|
|
105
|
+
// so it is flattened rather than reported as absent.
|
|
106
|
+
function linkText(children) {
|
|
107
|
+
if (typeof children === 'string') return children;
|
|
108
|
+
if (typeof children === 'number') return String(children);
|
|
109
|
+
if (Array.isArray(children)) return children.map(linkText).join('');
|
|
110
|
+
if (/*#__PURE__*/ React.isValidElement(children)) return linkText(children.props?.children);
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
// An in-app path renders through the app's Link, so following a citation is a client-side
|
|
114
|
+
// route rather than a reload that drops the conversation. Anything with a scheme opens in a
|
|
115
|
+
// new tab for the same reason. Both still report the click, so an app that handles
|
|
116
|
+
// onLinkClick can show the target in place instead.
|
|
117
|
+
function MarkdownLink({ Link, onLinkClick }) {
|
|
118
|
+
return function MarkdownAnchor({ href, children, ...props }) {
|
|
119
|
+
if (!href) return /*#__PURE__*/ React.createElement("span", props, children);
|
|
120
|
+
const text = linkText(children);
|
|
121
|
+
const handleClick = (domEvent)=>onLinkClick?.({
|
|
122
|
+
href,
|
|
123
|
+
text,
|
|
124
|
+
domEvent
|
|
125
|
+
});
|
|
126
|
+
const external = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
|
|
127
|
+
// A fragment cannot be expressed as a pageId link, so it keeps the anchor.
|
|
128
|
+
const inApp = !external && href.startsWith('/') && !href.includes('#');
|
|
129
|
+
if (!inApp || !Link) {
|
|
130
|
+
return /*#__PURE__*/ React.createElement("a", {
|
|
131
|
+
...props,
|
|
132
|
+
href: href,
|
|
133
|
+
target: external ? '_blank' : undefined,
|
|
134
|
+
rel: external ? 'noopener noreferrer' : undefined,
|
|
135
|
+
onClick: handleClick
|
|
136
|
+
}, children);
|
|
137
|
+
}
|
|
138
|
+
// `pageId`, not `href`: Link renders a plain anchor for href and url — its new-origin form —
|
|
139
|
+
// and only routes client-side for a pageId. Passing the path as href therefore went through
|
|
140
|
+
// Link and still reloaded the page, dropping the conversation this exists to preserve.
|
|
141
|
+
const [pathname, search = ''] = href.slice(1).split('?');
|
|
142
|
+
return /*#__PURE__*/ React.createElement(Link, {
|
|
143
|
+
...props,
|
|
144
|
+
pageId: pathname,
|
|
145
|
+
urlQuery: Object.fromEntries(new URLSearchParams(search)),
|
|
146
|
+
onClick: handleClick
|
|
147
|
+
}, children);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
104
150
|
function normalizeActions(actions) {
|
|
105
151
|
if (Array.isArray(actions)) {
|
|
106
152
|
const obj = {};
|
|
@@ -111,7 +157,7 @@ function normalizeActions(actions) {
|
|
|
111
157
|
}
|
|
112
158
|
return actions ?? {};
|
|
113
159
|
}
|
|
114
|
-
function BubbleActions({ actions, textContent, messageId, onFeedback, onRegenerate, onDelete, translate }) {
|
|
160
|
+
function BubbleActions({ actions, textContent, messageId, onFeedback, feedbackValue, onRegenerate, onDelete, translate }) {
|
|
115
161
|
const normalized = normalizeActions(actions);
|
|
116
162
|
const items = [];
|
|
117
163
|
if (normalized.copy) {
|
|
@@ -128,6 +174,7 @@ function BubbleActions({ actions, textContent, messageId, onFeedback, onRegenera
|
|
|
128
174
|
key: 'feedback',
|
|
129
175
|
label: translate('agent.message.feedback'),
|
|
130
176
|
actionRender: ()=>/*#__PURE__*/ React.createElement(Actions.Feedback, {
|
|
177
|
+
value: feedbackValue ?? 'default',
|
|
131
178
|
onChange: (rating)=>onFeedback?.({
|
|
132
179
|
messageId,
|
|
133
180
|
rating
|
|
@@ -176,7 +223,7 @@ function SourcesDisplay({ sourceParts, config, translate }) {
|
|
|
176
223
|
expandIconPosition: config?.sourcesDisplay?.expandIconPosition ?? 'end'
|
|
177
224
|
});
|
|
178
225
|
}
|
|
179
|
-
function MessageBubble({ content, isStreaming, parts, config, addToolApprovalResponse, actions, messageId, onFeedback, onRegenerate, onDelete, translate }) {
|
|
226
|
+
function MessageBubble({ content, isStreaming, parts, config, addToolApprovalResponse, actions, messageId, onFeedback, feedbackValue, onLinkClick, Link, onRegenerate, onDelete, translate }) {
|
|
180
227
|
const showThoughtChain = config?.showThoughtChain !== false;
|
|
181
228
|
const showReasoning = config?.showReasoning !== false;
|
|
182
229
|
const reasoningDisplay = config?.reasoningDisplay ?? 'interleaved';
|
|
@@ -186,18 +233,22 @@ function MessageBubble({ content, isStreaming, parts, config, addToolApprovalRes
|
|
|
186
233
|
const renderLatex = config?.renderLatex ?? false;
|
|
187
234
|
const markdownConfig = renderLatex ? getLatexConfig() : undefined;
|
|
188
235
|
const markdownComponents = useMemo(()=>{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
236
|
+
const code = !renderMermaid && !codeHighlighter ? PlainCodeBlock : RichCodeBlock({
|
|
237
|
+
renderMermaid,
|
|
238
|
+
codeHighlighter
|
|
239
|
+
});
|
|
192
240
|
return {
|
|
193
|
-
code
|
|
194
|
-
|
|
195
|
-
|
|
241
|
+
code,
|
|
242
|
+
a: MarkdownLink({
|
|
243
|
+
Link,
|
|
244
|
+
onLinkClick
|
|
196
245
|
})
|
|
197
246
|
};
|
|
198
247
|
}, [
|
|
199
248
|
renderMermaid,
|
|
200
|
-
codeHighlighter
|
|
249
|
+
codeHighlighter,
|
|
250
|
+
Link,
|
|
251
|
+
onLinkClick
|
|
201
252
|
]);
|
|
202
253
|
const normalizedActions = normalizeActions(actions);
|
|
203
254
|
const showActions = Object.values(normalizedActions).some(Boolean) && !isStreaming;
|
|
@@ -219,6 +270,7 @@ function MessageBubble({ content, isStreaming, parts, config, addToolApprovalRes
|
|
|
219
270
|
textContent: content,
|
|
220
271
|
messageId: messageId,
|
|
221
272
|
onFeedback: onFeedback,
|
|
273
|
+
feedbackValue: feedbackValue,
|
|
222
274
|
onRegenerate: onRegenerate,
|
|
223
275
|
onDelete: onDelete,
|
|
224
276
|
translate: translate
|
|
@@ -491,6 +543,7 @@ function MessageBubble({ content, isStreaming, parts, config, addToolApprovalRes
|
|
|
491
543
|
textContent: allTextContent,
|
|
492
544
|
messageId: messageId,
|
|
493
545
|
onFeedback: onFeedback,
|
|
546
|
+
feedbackValue: feedbackValue,
|
|
494
547
|
onRegenerate: onRegenerate,
|
|
495
548
|
onDelete: onDelete,
|
|
496
549
|
translate: translate
|
|
@@ -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, 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,10 +120,36 @@ 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',
|
|
@@ -189,6 +216,9 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
|
|
|
189
216
|
actions: config?.actions,
|
|
190
217
|
messageId: info.key,
|
|
191
218
|
onFeedback: onFeedback,
|
|
219
|
+
onLinkClick: onLinkClick,
|
|
220
|
+
Link: Link,
|
|
221
|
+
feedbackValue: feedbackValues?.[info.key],
|
|
192
222
|
onRegenerate: onRegenerate,
|
|
193
223
|
onDelete: onDelete,
|
|
194
224
|
translate: translate
|
|
@@ -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,
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
onUserMessage: 'Trigger when the user sends a message.',
|
|
24
24
|
onConversationStart: 'Trigger once when a conversation starts, on its first user message. Event contains the conversationId (auto-minted when no conversationId property is set).',
|
|
25
25
|
onError: 'Trigger on stream error.',
|
|
26
|
-
onFeedback: 'Trigger when the user clicks thumbs up or down on a message.',
|
|
26
|
+
onFeedback: 'Trigger when the user clicks thumbs up or down on a message. Event contains messageId, messageContent and rating, where rating is `like`, `dislike`, or `default` when the user clicks the selected thumb again to clear the rating.',
|
|
27
27
|
onRegenerate: 'Trigger when the user clicks regenerate on a message.',
|
|
28
28
|
onDeleteMessage: 'Trigger when the user deletes a message.',
|
|
29
29
|
onEditMessage: 'Trigger when the user edits and resubmits a message.',
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
onSuggestionClick: 'Trigger when the user clicks a follow-up suggestion.',
|
|
33
33
|
onTitleGenerated: 'Trigger when the agent generates a conversation title.',
|
|
34
34
|
onSwitchChange: 'Trigger when the user toggles a sender switch.',
|
|
35
|
-
onDataPart: 'Trigger when the agent sends a custom data part. Event contains type, data, and id.'
|
|
35
|
+
onDataPart: 'Trigger when the agent sends a custom data part. Event contains type, data, and id.',
|
|
36
|
+
onLinkClick: 'Trigger when the user clicks a link in a message. Event contains href and text. Wiring this event suppresses the default navigation for plain left clicks, so the app can open the target in place; modified clicks (new tab, middle click) always navigate.'
|
|
36
37
|
},
|
|
37
38
|
methods: {
|
|
38
39
|
regenerate: 'Regenerate the last assistant message. Accepts optional args.messageId to regenerate a specific message.',
|
|
@@ -162,6 +162,10 @@
|
|
|
162
162
|
"messages": {
|
|
163
163
|
"type": "array"
|
|
164
164
|
},
|
|
165
|
+
"feedbackValues": {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"description": "Ratings already recorded, keyed by message id, each `like` or `dislike`. Restores the thumbs on a conversation the block did not rate this visit. A rating clicked this visit takes precedence."
|
|
168
|
+
},
|
|
165
169
|
"conversationId": {
|
|
166
170
|
"type": "string",
|
|
167
171
|
"description": "Active conversation ID. When this value changes, messages are automatically cleared."
|
|
@@ -186,13 +190,28 @@
|
|
|
186
190
|
"allowSpeech": { "type": "boolean", "default": false },
|
|
187
191
|
"attachments": {
|
|
188
192
|
"type": "object",
|
|
193
|
+
"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.",
|
|
189
194
|
"properties": {
|
|
190
195
|
"enabled": { "type": "boolean" },
|
|
191
|
-
"accept": {
|
|
192
|
-
|
|
196
|
+
"accept": {
|
|
197
|
+
"type": "string",
|
|
198
|
+
"description": "Comma-separated accept list, as for an <input type=\"file\"> — e.g. \"image/*,.pdf\". Applied to picked, pasted and dropped files alike."
|
|
199
|
+
},
|
|
200
|
+
"maxSize": {
|
|
201
|
+
"type": "number",
|
|
202
|
+
"description": "Maximum file size in bytes. Larger files are silently skipped."
|
|
203
|
+
},
|
|
204
|
+
"uploadPolicyRequestId": {
|
|
205
|
+
"type": "string",
|
|
206
|
+
"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."
|
|
207
|
+
},
|
|
208
|
+
"downloadPolicyRequestId": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"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)."
|
|
211
|
+
},
|
|
193
212
|
"s3PostPolicyRequestId": {
|
|
194
213
|
"type": "string",
|
|
195
|
-
"description": "
|
|
214
|
+
"description": "Deprecated — use uploadPolicyRequestId instead."
|
|
196
215
|
}
|
|
197
216
|
}
|
|
198
217
|
},
|
|
@@ -0,0 +1,121 @@
|
|
|
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)'
|
|
@@ -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.0.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Lowdefy Ant Design X Blocks",
|
|
6
6
|
"homepage": "https://lowdefy.com",
|
|
@@ -41,13 +41,17 @@
|
|
|
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.0.0",
|
|
45
|
+
"@lowdefy/blocks-files": "6.0.0",
|
|
46
|
+
"@lowdefy/helpers": "6.0.0",
|
|
46
47
|
"ai": "6.0.176"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@
|
|
50
|
-
"@
|
|
50
|
+
"@lowdefy/block-dev-e2e": "6.0.0",
|
|
51
|
+
"@lowdefy/e2e-utils": "6.0.0",
|
|
52
|
+
"@playwright/test": "1.59.1",
|
|
53
|
+
"@swc/cli": "0.8.1",
|
|
54
|
+
"@swc/core": "1.15.32",
|
|
51
55
|
"copyfiles": "2.4.1"
|
|
52
56
|
},
|
|
53
57
|
"peerDependencies": {
|
|
@@ -60,6 +64,8 @@
|
|
|
60
64
|
"scripts": {
|
|
61
65
|
"build": "swc src --out-dir dist --config-file ../../../../.swcrc --cli-config-file ../../../../.swc-cli.json && pnpm copyfiles",
|
|
62
66
|
"clean": "rm -rf dist",
|
|
63
|
-
"copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.
|
|
67
|
+
"copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.snap\"",
|
|
68
|
+
"e2e": "playwright test --config e2e/playwright.config.js",
|
|
69
|
+
"e2e:ui": "playwright test --config e2e/playwright.config.js --ui"
|
|
64
70
|
}
|
|
65
71
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// AgentConversations styles — currently uses inline styles and Ant Design X defaults.
|