@lowdefy/blocks-antd-x 5.5.1 → 5.6.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.
|
@@ -12,25 +12,26 @@
|
|
|
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
22
|
import { getFileCardType, getFileCardIcon } from './fileCardUtils.js';
|
|
23
23
|
import DrawerWrapper from './DrawerWrapper.js';
|
|
24
24
|
import createLowdefyChatTransport from './LowdefyChatTransport.js';
|
|
25
25
|
import MessageList from './MessageList.js';
|
|
26
26
|
import useAgentEvents, { collectExternalEventIds } from './useAgentEvents.js';
|
|
27
27
|
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;
|
|
28
|
+
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;
|
|
30
30
|
const senderRef = useRef(null);
|
|
31
31
|
const finishMetaRef = useRef(null);
|
|
32
32
|
const fileInputRef = useRef(null);
|
|
33
33
|
const [attachedFiles, setAttachedFiles] = useState([]);
|
|
34
|
+
const [dragOver, setDragOver] = useState(false);
|
|
34
35
|
// Mirror the operator-evaluated sharedState object into a ref so transport.body()
|
|
35
36
|
// sees the freshest value at send time without re-constructing the transport.
|
|
36
37
|
const sharedStateRef = useRef(null);
|
|
@@ -68,6 +69,16 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
68
69
|
]);
|
|
69
70
|
const bubbleListRef = useRef(null);
|
|
70
71
|
const { messages, sendMessage, status, stop, addToolApprovalResponse, addToolOutput, setMessages, regenerate, clearError } = useChat({
|
|
72
|
+
// Key the Chat instance by conversation: without an id, useChat creates its
|
|
73
|
+
// Chat once per mount and CAPTURES that transport — the transport rebuilt by
|
|
74
|
+
// the useMemo above on a conversationId change is silently ignored, so every
|
|
75
|
+
// send posts under the mount-time conversationId. Continuing a conversation
|
|
76
|
+
// the app selected later then persists the whole transcript under the stale
|
|
77
|
+
// id (a duplicate conversation doc). Keying by id makes useChat swap Chat
|
|
78
|
+
// instances — and adopt the rebuilt transport — when the conversation
|
|
79
|
+
// changes; the clear-on-id-change and external-message-sync effects below
|
|
80
|
+
// compose with the swap unchanged.
|
|
81
|
+
id: effectiveConversationId,
|
|
71
82
|
transport,
|
|
72
83
|
experimental_throttle: 50,
|
|
73
84
|
sendAutomaticallyWhen: (args)=>lastAssistantMessageIsCompleteWithToolCalls(args) || lastAssistantMessageIsCompleteWithApprovalResponses(args),
|
|
@@ -84,8 +95,17 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
84
95
|
const writable = {};
|
|
85
96
|
const ignored = [];
|
|
86
97
|
for (const [key, value] of Object.entries(updates)){
|
|
87
|
-
if (allowedKeys.has(key))
|
|
88
|
-
|
|
98
|
+
if (!allowedKeys.has(key)) {
|
|
99
|
+
ignored.push(key);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Same policy as a non-allowlisted key: drop it and report it back
|
|
103
|
+
// so the model can self-correct on the next turn.
|
|
104
|
+
if (isReserved(key)) {
|
|
105
|
+
ignored.push(key);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
setKey(writable, key, value);
|
|
89
109
|
}
|
|
90
110
|
if (Object.keys(writable).length > 0) {
|
|
91
111
|
await methods.triggerEvent({
|
|
@@ -140,6 +160,21 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
140
160
|
});
|
|
141
161
|
}
|
|
142
162
|
});
|
|
163
|
+
// The control is otherwise write-only: it reports a rating and immediately forgets it,
|
|
164
|
+
// so the thumb un-highlights on the next render and the message looks unrated. This holds
|
|
165
|
+
// what was clicked THIS visit; the block still persists nothing itself.
|
|
166
|
+
const [feedbackValues, setFeedbackValues] = useState({});
|
|
167
|
+
// Ratings the app already knows about, keyed by message id — what a reload or a
|
|
168
|
+
// conversation switch would otherwise lose, since the block stores nothing. A click this
|
|
169
|
+
// visit wins over the stored value, so the thumb responds immediately and a rating the
|
|
170
|
+
// user has just withdrawn is not re-lit by a stale prop.
|
|
171
|
+
// Not memoised: the property arrives from operators that rebuild it every render, so a
|
|
172
|
+
// dependency on it would miss every time — the same reason the message sync below
|
|
173
|
+
// compares by count and id rather than by reference.
|
|
174
|
+
const effectiveFeedbackValues = {
|
|
175
|
+
...storedFeedbackValues ?? {},
|
|
176
|
+
...feedbackValues
|
|
177
|
+
};
|
|
143
178
|
// Clear messages when conversationId changes so the new conversation starts clean.
|
|
144
179
|
// Developers load saved messages via the messages property if needed.
|
|
145
180
|
const prevConversationIdRef = useRef(effectiveConversationId);
|
|
@@ -147,6 +182,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
147
182
|
if (effectiveConversationId !== prevConversationIdRef.current) {
|
|
148
183
|
prevConversationIdRef.current = effectiveConversationId;
|
|
149
184
|
setMessages([]);
|
|
185
|
+
// Ratings clicked in the thread being left must not carry over: they are keyed by
|
|
186
|
+
// message id, and the incoming conversation supplies its own through feedbackValues.
|
|
187
|
+
setFeedbackValues({});
|
|
150
188
|
}
|
|
151
189
|
}, [
|
|
152
190
|
effectiveConversationId,
|
|
@@ -318,6 +356,42 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
318
356
|
filename: name
|
|
319
357
|
};
|
|
320
358
|
}
|
|
359
|
+
// One intake for every way a file arrives — the paperclip picker, a
|
|
360
|
+
// clipboard paste, a drag-and-drop — so the accept list and size cap apply
|
|
361
|
+
// to all of them, not just the picker's native dialog. Pasted clipboard
|
|
362
|
+
// images all arrive as "image.png" (every browser), and the attached list
|
|
363
|
+
// is keyed by name + size, so those get a unique name.
|
|
364
|
+
function acceptsFile(file) {
|
|
365
|
+
const accept = attachmentsConfig?.accept;
|
|
366
|
+
if (!accept) return true;
|
|
367
|
+
const name = (file.name || '').toLowerCase();
|
|
368
|
+
const mime = (file.type || '').toLowerCase();
|
|
369
|
+
return accept.split(',').map((t)=>t.trim().toLowerCase()).filter(Boolean).some((t)=>{
|
|
370
|
+
if (t.startsWith('.')) return name.endsWith(t);
|
|
371
|
+
if (t.endsWith('/*')) return mime.startsWith(t.slice(0, -1));
|
|
372
|
+
return mime === t;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function addFiles(incoming, { pasted = false } = {}) {
|
|
376
|
+
const files = Array.from(incoming ?? []).filter((f)=>acceptsFile(f) && !(attachmentsConfig?.maxSize && f.size > attachmentsConfig.maxSize));
|
|
377
|
+
if (files.length === 0) return;
|
|
378
|
+
const named = files.map((f)=>{
|
|
379
|
+
if (!pasted || !/^image\.\w+$/i.test(f.name)) return f;
|
|
380
|
+
const ext = f.name.split('.').pop();
|
|
381
|
+
return new File([
|
|
382
|
+
f
|
|
383
|
+
], `pasted-${Date.now()}.${ext}`, {
|
|
384
|
+
type: f.type
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
setAttachedFiles((prev)=>[
|
|
388
|
+
...prev,
|
|
389
|
+
...named
|
|
390
|
+
]);
|
|
391
|
+
}
|
|
392
|
+
function dragHasFiles(e) {
|
|
393
|
+
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
394
|
+
}
|
|
321
395
|
async function handleSend(text) {
|
|
322
396
|
if (!text.trim() && attachedFiles.length === 0) return;
|
|
323
397
|
const filesMeta = attachedFiles.map((file)=>({
|
|
@@ -431,7 +505,40 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
431
505
|
messages
|
|
432
506
|
]);
|
|
433
507
|
const activeSuggestions = agentSuggestions ?? suggestions;
|
|
508
|
+
// A link in an answer was a plain anchor: a full browser navigation out of the
|
|
509
|
+
// conversation, with no way for an app to do anything else with it. Default is only
|
|
510
|
+
// prevented when the app actually handles onLinkClick — otherwise the link keeps
|
|
511
|
+
// navigating, so wiring nothing changes nothing.
|
|
512
|
+
//
|
|
513
|
+
// Modified and non-primary clicks are never intercepted: open-in-new-tab is a
|
|
514
|
+
// reasonable thing to do with a citation, and preventing it would be a regression.
|
|
515
|
+
const interceptLinks = Boolean(events?.onLinkClick);
|
|
516
|
+
// Stable identity: MessageBubble memoises its markdown component map on this, and the map
|
|
517
|
+
// is rebuilt on every streaming chunk otherwise — remounting every link in the answer as
|
|
518
|
+
// it streams.
|
|
519
|
+
const handleLinkClick = useCallback(({ href, text, domEvent })=>{
|
|
520
|
+
if (!interceptLinks || domEvent.defaultPrevented || domEvent.button !== 0 || domEvent.metaKey || domEvent.ctrlKey || domEvent.shiftKey || domEvent.altKey) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// Prevented synchronously: Link checks defaultPrevented immediately after calling
|
|
524
|
+
// this, and an external anchor's default fires before any async work could run.
|
|
525
|
+
domEvent.preventDefault();
|
|
526
|
+
methods.triggerEvent({
|
|
527
|
+
name: 'onLinkClick',
|
|
528
|
+
event: {
|
|
529
|
+
href,
|
|
530
|
+
text
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
}, [
|
|
534
|
+
interceptLinks,
|
|
535
|
+
methods
|
|
536
|
+
]);
|
|
434
537
|
function handleFeedback({ messageId, rating }) {
|
|
538
|
+
setFeedbackValues((prev)=>({
|
|
539
|
+
...prev,
|
|
540
|
+
[messageId]: rating
|
|
541
|
+
}));
|
|
435
542
|
const message = messages.find((msg)=>msg.id === messageId);
|
|
436
543
|
const messageContent = message?.parts?.filter((part)=>part.type === 'text').map((part)=>part.text).join('') ?? '';
|
|
437
544
|
methods.triggerEvent({
|
|
@@ -512,6 +619,9 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
512
619
|
config: messageDisplay,
|
|
513
620
|
addToolApprovalResponse: addToolApprovalResponse,
|
|
514
621
|
onFeedback: handleFeedback,
|
|
622
|
+
onLinkClick: handleLinkClick,
|
|
623
|
+
Link: Link,
|
|
624
|
+
feedbackValues: effectiveFeedbackValues,
|
|
515
625
|
onRegenerate: handleRegenerate,
|
|
516
626
|
onDelete: handleDelete,
|
|
517
627
|
onEditMessage: handleEditMessage,
|
|
@@ -530,8 +640,27 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
530
640
|
wrap: true
|
|
531
641
|
})), /*#__PURE__*/ React.createElement("div", {
|
|
532
642
|
style: {
|
|
533
|
-
padding: '8px 16px 24px'
|
|
534
|
-
|
|
643
|
+
padding: '8px 16px 24px',
|
|
644
|
+
borderRadius: 12,
|
|
645
|
+
outline: dragOver ? '2px dashed currentColor' : 'none',
|
|
646
|
+
outlineOffset: -6,
|
|
647
|
+
transition: 'outline-color 120ms'
|
|
648
|
+
},
|
|
649
|
+
// Drag-in lands in the same intake as the picker and paste. Only
|
|
650
|
+
// react to drags carrying files, so text selections dragged across
|
|
651
|
+
// the composer are left to the textarea.
|
|
652
|
+
onDragOver: attachmentsConfig?.enabled ? (e)=>{
|
|
653
|
+
if (!dragHasFiles(e)) return;
|
|
654
|
+
e.preventDefault();
|
|
655
|
+
if (!dragOver) setDragOver(true);
|
|
656
|
+
} : undefined,
|
|
657
|
+
onDragLeave: attachmentsConfig?.enabled ? ()=>setDragOver(false) : undefined,
|
|
658
|
+
onDrop: attachmentsConfig?.enabled ? (e)=>{
|
|
659
|
+
if (!dragHasFiles(e)) return;
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
setDragOver(false);
|
|
662
|
+
addFiles(e.dataTransfer.files);
|
|
663
|
+
} : undefined
|
|
535
664
|
}, attachmentsConfig?.enabled && attachedFiles.length > 0 && /*#__PURE__*/ React.createElement(FileCard.List, {
|
|
536
665
|
style: {
|
|
537
666
|
marginBottom: 4
|
|
@@ -565,12 +694,7 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
565
694
|
accept: attachmentsConfig.accept,
|
|
566
695
|
multiple: true,
|
|
567
696
|
onChange: (e)=>{
|
|
568
|
-
|
|
569
|
-
const valid = files.filter((f)=>!(attachmentsConfig.maxSize && f.size > attachmentsConfig.maxSize));
|
|
570
|
-
setAttachedFiles((prev)=>[
|
|
571
|
-
...prev,
|
|
572
|
-
...valid
|
|
573
|
-
]);
|
|
697
|
+
addFiles(e.target.files);
|
|
574
698
|
e.target.value = '';
|
|
575
699
|
}
|
|
576
700
|
}), /*#__PURE__*/ React.createElement(Sender, {
|
|
@@ -579,6 +703,11 @@ function AgentChat({ blockId, components: { Icon }, methods, pageId, properties
|
|
|
579
703
|
submitType: sender?.submitType ?? 'enter',
|
|
580
704
|
allowSpeech: sender?.allowSpeech ?? false,
|
|
581
705
|
onSubmit: handleSend,
|
|
706
|
+
// Clipboard paste (a screenshot, a copied file) attaches like the
|
|
707
|
+
// paperclip does.
|
|
708
|
+
onPasteFile: attachmentsConfig?.enabled ? (files)=>addFiles(files, {
|
|
709
|
+
pasted: true
|
|
710
|
+
}) : undefined,
|
|
582
711
|
onCancel: handleStop,
|
|
583
712
|
loading: isBusy,
|
|
584
713
|
prefix: attachmentsConfig?.enabled ? /*#__PURE__*/ React.createElement(Button, {
|
|
@@ -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
|
|
@@ -55,7 +55,7 @@ function ThinkingBubbleContent({ bubbleId, config }) {
|
|
|
55
55
|
label: label
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
-
const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, addToolApprovalResponse, onFeedback, onRegenerate, onDelete, onEditMessage, translate }, ref) {
|
|
58
|
+
const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, addToolApprovalResponse, onFeedback, onLinkClick, Link, feedbackValues, onRegenerate, onDelete, onEditMessage, translate }, ref) {
|
|
59
59
|
// Build a lookup map for message parts.
|
|
60
60
|
// Bubble.List's contentRender callback only receives (content, info) where info.key is the
|
|
61
61
|
// item key — it does not receive the full message object with its parts array. This map
|
|
@@ -189,6 +189,9 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
|
|
|
189
189
|
actions: config?.actions,
|
|
190
190
|
messageId: info.key,
|
|
191
191
|
onFeedback: onFeedback,
|
|
192
|
+
onLinkClick: onLinkClick,
|
|
193
|
+
Link: Link,
|
|
194
|
+
feedbackValue: feedbackValues?.[info.key],
|
|
192
195
|
onRegenerate: onRegenerate,
|
|
193
196
|
onDelete: onDelete,
|
|
194
197
|
translate: translate
|
|
@@ -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,10 +190,17 @@
|
|
|
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
|
+
},
|
|
193
204
|
"s3PostPolicyRequestId": {
|
|
194
205
|
"type": "string",
|
|
195
206
|
"description": "Request ID of type AwsS3PresignedPostPolicy. When set, files are uploaded to S3 and their URLs are sent to the LLM instead of base64 data."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lowdefy/blocks-antd-x",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Lowdefy Ant Design X Blocks",
|
|
6
6
|
"homepage": "https://lowdefy.com",
|
|
@@ -41,11 +41,14 @@
|
|
|
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": "5.
|
|
45
|
-
"@lowdefy/helpers": "5.
|
|
44
|
+
"@lowdefy/block-utils": "5.6.0",
|
|
45
|
+
"@lowdefy/helpers": "5.6.0",
|
|
46
46
|
"ai": "6.0.176"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
+
"@lowdefy/block-dev-e2e": "5.6.0",
|
|
50
|
+
"@lowdefy/e2e-utils": "5.6.0",
|
|
51
|
+
"@playwright/test": "1.50.1",
|
|
49
52
|
"@swc/cli": "0.8.0",
|
|
50
53
|
"@swc/core": "1.15.18",
|
|
51
54
|
"copyfiles": "2.4.1"
|
|
@@ -60,6 +63,8 @@
|
|
|
60
63
|
"scripts": {
|
|
61
64
|
"build": "swc src --out-dir dist --config-file ../../../../.swcrc --cli-config-file ../../../../.swc-cli.json && pnpm copyfiles",
|
|
62
65
|
"clean": "rm -rf dist",
|
|
63
|
-
"copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.yaml\" -e \"./src/**/*.snap\""
|
|
66
|
+
"copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.yaml\" -e \"./src/**/*.snap\"",
|
|
67
|
+
"e2e": "playwright test --config e2e/playwright.config.js",
|
|
68
|
+
"e2e:ui": "playwright test --config e2e/playwright.config.js --ui"
|
|
64
69
|
}
|
|
65
70
|
}
|