@finos/legend-lego 2.0.197 → 2.0.198

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 (67) 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 +60 -0
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -0
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +429 -0
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -0
  7. package/lib/legend-ai/LegendAITypes.d.ts +127 -1
  8. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  9. package/lib/legend-ai/LegendAITypes.js +111 -2
  10. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  11. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +14 -1
  12. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  13. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  14. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +2 -1
  15. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  16. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +37 -2
  17. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  18. package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts.map +1 -1
  19. package/lib/legend-ai/components/LegendAIAnalysisPanel.js +11 -12
  20. package/lib/legend-ai/components/LegendAIAnalysisPanel.js.map +1 -1
  21. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts +7 -0
  22. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts.map +1 -1
  23. package/lib/legend-ai/components/LegendAIAnalysisUtils.js +106 -41
  24. package/lib/legend-ai/components/LegendAIAnalysisUtils.js.map +1 -1
  25. package/lib/legend-ai/components/LegendAIChat.d.ts +1 -5
  26. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  27. package/lib/legend-ai/components/LegendAIChat.js +168 -109
  28. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  29. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts +21 -0
  30. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts.map +1 -0
  31. package/lib/legend-ai/components/LegendAIChatHelpers.js +85 -0
  32. package/lib/legend-ai/components/LegendAIChatHelpers.js.map +1 -0
  33. package/lib/legend-ai/components/LegendAIChatInput.d.ts +21 -0
  34. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -0
  35. package/lib/legend-ai/components/LegendAIChatInput.js +78 -0
  36. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -0
  37. package/lib/legend-ai/components/LegendAIScopeSelector.d.ts +25 -0
  38. package/lib/legend-ai/components/LegendAIScopeSelector.d.ts.map +1 -0
  39. package/lib/legend-ai/components/LegendAIScopeSelector.js +85 -0
  40. package/lib/legend-ai/components/LegendAIScopeSelector.js.map +1 -0
  41. package/lib/legend-ai/index.d.ts +8 -3
  42. package/lib/legend-ai/index.d.ts.map +1 -1
  43. package/lib/legend-ai/index.js +8 -3
  44. package/lib/legend-ai/index.js.map +1 -1
  45. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +105 -0
  46. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -0
  47. package/lib/legend-ai/stores/LegendAIChatProcessors.js +1482 -0
  48. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -0
  49. package/lib/legend-ai/stores/LegendAIChatState.d.ts +2 -35
  50. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  51. package/lib/legend-ai/stores/LegendAIChatState.js +114 -949
  52. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  53. package/package.json +5 -5
  54. package/src/legend-ai/LegendAIDocEnrichment.ts +572 -0
  55. package/src/legend-ai/LegendAITypes.ts +213 -5
  56. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +25 -0
  57. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +55 -1
  58. package/src/legend-ai/components/LegendAIAnalysisPanel.tsx +14 -34
  59. package/src/legend-ai/components/LegendAIAnalysisUtils.ts +157 -47
  60. package/src/legend-ai/components/LegendAIChat.tsx +389 -206
  61. package/src/legend-ai/components/LegendAIChatHelpers.ts +117 -0
  62. package/src/legend-ai/components/LegendAIChatInput.tsx +209 -0
  63. package/src/legend-ai/components/LegendAIScopeSelector.tsx +199 -0
  64. package/src/legend-ai/index.ts +31 -4
  65. package/src/legend-ai/stores/LegendAIChatProcessors.ts +2563 -0
  66. package/src/legend-ai/stores/LegendAIChatState.ts +161 -1697
  67. package/tsconfig.json +5 -0
@@ -14,175 +14,156 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
+ import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
17
18
  import {
18
- useMemo,
19
- useCallback,
20
- useState,
21
- useRef,
22
- useEffect,
23
- useLayoutEffect,
24
- } from 'react';
25
- import {
26
- SendIcon,
27
- LoadingIcon,
28
19
  SparkleStarsIcon,
29
20
  CodeIcon,
30
21
  TableIcon,
31
22
  CopyIcon,
32
- RefreshIcon,
33
23
  CheckIcon,
34
24
  TimesIcon,
25
+ MinusIcon,
26
+ PlusIcon,
35
27
  CaretDownIcon,
36
28
  CaretRightIcon,
37
29
  DotIcon,
30
+ LoadingIcon,
31
+ LikeIcon,
32
+ DislikeIcon,
33
+ ExternalLinkIcon,
38
34
  MarkdownTextViewer,
39
35
  } from '@finos/legend-art';
40
36
  import { noop } from '@finos/legend-shared';
41
- import { PRIMITIVE_TYPE } from '@finos/legend-graph';
42
37
  import {
43
38
  type LegendAIChatProps,
44
39
  type LegendAIAssistantMessage,
45
- type LegendAIProductMetadata,
46
- type TDSServiceSchema,
47
- type TDSColumnSchema,
40
+ type LegendAIMessageFeedback,
41
+ type LegendAIThinkingStep,
42
+ type LegendAIScopeItem,
43
+ type LegendAIQuestionIntent,
44
+ LegendAIMessageFeedbackRating,
48
45
  LegendAIThinkingStepStatus,
49
46
  LegendAIMessageRole,
50
- LegendAIQuestionIntent,
47
+ LegendAIErrorType,
48
+ classifyQuestionIntentFast,
51
49
  } from '../LegendAITypes.js';
52
50
  import { useLegendAIChatState } from '../stores/LegendAIChatState.js';
53
51
  import { LegendAIResultGrid } from './LegendAIResultGrid.js';
52
+ import { LegendAIAnalysisPanel } from './LegendAIAnalysisPanel.js';
53
+ import { LegendAIChatInput } from './LegendAIChatInput.js';
54
+ import { buildSuggestedQueries } from './LegendAIChatHelpers.js';
54
55
 
55
56
  export const LEGEND_AI_ANCHOR_ID = 'legend-ai-anchor';
56
57
 
57
58
  const COPY_FEEDBACK_DURATION_MS = 2000;
58
- const MAX_SUGGESTED_QUERIES = 8;
59
-
60
- const STRING_TYPES = new Set<string>([PRIMITIVE_TYPE.STRING]);
61
-
62
- const NUMERIC_TYPES = new Set<string>([
63
- PRIMITIVE_TYPE.NUMBER,
64
- PRIMITIVE_TYPE.INTEGER,
65
- PRIMITIVE_TYPE.FLOAT,
66
- PRIMITIVE_TYPE.DECIMAL,
67
- ]);
68
-
69
- const DATE_TYPES = new Set<string>([
70
- PRIMITIVE_TYPE.DATE,
71
- PRIMITIVE_TYPE.STRICTDATE,
72
- PRIMITIVE_TYPE.DATETIME,
73
- ]);
74
-
75
- export function isStringColumn(c: TDSColumnSchema): boolean {
76
- return STRING_TYPES.has(c.type ?? '') && !c.name.toLowerCase().includes('id');
77
- }
78
-
79
- export function isNumericColumn(c: TDSColumnSchema): boolean {
80
- return NUMERIC_TYPES.has(c.type ?? '');
81
- }
82
-
83
- export function isDateColumn(c: TDSColumnSchema): boolean {
84
- return (
85
- DATE_TYPES.has(c.type ?? '') ||
86
- c.name.toLowerCase().includes('date') ||
87
- c.name.toLowerCase().includes('time')
88
- );
89
- }
90
-
91
- function buildDataInsightSuggestions(
92
- primary: TDSServiceSchema,
93
- stringCol: TDSColumnSchema | undefined,
94
- numericCol: TDSColumnSchema | undefined,
95
- dateCol: TDSColumnSchema | undefined,
96
- ): string[] {
97
- const result: string[] = [];
98
- if (stringCol && numericCol) {
99
- result.push(
100
- `What are the top ${stringCol.name} values by total ${numericCol.name} in ${primary.title}?`,
101
- );
102
- } else if (stringCol) {
103
- result.push(
104
- `What are the distinct ${stringCol.name} values in ${primary.title}?`,
105
- );
59
+ const METADATA_CONTEXT_HEADING = '### Metadata context';
60
+ const QUERY_ANALYSIS_HEADING = '### Query analysis';
61
+
62
+ function toUserFacingThinkingLabel(label: string): string {
63
+ const normalized = label.toLowerCase();
64
+ if (
65
+ normalized.includes('analyzing your question') ||
66
+ normalized.includes('intent is ambiguous')
67
+ ) {
68
+ return 'Understanding your request';
106
69
  }
107
-
108
- if (dateCol && stringCol) {
109
- result.push(
110
- `Show ${primary.title} records from the last month grouped by ${stringCol.name}`,
111
- );
70
+ if (
71
+ normalized.includes('building metadata context') ||
72
+ normalized.includes('answering from product metadata')
73
+ ) {
74
+ return 'Checking product capabilities and services';
112
75
  }
113
-
114
- if (numericCol && !stringCol) {
115
- result.push(`What is the total ${numericCol.name} in ${primary.title}?`);
76
+ if (
77
+ normalized.includes('found relevant services') ||
78
+ normalized.includes('selecting best service') ||
79
+ normalized.includes('building context from service schemas') ||
80
+ normalized.includes('preparing data query') ||
81
+ normalized.includes('generating sql query') ||
82
+ normalized.includes('verifying query correctness') ||
83
+ normalized.includes('query corrected') ||
84
+ normalized.includes('max verification attempts reached') ||
85
+ normalized.includes('judge approved a non-sql draft')
86
+ ) {
87
+ return 'Trying a data query when helpful';
116
88
  }
117
- return result;
118
- }
119
-
120
- function buildMultiServiceSuggestion(services: TDSServiceSchema[]): string[] {
121
- if (services.length < 2) {
122
- return [];
89
+ if (
90
+ normalized.includes('retrieved ') ||
91
+ normalized.includes('executing') ||
92
+ normalized.includes('analyzing results') ||
93
+ normalized.includes('verifying answer coverage')
94
+ ) {
95
+ return 'Summarizing what matters for your question';
123
96
  }
124
- const svcA = services[0];
125
- const svcB = services[1];
126
- if (!svcA || !svcB) {
127
- return [];
97
+ if (normalized.includes('error')) {
98
+ return 'Hit an issue while preparing the answer';
128
99
  }
100
+ return label;
101
+ }
129
102
 
130
- const result: string[] = [`Show the latest 10 records from ${svcB.title}`];
131
-
132
- const colNamesA = new Set(svcA.columns.map((c) => c.name.toLowerCase()));
133
- const sharedCol = svcB.columns.find((c) =>
134
- colNamesA.has(c.name.toLowerCase()),
135
- );
136
- if (sharedCol) {
137
- result.push(
138
- `Compare ${svcA.title} and ${svcB.title} by ${sharedCol.name}, show 10 rows`,
139
- );
103
+ function formatThinkingSteps(
104
+ thinkingSteps: LegendAIThinkingStep[],
105
+ ): LegendAIThinkingStep[] {
106
+ const formatted: LegendAIThinkingStep[] = [];
107
+ for (const step of thinkingSteps) {
108
+ const userLabel = toUserFacingThinkingLabel(step.label);
109
+ const last = formatted[formatted.length - 1];
110
+ if (last?.label === userLabel) {
111
+ formatted[formatted.length - 1] = {
112
+ ...last,
113
+ status: step.status,
114
+ };
115
+ } else {
116
+ formatted.push({
117
+ ...step,
118
+ label: userLabel,
119
+ });
120
+ }
140
121
  }
141
-
142
- return result;
122
+ return formatted;
143
123
  }
144
124
 
145
- export function buildSuggestedQueries(
146
- services: TDSServiceSchema[],
147
- metadata: LegendAIProductMetadata,
148
- ): string[] {
149
- const suggestions: string[] = [
150
- `What data does ${metadata.name} offer and how can I use it?`,
151
- ];
152
-
153
- if (services.length === 0) {
154
- return [
155
- ...suggestions,
156
- 'What access points are available?',
157
- 'Describe the data model and key entities',
158
- ];
125
+ function splitCombinedAnswer(textAnswer: string | null): {
126
+ metadataContext: string | null;
127
+ queryAnalysis: string | null;
128
+ } {
129
+ if (!textAnswer) {
130
+ return { metadataContext: null, queryAnalysis: null };
159
131
  }
160
-
161
- const primary = services[0];
162
- if (!primary) {
163
- return [
164
- ...suggestions,
165
- 'What access points are available and what columns do they have?',
166
- ];
132
+ const metadataIndex = textAnswer.indexOf(METADATA_CONTEXT_HEADING);
133
+ if (metadataIndex < 0) {
134
+ return { metadataContext: null, queryAnalysis: textAnswer };
167
135
  }
168
136
 
169
- const stringCol = primary.columns.find(isStringColumn);
170
- const numericCol = primary.columns.find(isNumericColumn);
171
- const dateCol = primary.columns.find(isDateColumn);
172
-
173
- const multiSvcSuggestions = buildMultiServiceSuggestion(services);
137
+ const metadataStart = metadataIndex + METADATA_CONTEXT_HEADING.length;
138
+ const queryIndex = textAnswer.indexOf(QUERY_ANALYSIS_HEADING, metadataStart);
139
+
140
+ const metadataContext =
141
+ queryIndex >= 0
142
+ ? textAnswer.slice(metadataStart, queryIndex).trim()
143
+ : textAnswer.slice(metadataStart).trim();
144
+ const queryAnalysis =
145
+ queryIndex >= 0
146
+ ? textAnswer.slice(queryIndex + QUERY_ANALYSIS_HEADING.length).trim() ||
147
+ null
148
+ : null;
149
+
150
+ return {
151
+ metadataContext: metadataContext.length > 0 ? metadataContext : null,
152
+ queryAnalysis,
153
+ };
154
+ }
174
155
 
175
- const primaryRecordsSuggestion = dateCol
176
- ? `Show the 10 most recent records from ${primary.title} by ${dateCol.name}`
177
- : `Show 10 records from ${primary.title}`;
156
+ const AISummaryRenderer = ({ value }: { value: string }): React.ReactNode => (
157
+ <MarkdownTextViewer value={{ value }} className="legend-ai__text-answer-md" />
158
+ );
178
159
 
179
- return [
180
- ...suggestions,
181
- primaryRecordsSuggestion,
182
- ...buildDataInsightSuggestions(primary, stringCol, numericCol, dateCol),
183
- ...multiSvcSuggestions,
184
- ].slice(0, MAX_SUGGESTED_QUERIES);
185
- }
160
+ const DEFAULT_SCOPES: LegendAIScopeItem[] = [
161
+ {
162
+ id: 'legend-ai-mcp',
163
+ label: 'Legend AI MCP',
164
+ description: 'Model Context Protocol via Marketplace /mcp proxy',
165
+ },
166
+ ];
186
167
 
187
168
  export function renderStepStatusIcon(
188
169
  status: LegendAIThinkingStepStatus,
@@ -199,19 +180,36 @@ export function renderStepStatusIcon(
199
180
 
200
181
  const AssistantMessageView = (props: {
201
182
  msg: LegendAIAssistantMessage;
183
+ questionText: string;
202
184
  isThinkingVisible: boolean;
203
185
  onToggleThinking: () => void;
186
+ onMessageFeedback?: (
187
+ feedback: LegendAIMessageFeedback,
188
+ ) => Promise<void> | void;
189
+ selectedFeedbackRating: LegendAIMessageFeedbackRating | undefined;
190
+ feedbackSubmitting: boolean;
204
191
  onSuggestedQueryClick?: (query: string) => void;
205
192
  onFallbackAction?: (messageId: string) => void;
193
+ enghubDocUrl?: string;
194
+ enthubRequestAccessUrl?: string;
206
195
  }): React.ReactNode => {
207
196
  const {
208
197
  msg,
198
+ questionText,
209
199
  isThinkingVisible,
210
200
  onToggleThinking,
201
+ onMessageFeedback,
202
+ selectedFeedbackRating,
203
+ feedbackSubmitting,
211
204
  onSuggestedQueryClick,
212
205
  onFallbackAction,
206
+ enghubDocUrl,
207
+ enthubRequestAccessUrl,
213
208
  } = props;
214
209
 
210
+ const hasPermissionAccessLinks =
211
+ enghubDocUrl !== undefined || enthubRequestAccessUrl !== undefined;
212
+
215
213
  const [sqlCopied, setSqlCopied] = useState(false);
216
214
  const copyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
217
215
  undefined,
@@ -228,7 +226,7 @@ const AssistantMessageView = (props: {
228
226
 
229
227
  const handleCopySql = useCallback(() => {
230
228
  if (msg.sql) {
231
- navigator.clipboard.writeText(msg.sql).catch(noop());
229
+ navigator.clipboard.writeText(msg.sql).catch(noop);
232
230
  setSqlCopied(true);
233
231
  if (copyTimerRef.current !== undefined) {
234
232
  clearTimeout(copyTimerRef.current);
@@ -240,13 +238,48 @@ const AssistantMessageView = (props: {
240
238
  }
241
239
  }, [msg.sql]);
242
240
 
241
+ const canShowFeedback =
242
+ !msg.isProcessing &&
243
+ (msg.textAnswer !== null || msg.gridData !== null || msg.error !== null);
244
+ const visibleThinkingSteps = formatThinkingSteps(msg.thinkingSteps);
245
+ const { metadataContext, queryAnalysis } = splitCombinedAnswer(
246
+ msg.textAnswer,
247
+ );
248
+ const analysisSummary = (() => {
249
+ if (msg.gridData === null) {
250
+ return null;
251
+ }
252
+ return queryAnalysis ?? (metadataContext === null ? msg.textAnswer : null);
253
+ })();
254
+ const plainAnswer =
255
+ msg.gridData === null ? (metadataContext ?? msg.textAnswer) : null;
256
+
257
+ const submitFeedback = useCallback(
258
+ (rating: LegendAIMessageFeedbackRating): void => {
259
+ const result = onMessageFeedback?.({
260
+ messageId: msg.id,
261
+ rating,
262
+ question: questionText,
263
+ ...(msg.textAnswer === null ? {} : { answer: msg.textAnswer }),
264
+ ...(msg.sql === null ? {} : { sql: msg.sql }),
265
+ ...(msg.gridData === null
266
+ ? {}
267
+ : { rowCount: msg.gridData.rowData.length }),
268
+ });
269
+ if (result instanceof Promise) {
270
+ result.catch(noop);
271
+ }
272
+ },
273
+ [msg, onMessageFeedback, questionText],
274
+ );
275
+
243
276
  return (
244
277
  <div className="legend-ai__msg legend-ai__msg--assistant">
245
278
  <div className="legend-ai__msg-avatar">
246
279
  <SparkleStarsIcon />
247
280
  </div>
248
281
  <div className="legend-ai__msg-content">
249
- {msg.thinkingSteps.length > 0 && (
282
+ {visibleThinkingSteps.length > 0 && (
250
283
  <div className="legend-ai__thinking">
251
284
  {!msg.isProcessing && (
252
285
  <button
@@ -262,7 +295,7 @@ const AssistantMessageView = (props: {
262
295
  )}
263
296
  {isThinkingVisible && (
264
297
  <div className="legend-ai__thinking-steps">
265
- {msg.thinkingSteps.map((step) => (
298
+ {visibleThinkingSteps.map((step) => (
266
299
  <div
267
300
  key={step.id}
268
301
  className={`legend-ai__thinking-step legend-ai__thinking-step--${step.status}`}
@@ -278,6 +311,15 @@ const AssistantMessageView = (props: {
278
311
  </div>
279
312
  )}
280
313
 
314
+ {metadataContext && msg.gridData && (
315
+ <div className="legend-ai__inline-answer">
316
+ <MarkdownTextViewer
317
+ value={{ value: metadataContext }}
318
+ className="legend-ai__text-answer-md"
319
+ />
320
+ </div>
321
+ )}
322
+
281
323
  {msg.sql && (
282
324
  <div className="legend-ai__sql-block">
283
325
  <div className="legend-ai__sql-block-header">
@@ -319,7 +361,43 @@ const AssistantMessageView = (props: {
319
361
  </div>
320
362
  )}
321
363
 
322
- {msg.error && <div className="legend-ai__exec-error">{msg.error}</div>}
364
+ {msg.error && (
365
+ <div className="legend-ai__exec-error">
366
+ {msg.error}
367
+ {msg.errorType === LegendAIErrorType.PERMISSION &&
368
+ hasPermissionAccessLinks && (
369
+ <div className="legend-ai__permission-error-action">
370
+ <span className="legend-ai__permission-error-note">
371
+ Need access?
372
+ </span>
373
+ <div className="legend-ai__permission-error-btns">
374
+ {enghubDocUrl && (
375
+ <a
376
+ className="legend-ai__permission-error-btn"
377
+ href={enghubDocUrl}
378
+ target="_blank"
379
+ rel="noopener noreferrer"
380
+ >
381
+ <ExternalLinkIcon />
382
+ <span>View Documentation</span>
383
+ </a>
384
+ )}
385
+ {enthubRequestAccessUrl && (
386
+ <a
387
+ className="legend-ai__permission-error-btn legend-ai__permission-error-btn--primary"
388
+ href={enthubRequestAccessUrl}
389
+ target="_blank"
390
+ rel="noopener noreferrer"
391
+ >
392
+ <ExternalLinkIcon />
393
+ <span>Request Access</span>
394
+ </a>
395
+ )}
396
+ </div>
397
+ </div>
398
+ )}
399
+ </div>
400
+ )}
323
401
 
324
402
  {msg.gridData && (
325
403
  <div className="legend-ai__results-block">
@@ -346,15 +424,30 @@ const AssistantMessageView = (props: {
346
424
  </div>
347
425
  )}
348
426
 
349
- {msg.textAnswer && (
350
- <div className="legend-ai__text-answer">
427
+ {plainAnswer && (
428
+ <div className="legend-ai__inline-answer">
351
429
  <MarkdownTextViewer
352
- value={{ value: msg.textAnswer }}
430
+ value={{ value: plainAnswer }}
353
431
  className="legend-ai__text-answer-md"
354
432
  />
355
433
  </div>
356
434
  )}
357
435
 
436
+ {analysisSummary && msg.gridData && (
437
+ <LegendAIAnalysisPanel
438
+ gridData={msg.gridData}
439
+ summary={analysisSummary}
440
+ SummaryRenderer={AISummaryRenderer}
441
+ />
442
+ )}
443
+
444
+ {msg.isProcessing && !msg.isExecuting && msg.gridData && (
445
+ <div className="legend-ai__analyzing">
446
+ <LoadingIcon isLoading={true} />
447
+ <span>Analyzing results...</span>
448
+ </div>
449
+ )}
450
+
358
451
  {!msg.isProcessing &&
359
452
  msg.suggestedQueries.length > 0 &&
360
453
  onSuggestedQueryClick && (
@@ -389,6 +482,50 @@ const AssistantMessageView = (props: {
389
482
  <span>{msg.fallbackAction.label}</span>
390
483
  </button>
391
484
  )}
485
+
486
+ {canShowFeedback && (
487
+ <div className="legend-ai__message-feedback">
488
+ <span className="legend-ai__message-feedback-label">
489
+ Did this answer your question?
490
+ </span>
491
+ <div className="legend-ai__message-feedback-actions">
492
+ <button
493
+ type="button"
494
+ className={`legend-ai__message-feedback-btn${
495
+ selectedFeedbackRating ===
496
+ LegendAIMessageFeedbackRating.THUMBS_UP
497
+ ? 'legend-ai__message-feedback-btn--selected'
498
+ : ''
499
+ }`}
500
+ title="Thumbs up"
501
+ aria-label="Thumbs up"
502
+ onClick={(): void =>
503
+ submitFeedback(LegendAIMessageFeedbackRating.THUMBS_UP)
504
+ }
505
+ disabled={feedbackSubmitting}
506
+ >
507
+ <LikeIcon />
508
+ </button>
509
+ <button
510
+ type="button"
511
+ className={`legend-ai__message-feedback-btn${
512
+ selectedFeedbackRating ===
513
+ LegendAIMessageFeedbackRating.THUMBS_DOWN
514
+ ? 'legend-ai__message-feedback-btn--selected'
515
+ : ''
516
+ }`}
517
+ title="Thumbs down"
518
+ aria-label="Thumbs down"
519
+ onClick={(): void =>
520
+ submitFeedback(LegendAIMessageFeedbackRating.THUMBS_DOWN)
521
+ }
522
+ disabled={feedbackSubmitting}
523
+ >
524
+ <DislikeIcon />
525
+ </button>
526
+ </div>
527
+ </div>
528
+ )}
392
529
  </div>
393
530
  </div>
394
531
  );
@@ -404,6 +541,10 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
404
541
  plugin,
405
542
  dataProductCoordinates,
406
543
  pureExecutionContext,
544
+ availableScopes,
545
+ onMessageFeedback,
546
+ onClose,
547
+ onMinimize,
407
548
  } = props;
408
549
  const state = useLegendAIChatState(
409
550
  services,
@@ -418,16 +559,58 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
418
559
  () => buildSuggestedQueries(services, metadata),
419
560
  [services, metadata],
420
561
  );
562
+ const hasServices = services.length > 0;
563
+
564
+ const inferSuggestedQueryIntent = useCallback(
565
+ (query: string): LegendAIQuestionIntent =>
566
+ classifyQuestionIntentFast(query, hasServices).intent,
567
+ [hasServices],
568
+ );
421
569
  const hasMessages = state.messages.length > 0;
422
- const textareaRef = useRef<HTMLTextAreaElement>(null);
570
+ const scopes = availableScopes ?? DEFAULT_SCOPES;
571
+ const [feedbackByMessageId, setFeedbackByMessageId] = useState<
572
+ Map<string, LegendAIMessageFeedbackRating>
573
+ >(new Map());
574
+ const [pendingFeedbackByMessageId, setPendingFeedbackByMessageId] = useState<
575
+ Set<string>
576
+ >(new Set());
577
+
578
+ const handleMessageFeedback = useCallback(
579
+ async (feedback: LegendAIMessageFeedback): Promise<void> => {
580
+ setFeedbackByMessageId((prev) => {
581
+ const next = new Map(prev);
582
+ next.set(feedback.messageId, feedback.rating);
583
+ return next;
584
+ });
585
+
586
+ if (!onMessageFeedback) {
587
+ return;
588
+ }
423
589
 
424
- useLayoutEffect(() => {
425
- const el = textareaRef.current;
426
- if (el) {
427
- el.style.height = 'auto';
428
- el.style.height = `${el.scrollHeight}px`;
429
- }
430
- }, [state.questionText]);
590
+ setPendingFeedbackByMessageId((prev) => {
591
+ const next = new Set(prev);
592
+ next.add(feedback.messageId);
593
+ return next;
594
+ });
595
+
596
+ try {
597
+ await onMessageFeedback(feedback);
598
+ } catch {
599
+ setFeedbackByMessageId((prev) => {
600
+ const next = new Map(prev);
601
+ next.delete(feedback.messageId);
602
+ return next;
603
+ });
604
+ } finally {
605
+ setPendingFeedbackByMessageId((prev) => {
606
+ const next = new Set(prev);
607
+ next.delete(feedback.messageId);
608
+ return next;
609
+ });
610
+ }
611
+ },
612
+ [onMessageFeedback],
613
+ );
431
614
 
432
615
  return (
433
616
  <div className="legend-ai" id={LEGEND_AI_ANCHOR_ID}>
@@ -436,18 +619,39 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
436
619
  <SparkleStarsIcon />
437
620
  </div>
438
621
  <div className="legend-ai__title">{title ?? 'Legend AI'}</div>
439
- {hasMessages && (
622
+ <div className="legend-ai__header-actions">
440
623
  <button
441
624
  type="button"
442
- className="legend-ai__clear-btn"
443
- title="Clear chat"
444
- aria-label="Clear chat"
625
+ className="legend-ai__header-action"
626
+ title="New chat"
627
+ aria-label="New chat"
445
628
  onClick={(): void => state.clearChat()}
446
629
  >
447
- <RefreshIcon />
448
- <span>Clear</span>
630
+ <PlusIcon />
449
631
  </button>
450
- )}
632
+ {onMinimize && (
633
+ <button
634
+ type="button"
635
+ className="legend-ai__header-action"
636
+ title="Minimize"
637
+ aria-label="Minimize"
638
+ onClick={onMinimize}
639
+ >
640
+ <MinusIcon />
641
+ </button>
642
+ )}
643
+ {onClose && (
644
+ <button
645
+ type="button"
646
+ className="legend-ai__header-action"
647
+ title="Close"
648
+ aria-label="Close"
649
+ onClick={onClose}
650
+ >
651
+ <TimesIcon />
652
+ </button>
653
+ )}
654
+ </div>
451
655
  </div>
452
656
 
453
657
  <div className="legend-ai__conversation" ref={state.conversationRef}>
@@ -460,23 +664,21 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
460
664
  Ask a question about your data
461
665
  </div>
462
666
  <div className="legend-ai__suggestions">
463
- <div className="legend-ai__suggestions-grid">
464
- {suggestedQueries.map((q) => (
465
- <button
466
- key={q}
467
- type="button"
468
- className="legend-ai__suggestion-card"
469
- onClick={(): void => {
470
- state.setQuestionText(q);
471
- }}
472
- >
473
- <span className="legend-ai__suggestion-card-icon">
474
- <SparkleStarsIcon />
475
- </span>
476
- <span className="legend-ai__suggestion-card-text">{q}</span>
477
- </button>
478
- ))}
479
- </div>
667
+ {suggestedQueries.map((q) => (
668
+ <button
669
+ key={q}
670
+ type="button"
671
+ className="legend-ai__suggestion-chip"
672
+ onClick={(): void => {
673
+ state.askQuestionWithIntent(
674
+ q,
675
+ inferSuggestedQueryIntent(q),
676
+ );
677
+ }}
678
+ >
679
+ {q}
680
+ </button>
681
+ ))}
480
682
  </div>
481
683
  </div>
482
684
  )}
@@ -492,59 +694,40 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
492
694
 
493
695
  const isThinkingVisible =
494
696
  msg.isProcessing || state.expandedThinking.has(msgIndex);
697
+ const previousMessage =
698
+ msgIndex > 0 ? state.messages[msgIndex - 1] : null;
699
+ const questionText =
700
+ previousMessage?.role === LegendAIMessageRole.USER
701
+ ? previousMessage.text
702
+ : '';
495
703
  return (
496
704
  <AssistantMessageView
497
705
  key={msg.id}
498
706
  msg={msg}
707
+ questionText={questionText}
499
708
  isThinkingVisible={isThinkingVisible}
500
709
  onToggleThinking={(): void => state.toggleThinking(msgIndex)}
710
+ onMessageFeedback={handleMessageFeedback}
711
+ selectedFeedbackRating={feedbackByMessageId.get(msg.id)}
712
+ feedbackSubmitting={pendingFeedbackByMessageId.has(msg.id)}
713
+ {...(config.enghubDocUrl === undefined
714
+ ? {}
715
+ : { enghubDocUrl: config.enghubDocUrl })}
716
+ {...(config.enthubRequestAccessUrl === undefined
717
+ ? {}
718
+ : { enthubRequestAccessUrl: config.enthubRequestAccessUrl })}
501
719
  onFallbackAction={(messageId): void =>
502
720
  state.runFallbackAction(messageId)
503
721
  }
504
722
  onSuggestedQueryClick={(q): void =>
505
- state.askQuestionWithIntent(
506
- q,
507
- services.length > 0
508
- ? LegendAIQuestionIntent.DATA_QUERY
509
- : LegendAIQuestionIntent.ORCHESTRATOR,
510
- )
723
+ state.askQuestionWithIntent(q, inferSuggestedQueryIntent(q))
511
724
  }
512
725
  />
513
726
  );
514
727
  })}
515
728
  </div>
516
729
 
517
- <div className="legend-ai__input-area">
518
- <div className="legend-ai__question-wrapper">
519
- <textarea
520
- ref={textareaRef}
521
- className="legend-ai__question"
522
- placeholder="Ask anything about the data..."
523
- rows={1}
524
- spellCheck={false}
525
- value={state.questionText}
526
- onChange={(e): void => state.setQuestionText(e.target.value)}
527
- onKeyDown={(e): void => {
528
- if (e.key === 'Enter' && !e.shiftKey) {
529
- e.preventDefault();
530
- if (!state.isSending && state.questionText.trim()) {
531
- state.askQuestion();
532
- }
533
- }
534
- }}
535
- />
536
- <button
537
- type="button"
538
- title="Send"
539
- aria-label="Send"
540
- className="legend-ai__send-btn"
541
- disabled={state.isSending || !state.questionText.trim()}
542
- onClick={(): void => state.askQuestion()}
543
- >
544
- {state.isSending ? <LoadingIcon isLoading={true} /> : <SendIcon />}
545
- </button>
546
- </div>
547
- </div>
730
+ <LegendAIChatInput state={state} scopes={scopes} />
548
731
  </div>
549
732
  );
550
733
  };