orchid-ai 2.1.4 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/orchid-ai.css +297 -4
- package/package.json +1 -1
- package/src/components/ChatInput.jsx +199 -43
- package/src/components/ChatWindow.jsx +62 -3
- package/src/components/Message.jsx +213 -8
- package/src/components/visualizations/DataTable.jsx +48 -1
- package/src/hooks/useOrchidAiChat.js +236 -145
- package/src/index.d.ts +45 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useEffect } from 'react';
|
|
1
|
+
import React, { useRef, useEffect, useCallback } from 'react';
|
|
2
2
|
import Message from './Message';
|
|
3
3
|
|
|
4
4
|
const DEFAULT_SUGGESTIONS = [
|
|
@@ -14,6 +14,9 @@ export default function ChatWindow({
|
|
|
14
14
|
messages,
|
|
15
15
|
loading,
|
|
16
16
|
statusText,
|
|
17
|
+
queuedMessages,
|
|
18
|
+
onCancelQueue,
|
|
19
|
+
onStop,
|
|
17
20
|
onSuggestionClick,
|
|
18
21
|
aiEnabled,
|
|
19
22
|
organisationName,
|
|
@@ -27,9 +30,40 @@ export default function ChatWindow({
|
|
|
27
30
|
}) {
|
|
28
31
|
const exportPrefix = appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
29
32
|
const bottomRef = useRef(null);
|
|
33
|
+
const scrollerRef = useRef(null);
|
|
34
|
+
const stickToBottomRef = useRef(true);
|
|
35
|
+
const prevLoadingRef = useRef(false);
|
|
30
36
|
|
|
37
|
+
// Track whether the user has scrolled away from the bottom. Only update on actual
|
|
38
|
+
// scroll events — not on content growth — so appending text never disengages sticky.
|
|
39
|
+
const handleScroll = useCallback(() => {
|
|
40
|
+
const el = scrollerRef.current;
|
|
41
|
+
if (!el) return;
|
|
42
|
+
stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
// Re-engage sticky scroll whenever a new request starts so the user always sees
|
|
46
|
+
// their message and the incoming response, even if they had scrolled up.
|
|
31
47
|
useEffect(() => {
|
|
32
|
-
|
|
48
|
+
if (loading && !prevLoadingRef.current) {
|
|
49
|
+
stickToBottomRef.current = true;
|
|
50
|
+
}
|
|
51
|
+
prevLoadingRef.current = loading;
|
|
52
|
+
}, [loading]);
|
|
53
|
+
|
|
54
|
+
// Pin the viewport to the bottom on every streaming delta. Direct scrollTop
|
|
55
|
+
// assignment is instant so the bubble always extends naturally from the bottom
|
|
56
|
+
// of the screen with no lag or chasing. A single smooth scroll runs when
|
|
57
|
+
// streaming ends to settle the final render.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!stickToBottomRef.current) return;
|
|
60
|
+
const el = scrollerRef.current;
|
|
61
|
+
if (!el) return;
|
|
62
|
+
if (loading) {
|
|
63
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
64
|
+
} else {
|
|
65
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
66
|
+
}
|
|
33
67
|
}, [messages, loading, statusText]);
|
|
34
68
|
|
|
35
69
|
const renderEmptyState = () => {
|
|
@@ -106,7 +140,7 @@ export default function ChatWindow({
|
|
|
106
140
|
const hasStreamingMessage = lastMsg?.isStreaming === true;
|
|
107
141
|
|
|
108
142
|
return (
|
|
109
|
-
<div className="ai-chat-window">
|
|
143
|
+
<div className="ai-chat-window" ref={scrollerRef} onScroll={handleScroll}>
|
|
110
144
|
{messages?.length === 0 && !loading && renderEmptyState()}
|
|
111
145
|
{(messages ?? []).map((msg, i) => {
|
|
112
146
|
const isLast = i === (messages?.length ?? 0) - 1;
|
|
@@ -120,12 +154,14 @@ export default function ChatWindow({
|
|
|
120
154
|
truncated={msg.truncated}
|
|
121
155
|
exportPrefix={exportPrefix}
|
|
122
156
|
isStreaming={msg.isStreaming}
|
|
157
|
+
stopped={msg.stopped}
|
|
123
158
|
streamingStatusText={streamingStatusText}
|
|
124
159
|
processTrace={msg.processTrace}
|
|
125
160
|
processInterimLive={msg.processInterimLive}
|
|
126
161
|
showProcessTracePanel={showProcessTracePanel}
|
|
127
162
|
queryContext={msg.queryContext}
|
|
128
163
|
showQuerySummary={showQuerySummary}
|
|
164
|
+
attachments={msg.attachments}
|
|
129
165
|
/>
|
|
130
166
|
);
|
|
131
167
|
})}
|
|
@@ -146,6 +182,29 @@ export default function ChatWindow({
|
|
|
146
182
|
</div>
|
|
147
183
|
</div>
|
|
148
184
|
)}
|
|
185
|
+
{(queuedMessages ?? []).map((msg, i) => (
|
|
186
|
+
<div key={i} className="ai-chat-message user ai-chat-message--queued">
|
|
187
|
+
<div className="ai-chat-avatar user">YOU</div>
|
|
188
|
+
<div className="ai-chat-bubble user ai-chat-bubble--queued">
|
|
189
|
+
<div className="ai-chat-pending-msg-text">{msg}</div>
|
|
190
|
+
<div className="ai-chat-pending-footer">
|
|
191
|
+
<span className="ai-chat-pending-status">Queued</span>
|
|
192
|
+
<div className="ai-chat-pending-actions">
|
|
193
|
+
{onCancelQueue && (
|
|
194
|
+
<button type="button" className="ai-chat-pending-btn" onClick={() => onCancelQueue(i)}>
|
|
195
|
+
Cancel
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
{i === 0 && onStop && (
|
|
199
|
+
<button type="button" className="ai-chat-pending-btn ai-chat-pending-btn--primary" onClick={onStop}>
|
|
200
|
+
Send now
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
149
208
|
<div ref={bottomRef} />
|
|
150
209
|
</div>
|
|
151
210
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useState, useMemo, useEffect } from "react";
|
|
1
|
+
import React, { useRef, useState, useMemo, useEffect, useLayoutEffect } from "react";
|
|
2
2
|
import ReactMarkdown from "react-markdown";
|
|
3
3
|
import remarkGfm from "remark-gfm";
|
|
4
4
|
import AiVisualization from "./visualizations/AiVisualization";
|
|
@@ -46,6 +46,72 @@ function getBlockLabel(lang) {
|
|
|
46
46
|
return `Generating ${lang}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Split fully-closed orchid-ai-chart / hemiq-chart fences out of the content string
|
|
51
|
+
* so they can be rendered as stable memoized components. Text between/around charts
|
|
52
|
+
* becomes separate ReactMarkdown segments. Keys are based on character position so
|
|
53
|
+
* they stay stable as trailing text streams in.
|
|
54
|
+
*
|
|
55
|
+
* Returns an array of { type: 'text'|'chart', content|language+rawValue, key }.
|
|
56
|
+
*/
|
|
57
|
+
const COMPLETE_CHART_FENCE_RE = /```(orchid-ai-chart|hemiq-chart)\r?\n([\s\S]*?)```/g;
|
|
58
|
+
|
|
59
|
+
function splitContentSegments(content) {
|
|
60
|
+
const segments = [];
|
|
61
|
+
let lastIndex = 0;
|
|
62
|
+
const re = new RegExp(COMPLETE_CHART_FENCE_RE.source, 'g');
|
|
63
|
+
let match;
|
|
64
|
+
while ((match = re.exec(content)) !== null) {
|
|
65
|
+
if (match.index > lastIndex) {
|
|
66
|
+
segments.push({ type: 'text', content: content.slice(lastIndex, match.index), key: `t${lastIndex}` });
|
|
67
|
+
}
|
|
68
|
+
segments.push({
|
|
69
|
+
type: 'chart',
|
|
70
|
+
language: match[1],
|
|
71
|
+
rawValue: match[2].replace(/\n$/, ''),
|
|
72
|
+
key: `c${match.index}`,
|
|
73
|
+
});
|
|
74
|
+
lastIndex = match.index + match[0].length;
|
|
75
|
+
}
|
|
76
|
+
if (lastIndex < content.length) {
|
|
77
|
+
segments.push({ type: 'text', content: content.slice(lastIndex), key: `t${lastIndex}` });
|
|
78
|
+
}
|
|
79
|
+
return segments;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Renders a completed chart block. Wrapped in React.memo so it only re-renders
|
|
84
|
+
* when the chart JSON actually changes — not on every streaming delta.
|
|
85
|
+
*/
|
|
86
|
+
const MemoizedChartBlock = React.memo(function MemoizedChartBlock({ language, rawValue }) {
|
|
87
|
+
const chartResult = resolveChartBlock(language, rawValue);
|
|
88
|
+
if (chartResult.type === "chart") {
|
|
89
|
+
return <AiVisualization chart={chartResult.chart} />;
|
|
90
|
+
}
|
|
91
|
+
if (chartResult.type === "invalid_chart_code") {
|
|
92
|
+
const cutOff = chartResult.error?.includes("JSON");
|
|
93
|
+
return (
|
|
94
|
+
<div className="ai-chart-card ai-chart-error">
|
|
95
|
+
<p>
|
|
96
|
+
{cutOff
|
|
97
|
+
? "Visualization could not be rendered — the response was cut off. Try asking again or request fewer records."
|
|
98
|
+
: "Visualization could not be rendered — the AI used an unsupported format. Try rephrasing your request."}
|
|
99
|
+
</p>
|
|
100
|
+
{IS_DEV && (
|
|
101
|
+
<details className="ai-chart-debug-details">
|
|
102
|
+
<summary>Debug (development only)</summary>
|
|
103
|
+
<pre className="ai-chart-debug-pre">
|
|
104
|
+
{chartResult.error ? `Validator: ${chartResult.error}\n\n` : ""}
|
|
105
|
+
{`--- raw ${language} fence ---\n${rawValue}`}
|
|
106
|
+
</pre>
|
|
107
|
+
</details>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
});
|
|
114
|
+
|
|
49
115
|
const UPPER_ABBREV = new Set(['id', 'url', 'api', 'uuid', 'ip', 'sku', 'po', 'eta', 'ref']);
|
|
50
116
|
|
|
51
117
|
function camelToTitleCase(str) {
|
|
@@ -154,6 +220,39 @@ function formatToolName(tool) {
|
|
|
154
220
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
155
221
|
}
|
|
156
222
|
|
|
223
|
+
function urlHost(url) {
|
|
224
|
+
try { return new URL(url).hostname; } catch { return url; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderQueryParam(value) {
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
const items = value.map(String).filter(Boolean);
|
|
230
|
+
if (items.length && items.every((v) => /^https?:\/\//i.test(v))) {
|
|
231
|
+
return (
|
|
232
|
+
<ul className="ai-chat-process-url-list">
|
|
233
|
+
{items.map((url, i) => (
|
|
234
|
+
<li key={i}>
|
|
235
|
+
<a href={url} title={url} target="_blank" rel="noopener noreferrer" className="ai-chat-process-url">
|
|
236
|
+
{urlHost(url)}
|
|
237
|
+
</a>
|
|
238
|
+
</li>
|
|
239
|
+
))}
|
|
240
|
+
</ul>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return formatQueryValue(value);
|
|
244
|
+
}
|
|
245
|
+
const s = String(value ?? '');
|
|
246
|
+
if (/^https?:\/\//i.test(s)) {
|
|
247
|
+
return (
|
|
248
|
+
<a href={s} title={s} target="_blank" rel="noopener noreferrer" className="ai-chat-process-url">
|
|
249
|
+
{urlHost(s)}
|
|
250
|
+
</a>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return formatQueryValue(value);
|
|
254
|
+
}
|
|
255
|
+
|
|
157
256
|
function QueryStep({ entry }) {
|
|
158
257
|
const params = Object.entries(entry.input || {}).filter(([, v]) => v !== null && v !== undefined && v !== '');
|
|
159
258
|
return (
|
|
@@ -168,7 +267,7 @@ function QueryStep({ entry }) {
|
|
|
168
267
|
{params.map(([key, value]) => (
|
|
169
268
|
<div key={key} className="ai-chat-process-trace__query-param">
|
|
170
269
|
<dt>{camelToTitleCase(key)}</dt>
|
|
171
|
-
<dd>{
|
|
270
|
+
<dd>{renderQueryParam(value)}</dd>
|
|
172
271
|
</div>
|
|
173
272
|
))}
|
|
174
273
|
</dl>
|
|
@@ -257,21 +356,79 @@ function ProcessTracePanel({ processTrace, processInterimLive, isStreaming, show
|
|
|
257
356
|
);
|
|
258
357
|
}
|
|
259
358
|
|
|
359
|
+
function AttachmentFileIcon() {
|
|
360
|
+
return (
|
|
361
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
362
|
+
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
|
363
|
+
<polyline points="13 2 13 9 20 9" />
|
|
364
|
+
</svg>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
260
368
|
export default function Message({
|
|
261
369
|
role,
|
|
262
370
|
content,
|
|
263
371
|
truncated,
|
|
264
372
|
exportPrefix = "orchid-ai",
|
|
265
373
|
isStreaming = false,
|
|
374
|
+
stopped = false,
|
|
266
375
|
streamingStatusText,
|
|
267
376
|
processTrace,
|
|
268
377
|
processInterimLive = "",
|
|
269
378
|
showProcessTracePanel = true,
|
|
379
|
+
queryContext,
|
|
380
|
+
showQuerySummary = false,
|
|
381
|
+
attachments,
|
|
270
382
|
}) {
|
|
271
383
|
const isUser = role === "user";
|
|
272
384
|
const [copied, setCopied] = useState(false);
|
|
273
385
|
const [isPrinting, setIsPrinting] = useState(false);
|
|
274
386
|
const messageRef = useRef(null);
|
|
387
|
+
const bubbleRef = useRef(null);
|
|
388
|
+
const prevBubbleHeightRef = useRef(null); // height after the previous paint
|
|
389
|
+
const pendingRevealRef = useRef(0); // accumulated clip-path reveal not yet finished (px)
|
|
390
|
+
|
|
391
|
+
// Record bubble height AFTER each paint so the next useLayoutEffect can detect line wraps.
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (isUser || !isStreaming) {
|
|
394
|
+
prevBubbleHeightRef.current = null;
|
|
395
|
+
pendingRevealRef.current = 0;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const el = bubbleRef.current;
|
|
399
|
+
if (!el) return;
|
|
400
|
+
prevBubbleHeightRef.current = el.scrollHeight;
|
|
401
|
+
// Reset the accumulator when the clip-path transition finishes naturally.
|
|
402
|
+
const onEnd = (e) => {
|
|
403
|
+
if (e.propertyName === 'clip-path') pendingRevealRef.current = 0;
|
|
404
|
+
};
|
|
405
|
+
el.addEventListener('transitionend', onEnd, { once: true });
|
|
406
|
+
return () => el.removeEventListener('transitionend', onEnd);
|
|
407
|
+
}, [content, isStreaming, isUser]);
|
|
408
|
+
|
|
409
|
+
// Reveal new line-wrap content via clip-path BEFORE each paint.
|
|
410
|
+
// clip-path is layout-neutral — it never changes the bubble's layout height,
|
|
411
|
+
// so scrollHeight / scrollTop stay correct and the scroll stays pinned.
|
|
412
|
+
useLayoutEffect(() => {
|
|
413
|
+
if (isUser) return;
|
|
414
|
+
const el = bubbleRef.current;
|
|
415
|
+
if (!el) return;
|
|
416
|
+
if (!isStreaming) {
|
|
417
|
+
el.style.clipPath = '';
|
|
418
|
+
el.style.transition = '';
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const newHeight = el.scrollHeight;
|
|
422
|
+
const prevHeight = prevBubbleHeightRef.current;
|
|
423
|
+
if (prevHeight !== null && newHeight > prevHeight + 2) {
|
|
424
|
+
pendingRevealRef.current += newHeight - prevHeight;
|
|
425
|
+
el.style.transition = 'none';
|
|
426
|
+
el.style.clipPath = `inset(0 0 ${pendingRevealRef.current}px 0)`;
|
|
427
|
+
el.offsetHeight; // force reflow so transition: none takes effect
|
|
428
|
+
el.style.transition = 'clip-path 0.12s ease-out';
|
|
429
|
+
el.style.clipPath = 'inset(0 0 0px 0)';
|
|
430
|
+
}
|
|
431
|
+
}, [content, isStreaming, isUser]);
|
|
275
432
|
|
|
276
433
|
// Extract AI-provided title comment and strip it from rendered content
|
|
277
434
|
const { responseTitle, renderContent } = useMemo(
|
|
@@ -282,7 +439,12 @@ export default function Message({
|
|
|
282
439
|
// When streaming, detect an unclosed code block so we can show a labeled placeholder
|
|
283
440
|
// instead of passing a broken fence to ReactMarkdown.
|
|
284
441
|
const streamingPrefix = !isUser && isStreaming ? getStreamingPrefix(renderContent) : null;
|
|
285
|
-
|
|
442
|
+
// When stopped mid-generation, detect any unclosed chart fence so we can show a
|
|
443
|
+
// static "Rendering stopped" block instead of the animated placeholder.
|
|
444
|
+
const stoppedPrefix = !isUser && stopped && !isStreaming ? getStreamingPrefix(renderContent) : null;
|
|
445
|
+
const openBlockLabel = (streamingPrefix !== null || stoppedPrefix !== null)
|
|
446
|
+
? getBlockLabel(getOpenBlockLanguage(renderContent))
|
|
447
|
+
: null;
|
|
286
448
|
|
|
287
449
|
const handleCopy = () => {
|
|
288
450
|
const stripChartBlocks = (value) =>
|
|
@@ -370,7 +532,9 @@ export default function Message({
|
|
|
370
532
|
showProcessTracePanel !== false &&
|
|
371
533
|
orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, { isStreaming });
|
|
372
534
|
|
|
373
|
-
|
|
535
|
+
// Stable reference — none of these renderers close over changing component state,
|
|
536
|
+
// so a single memoized object prevents ReactMarkdown from re-rendering on every delta.
|
|
537
|
+
const markdownComponents = useMemo(() => ({
|
|
374
538
|
pre({ children, ...props }) {
|
|
375
539
|
const onlyChild = React.Children.toArray(children)[0];
|
|
376
540
|
const className = onlyChild?.props?.className || "";
|
|
@@ -431,14 +595,21 @@ export default function Message({
|
|
|
431
595
|
</a>
|
|
432
596
|
);
|
|
433
597
|
},
|
|
434
|
-
};
|
|
598
|
+
}), []);
|
|
599
|
+
|
|
600
|
+
// Split completed chart blocks into their own memoized components so streaming
|
|
601
|
+
// text after a chart doesn't cause already-rendered visualizations to redraw.
|
|
602
|
+
const contentSegments = useMemo(
|
|
603
|
+
() => splitContentSegments(renderContent),
|
|
604
|
+
[renderContent]
|
|
605
|
+
);
|
|
435
606
|
|
|
436
607
|
return (
|
|
437
608
|
<div className={`ai-chat-message ${role}`} ref={messageRef}>
|
|
438
609
|
<div className={`ai-chat-avatar ${role}`}>
|
|
439
610
|
{isUser ? "You" : "AI"}
|
|
440
611
|
</div>
|
|
441
|
-
<div className={`ai-chat-bubble ${role}`}>
|
|
612
|
+
<div className={`ai-chat-bubble ${role}`} ref={bubbleRef}>
|
|
442
613
|
<div className="ai-chat-message-content">
|
|
443
614
|
{!isUser ? (
|
|
444
615
|
<ProcessTracePanel
|
|
@@ -459,7 +630,18 @@ export default function Message({
|
|
|
459
630
|
</div>
|
|
460
631
|
) : null}
|
|
461
632
|
{isUser ? (
|
|
462
|
-
|
|
633
|
+
<>
|
|
634
|
+
{attachments?.length > 0 && (
|
|
635
|
+
<div className="ai-chat-user-attachments">
|
|
636
|
+
{attachments.map((att, i) => (
|
|
637
|
+
att.preview
|
|
638
|
+
? <img key={i} src={att.preview} alt={att.name} className="ai-chat-attachment-img" />
|
|
639
|
+
: <div key={i} className="ai-chat-attachment-file"><AttachmentFileIcon />{att.name}</div>
|
|
640
|
+
))}
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
<UserBubbleContent content={content} />
|
|
644
|
+
</>
|
|
463
645
|
) : streamingPrefix !== null ? (
|
|
464
646
|
<>
|
|
465
647
|
{streamingPrefix && (
|
|
@@ -476,8 +658,31 @@ export default function Message({
|
|
|
476
658
|
</div>
|
|
477
659
|
</div>
|
|
478
660
|
</>
|
|
661
|
+
) : stoppedPrefix !== null ? (
|
|
662
|
+
<>
|
|
663
|
+
{stoppedPrefix && (
|
|
664
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{stoppedPrefix}</ReactMarkdown>
|
|
665
|
+
)}
|
|
666
|
+
<div className="ai-building-block ai-building-block--stopped" role="status">
|
|
667
|
+
<span className="ai-building-block__label">Rendering stopped</span>
|
|
668
|
+
</div>
|
|
669
|
+
</>
|
|
479
670
|
) : (
|
|
480
|
-
|
|
671
|
+
contentSegments.map((seg) =>
|
|
672
|
+
seg.type === 'chart' ? (
|
|
673
|
+
<MemoizedChartBlock key={seg.key} language={seg.language} rawValue={seg.rawValue} />
|
|
674
|
+
) : (
|
|
675
|
+
<ReactMarkdown key={seg.key} remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
676
|
+
{seg.content}
|
|
677
|
+
</ReactMarkdown>
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
)}
|
|
681
|
+
{!isUser && stopped && stoppedPrefix === null && (
|
|
682
|
+
<div className="ai-chat-interrupted-notice">Response interrupted</div>
|
|
683
|
+
)}
|
|
684
|
+
{!isUser && showQuerySummary && queryContext && (
|
|
685
|
+
<QuerySummaryPanel queryContext={queryContext} />
|
|
481
686
|
)}
|
|
482
687
|
{!isUser && truncated && (
|
|
483
688
|
<div className="ai-truncation-warning">
|
|
@@ -1,11 +1,58 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
+
function csvEscape(value) {
|
|
4
|
+
const s = (value ?? "").toString();
|
|
5
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function buildCsv(columns, rows) {
|
|
9
|
+
const header = columns.map((c) => csvEscape(c.label)).join(",");
|
|
10
|
+
const body = rows.map((row) => columns.map((c) => csvEscape(row[c.key])).join(",")).join("\r\n");
|
|
11
|
+
return `${header}\r\n${body}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function downloadCsv(filename, csv) {
|
|
15
|
+
// Prepend a BOM so Excel reads UTF-8 correctly.
|
|
16
|
+
const blob = new Blob([`${csv}`], { type: "text/csv;charset=utf-8;" });
|
|
17
|
+
const url = URL.createObjectURL(blob);
|
|
18
|
+
const a = document.createElement("a");
|
|
19
|
+
a.href = url;
|
|
20
|
+
a.download = filename;
|
|
21
|
+
document.body.appendChild(a);
|
|
22
|
+
a.click();
|
|
23
|
+
document.body.removeChild(a);
|
|
24
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeFilename(title) {
|
|
28
|
+
const base = (title || "table").replace(/[\\/:*?"<>|]+/g, "").trim().slice(0, 80);
|
|
29
|
+
return `${base || "table"}.csv`;
|
|
30
|
+
}
|
|
31
|
+
|
|
3
32
|
export default function DataTable({ chart }) {
|
|
4
33
|
const { title, columns, rows } = chart;
|
|
5
34
|
|
|
35
|
+
const handleDownload = () => downloadCsv(safeFilename(title), buildCsv(columns, rows));
|
|
36
|
+
|
|
6
37
|
return (
|
|
7
38
|
<div className="ai-chart-card">
|
|
8
|
-
|
|
39
|
+
<div className="ai-chart-card-header">
|
|
40
|
+
{title ? <h4 className="ai-chart-title">{title}</h4> : <span />}
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className="ai-chart-csv-btn"
|
|
44
|
+
onClick={handleDownload}
|
|
45
|
+
title="Download as CSV"
|
|
46
|
+
aria-label="Download table as CSV"
|
|
47
|
+
>
|
|
48
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
49
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
50
|
+
<polyline points="7 10 12 15 17 10" />
|
|
51
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
52
|
+
</svg>
|
|
53
|
+
CSV
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
9
56
|
<div className="ai-data-table-wrap">
|
|
10
57
|
<table className="ai-data-table">
|
|
11
58
|
<thead>
|