Skip to content

Commit 0c0e14e

Browse files
authored
fix(lightspeed): added empty state for unconfigured LLM (#2781)
* fix(lightspeed): added empty state for unconfigured LLM Signed-off-by: Yi Cai <yicai@redhat.com> * updated report-alpha.api.mf Signed-off-by: Yi Cai <yicai@redhat.com> * resolved a qoto comment Signed-off-by: Yi Cai <yicai@redhat.com> --------- Signed-off-by: Yi Cai <yicai@redhat.com>
1 parent 4184083 commit 0c0e14e

17 files changed

Lines changed: 585 additions & 20 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed': patch
3+
---
4+
5+
Add an empty state for unconfigured LLM.

workspaces/lightspeed/e2e-tests/lightspeed.ui.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import { test, expect, Page } from '@playwright/test';
18-
import { models } from './fixtures/responses';
18+
import { modelBaseUrl, models } from './fixtures/responses';
1919
import { openLightspeed } from './utils/testHelper';
2020
import {
2121
openChatbot,
@@ -45,6 +45,7 @@ import {
4545
import { LightspeedMessages, evaluateMessage } from './utils/translations';
4646
import { runAccessibilityTests } from './utils/accessibility';
4747
import { bootstrapLightspeedE2ePage } from './utils/lightspeedE2eSetup';
48+
import { mockModels, mockShields } from './utils/devMode';
4849

4950
test.describe('Lightspeed UI', () => {
5051
let translations: LightspeedMessages;
@@ -118,6 +119,30 @@ test.describe('Lightspeed UI', () => {
118119
await runAccessibilityTests(sharedPage, testInfo);
119120
});
120121

122+
test('Validate Empty State', async () => {
123+
await sharedPage.unroute(`${modelBaseUrl}/v1/shields`);
124+
await sharedPage.unroute(`${modelBaseUrl}/v1/models`);
125+
await mockShields(sharedPage, []);
126+
await mockModels(sharedPage, []);
127+
128+
await sharedPage.goto('/lightspeed');
129+
await sharedPage
130+
.getByTestId('lightspeed-lcore-not-configured')
131+
.waitFor({ state: 'visible' });
132+
133+
await expect(
134+
sharedPage.getByLabel(translations['lcore.notConfigured.title']),
135+
).toMatchAriaSnapshot(`
136+
- region "${translations['lcore.notConfigured.title']}":
137+
- heading "${translations['lcore.notConfigured.title']}" [level=2]
138+
- paragraph: ${translations['lcore.notConfigured.description']}
139+
- link "${translations['lcore.notConfigured.developerLightspeedDocs']}":
140+
- /url: https://docs.redhat.com/en/documentation/red_hat_developer_hub/latest/html/interacting_with_red_hat_developer_lightspeed_for_red_hat_developer_hub/developer-lightspeed#proc-installing-and-configuring-lightspeed_developer-lightspeed
141+
- link "${translations['lcore.notConfigured.backendDocs']}":
142+
- /url: https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/lightspeed/plugins/lightspeed-backend/README.md
143+
`);
144+
});
145+
121146
test('Verify disclaimer to be visible', async () => {
122147
await expect(sharedPage.getByLabel('Scrollable message log'))
123148
.toMatchAriaSnapshot(`

workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,18 @@ export const lightspeedTranslationRef: TranslationRef<
258258
readonly 'permission.subject.plugin': string;
259259
readonly 'permission.subject.notebooks': string;
260260
readonly 'permission.notebooks.goBack': string;
261+
readonly 'lcore.notConfigured.title': string;
262+
readonly 'lcore.notConfigured.description': string;
263+
readonly 'lcore.notConfigured.developerLightspeedDocs': string;
264+
readonly 'lcore.notConfigured.backendDocs': string;
265+
readonly 'lcore.loadError.title': string;
266+
readonly 'lcore.loadError.description': string;
261267
readonly 'footer.accuracy.label': string;
262268
readonly 'common.cancel': string;
263269
readonly 'common.close': string;
264270
readonly 'common.readMore': string;
271+
readonly 'common.retry': string;
272+
readonly 'common.loading': string;
265273
readonly 'common.noSearchResults': string;
266274
readonly 'menu.newConversation': string;
267275
readonly 'chatbox.header.title': string;

workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ const useStyles = makeStyles(theme => ({
135135
errorContainer: {
136136
padding: theme.spacing(3),
137137
},
138+
drawerFileDropZone: {
139+
gap: 0,
140+
rowGap: 0,
141+
columnGap: 0,
142+
'--pf-v6-c-multiple-file-upload--Gap': '0',
143+
'--pf-v5-c-multiple-file-upload--Gap': '0',
144+
},
138145
headerMenu: {
139146
// align hamburger icon with title
140147
'& .pf-v6-c-button': {
@@ -1577,6 +1584,7 @@ export const LightspeedChat = ({
15771584
}
15781585
drawerContent={
15791586
<FileDropZone
1587+
className={classes.drawerFileDropZone}
15801588
onFileDrop={(e, data) => handleAttach(data, e)}
15811589
displayMode={ChatbotDisplayMode.embedded}
15821590
infoText={t('chatbox.fileUpload.infoText')}

workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatContainer.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ import { useTranslation } from '../hooks/useTranslation';
3131
import queryClient from '../utils/queryClient';
3232
import FileAttachmentContextProvider from './AttachmentContext';
3333
import { LightspeedChat } from './LightSpeedChat';
34+
import {
35+
LcoreNotConfiguredEmptyState,
36+
LightspeedChatModelsLoading,
37+
ModelsLoadErrorEmptyState,
38+
} from './LightspeedChatModelsState';
3439
import PermissionRequiredState from './PermissionRequiredState';
3540

3641
const THEME_DARK = 'dark';
@@ -48,7 +53,12 @@ const LightspeedChatContainerInner = () => {
4853

4954
const identityApi = useApi(identityApiRef);
5055

51-
const { data: models } = useAllModels();
56+
const {
57+
data: models,
58+
isLoading: modelsLoading,
59+
isError: modelsError,
60+
refetch: refetchModels,
61+
} = useAllModels();
5262

5363
const { allowed: hasViewAccess, loading } = useLightspeedViewPermission();
5464

@@ -137,7 +147,10 @@ const LightspeedChatContainerInner = () => {
137147
}, [selectedModel, selectedProvider]);
138148

139149
if (loading) {
140-
return null;
150+
// Never return null inside the overlay modal: PatternFly's focus-trap requires at least
151+
// one tabbable node (e.g. after removing the modal close button). Locale switches can
152+
// briefly re-enter this loading state.
153+
return <LightspeedChatModelsLoading />;
141154
}
142155

143156
if (!hasViewAccess) {
@@ -159,6 +172,21 @@ const LightspeedChatContainerInner = () => {
159172
);
160173
}
161174

175+
if (modelsLoading) {
176+
return <LightspeedChatModelsLoading />;
177+
}
178+
179+
// TanStack Query can keep the last successful `data` while `isError` is true after a
180+
// failed refetch. Prefer showing chat when we still have LLM rows; only use the full-page
181+
// error state when there is nothing usable to render.
182+
if (modelsError && modelsItems.length === 0) {
183+
return <ModelsLoadErrorEmptyState onRetry={() => refetchModels()} />;
184+
}
185+
186+
if (modelsItems.length === 0) {
187+
return <LcoreNotConfiguredEmptyState />;
188+
}
189+
162190
return (
163191
<FileAttachmentContextProvider>
164192
<LightspeedChat
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
* Copyright Red Hat, Inc.
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 {
18+
Box,
19+
Button,
20+
CircularProgress,
21+
Link,
22+
Typography,
23+
} from '@material-ui/core';
24+
import { createStyles, makeStyles } from '@material-ui/core/styles';
25+
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
26+
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
27+
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
28+
29+
import { useTranslation } from '../hooks/useTranslation';
30+
31+
const LLAMA_STACK_CONFIGURE_DOCS_URL =
32+
'https://docs.redhat.com/en/documentation/red_hat_developer_hub/latest/html/interacting_with_red_hat_developer_lightspeed_for_red_hat_developer_hub/developer-lightspeed#proc-installing-and-configuring-lightspeed_developer-lightspeed';
33+
const LIGHTSPEED_BACKEND_README_URL =
34+
'https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/lightspeed/plugins/lightspeed-backend/README.md';
35+
36+
const useStyles = makeStyles(theme =>
37+
createStyles({
38+
root: {
39+
display: 'flex',
40+
flexDirection: 'column',
41+
boxSizing: 'border-box',
42+
width: '100%',
43+
maxWidth: '100%',
44+
minWidth: 0,
45+
minHeight: '100%',
46+
height: '100%',
47+
flex: '1 1 auto',
48+
alignItems: 'center',
49+
justifyContent: 'center',
50+
padding: theme.spacing(4, 2),
51+
backgroundColor: theme.palette.background.default,
52+
},
53+
panel: {
54+
display: 'flex',
55+
flexDirection: 'column',
56+
alignItems: 'center',
57+
textAlign: 'center',
58+
width: '100%',
59+
maxWidth: 440,
60+
gap: theme.spacing(2),
61+
},
62+
emptyStateIcon: {
63+
fontSize: 64,
64+
color: theme.palette.text.secondary,
65+
},
66+
errorIcon: {
67+
fontSize: 64,
68+
color: theme.palette.warning.main,
69+
},
70+
description: {
71+
lineHeight: 1.5,
72+
color: theme.palette.text.secondary,
73+
},
74+
actions: {
75+
display: 'flex',
76+
flexDirection: 'column',
77+
alignItems: 'center',
78+
gap: theme.spacing(1.5),
79+
marginTop: theme.spacing(1),
80+
},
81+
backendLink: {
82+
display: 'inline-flex',
83+
alignItems: 'center',
84+
gap: theme.spacing(0.5),
85+
fontSize: theme.typography.body1.fontSize,
86+
fontWeight: 500,
87+
},
88+
}),
89+
);
90+
91+
/**
92+
* Shown while the models list is loading for an authorized user.
93+
*/
94+
export const LightspeedChatModelsLoading = () => {
95+
const classes = useStyles();
96+
const { t } = useTranslation();
97+
return (
98+
<div
99+
className={classes.root}
100+
data-testid="lightspeed-models-loading"
101+
role="status"
102+
aria-busy="true"
103+
aria-label={t('common.loading')}
104+
>
105+
<CircularProgress aria-hidden />
106+
</div>
107+
);
108+
};
109+
110+
/**
111+
* Shown when LCORE / Llama Stack is up but no LLM models are registered.
112+
*/
113+
export const LcoreNotConfiguredEmptyState = () => {
114+
const classes = useStyles();
115+
const { t } = useTranslation();
116+
117+
return (
118+
<div className={classes.root} data-testid="lightspeed-lcore-not-configured">
119+
<Box
120+
className={classes.panel}
121+
component="section"
122+
aria-labelledby="lightspeed-lcore-empty-title"
123+
>
124+
<SmartToyOutlinedIcon
125+
className={classes.emptyStateIcon}
126+
aria-hidden
127+
focusable={false}
128+
/>
129+
<Typography
130+
id="lightspeed-lcore-empty-title"
131+
variant="h5"
132+
component="h2"
133+
>
134+
{t('lcore.notConfigured.title')}
135+
</Typography>
136+
<Typography
137+
variant="body1"
138+
component="p"
139+
className={classes.description}
140+
>
141+
{t('lcore.notConfigured.description')}
142+
</Typography>
143+
<Box className={classes.actions}>
144+
<Button
145+
variant="contained"
146+
color="primary"
147+
target="_blank"
148+
rel="noopener noreferrer"
149+
href={LLAMA_STACK_CONFIGURE_DOCS_URL}
150+
>
151+
{t('lcore.notConfigured.developerLightspeedDocs')} &nbsp;{' '}
152+
<OpenInNewIcon fontSize="small" aria-hidden />
153+
</Button>
154+
<Link
155+
className={classes.backendLink}
156+
component="a"
157+
color="primary"
158+
href={LIGHTSPEED_BACKEND_README_URL}
159+
target="_blank"
160+
rel="noopener noreferrer"
161+
>
162+
{t('lcore.notConfigured.backendDocs')}
163+
<OpenInNewIcon fontSize="small" aria-hidden />
164+
</Link>
165+
</Box>
166+
</Box>
167+
</div>
168+
);
169+
};
170+
171+
type ModelsLoadErrorEmptyStateProps = {
172+
onRetry: () => void;
173+
};
174+
175+
/**
176+
* Shown when the models API fails (distinct from “no models configured”).
177+
*/
178+
export const ModelsLoadErrorEmptyState = ({
179+
onRetry,
180+
}: ModelsLoadErrorEmptyStateProps) => {
181+
const classes = useStyles();
182+
const { t } = useTranslation();
183+
184+
return (
185+
<div className={classes.root} data-testid="lightspeed-models-load-error">
186+
<Box
187+
className={classes.panel}
188+
component="section"
189+
aria-labelledby="lightspeed-models-error-title"
190+
>
191+
<ErrorOutlineIcon
192+
className={classes.errorIcon}
193+
aria-hidden
194+
focusable={false}
195+
/>
196+
<Typography
197+
id="lightspeed-models-error-title"
198+
variant="h5"
199+
component="h2"
200+
>
201+
{t('lcore.loadError.title')}
202+
</Typography>
203+
<Typography
204+
variant="body1"
205+
component="p"
206+
className={classes.description}
207+
>
208+
{t('lcore.loadError.description')}
209+
</Typography>
210+
<Box className={classes.actions}>
211+
<Button variant="contained" color="primary" onClick={() => onRetry()}>
212+
{t('common.retry')}
213+
</Button>
214+
</Box>
215+
</Box>
216+
</div>
217+
);
218+
};

workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => {
5454
<ChatbotModal
5555
isOpen
5656
displayMode={contextValue.displayMode}
57-
onClose={closeChatbot}
57+
disableFocusTrap
58+
onEscapePress={() => closeChatbot()}
5859
ouiaId="LightspeedChatbotModal"
5960
aria-labelledby="lightspeed-chatpopup-modal"
6061
className={classes.chatbotModal}

0 commit comments

Comments
 (0)