@finos/legend-lego 2.0.211 → 2.0.213

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.
Files changed (74) hide show
  1. package/lib/index.css +2 -2
  2. package/lib/index.css.map +1 -1
  3. package/lib/legend-ai/LegendAIDocEnrichment.d.ts +12 -12
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +317 -86
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAIHostIntegration.d.ts +31 -0
  8. package/lib/legend-ai/LegendAIHostIntegration.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAIHostIntegration.js +50 -0
  10. package/lib/legend-ai/LegendAIHostIntegration.js.map +1 -0
  11. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -1
  12. package/lib/legend-ai/LegendAIServiceRetrieval.js +34 -35
  13. package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -1
  14. package/lib/legend-ai/LegendAITypes.d.ts +85 -7
  15. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  16. package/lib/legend-ai/LegendAITypes.js +99 -1
  17. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  18. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +36 -3
  19. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  20. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +29 -0
  21. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  22. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  23. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +7 -0
  24. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  25. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
  26. package/lib/legend-ai/components/LegendAICharts.js +6 -10
  27. package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
  28. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  29. package/lib/legend-ai/components/LegendAIChat.js +263 -41
  30. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  31. package/lib/legend-ai/components/LegendAIChatInput.d.ts +1 -1
  32. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
  33. package/lib/legend-ai/components/LegendAIChatInput.js +2 -1
  34. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
  35. package/lib/legend-ai/index.d.ts +5 -2
  36. package/lib/legend-ai/index.d.ts.map +1 -1
  37. package/lib/legend-ai/index.js +5 -2
  38. package/lib/legend-ai/index.js.map +1 -1
  39. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +9 -3
  40. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  41. package/lib/legend-ai/stores/LegendAIChatProcessors.js +794 -220
  42. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  43. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  44. package/lib/legend-ai/stores/LegendAIChatState.js +47 -21
  45. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  46. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts +29 -0
  47. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts.map +1 -0
  48. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js +124 -0
  49. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js.map +1 -0
  50. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts +25 -0
  51. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts.map +1 -0
  52. package/lib/legend-ai/stores/LegendAISqlHelpers.js +64 -0
  53. package/lib/legend-ai/stores/LegendAISqlHelpers.js.map +1 -0
  54. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts +22 -0
  55. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts.map +1 -0
  56. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js +411 -0
  57. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js.map +1 -0
  58. package/package.json +6 -6
  59. package/src/legend-ai/LegendAIDocEnrichment.ts +435 -97
  60. package/src/legend-ai/LegendAIHostIntegration.tsx +80 -0
  61. package/src/legend-ai/LegendAIServiceRetrieval.ts +50 -35
  62. package/src/legend-ai/LegendAITypes.ts +169 -5
  63. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +73 -1
  64. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +10 -0
  65. package/src/legend-ai/components/LegendAICharts.tsx +30 -33
  66. package/src/legend-ai/components/LegendAIChat.tsx +555 -91
  67. package/src/legend-ai/components/LegendAIChatInput.tsx +6 -2
  68. package/src/legend-ai/index.ts +22 -3
  69. package/src/legend-ai/stores/LegendAIChatProcessors.ts +1286 -322
  70. package/src/legend-ai/stores/LegendAIChatState.ts +73 -28
  71. package/src/legend-ai/stores/LegendAIJoinAnalysis.ts +165 -0
  72. package/src/legend-ai/stores/LegendAISqlHelpers.ts +82 -0
  73. package/src/legend-ai/stores/LegendAISqlJoinSanitizers.ts +515 -0
  74. package/tsconfig.json +4 -0
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
17
+ import { memo, useMemo, useCallback, useState, useRef, useEffect } from 'react';
18
18
  import {
19
19
  SparkleStarsIcon,
20
20
  CodeIcon,
@@ -32,10 +32,18 @@ import {
32
32
  DislikeIcon,
33
33
  ExternalLinkIcon,
34
34
  InfoCircleIcon,
35
+ PythonIcon,
36
+ JupyterIcon,
37
+ CubeIcon,
35
38
  MarkdownTextViewer,
36
39
  clsx,
37
40
  } from '@finos/legend-art';
38
- import { noop } from '@finos/legend-shared';
41
+ import { assertErrorThrown, noop } from '@finos/legend-shared';
42
+ import type {
43
+ LegendAIPythonQueryCode,
44
+ LegendAIPythonCodegenRequest,
45
+ LegendAIDataCubeQueryTranslationRequest,
46
+ } from '../LegendAI_LegendApplicationPlugin_Extension.js';
39
47
  import {
40
48
  type LegendAIChatProps,
41
49
  type LegendAIAssistantMessage,
@@ -43,14 +51,20 @@ import {
43
51
  type LegendAIThinkingStep,
44
52
  type LegendAIScopeItem,
45
53
  type LegendAIQuestionIntent,
54
+ type LegendAIChatTelemetryEvent,
46
55
  LegendAIMessageFeedbackRating,
47
56
  LegendAIThinkingStepStatus,
48
57
  LegendAIMessageRole,
49
58
  LegendAIErrorType,
50
59
  TDSServiceSourceType,
60
+ LEGEND_AI_FEEDBACK_PROMPT,
51
61
  classifyQuestionIntentFast,
62
+ LegendAIChatTelemetryEventType,
63
+ LegendAITelemetryArtifact,
64
+ LegendAISuggestedQuerySource,
52
65
  } from '../LegendAITypes.js';
53
66
  import { useLegendAIChatState } from '../stores/LegendAIChatState.js';
67
+ import { looksLikeAccessError } from '../stores/LegendAIChatProcessors.js';
54
68
  import { LegendAIResultGrid } from './LegendAIResultGrid.js';
55
69
  import { LegendAIAnalysisPanel } from './LegendAIAnalysisPanel.js';
56
70
  import { LegendAIChatInput } from './LegendAIChatInput.js';
@@ -61,6 +75,11 @@ export const LEGEND_AI_ANCHOR_ID = 'legend-ai-anchor';
61
75
  const COPY_FEEDBACK_DURATION_MS = 2000;
62
76
  const CONTEXT_BANNER_AUTO_DISMISS_MS = 20000;
63
77
 
78
+ type LegendAIPythonCodeEntry =
79
+ | { status: 'loading' }
80
+ | { status: 'ready'; code: LegendAIPythonQueryCode | undefined }
81
+ | { status: 'error'; errorMessage: string };
82
+
64
83
  const LegendAIContextBanner = (props: {
65
84
  message: string;
66
85
  onDismiss: () => void;
@@ -211,26 +230,40 @@ export function renderStepStatusIcon(
211
230
  );
212
231
  }
213
232
 
214
- /**
215
- * Determines whether an execution error message indicates an access/permission
216
- * problem (as opposed to a SQL compilation or schema error). Only access-like
217
- * errors should show the "Request Access" button.
218
- */
219
- const ACCESS_ERROR_PATTERN =
220
- /insufficient privileges|access.*denied|permission.*denied|not authorized|unauthorized|403|entitlement/i;
221
-
222
- function looksLikeAccessError(errorMsg: string | undefined): boolean {
223
- if (!errorMsg) {
224
- return false;
225
- }
226
- return ACCESS_ERROR_PATTERN.test(errorMsg);
227
- }
233
+ const SuggestionButton = (props: {
234
+ query: string;
235
+ position: number;
236
+ className: string;
237
+ source: LegendAISuggestedQuerySource;
238
+ onSelect: (query: string) => void;
239
+ onLogTelemetryEvent?: (event: LegendAIChatTelemetryEvent) => void;
240
+ }): React.ReactNode => {
241
+ const { query, position, className, source, onSelect, onLogTelemetryEvent } =
242
+ props;
243
+ return (
244
+ <button
245
+ type="button"
246
+ className={className}
247
+ onClick={(): void => {
248
+ onLogTelemetryEvent?.({
249
+ type: LegendAIChatTelemetryEventType.SUGGESTED_QUERY_CLICKED,
250
+ position,
251
+ source,
252
+ });
253
+ onSelect(query);
254
+ }}
255
+ >
256
+ {query}
257
+ </button>
258
+ );
259
+ };
228
260
 
229
- const AssistantMessageView = (props: {
261
+ const AssistantMessageView = memo(function AssistantMessageView(props: {
230
262
  msg: LegendAIAssistantMessage;
263
+ msgIndex: number;
231
264
  questionText: string;
232
265
  isThinkingVisible: boolean;
233
- onToggleThinking: () => void;
266
+ onToggleThinking: (msgIndex: number) => void;
234
267
  onMessageFeedback?: (
235
268
  feedback: LegendAIMessageFeedback,
236
269
  ) => Promise<void> | void;
@@ -241,9 +274,15 @@ const AssistantMessageView = (props: {
241
274
  enghubDocUrl?: string;
242
275
  enthubRequestAccessUrl?: string;
243
276
  onRequestAccess?: (accessPointGroupTitle: string) => void;
244
- }): React.ReactNode => {
277
+ pythonEntry?: LegendAIPythonCodeEntry;
278
+ onRequestPython?: (msg: LegendAIAssistantMessage) => void;
279
+ onOpenInDataCube?: (msg: LegendAIAssistantMessage) => void;
280
+ isOpeningInDataCube?: boolean;
281
+ onLogTelemetryEvent?: (event: LegendAIChatTelemetryEvent) => void;
282
+ }): React.ReactNode {
245
283
  const {
246
284
  msg,
285
+ msgIndex,
247
286
  questionText,
248
287
  isThinkingVisible,
249
288
  onToggleThinking,
@@ -255,6 +294,11 @@ const AssistantMessageView = (props: {
255
294
  enghubDocUrl,
256
295
  enthubRequestAccessUrl,
257
296
  onRequestAccess,
297
+ pythonEntry,
298
+ onRequestPython,
299
+ onOpenInDataCube,
300
+ isOpeningInDataCube,
301
+ onLogTelemetryEvent,
258
302
  } = props;
259
303
 
260
304
  const hasPermissionAccessLinks =
@@ -264,12 +308,20 @@ const AssistantMessageView = (props: {
264
308
  const copyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
265
309
  undefined,
266
310
  );
311
+ const [showPython, setShowPython] = useState(false);
312
+ const [pythonCopied, setPythonCopied] = useState(false);
313
+ const pythonCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
314
+ undefined,
315
+ );
267
316
 
268
317
  useEffect(
269
318
  () => () => {
270
319
  if (copyTimerRef.current !== undefined) {
271
320
  clearTimeout(copyTimerRef.current);
272
321
  }
322
+ if (pythonCopyTimerRef.current !== undefined) {
323
+ clearTimeout(pythonCopyTimerRef.current);
324
+ }
273
325
  },
274
326
  [],
275
327
  );
@@ -278,6 +330,10 @@ const AssistantMessageView = (props: {
278
330
  if (msg.sql) {
279
331
  navigator.clipboard.writeText(msg.sql).catch(noop);
280
332
  setSqlCopied(true);
333
+ onLogTelemetryEvent?.({
334
+ type: LegendAIChatTelemetryEventType.ARTIFACT_COPIED,
335
+ artifact: LegendAITelemetryArtifact.SQL,
336
+ });
281
337
  if (copyTimerRef.current !== undefined) {
282
338
  clearTimeout(copyTimerRef.current);
283
339
  }
@@ -286,23 +342,64 @@ const AssistantMessageView = (props: {
286
342
  copyTimerRef.current = undefined;
287
343
  }, COPY_FEEDBACK_DURATION_MS);
288
344
  }
289
- }, [msg.sql]);
345
+ }, [msg.sql, onLogTelemetryEvent]);
346
+
347
+ const handleCopyPython = useCallback(() => {
348
+ if (pythonEntry?.status === 'ready' && pythonEntry.code) {
349
+ navigator.clipboard.writeText(pythonEntry.code.code).catch(noop);
350
+ setPythonCopied(true);
351
+ onLogTelemetryEvent?.({
352
+ type: LegendAIChatTelemetryEventType.ARTIFACT_COPIED,
353
+ artifact: LegendAITelemetryArtifact.PYTHON,
354
+ });
355
+ if (pythonCopyTimerRef.current !== undefined) {
356
+ clearTimeout(pythonCopyTimerRef.current);
357
+ }
358
+ pythonCopyTimerRef.current = setTimeout(() => {
359
+ setPythonCopied(false);
360
+ pythonCopyTimerRef.current = undefined;
361
+ }, COPY_FEEDBACK_DURATION_MS);
362
+ }
363
+ }, [pythonEntry, onLogTelemetryEvent]);
364
+
365
+ const handleTogglePython = useCallback((): void => {
366
+ const opening = !showPython;
367
+ setShowPython(opening);
368
+ onLogTelemetryEvent?.({
369
+ type: LegendAIChatTelemetryEventType.PYTHON_CODE_TOGGLED,
370
+ shown: opening,
371
+ });
372
+ if (opening && !pythonEntry && onRequestPython) {
373
+ onLogTelemetryEvent?.({
374
+ type: LegendAIChatTelemetryEventType.PYTHON_CODE_REQUESTED,
375
+ });
376
+ onRequestPython(msg);
377
+ }
378
+ }, [showPython, pythonEntry, onRequestPython, msg, onLogTelemetryEvent]);
290
379
 
291
380
  const canShowFeedback =
292
381
  !msg.isProcessing &&
293
382
  (msg.textAnswer !== null || msg.gridData !== null || msg.error !== null);
294
- const visibleThinkingSteps = formatThinkingSteps(msg.thinkingSteps);
295
- const { metadataContext, queryAnalysis } = splitCombinedAnswer(
296
- msg.textAnswer,
383
+ const visibleThinkingSteps = useMemo(
384
+ () => formatThinkingSteps(msg.thinkingSteps),
385
+ [msg.thinkingSteps],
297
386
  );
298
- const analysisSummary = (() => {
299
- if (msg.gridData === null) {
300
- return null;
301
- }
302
- return queryAnalysis ?? (metadataContext === null ? msg.textAnswer : null);
303
- })();
304
- const plainAnswer =
305
- msg.gridData === null ? (metadataContext ?? msg.textAnswer) : null;
387
+ const { metadataContext, analysisSummary, plainAnswer } = useMemo(() => {
388
+ const split = splitCombinedAnswer(msg.textAnswer);
389
+ const gridAnalysisFallback =
390
+ split.metadataContext === null ? msg.textAnswer : null;
391
+ return {
392
+ metadataContext: split.metadataContext,
393
+ analysisSummary:
394
+ msg.gridData === null
395
+ ? null
396
+ : (split.queryAnalysis ?? gridAnalysisFallback),
397
+ plainAnswer:
398
+ msg.gridData === null
399
+ ? (split.metadataContext ?? msg.textAnswer)
400
+ : null,
401
+ };
402
+ }, [msg.textAnswer, msg.gridData]);
306
403
 
307
404
  const submitFeedback = useCallback(
308
405
  (rating: LegendAIMessageFeedbackRating): void => {
@@ -335,7 +432,7 @@ const AssistantMessageView = (props: {
335
432
  <button
336
433
  type="button"
337
434
  className="legend-ai__thinking-toggle"
338
- onClick={onToggleThinking}
435
+ onClick={(): void => onToggleThinking(msgIndex)}
339
436
  >
340
437
  <span className="legend-ai__thinking-toggle-icon">
341
438
  {isThinkingVisible ? <CaretDownIcon /> : <CaretRightIcon />}
@@ -374,37 +471,51 @@ const AssistantMessageView = (props: {
374
471
  )}
375
472
 
376
473
  {msg.sql && (
377
- <div className="legend-ai__sql-block">
378
- <div className="legend-ai__sql-block-header">
379
- <span className="legend-ai__sql-block-header-icon">
380
- <CodeIcon />
381
- </span>
382
- <span>Generated SQL</span>
383
- {msg.sqlGenTime && (
384
- <span className="legend-ai__sql-block-time">
385
- {msg.sqlGenTime}s
474
+ <details
475
+ className="legend-ai__sql-details"
476
+ open={msg.gridData !== null}
477
+ onToggle={(event): void =>
478
+ onLogTelemetryEvent?.({
479
+ type: LegendAIChatTelemetryEventType.SQL_DETAILS_TOGGLED,
480
+ shown: event.currentTarget.open,
481
+ })
482
+ }
483
+ >
484
+ <summary className="legend-ai__sql-details-summary">
485
+ {msg.gridData === null ? 'Show the query I tried' : 'Query'}
486
+ </summary>
487
+ <div className="legend-ai__sql-block">
488
+ <div className="legend-ai__sql-block-header">
489
+ <span className="legend-ai__sql-block-header-icon">
490
+ <CodeIcon />
386
491
  </span>
387
- )}
388
- <button
389
- type="button"
390
- className="legend-ai__sql-copy-btn"
391
- title="Copy SQL"
392
- aria-label="Copy SQL"
393
- onClick={handleCopySql}
394
- >
395
- {sqlCopied ? (
396
- <span className="legend-ai__sql-copy-btn--copied">
397
- <CheckIcon />
492
+ <span>Generated SQL</span>
493
+ {msg.sqlGenTime && (
494
+ <span className="legend-ai__sql-block-time">
495
+ {msg.sqlGenTime}s
398
496
  </span>
399
- ) : (
400
- <CopyIcon />
401
497
  )}
402
- </button>
403
- </div>
404
- <div className="legend-ai__sql-scroll">
405
- <pre className="legend-ai__sql-display">{msg.sql}</pre>
498
+ <button
499
+ type="button"
500
+ className="legend-ai__sql-copy-btn"
501
+ title="Copy SQL"
502
+ aria-label="Copy SQL"
503
+ onClick={handleCopySql}
504
+ >
505
+ {sqlCopied ? (
506
+ <span className="legend-ai__sql-copy-btn--copied">
507
+ <CheckIcon />
508
+ </span>
509
+ ) : (
510
+ <CopyIcon />
511
+ )}
512
+ </button>
513
+ </div>
514
+ <div className="legend-ai__sql-scroll">
515
+ <pre className="legend-ai__sql-display">{msg.sql}</pre>
516
+ </div>
406
517
  </div>
407
- </div>
518
+ </details>
408
519
  )}
409
520
 
410
521
  {msg.isExecuting && (
@@ -533,15 +644,16 @@ const AssistantMessageView = (props: {
533
644
  <span className="legend-ai__follow-up-label">
534
645
  Try a data query:
535
646
  </span>
536
- {msg.suggestedQueries.map((q) => (
537
- <button
647
+ {msg.suggestedQueries.map((q, position) => (
648
+ <SuggestionButton
538
649
  key={q}
539
- type="button"
650
+ query={q}
651
+ position={position}
540
652
  className="legend-ai__follow-up-btn"
541
- onClick={(): void => onSuggestedQueryClick(q)}
542
- >
543
- {q}
544
- </button>
653
+ source={LegendAISuggestedQuerySource.FOLLOW_UP}
654
+ onSelect={onSuggestedQueryClick}
655
+ {...(onLogTelemetryEvent ? { onLogTelemetryEvent } : {})}
656
+ />
545
657
  ))}
546
658
  </div>
547
659
  )}
@@ -561,10 +673,147 @@ const AssistantMessageView = (props: {
561
673
  </button>
562
674
  )}
563
675
 
676
+ {(onRequestPython || onOpenInDataCube) &&
677
+ !msg.isProcessing &&
678
+ (msg.sql !== null || msg.gridData !== null) && (
679
+ <div className="legend-ai__python-block">
680
+ <div className="legend-ai__cta-row">
681
+ {onRequestPython && (
682
+ <button
683
+ type="button"
684
+ className="legend-ai__python-cta"
685
+ onClick={handleTogglePython}
686
+ >
687
+ <PythonIcon />
688
+ <span>
689
+ {showPython
690
+ ? 'Hide Python code'
691
+ : 'Want the Python code for this query?'}
692
+ </span>
693
+ <span className="legend-ai__python-cta-caret">
694
+ {showPython ? <CaretDownIcon /> : <CaretRightIcon />}
695
+ </span>
696
+ </button>
697
+ )}
698
+ {onOpenInDataCube && msg.sql !== null && (
699
+ <button
700
+ type="button"
701
+ className="legend-ai__datacube-cta"
702
+ onClick={(): void => {
703
+ onLogTelemetryEvent?.({
704
+ type: LegendAIChatTelemetryEventType.OPEN_IN_DATACUBE_CLICKED,
705
+ });
706
+ onOpenInDataCube(msg);
707
+ }}
708
+ disabled={isOpeningInDataCube}
709
+ title="Open this query in DataCube"
710
+ aria-label="Open this query in DataCube"
711
+ >
712
+ {isOpeningInDataCube ? (
713
+ <LoadingIcon isLoading={true} />
714
+ ) : (
715
+ <CubeIcon />
716
+ )}
717
+ <span>
718
+ {isOpeningInDataCube
719
+ ? 'Opening in DataCube...'
720
+ : 'Open in DataCube'}
721
+ </span>
722
+ <span className="legend-ai__datacube-cta-launch">
723
+ <ExternalLinkIcon />
724
+ </span>
725
+ </button>
726
+ )}
727
+ </div>
728
+ {showPython && onRequestPython && (
729
+ <div className="legend-ai__python-panel">
730
+ <div className="legend-ai__python-panel-header">
731
+ <span className="legend-ai__python-panel-header-icon">
732
+ <CodeIcon />
733
+ </span>
734
+ <span>Python</span>
735
+ {pythonEntry?.status === 'ready' && pythonEntry.code && (
736
+ <button
737
+ type="button"
738
+ className="legend-ai__sql-copy-btn"
739
+ title="Copy Python code"
740
+ aria-label="Copy Python code"
741
+ onClick={handleCopyPython}
742
+ >
743
+ {pythonCopied ? (
744
+ <span className="legend-ai__sql-copy-btn--copied">
745
+ <CheckIcon />
746
+ </span>
747
+ ) : (
748
+ <CopyIcon />
749
+ )}
750
+ </button>
751
+ )}
752
+ </div>
753
+ {pythonEntry?.status === 'loading' && (
754
+ <div className="legend-ai__python-panel-loading">
755
+ <LoadingIcon isLoading={true} />
756
+ <span>Generating Python…</span>
757
+ </div>
758
+ )}
759
+ {pythonEntry?.status === 'error' && (
760
+ <div className="legend-ai__python-panel-error">
761
+ <span>
762
+ Could not generate Python code. Try again in a moment.
763
+ </span>
764
+ <button
765
+ type="button"
766
+ className="legend-ai__permission-error-btn"
767
+ onClick={(): void => {
768
+ onLogTelemetryEvent?.({
769
+ type: LegendAIChatTelemetryEventType.PYTHON_CODE_REQUESTED,
770
+ });
771
+ onRequestPython(msg);
772
+ }}
773
+ >
774
+ Retry
775
+ </button>
776
+ </div>
777
+ )}
778
+ {pythonEntry?.status === 'ready' &&
779
+ pythonEntry.code === undefined && (
780
+ <div className="legend-ai__python-panel-loading">
781
+ <span>
782
+ Python code is not available for this data source.
783
+ </span>
784
+ </div>
785
+ )}
786
+ {pythonEntry?.status === 'ready' && pythonEntry.code && (
787
+ <>
788
+ <div className="legend-ai__sql-scroll">
789
+ <pre className="legend-ai__sql-display">
790
+ {pythonEntry.code.code}
791
+ </pre>
792
+ </div>
793
+ {pythonEntry.code.notebookUrl && (
794
+ <div className="legend-ai__python-panel-actions">
795
+ <a
796
+ className="legend-ai__permission-error-btn"
797
+ href={pythonEntry.code.notebookUrl}
798
+ target="_blank"
799
+ rel="noopener noreferrer"
800
+ >
801
+ <JupyterIcon />
802
+ <span>Launch Notebook</span>
803
+ </a>
804
+ </div>
805
+ )}
806
+ </>
807
+ )}
808
+ </div>
809
+ )}
810
+ </div>
811
+ )}
812
+
564
813
  {canShowFeedback && (
565
814
  <div className="legend-ai__message-feedback">
566
815
  <span className="legend-ai__message-feedback-label">
567
- Did this answer your question?
816
+ {LEGEND_AI_FEEDBACK_PROMPT}
568
817
  </span>
569
818
  <div className="legend-ai__message-feedback-actions">
570
819
  <button
@@ -605,7 +854,7 @@ const AssistantMessageView = (props: {
605
854
  </div>
606
855
  </div>
607
856
  );
608
- };
857
+ });
609
858
 
610
859
  export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
611
860
  const {
@@ -623,6 +872,7 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
623
872
  onClose,
624
873
  onMinimize,
625
874
  onRequestAccess,
875
+ onOpenInDataCube,
626
876
  contextBannerMessage,
627
877
  onLogTelemetryEvent,
628
878
  } = props;
@@ -641,6 +891,26 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
641
891
  () => buildSuggestedQueries(services, metadata),
642
892
  [services, metadata],
643
893
  );
894
+ const overview = useMemo(() => {
895
+ const raw =
896
+ modelContext?.dataspaceDescription ?? metadata.description ?? '';
897
+ const summary = raw
898
+ .split('\n')
899
+ .map((line) => line.trim())
900
+ .filter(
901
+ (line) =>
902
+ line.length > 0 && !line.startsWith('#') && !line.startsWith('|'),
903
+ )
904
+ .slice(0, 3)
905
+ .join(' ')
906
+ .slice(0, 300);
907
+ const entityCount = modelContext?.entities.length ?? 0;
908
+ const serviceCount =
909
+ metadata.serviceSummaries.length > 0
910
+ ? metadata.serviceSummaries.length
911
+ : services.length;
912
+ return { summary, entityCount, serviceCount };
913
+ }, [modelContext, metadata, services]);
644
914
  const hasServices = services.length > 0;
645
915
 
646
916
  const inferSuggestedQueryIntent = useCallback(
@@ -648,13 +918,154 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
648
918
  classifyQuestionIntentFast(query, hasServices).intent,
649
919
  [hasServices],
650
920
  );
651
- const isDataProduct = services.some(
652
- (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
921
+ const { isDataProduct, supportsPython, supportsDataCube } = useMemo(
922
+ () => ({
923
+ isDataProduct: services.some(
924
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
925
+ ),
926
+ supportsPython: services.some((s) => plugin.supportsPythonCodegen(s)),
927
+ supportsDataCube: services.some((s) => plugin.supportsOpenInDataCube(s)),
928
+ }),
929
+ [services, plugin],
930
+ );
931
+ const [pythonCodeByMessageId, setPythonCodeByMessageId] = useState<
932
+ Map<string, LegendAIPythonCodeEntry>
933
+ >(new Map());
934
+ const pythonCodeByMessageIdRef = useRef(pythonCodeByMessageId);
935
+ pythonCodeByMessageIdRef.current = pythonCodeByMessageId;
936
+ const requestPythonCode = useCallback(
937
+ async (msg: LegendAIAssistantMessage): Promise<void> => {
938
+ const existing = pythonCodeByMessageIdRef.current.get(msg.id);
939
+ if (existing?.status === 'loading' || existing?.status === 'ready') {
940
+ return;
941
+ }
942
+ setPythonCodeByMessageId((prev) => {
943
+ const next = new Map(prev);
944
+ next.set(msg.id, { status: 'loading' });
945
+ return next;
946
+ });
947
+ const resolvedSet = new Set(msg.queriedAccessPoints);
948
+ const service =
949
+ services.find((s) => resolvedSet.has(s.pattern.replace(/^\//u, ''))) ??
950
+ services[0];
951
+ if (!service) {
952
+ setPythonCodeByMessageId((prev) => {
953
+ const next = new Map(prev);
954
+ next.set(msg.id, { status: 'ready', code: undefined });
955
+ return next;
956
+ });
957
+ return;
958
+ }
959
+ const msgIndex = state.messages.findIndex((m) => m.id === msg.id);
960
+ const previousUser =
961
+ msgIndex > 0 ? state.messages[msgIndex - 1] : undefined;
962
+ const question =
963
+ previousUser?.role === LegendAIMessageRole.USER
964
+ ? previousUser.text
965
+ : undefined;
966
+ const request: LegendAIPythonCodegenRequest = {
967
+ service,
968
+ config,
969
+ ...(dataProductCoordinates ? { dataProductCoordinates } : {}),
970
+ ...(question === undefined ? {} : { question }),
971
+ ...(msg.sql === null ? {} : { sql: msg.sql }),
972
+ };
973
+ try {
974
+ const code = await plugin.generatePythonQueryCodeAsync(request);
975
+ setPythonCodeByMessageId((prev) => {
976
+ const next = new Map(prev);
977
+ next.set(msg.id, { status: 'ready', code });
978
+ return next;
979
+ });
980
+ } catch (error) {
981
+ assertErrorThrown(error);
982
+ setPythonCodeByMessageId((prev) => {
983
+ const next = new Map(prev);
984
+ next.set(msg.id, { status: 'error', errorMessage: error.message });
985
+ return next;
986
+ });
987
+ }
988
+ },
989
+ [plugin, services, dataProductCoordinates, config, state.messages],
990
+ );
991
+ const [openingDataCubeMessageIds, setOpeningDataCubeMessageIds] = useState<
992
+ Set<string>
993
+ >(new Set());
994
+
995
+ // Resolves the AP, best-effort translates its SQL to a DataCube Pure query, and
996
+ // launches DataCube; on translation failure it opens on the bare access point.
997
+ const handleOpenInDataCubeMsg = useCallback(
998
+ async (msg: LegendAIAssistantMessage): Promise<void> => {
999
+ if (!onOpenInDataCube || msg.sql === null) {
1000
+ return;
1001
+ }
1002
+ const accessPointName = msg.queriedAccessPoints[0];
1003
+ if (accessPointName === undefined) {
1004
+ return;
1005
+ }
1006
+ const service =
1007
+ services.find(
1008
+ (s) => s.pattern.replace(/^\//u, '') === accessPointName,
1009
+ ) ?? services[0];
1010
+ if (!service) {
1011
+ return;
1012
+ }
1013
+ const dataProductPath = dataProductCoordinates?.data_product ?? '';
1014
+ setOpeningDataCubeMessageIds((prev) => {
1015
+ const next = new Set(prev);
1016
+ next.add(msg.id);
1017
+ return next;
1018
+ });
1019
+ const msgIndex = state.messages.findIndex((m) => m.id === msg.id);
1020
+ const previousUser =
1021
+ msgIndex > 0 ? state.messages[msgIndex - 1] : undefined;
1022
+ const question =
1023
+ previousUser?.role === LegendAIMessageRole.USER
1024
+ ? previousUser.text
1025
+ : undefined;
1026
+ const request: LegendAIDataCubeQueryTranslationRequest = {
1027
+ sql: msg.sql,
1028
+ service,
1029
+ dataProductPath,
1030
+ config,
1031
+ ...(question === undefined ? {} : { question }),
1032
+ };
1033
+ let pureQuery: string | undefined;
1034
+ try {
1035
+ pureQuery =
1036
+ await plugin.translateAccessPointSqlToDataCubeQuery(request);
1037
+ } catch (error) {
1038
+ assertErrorThrown(error);
1039
+ }
1040
+ try {
1041
+ onOpenInDataCube(accessPointName, pureQuery);
1042
+ } finally {
1043
+ setOpeningDataCubeMessageIds((prev) => {
1044
+ if (!prev.has(msg.id)) {
1045
+ return prev;
1046
+ }
1047
+ const next = new Set(prev);
1048
+ next.delete(msg.id);
1049
+ return next;
1050
+ });
1051
+ }
1052
+ },
1053
+ [
1054
+ onOpenInDataCube,
1055
+ plugin,
1056
+ services,
1057
+ dataProductCoordinates,
1058
+ config,
1059
+ state.messages,
1060
+ ],
653
1061
  );
654
1062
  const [showContextBanner, setShowContextBanner] = useState(true);
655
1063
  const dismissBanner = useCallback(() => setShowContextBanner(false), []);
656
1064
  const hasMessages = state.messages.length > 0;
657
- const scopes = isDataProduct ? [] : (availableScopes ?? DEFAULT_SCOPES);
1065
+ const scopes = useMemo(
1066
+ () => (isDataProduct ? [] : (availableScopes ?? DEFAULT_SCOPES)),
1067
+ [isDataProduct, availableScopes],
1068
+ );
658
1069
  const [feedbackByMessageId, setFeedbackByMessageId] = useState<
659
1070
  Map<string, LegendAIMessageFeedbackRating>
660
1071
  >(new Map());
@@ -682,7 +1093,8 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
682
1093
 
683
1094
  try {
684
1095
  await onMessageFeedback(feedback);
685
- } catch {
1096
+ } catch (error) {
1097
+ assertErrorThrown(error);
686
1098
  setFeedbackByMessageId((prev) => {
687
1099
  const next = new Map(prev);
688
1100
  next.delete(feedback.messageId);
@@ -699,6 +1111,25 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
699
1111
  [onMessageFeedback],
700
1112
  );
701
1113
 
1114
+ const { toggleThinking, runFallbackAction, askQuestionWithIntent } = state;
1115
+ const handleSuggestedQueryClick = useCallback(
1116
+ (query: string): void =>
1117
+ askQuestionWithIntent(query, inferSuggestedQueryIntent(query)),
1118
+ [askQuestionWithIntent, inferSuggestedQueryIntent],
1119
+ );
1120
+ const handleRequestPython = useCallback(
1121
+ (message: LegendAIAssistantMessage): void => {
1122
+ requestPythonCode(message).catch(noop);
1123
+ },
1124
+ [requestPythonCode],
1125
+ );
1126
+ const handleOpenInDataCube = useCallback(
1127
+ (message: LegendAIAssistantMessage): void => {
1128
+ handleOpenInDataCubeMsg(message).catch(noop);
1129
+ },
1130
+ [handleOpenInDataCubeMsg],
1131
+ );
1132
+
702
1133
  return (
703
1134
  <div className="legend-ai" id={LEGEND_AI_ANCHOR_ID}>
704
1135
  <div className="legend-ai__header">
@@ -733,7 +1164,12 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
733
1164
  className="legend-ai__header-action"
734
1165
  title="Close"
735
1166
  aria-label="Close"
736
- onClick={onClose}
1167
+ onClick={(): void => {
1168
+ onLogTelemetryEvent?.({
1169
+ type: LegendAIChatTelemetryEventType.ASSISTANT_CLOSED,
1170
+ });
1171
+ onClose();
1172
+ }}
737
1173
  >
738
1174
  <TimesIcon />
739
1175
  </button>
@@ -757,21 +1193,40 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
757
1193
  <div className="legend-ai__empty-text">
758
1194
  Ask a question about your data
759
1195
  </div>
1196
+ {overview.summary.length > 0 && (
1197
+ <div className="legend-ai__empty-overview">
1198
+ {overview.summary}
1199
+ </div>
1200
+ )}
1201
+ {(overview.entityCount > 0 || overview.serviceCount > 0) && (
1202
+ <div className="legend-ai__empty-meta">
1203
+ {overview.entityCount > 0 &&
1204
+ `${overview.entityCount} entit${
1205
+ overview.entityCount === 1 ? 'y' : 'ies'
1206
+ }`}
1207
+ {overview.entityCount > 0 && overview.serviceCount > 0 && ' · '}
1208
+ {overview.serviceCount > 0 &&
1209
+ `${overview.serviceCount} service${
1210
+ overview.serviceCount === 1 ? '' : 's'
1211
+ }`}
1212
+ </div>
1213
+ )}
760
1214
  <div className="legend-ai__suggestions">
761
- {suggestedQueries.map((q) => (
762
- <button
1215
+ {suggestedQueries.map((q, position) => (
1216
+ <SuggestionButton
763
1217
  key={q}
764
- type="button"
1218
+ query={q}
1219
+ position={position}
765
1220
  className="legend-ai__suggestion-chip"
766
- onClick={(): void => {
1221
+ source={LegendAISuggestedQuerySource.STARTER}
1222
+ onSelect={(query): void =>
767
1223
  state.askQuestionWithIntent(
768
- q,
769
- inferSuggestedQueryIntent(q),
770
- );
771
- }}
772
- >
773
- {q}
774
- </button>
1224
+ query,
1225
+ inferSuggestedQueryIntent(query),
1226
+ )
1227
+ }
1228
+ {...(onLogTelemetryEvent ? { onLogTelemetryEvent } : {})}
1229
+ />
775
1230
  ))}
776
1231
  </div>
777
1232
  </div>
@@ -794,13 +1249,15 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
794
1249
  previousMessage?.role === LegendAIMessageRole.USER
795
1250
  ? previousMessage.text
796
1251
  : '';
1252
+ const messagePython = pythonCodeByMessageId.get(msg.id);
797
1253
  return (
798
1254
  <AssistantMessageView
799
1255
  key={msg.id}
800
1256
  msg={msg}
1257
+ msgIndex={msgIndex}
801
1258
  questionText={questionText}
802
1259
  isThinkingVisible={isThinkingVisible}
803
- onToggleThinking={(): void => state.toggleThinking(msgIndex)}
1260
+ onToggleThinking={toggleThinking}
804
1261
  onMessageFeedback={handleMessageFeedback}
805
1262
  selectedFeedbackRating={feedbackByMessageId.get(msg.id)}
806
1263
  feedbackSubmitting={pendingFeedbackByMessageId.has(msg.id)}
@@ -811,12 +1268,19 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
811
1268
  ? {}
812
1269
  : { enthubRequestAccessUrl: config.enthubRequestAccessUrl })}
813
1270
  {...(onRequestAccess ? { onRequestAccess } : {})}
814
- onFallbackAction={(messageId): void =>
815
- state.runFallbackAction(messageId)
816
- }
817
- onSuggestedQueryClick={(q): void =>
818
- state.askQuestionWithIntent(q, inferSuggestedQueryIntent(q))
819
- }
1271
+ {...(messagePython ? { pythonEntry: messagePython } : {})}
1272
+ {...(supportsPython
1273
+ ? { onRequestPython: handleRequestPython }
1274
+ : {})}
1275
+ {...(supportsDataCube && onOpenInDataCube
1276
+ ? {
1277
+ onOpenInDataCube: handleOpenInDataCube,
1278
+ isOpeningInDataCube: openingDataCubeMessageIds.has(msg.id),
1279
+ }
1280
+ : {})}
1281
+ onFallbackAction={runFallbackAction}
1282
+ onSuggestedQueryClick={handleSuggestedQueryClick}
1283
+ {...(onLogTelemetryEvent ? { onLogTelemetryEvent } : {})}
820
1284
  />
821
1285
  );
822
1286
  })}