@finos/legend-lego 2.0.188 → 2.0.190
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/lib/index.css +2 -2
- package/lib/index.css.map +1 -1
- package/lib/legend-ai/LegendAITypes.d.ts +196 -0
- package/lib/legend-ai/LegendAITypes.d.ts.map +1 -0
- package/lib/legend-ai/LegendAITypes.js +281 -0
- package/lib/legend-ai/LegendAITypes.js.map +1 -0
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +127 -0
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -0
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +63 -0
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -0
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +29 -0
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -0
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +98 -0
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -0
- package/lib/legend-ai/components/LegendAIChat.d.ts +23 -0
- package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -0
- package/lib/legend-ai/components/LegendAIChat.js +179 -0
- package/lib/legend-ai/components/LegendAIChat.js.map +1 -0
- package/lib/legend-ai/components/LegendAIErrorBoundary.d.ts +31 -0
- package/lib/legend-ai/components/LegendAIErrorBoundary.d.ts.map +1 -0
- package/lib/legend-ai/components/LegendAIErrorBoundary.js +35 -0
- package/lib/legend-ai/components/LegendAIErrorBoundary.js.map +1 -0
- package/lib/legend-ai/components/LegendAIResultGrid.d.ts +20 -0
- package/lib/legend-ai/components/LegendAIResultGrid.d.ts.map +1 -0
- package/lib/legend-ai/components/LegendAIResultGrid.js +90 -0
- package/lib/legend-ai/components/LegendAIResultGrid.js.map +1 -0
- package/lib/legend-ai/index.d.ts +22 -0
- package/lib/legend-ai/index.d.ts.map +1 -0
- package/lib/legend-ai/index.js +22 -0
- package/lib/legend-ai/index.js.map +1 -0
- package/lib/legend-ai/stores/LegendAIChatState.d.ts +46 -0
- package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -0
- package/lib/legend-ai/stores/LegendAIChatState.js +559 -0
- package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -0
- package/package.json +7 -3
- package/src/legend-ai/LegendAITypes.ts +386 -0
- package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +208 -0
- package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +139 -0
- package/src/legend-ai/components/LegendAIChat.tsx +502 -0
- package/src/legend-ai/components/LegendAIErrorBoundary.tsx +42 -0
- package/src/legend-ai/components/LegendAIResultGrid.tsx +132 -0
- package/src/legend-ai/index.ts +46 -0
- package/src/legend-ai/stores/LegendAIChatState.ts +1004 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createMock } from '@finos/legend-shared/test';
|
|
18
|
+
import type { MessageSetter } from '../stores/LegendAIChatState.js';
|
|
19
|
+
import {
|
|
20
|
+
type LegendAIMessage,
|
|
21
|
+
type LegendAIAssistantMessage,
|
|
22
|
+
type LegendAIConfig,
|
|
23
|
+
type LegendAIProductMetadata,
|
|
24
|
+
type TDSServiceSchema,
|
|
25
|
+
type LegendAIConversationTurn,
|
|
26
|
+
LegendAIMessageRole,
|
|
27
|
+
LegendAIQuestionIntent,
|
|
28
|
+
} from '../LegendAITypes.js';
|
|
29
|
+
import {
|
|
30
|
+
type LegendAI_LegendApplicationPlugin_Extension,
|
|
31
|
+
type LegendAISqlExtractionResult,
|
|
32
|
+
type LegendAIJudgeResult,
|
|
33
|
+
LegendAIJudgeVerdict,
|
|
34
|
+
} from '../LegendAI_LegendApplicationPlugin_Extension.js';
|
|
35
|
+
|
|
36
|
+
export const TEST__createMockSetter = (): {
|
|
37
|
+
setter: MessageSetter;
|
|
38
|
+
getMessages: () => LegendAIMessage[];
|
|
39
|
+
} => {
|
|
40
|
+
let messages: LegendAIMessage[] = [];
|
|
41
|
+
const setter: MessageSetter = (action) => {
|
|
42
|
+
if (typeof action === 'function') {
|
|
43
|
+
messages = action(messages);
|
|
44
|
+
} else {
|
|
45
|
+
messages = action;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return { setter, getMessages: () => messages };
|
|
49
|
+
};
|
|
50
|
+
export const TEST__makeAssistantMessage = (
|
|
51
|
+
overrides?: Partial<LegendAIAssistantMessage>,
|
|
52
|
+
): LegendAIAssistantMessage => ({
|
|
53
|
+
id: 'test-assistant-msg',
|
|
54
|
+
role: LegendAIMessageRole.ASSISTANT,
|
|
55
|
+
thinkingSteps: [],
|
|
56
|
+
sql: null,
|
|
57
|
+
textAnswer: null,
|
|
58
|
+
gridData: null,
|
|
59
|
+
error: null,
|
|
60
|
+
sqlGenTime: null,
|
|
61
|
+
execTime: null,
|
|
62
|
+
thinkingDuration: null,
|
|
63
|
+
isProcessing: true,
|
|
64
|
+
isExecuting: false,
|
|
65
|
+
suggestedQueries: [],
|
|
66
|
+
...overrides,
|
|
67
|
+
});
|
|
68
|
+
export const TEST__seedAssistant = (setter: MessageSetter): void => {
|
|
69
|
+
setter([
|
|
70
|
+
{ id: 'test-user-msg', role: LegendAIMessageRole.USER, text: 'test' },
|
|
71
|
+
TEST__makeAssistantMessage(),
|
|
72
|
+
]);
|
|
73
|
+
};
|
|
74
|
+
export const TEST_DATA__legendAIConfig: LegendAIConfig = {
|
|
75
|
+
enabled: true,
|
|
76
|
+
llmServiceUrl: 'http://localhost/llm',
|
|
77
|
+
llmModelName: 'test-model',
|
|
78
|
+
sqlExecutionUrl: 'http://localhost/sql',
|
|
79
|
+
orchestratorUrl: undefined,
|
|
80
|
+
marketplaceSearchUrl: undefined,
|
|
81
|
+
engineUrl: undefined,
|
|
82
|
+
maxJudgeAttempts: 2,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const TEST_DATA__legendAIMetadata: LegendAIProductMetadata = {
|
|
86
|
+
name: 'TestProduct',
|
|
87
|
+
coordinates: 'com.test:prod:1.0.0',
|
|
88
|
+
serviceSummaries: [{ title: 'Svc', description: 'desc' }],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const TEST_DATA__legendAIServices: TDSServiceSchema[] = [
|
|
92
|
+
{
|
|
93
|
+
title: 'TradeService',
|
|
94
|
+
pattern: '/trades',
|
|
95
|
+
columns: [
|
|
96
|
+
{ name: 'tradeId', type: 'String' },
|
|
97
|
+
{ name: 'amount', type: 'Number' },
|
|
98
|
+
],
|
|
99
|
+
parameters: [],
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
export const TEST__createMockLegendAIPlugin = (
|
|
103
|
+
overrides?: Partial<LegendAI_LegendApplicationPlugin_Extension>,
|
|
104
|
+
): LegendAI_LegendApplicationPlugin_Extension =>
|
|
105
|
+
({
|
|
106
|
+
getName: () => 'test-plugin',
|
|
107
|
+
getVersion: () => '1.0.0',
|
|
108
|
+
install: () => {},
|
|
109
|
+
classifyQuestionIntent: (_q: string, _h: boolean): LegendAIQuestionIntent =>
|
|
110
|
+
LegendAIQuestionIntent.DATA_QUERY,
|
|
111
|
+
buildMetadataPrompt: (
|
|
112
|
+
_q: string,
|
|
113
|
+
_m: LegendAIProductMetadata,
|
|
114
|
+
_h?: LegendAIConversationTurn[],
|
|
115
|
+
) => 'metadata prompt',
|
|
116
|
+
buildGeneratorPrompt: (
|
|
117
|
+
_q: string,
|
|
118
|
+
_s: TDSServiceSchema[],
|
|
119
|
+
_c: string,
|
|
120
|
+
_h?: LegendAIConversationTurn[],
|
|
121
|
+
) => 'generator prompt',
|
|
122
|
+
buildJudgePrompt: (
|
|
123
|
+
_sql: string,
|
|
124
|
+
_q: string,
|
|
125
|
+
_s: TDSServiceSchema[],
|
|
126
|
+
_c: string,
|
|
127
|
+
_h?: LegendAIConversationTurn[],
|
|
128
|
+
) => 'judge prompt',
|
|
129
|
+
callLLM: createMock(),
|
|
130
|
+
executeSql: createMock(),
|
|
131
|
+
extractSqlFromResponse: (_a: string): LegendAISqlExtractionResult => ({
|
|
132
|
+
sql: 'SELECT * FROM t',
|
|
133
|
+
failure: null,
|
|
134
|
+
}),
|
|
135
|
+
extractJudgeResult: (_a: string): LegendAIJudgeResult => ({
|
|
136
|
+
verdict: LegendAIJudgeVerdict.PASS,
|
|
137
|
+
}),
|
|
138
|
+
...overrides,
|
|
139
|
+
}) as LegendAI_LegendApplicationPlugin_Extension;
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
|
18
|
+
import {
|
|
19
|
+
SendIcon,
|
|
20
|
+
LoadingIcon,
|
|
21
|
+
SparkleStarsIcon,
|
|
22
|
+
CodeIcon,
|
|
23
|
+
TableIcon,
|
|
24
|
+
CopyIcon,
|
|
25
|
+
RefreshIcon,
|
|
26
|
+
MarkdownTextViewer,
|
|
27
|
+
} from '@finos/legend-art';
|
|
28
|
+
import { noop } from '@finos/legend-shared';
|
|
29
|
+
import { PRIMITIVE_TYPE } from '@finos/legend-graph';
|
|
30
|
+
import {
|
|
31
|
+
type LegendAIChatProps,
|
|
32
|
+
type LegendAIAssistantMessage,
|
|
33
|
+
type LegendAIProductMetadata,
|
|
34
|
+
type TDSServiceSchema,
|
|
35
|
+
type TDSColumnSchema,
|
|
36
|
+
LegendAIThinkingStepStatus,
|
|
37
|
+
LegendAIMessageRole,
|
|
38
|
+
LegendAIQuestionIntent,
|
|
39
|
+
} from '../LegendAITypes.js';
|
|
40
|
+
import { useLegendAIChatState } from '../stores/LegendAIChatState.js';
|
|
41
|
+
import { LegendAIResultGrid } from './LegendAIResultGrid.js';
|
|
42
|
+
|
|
43
|
+
export const LEGEND_AI_ANCHOR_ID = 'legend-ai-anchor';
|
|
44
|
+
|
|
45
|
+
const COPY_FEEDBACK_DURATION_MS = 2000;
|
|
46
|
+
const MAX_SUGGESTED_QUERIES = 8;
|
|
47
|
+
|
|
48
|
+
const STRING_TYPES = new Set<string>([PRIMITIVE_TYPE.STRING]);
|
|
49
|
+
|
|
50
|
+
const NUMERIC_TYPES = new Set<string>([
|
|
51
|
+
PRIMITIVE_TYPE.NUMBER,
|
|
52
|
+
PRIMITIVE_TYPE.INTEGER,
|
|
53
|
+
PRIMITIVE_TYPE.FLOAT,
|
|
54
|
+
PRIMITIVE_TYPE.DECIMAL,
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const DATE_TYPES = new Set<string>([
|
|
58
|
+
PRIMITIVE_TYPE.DATE,
|
|
59
|
+
PRIMITIVE_TYPE.STRICTDATE,
|
|
60
|
+
PRIMITIVE_TYPE.DATETIME,
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export function isStringColumn(c: TDSColumnSchema): boolean {
|
|
64
|
+
return STRING_TYPES.has(c.type ?? '') && !c.name.toLowerCase().includes('id');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isNumericColumn(c: TDSColumnSchema): boolean {
|
|
68
|
+
return NUMERIC_TYPES.has(c.type ?? '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isDateColumn(c: TDSColumnSchema): boolean {
|
|
72
|
+
return (
|
|
73
|
+
DATE_TYPES.has(c.type ?? '') ||
|
|
74
|
+
c.name.toLowerCase().includes('date') ||
|
|
75
|
+
c.name.toLowerCase().includes('time')
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildDataInsightSuggestions(
|
|
80
|
+
primary: TDSServiceSchema,
|
|
81
|
+
stringCol: TDSColumnSchema | undefined,
|
|
82
|
+
numericCol: TDSColumnSchema | undefined,
|
|
83
|
+
dateCol: TDSColumnSchema | undefined,
|
|
84
|
+
): string[] {
|
|
85
|
+
const result: string[] = [];
|
|
86
|
+
if (stringCol && numericCol) {
|
|
87
|
+
result.push(
|
|
88
|
+
`What are the top ${stringCol.name} values by total ${numericCol.name} in ${primary.title}?`,
|
|
89
|
+
);
|
|
90
|
+
} else if (stringCol) {
|
|
91
|
+
result.push(
|
|
92
|
+
`What are the distinct ${stringCol.name} values in ${primary.title}?`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (dateCol && stringCol) {
|
|
97
|
+
result.push(
|
|
98
|
+
`Show ${primary.title} records from the last month grouped by ${stringCol.name}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (numericCol && !stringCol) {
|
|
103
|
+
result.push(`What is the total ${numericCol.name} in ${primary.title}?`);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildMultiServiceSuggestion(services: TDSServiceSchema[]): string[] {
|
|
109
|
+
if (services.length < 2) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const svcA = services[0];
|
|
113
|
+
const svcB = services[1];
|
|
114
|
+
if (!svcA || !svcB) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result: string[] = [`Show the latest 10 records from ${svcB.title}`];
|
|
119
|
+
|
|
120
|
+
const colNamesA = new Set(svcA.columns.map((c) => c.name.toLowerCase()));
|
|
121
|
+
const sharedCol = svcB.columns.find((c) =>
|
|
122
|
+
colNamesA.has(c.name.toLowerCase()),
|
|
123
|
+
);
|
|
124
|
+
if (sharedCol) {
|
|
125
|
+
result.push(
|
|
126
|
+
`Compare ${svcA.title} and ${svcB.title} by ${sharedCol.name}, show 10 rows`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function buildSuggestedQueries(
|
|
134
|
+
services: TDSServiceSchema[],
|
|
135
|
+
metadata: LegendAIProductMetadata,
|
|
136
|
+
): string[] {
|
|
137
|
+
const suggestions: string[] = [
|
|
138
|
+
`What data does ${metadata.name} offer and how can I use it?`,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
if (services.length === 0) {
|
|
142
|
+
return [
|
|
143
|
+
...suggestions,
|
|
144
|
+
'What access points are available?',
|
|
145
|
+
'Describe the data model and key entities',
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const primary = services[0];
|
|
150
|
+
if (!primary) {
|
|
151
|
+
return [
|
|
152
|
+
...suggestions,
|
|
153
|
+
'What access points are available and what columns do they have?',
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const stringCol = primary.columns.find(isStringColumn);
|
|
158
|
+
const numericCol = primary.columns.find(isNumericColumn);
|
|
159
|
+
const dateCol = primary.columns.find(isDateColumn);
|
|
160
|
+
|
|
161
|
+
const multiSvcSuggestions = buildMultiServiceSuggestion(services);
|
|
162
|
+
|
|
163
|
+
const primaryRecordsSuggestion = dateCol
|
|
164
|
+
? `Show the 10 most recent records from ${primary.title} by ${dateCol.name}`
|
|
165
|
+
: `Show 10 records from ${primary.title}`;
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
...suggestions,
|
|
169
|
+
primaryRecordsSuggestion,
|
|
170
|
+
...buildDataInsightSuggestions(primary, stringCol, numericCol, dateCol),
|
|
171
|
+
...multiSvcSuggestions,
|
|
172
|
+
].slice(0, MAX_SUGGESTED_QUERIES);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderStepStatusIcon(
|
|
176
|
+
status: LegendAIThinkingStepStatus,
|
|
177
|
+
): React.ReactNode {
|
|
178
|
+
if (status === LegendAIThinkingStepStatus.ACTIVE) {
|
|
179
|
+
return <LoadingIcon isLoading={true} />;
|
|
180
|
+
}
|
|
181
|
+
return status === LegendAIThinkingStepStatus.DONE ? '\u2713' : '\u2717';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const AssistantMessageView = (props: {
|
|
185
|
+
msg: LegendAIAssistantMessage;
|
|
186
|
+
isThinkingVisible: boolean;
|
|
187
|
+
onToggleThinking: () => void;
|
|
188
|
+
onSuggestedQueryClick?: (query: string) => void;
|
|
189
|
+
}): React.ReactNode => {
|
|
190
|
+
const { msg, isThinkingVisible, onToggleThinking, onSuggestedQueryClick } =
|
|
191
|
+
props;
|
|
192
|
+
|
|
193
|
+
const [sqlCopied, setSqlCopied] = useState(false);
|
|
194
|
+
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
195
|
+
undefined,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
useEffect(
|
|
199
|
+
() => () => {
|
|
200
|
+
if (copyTimerRef.current !== undefined) {
|
|
201
|
+
clearTimeout(copyTimerRef.current);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const handleCopySql = useCallback(() => {
|
|
208
|
+
if (msg.sql) {
|
|
209
|
+
navigator.clipboard.writeText(msg.sql).catch(noop());
|
|
210
|
+
setSqlCopied(true);
|
|
211
|
+
if (copyTimerRef.current !== undefined) {
|
|
212
|
+
clearTimeout(copyTimerRef.current);
|
|
213
|
+
}
|
|
214
|
+
copyTimerRef.current = setTimeout(() => {
|
|
215
|
+
setSqlCopied(false);
|
|
216
|
+
copyTimerRef.current = undefined;
|
|
217
|
+
}, COPY_FEEDBACK_DURATION_MS);
|
|
218
|
+
}
|
|
219
|
+
}, [msg.sql]);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div className="legend-ai__msg legend-ai__msg--assistant">
|
|
223
|
+
<div className="legend-ai__msg-avatar">
|
|
224
|
+
<SparkleStarsIcon />
|
|
225
|
+
</div>
|
|
226
|
+
<div className="legend-ai__msg-content">
|
|
227
|
+
{msg.thinkingSteps.length > 0 && (
|
|
228
|
+
<div className="legend-ai__thinking">
|
|
229
|
+
{!msg.isProcessing && (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
className="legend-ai__thinking-toggle"
|
|
233
|
+
onClick={onToggleThinking}
|
|
234
|
+
>
|
|
235
|
+
<span className="legend-ai__thinking-toggle-icon">
|
|
236
|
+
{isThinkingVisible ? '\u25BC' : '\u25B6'}
|
|
237
|
+
</span>
|
|
238
|
+
Thought for {msg.thinkingDuration ?? '...'}s
|
|
239
|
+
</button>
|
|
240
|
+
)}
|
|
241
|
+
{isThinkingVisible && (
|
|
242
|
+
<div className="legend-ai__thinking-steps">
|
|
243
|
+
{msg.thinkingSteps.map((step) => (
|
|
244
|
+
<div
|
|
245
|
+
key={step.label}
|
|
246
|
+
className={`legend-ai__thinking-step legend-ai__thinking-step--${step.status}`}
|
|
247
|
+
>
|
|
248
|
+
<span className="legend-ai__thinking-step-icon">
|
|
249
|
+
{renderStepStatusIcon(step.status)}
|
|
250
|
+
</span>
|
|
251
|
+
<span>{step.label}</span>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{msg.sql && (
|
|
260
|
+
<div className="legend-ai__sql-block">
|
|
261
|
+
<div className="legend-ai__sql-block-header">
|
|
262
|
+
<span className="legend-ai__sql-block-header-icon">
|
|
263
|
+
<CodeIcon />
|
|
264
|
+
</span>
|
|
265
|
+
<span>Generated SQL</span>
|
|
266
|
+
{msg.sqlGenTime && (
|
|
267
|
+
<span className="legend-ai__sql-block-time">
|
|
268
|
+
{msg.sqlGenTime}s
|
|
269
|
+
</span>
|
|
270
|
+
)}
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
className="legend-ai__sql-copy-btn"
|
|
274
|
+
title="Copy SQL"
|
|
275
|
+
aria-label="Copy SQL"
|
|
276
|
+
onClick={handleCopySql}
|
|
277
|
+
>
|
|
278
|
+
{sqlCopied ? (
|
|
279
|
+
<span className="legend-ai__sql-copy-btn--copied">
|
|
280
|
+
\u2713
|
|
281
|
+
</span>
|
|
282
|
+
) : (
|
|
283
|
+
<CopyIcon />
|
|
284
|
+
)}
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
<div className="legend-ai__sql-scroll">
|
|
288
|
+
<pre className="legend-ai__sql-display">{msg.sql}</pre>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{msg.isExecuting && (
|
|
294
|
+
<div className="legend-ai__executing">
|
|
295
|
+
<LoadingIcon isLoading={true} />
|
|
296
|
+
<span>Executing query...</span>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{msg.textAnswer && (
|
|
301
|
+
<div className="legend-ai__text-answer">
|
|
302
|
+
<MarkdownTextViewer
|
|
303
|
+
value={{ value: msg.textAnswer }}
|
|
304
|
+
className="legend-ai__text-answer-md"
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{msg.error && <div className="legend-ai__exec-error">{msg.error}</div>}
|
|
310
|
+
|
|
311
|
+
{!msg.isProcessing &&
|
|
312
|
+
msg.suggestedQueries.length > 0 &&
|
|
313
|
+
onSuggestedQueryClick && (
|
|
314
|
+
<div className="legend-ai__follow-up-suggestions">
|
|
315
|
+
<span className="legend-ai__follow-up-label">
|
|
316
|
+
Try a data query:
|
|
317
|
+
</span>
|
|
318
|
+
{msg.suggestedQueries.map((q) => (
|
|
319
|
+
<button
|
|
320
|
+
key={q}
|
|
321
|
+
type="button"
|
|
322
|
+
className="legend-ai__follow-up-btn"
|
|
323
|
+
onClick={(): void => onSuggestedQueryClick(q)}
|
|
324
|
+
>
|
|
325
|
+
{q}
|
|
326
|
+
</button>
|
|
327
|
+
))}
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{msg.gridData && (
|
|
332
|
+
<div className="legend-ai__results-block">
|
|
333
|
+
<div className="legend-ai__results-header">
|
|
334
|
+
<span className="legend-ai__results-header-icon">
|
|
335
|
+
<TableIcon />
|
|
336
|
+
</span>
|
|
337
|
+
<span>Results</span>
|
|
338
|
+
<span className="legend-ai__results-meta">
|
|
339
|
+
{msg.gridData.rowData.length} row
|
|
340
|
+
{msg.gridData.rowData.length === 1 ? '' : 's'}
|
|
341
|
+
{msg.execTime ? ` \u00B7 ${msg.execTime}s` : ''}
|
|
342
|
+
</span>
|
|
343
|
+
</div>
|
|
344
|
+
<LegendAIResultGrid data={msg.gridData} />
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
|
|
353
|
+
const {
|
|
354
|
+
services,
|
|
355
|
+
coordinates,
|
|
356
|
+
config,
|
|
357
|
+
metadata,
|
|
358
|
+
title,
|
|
359
|
+
plugin,
|
|
360
|
+
dataProductCoordinates,
|
|
361
|
+
pureExecutionContext,
|
|
362
|
+
} = props;
|
|
363
|
+
const state = useLegendAIChatState(
|
|
364
|
+
services,
|
|
365
|
+
coordinates,
|
|
366
|
+
config,
|
|
367
|
+
metadata,
|
|
368
|
+
plugin,
|
|
369
|
+
dataProductCoordinates,
|
|
370
|
+
pureExecutionContext,
|
|
371
|
+
);
|
|
372
|
+
const suggestedQueries = useMemo(
|
|
373
|
+
() => buildSuggestedQueries(services, metadata),
|
|
374
|
+
[services, metadata],
|
|
375
|
+
);
|
|
376
|
+
const hasMessages = state.messages.length > 0;
|
|
377
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
378
|
+
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
const el = textareaRef.current;
|
|
381
|
+
if (el) {
|
|
382
|
+
el.style.height = 'auto';
|
|
383
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
384
|
+
}
|
|
385
|
+
}, [state.questionText]);
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div className="legend-ai" id={LEGEND_AI_ANCHOR_ID}>
|
|
389
|
+
<div className="legend-ai__header">
|
|
390
|
+
<div className="legend-ai__header-icon">
|
|
391
|
+
<SparkleStarsIcon />
|
|
392
|
+
</div>
|
|
393
|
+
<div className="legend-ai__title">{title ?? 'Legend AI'}</div>
|
|
394
|
+
{hasMessages && (
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
className="legend-ai__clear-btn"
|
|
398
|
+
title="Clear chat"
|
|
399
|
+
aria-label="Clear chat"
|
|
400
|
+
onClick={(): void => state.clearChat()}
|
|
401
|
+
>
|
|
402
|
+
<RefreshIcon />
|
|
403
|
+
<span>Clear</span>
|
|
404
|
+
</button>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<div className="legend-ai__conversation" ref={state.conversationRef}>
|
|
409
|
+
{!hasMessages && (
|
|
410
|
+
<div className="legend-ai__empty-state">
|
|
411
|
+
<div className="legend-ai__empty-icon">
|
|
412
|
+
<SparkleStarsIcon />
|
|
413
|
+
</div>
|
|
414
|
+
<div className="legend-ai__empty-text">
|
|
415
|
+
Ask a question about your data
|
|
416
|
+
</div>
|
|
417
|
+
<div className="legend-ai__suggestions">
|
|
418
|
+
<div className="legend-ai__suggestions-grid">
|
|
419
|
+
{suggestedQueries.map((q) => (
|
|
420
|
+
<button
|
|
421
|
+
key={q}
|
|
422
|
+
type="button"
|
|
423
|
+
className="legend-ai__suggestion-card"
|
|
424
|
+
onClick={(): void => {
|
|
425
|
+
state.setQuestionText(q);
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
<span className="legend-ai__suggestion-card-icon">
|
|
429
|
+
<SparkleStarsIcon />
|
|
430
|
+
</span>
|
|
431
|
+
<span className="legend-ai__suggestion-card-text">{q}</span>
|
|
432
|
+
</button>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
|
|
439
|
+
{state.messages.map((msg, msgIndex) => {
|
|
440
|
+
if (msg.role === LegendAIMessageRole.USER) {
|
|
441
|
+
return (
|
|
442
|
+
<div key={msg.id} className="legend-ai__msg legend-ai__msg--user">
|
|
443
|
+
<div className="legend-ai__msg-bubble">{msg.text}</div>
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const isThinkingVisible =
|
|
449
|
+
msg.isProcessing || state.expandedThinking.has(msgIndex);
|
|
450
|
+
return (
|
|
451
|
+
<AssistantMessageView
|
|
452
|
+
key={msg.id}
|
|
453
|
+
msg={msg}
|
|
454
|
+
isThinkingVisible={isThinkingVisible}
|
|
455
|
+
onToggleThinking={(): void => state.toggleThinking(msgIndex)}
|
|
456
|
+
onSuggestedQueryClick={(q): void =>
|
|
457
|
+
state.askQuestionWithIntent(
|
|
458
|
+
q,
|
|
459
|
+
services.length > 0
|
|
460
|
+
? LegendAIQuestionIntent.DATA_QUERY
|
|
461
|
+
: LegendAIQuestionIntent.ORCHESTRATOR,
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
/>
|
|
465
|
+
);
|
|
466
|
+
})}
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
<div className="legend-ai__input-area">
|
|
470
|
+
<div className="legend-ai__question-wrapper">
|
|
471
|
+
<textarea
|
|
472
|
+
ref={textareaRef}
|
|
473
|
+
className="legend-ai__question"
|
|
474
|
+
placeholder="Ask anything about the data..."
|
|
475
|
+
rows={1}
|
|
476
|
+
spellCheck={false}
|
|
477
|
+
value={state.questionText}
|
|
478
|
+
onChange={(e): void => state.setQuestionText(e.target.value)}
|
|
479
|
+
onKeyDown={(e): void => {
|
|
480
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
if (!state.isSending && state.questionText.trim()) {
|
|
483
|
+
state.askQuestion();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}}
|
|
487
|
+
/>
|
|
488
|
+
<button
|
|
489
|
+
type="button"
|
|
490
|
+
title="Send"
|
|
491
|
+
aria-label="Send"
|
|
492
|
+
className="legend-ai__send-btn"
|
|
493
|
+
disabled={state.isSending || !state.questionText.trim()}
|
|
494
|
+
onClick={(): void => state.askQuestion()}
|
|
495
|
+
>
|
|
496
|
+
{state.isSending ? <LoadingIcon isLoading={true} /> : <SendIcon />}
|
|
497
|
+
</button>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
18
|
+
|
|
19
|
+
export class LegendAIErrorBoundary extends Component<
|
|
20
|
+
{ children: ReactNode },
|
|
21
|
+
{ hasError: boolean }
|
|
22
|
+
> {
|
|
23
|
+
constructor(props: { children: ReactNode }) {
|
|
24
|
+
super(props);
|
|
25
|
+
this.state = { hasError: false };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static getDerivedStateFromError(): { hasError: boolean } {
|
|
29
|
+
return { hasError: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override componentDidCatch(_error: Error, _info: ErrorInfo): void {
|
|
33
|
+
/* noop */
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override render(): ReactNode {
|
|
37
|
+
if (this.state.hasError) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return this.props.children;
|
|
41
|
+
}
|
|
42
|
+
}
|