diff --git a/workspaces/lightspeed/.changeset/late-beers-taste.md b/workspaces/lightspeed/.changeset/late-beers-taste.md new file mode 100644 index 0000000000..4070b0290f --- /dev/null +++ b/workspaces/lightspeed/.changeset/late-beers-taste.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor +'@red-hat-developer-hub/backstage-plugin-lightspeed': minor +--- + +- Hide notebooks tab when `lightspeed.notebooks.enabled: false` in config +- Fix notebook queries to display correct model from config instead of chat's selected model +- Add `/notebook-conversation-ids` endpoint to filter notebook conversations from chat list even when notebooks disabled diff --git a/workspaces/lightspeed/app-config.yaml b/workspaces/lightspeed/app-config.yaml index cef0853485..58105ce5e0 100644 --- a/workspaces/lightspeed/app-config.yaml +++ b/workspaces/lightspeed/app-config.yaml @@ -16,12 +16,13 @@ app: organization: name: Red Hat +# Disable AI Notebooks feature by default lightspeed: notebooks: - enabled: true + enabled: ${NOTEBOOKS_ENABLED:-false} queryDefaults: - model: llama3.2:3b - provider_id: vllm + model: ${NOTEBOOKS_QUERY_MODEL} + provider_id: ${NOTEBOOKS_QUERY_PROVIDER_ID} backend: # Used for enabling authentication, secret is shared by all backend plugins @@ -118,3 +119,6 @@ catalog: pullRequestBranchName: backstage-integration rules: - allow: [Component, System, API, Resource, Location] + locations: + - type: file + target: ./catalog-info.yaml diff --git a/workspaces/lightspeed/playwright.config.ts b/workspaces/lightspeed/playwright.config.ts index 0e5e0acf2c..2555d362a7 100644 --- a/workspaces/lightspeed/playwright.config.ts +++ b/workspaces/lightspeed/playwright.config.ts @@ -38,6 +38,11 @@ export default defineConfig({ port: 3000, reuseExistingServer: true, cwd: __dirname, + env: { + NOTEBOOKS_ENABLED: 'true', + NOTEBOOKS_QUERY_MODEL: 'gpt-4', + NOTEBOOKS_QUERY_PROVIDER_ID: 'openai', + }, }, retries: process.env.CI ? 2 : 0, diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts index 54527f1b87..c8aef55efa 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/plugin.ts @@ -64,22 +64,38 @@ export const lightspeedPlugin = createBackendPlugin({ const aiNotebooksEnabled = config.getOptionalBoolean('lightspeed.notebooks.enabled') ?? false; + if (aiNotebooksEnabled) { - http.use( - await createNotebooksRouter({ - config: config, - logger: logger, - httpAuth: httpAuth, - userInfo: userInfo, - permissions, - }), + const queryModel = config.getOptionalString( + 'lightspeed.notebooks.queryDefaults.model', + ); + const queryProvider = config.getOptionalString( + 'lightspeed.notebooks.queryDefaults.provider_id', ); - logger.info('AI Notebooks enabled'); - http.addAuthPolicy({ - path: '/notebooks/health', - allow: 'unauthenticated', - }); + if (!queryModel || !queryProvider) { + logger.warn( + 'AI Notebooks feature is enabled but required configuration is missing. ' + + 'Please configure lightspeed.notebooks.queryDefaults.model and lightspeed.notebooks.queryDefaults.provider_id. ' + + 'Notebooks will not be available until these are set.', + ); + } else { + http.use( + await createNotebooksRouter({ + config: config, + logger: logger, + httpAuth: httpAuth, + userInfo: userInfo, + permissions, + }), + ); + logger.info('AI Notebooks enabled'); + + http.addAuthPolicy({ + path: '/notebooks/health', + allow: 'unauthenticated', + }); + } } // Configure authentication policies diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts index a00e02a9b5..3b5278d4b7 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts @@ -69,20 +69,14 @@ export async function createNotebooksRouter( config.getOptionalNumber('lightspeed.servicePort') ?? DEFAULT_LIGHTSPEED_SERVICE_PORT; const lightspeedBaseUrl = `http://${DEFAULT_LIGHTSPEED_SERVICE_HOST}:${lightSpeedPort}`; - const queryModel = config.getOptionalString( + const queryModel = config.getString( 'lightspeed.notebooks.queryDefaults.model', ); - const queryProvider = config.getOptionalString( + const queryProvider = config.getString( 'lightspeed.notebooks.queryDefaults.provider_id', ); const systemPrompt = NOTEBOOKS_SYSTEM_PROMPT; - if (!queryModel || !queryProvider) { - throw new Error( - 'Query model and provider are required. Please configure lightspeed.notebooks.queryDefaults.model and lightspeed.notebooks.queryDefaults.provider_id', - ); - } - logger.info( `AI Notebooks connecting to Lightspeed-Core at ${lightspeedBaseUrl}`, ); @@ -496,9 +490,9 @@ export async function createNotebooksRouter( tools: [{ type: 'file_search', vector_store_ids: [sessionId] }], model: `${queryProvider}/${queryModel}`, stream: true, - temperature: 0.05, + temperature: 0.35, shield_ids: [], - max_tool_calls: 10, + max_tool_calls: 15, ...(conversationId && { conversation: conversationId }), }; @@ -558,6 +552,7 @@ export async function createNotebooksRouter( .pipe(createResponsesApiTransform(session, sessionId, userId)) .pipe(res); } + console.log('response1234', response.body); break; } }), diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts index f102570df3..b49feda08a 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts @@ -415,6 +415,43 @@ export async function createRouter( } }); + // Returns conversation IDs associated with notebook sessions for filtering + router.get('/notebook-conversation-ids', async (req, res) => { + try { + const credentials = await httpAuth.credentials(req); + const user = await userInfo.getUserInfo(credentials); + const userId = user.userEntityRef; + + const vectorStoresPage = await vectorStoresOperator.vectorStores.list(); + const vectorStores = vectorStoresPage.data || []; + + const conversationIds: string[] = []; + + for (const store of vectorStores) { + const sessionUserId = store.metadata?.user_id as string; + const conversationId = store.metadata?.conversation_id as string | null; + + // Only include this user's sessions with a conversation_id + if (sessionUserId === userId && conversationId) { + conversationIds.push(conversationId); + } + } + + res.json({ + conversation_ids: conversationIds, + }); + } catch (error) { + const errormsg = `Error fetching notebook conversation IDs: ${error}`; + logger.error(errormsg); + + if (error instanceof NotAllowedError) { + res.status(403).json({ error: error.message }); + } else { + res.status(500).json({ error: errormsg }); + } + } + }); + // ─── Proxy Middleware (existing) ──────────────────────────────────── router.use('/', async (req, res, next) => { diff --git a/workspaces/lightspeed/plugins/lightspeed/config.d.ts b/workspaces/lightspeed/plugins/lightspeed/config.d.ts index 91b77d4815..2dab2341b9 100644 --- a/workspaces/lightspeed/plugins/lightspeed/config.d.ts +++ b/workspaces/lightspeed/plugins/lightspeed/config.d.ts @@ -36,5 +36,35 @@ export interface Config { */ message: string; }>; + /** + * Configuration for AI Notebooks + * @visibility frontend + */ + notebooks?: { + /** + * Enable/disable AI Notebooks feature + * When enabled, exposes AI Notebooks REST API endpoints for document-based conversations with RAG. + * Requires Lightspeed service to be running (default: http://0.0.0.0:8080). + * @default false + * @visibility frontend + */ + enabled: boolean; + /** + * Query configuration for notebooks + * @visibility frontend + */ + queryDefaults?: { + /** + * Model to use for answering queries + * @visibility frontend + */ + model: string; + /** + * AI provider for the query model + * @visibility frontend + */ + provider_id: string; + }; + }; }; } diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts index a07229f302..a6cb6efe85 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/LightspeedApiClient.ts @@ -183,6 +183,20 @@ export class LightspeedApiClient implements LightspeedAPI { return response.conversations ?? []; } + async getNotebookConversationIds() { + const baseUrl = await this.getBaseUrl(); + const result = await this.fetcher(`${baseUrl}/notebook-conversation-ids`); + + if (!result.ok) { + throw new Error( + `failed to get notebook conversation IDs, status ${result.status}: ${result.statusText}`, + ); + } + + const response = await result.json(); + return response.conversation_ids ?? []; + } + async stopMessage(requestId: string): Promise<{ success: boolean }> { const baseUrl = await this.getBaseUrl(); const response = await this.fetchApi.fetch( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts index d5491bfea8..88966b3aff 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/api.ts @@ -47,6 +47,7 @@ export type LightspeedAPI = { newName: string, ) => Promise<{ success: boolean }>; getConversations: () => Promise; + getNotebookConversationIds: () => Promise; getFeedbackStatus: () => Promise; captureFeedback: (payload: CaptureFeedback) => Promise<{ response: string }>; isTopicRestrictionEnabled: () => Promise; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 5e9e60ff68..f2bc90ebca 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -31,6 +31,8 @@ import { } from 'react-dropzone'; import { useLocation, useMatch, useNavigate } from 'react-router-dom'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; + import { Button, makeStyles } from '@material-ui/core'; import { Chatbot, @@ -87,6 +89,7 @@ import { useLastOpenedConversation, useLightspeedDeletePermission, useLightspeedNotebooksPermission, + useNotebookConversationIds, useNotebookSession, useNotebookSessions, usePinnedChatsSettings, @@ -487,9 +490,16 @@ export const LightspeedChat = ({ const classes = useStyles(); const { t } = useTranslation(); const navigate = useNavigate(); + const configApi = useApi(configApiRef); + const notebooksEnabled = + configApi.getOptionalBoolean('lightspeed.notebooks.enabled') ?? false; const notebooksRouteMatch = useMatch('/lightspeed/notebooks'); const notebookViewRouteMatch = useMatch('/lightspeed/notebooks/:notebookId'); const routeNotebookId = notebookViewRouteMatch?.params?.notebookId; + const isOnNotebookRoute = Boolean( + notebooksRouteMatch || notebookViewRouteMatch, + ); + const shouldShowTabs = notebooksEnabled || isOnNotebookRoute; const { displayMode, setDisplayMode, @@ -529,6 +539,9 @@ export const LightspeedChat = ({ useLightspeedNotebooksPermission(); const notebooksPermissionResolved = !notebooksPermissionLoading && hasNotebooksAccess; + + const { data: notebookConversationIdsArray = [] } = + useNotebookConversationIds(); const { data: notebooks = [], refetch: refetchNotebooks } = useNotebookSessions(notebooksPermissionResolved); const hasNotebooks = notebooks.length > 0; @@ -588,9 +601,9 @@ export const LightspeedChat = ({ const wasStoppedByUserRef = useRef(false); const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = useLastOpenedConversation(user); - // Chat vs Notebooks tabs are fullscreen-only; overlay and docked always show Chat. const showChatPanel = !isFullscreenMode || activeTab === 0; - const showNotebooksPanel = isFullscreenMode && activeTab !== 0; + const showNotebooksPanel = + (notebooksEnabled || isOnNotebookRoute) && activeTab !== 0; const [isChatHistoryDrawerOpen, setIsChatHistoryDrawerOpen] = useState(!isMobile && isFullscreenMode); @@ -1066,13 +1079,8 @@ export const LightspeedChat = ({ ); const notebookConversationIds = useMemo( - () => - new Set( - notebooks - .map(n => n.metadata?.conversation_id) - .filter((id): id is string => !!id), - ), - [notebooks], + () => new Set(notebookConversationIdsArray), + [notebookConversationIdsArray], ); const chatOnlyConversations = useMemo( @@ -1765,7 +1773,7 @@ export const LightspeedChat = ({ onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} /> - {isFullscreenMode && ( + {isFullscreenMode && shouldShowTabs && ( <> )} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx index eb0f063bdc..f66e4c5aba 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx @@ -154,14 +154,28 @@ const mockUseLightspeedDrawerContext = typeof useLightspeedDrawerContext >; -const configAPi = mockApis.config({}); +const configAPi = mockApis.config({ + data: { + lightspeed: { + notebooks: { + enabled: true, + queryDefaults: { + model: 'gpt-4', + provider_id: 'openai', + }, + }, + }, + }, +}); const mockLightspeedApi = { getAllModels: jest.fn().mockResolvedValue([]), getConversationMessages: jest.fn().mockResolvedValue([]), createMessage: jest.fn().mockResolvedValue(new Response().body), deleteConversation: jest.fn().mockResolvedValue({ success: true }), + renameConversation: jest.fn().mockResolvedValue({ success: true }), getConversations: jest.fn().mockResolvedValue([]), + getNotebookConversationIds: jest.fn().mockResolvedValue([]), getFeedbackStatus: jest.fn().mockResolvedValue(false), captureFeedback: jest.fn().mockResolvedValue({ response: 'success' }), isTopicRestrictionEnabled: jest.fn().mockResolvedValue(false), diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index b291df22db..7bae7eaea0 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { makeStyles, Typography } from '@material-ui/core'; import { @@ -216,7 +216,6 @@ type NotebookViewProps = { avatar?: string; profileLoading: boolean; topicRestrictionEnabled: boolean; - selectedModel: string; onClose: () => void; }; @@ -230,15 +229,20 @@ export const NotebookView = ({ avatar, profileLoading, topicRestrictionEnabled, - selectedModel, onClose, }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); const queryClient = useQueryClient(); + const configApi = useApi(configApiRef); const notebooksApi = useApi(notebooksApiRef); const { mutateAsync: notebookCreateMessage } = useCreateNotebookMessage(); + // Use notebook-specific model from config instead of chat's selected model + const notebookModel = + configApi.getOptionalString('lightspeed.notebooks.queryDefaults.model') || + ''; + const [conversationId, setConversationId] = useState( metadata?.conversation_id ?? TEMP_CONVERSATION_ID, ); @@ -298,7 +302,7 @@ export const NotebookView = ({ useConversationMessages( conversationId, userName, - selectedModel, + notebookModel, '', avatar, onComplete, diff --git a/workspaces/lightspeed/plugins/lightspeed/src/const.ts b/workspaces/lightspeed/plugins/lightspeed/src/const.ts index 17a01e55d0..15c299814f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/const.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/const.ts @@ -45,10 +45,6 @@ export const NOTEBOOK_ALLOWED_EXTENSIONS: Record = { 'application/pdf': ['.pdf'], 'application/json': ['.json'], 'application/x-yaml': ['.yaml', '.yml'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [ - '.docx', - ], - 'application/vnd.oasis.opendocument.text': ['.odt'], }; export const NOTEBOOK_EXTENSION_TO_FILE_TYPE: Record = { @@ -59,8 +55,6 @@ export const NOTEBOOK_EXTENSION_TO_FILE_TYPE: Record = { '.yaml': 'yaml', '.yml': 'yaml', '.log': 'log', - '.docx': 'txt', - '.odt': 'txt', }; export const DEFAULT_SAMPLE_PROMPTS: SamplePrompts = [ diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts index b081503761..c60855f7c3 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts @@ -25,6 +25,7 @@ export * from './useLastOpenedConversation'; export * from './useLightspeedDeletePermission'; export * from './notebooks/useLightspeedNotebooksPermission'; export * from './useLightspeedViewPermission'; +export * from './useNotebookConversationIds'; export * from './useDisplayModeSettings'; export * from './notebooks/useNotebookSession'; export * from './notebooks/useNotebookSessions'; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useNotebookConversationIds.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useNotebookConversationIds.ts new file mode 100644 index 0000000000..dc49594ed3 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useNotebookConversationIds.ts @@ -0,0 +1,41 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core-plugin-api'; + +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { lightspeedApiRef } from '../api/api'; + +/** + * Hook to fetch conversation IDs associated with notebook sessions for filtering + * Works even when notebooks feature is disabled + */ +export const useNotebookConversationIds = (): UseQueryResult< + string[], + Error +> => { + const lightspeedApi = useApi(lightspeedApiRef); + + return useQuery({ + queryKey: ['notebookConversationIds'], + queryFn: async () => { + return await lightspeedApi.getNotebookConversationIds(); + }, + staleTime: 1000 * 60 * 5, // 5 minutes + retry: false, + }); +};