diff --git a/workspaces/lightspeed/.changeset/bright-notebooks-stream.md b/workspaces/lightspeed/.changeset/bright-notebooks-stream.md new file mode 100644 index 00000000000..68e48325744 --- /dev/null +++ b/workspaces/lightspeed/.changeset/bright-notebooks-stream.md @@ -0,0 +1,11 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': minor +'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': patch +--- + +Add notebook chat with streaming support, document management, and UI improvements. + +- Backend: add SSE transform to normalize Responses API format to legacy streaming format so notebook chat streams token-by-token like the chat tab. +- Frontend: add notebook chat view with conversation messages, document sidebar with per-document delete, and topic summary display. +- Fix stale document list when re-opening a notebook by setting query staleTime to 0. +- Hide model selector on the Notebooks tab while keeping the settings ellipsis menu visible. 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 f60356a3aca..0701856666a 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts @@ -26,7 +26,7 @@ import express, { Router } from 'express'; import { lightspeedNotebooksUsePermission } from '@red-hat-developer-hub/backstage-plugin-lightspeed-common'; -import { Readable } from 'stream'; +import { Readable, Transform } from 'stream'; import { DEFAULT_LIGHTSPEED_SERVICE_PORT, @@ -153,52 +153,115 @@ export async function createNotebooksRouter( } }; - const createConversationIdCaptureTransform = ( + /** + * Transforms Responses API SSE (event:/data: lines) into the legacy + * streaming format that the frontend useConversationMessages hook expects: + * data: {"event": "", "data": {...}}\n\n + * + * Also captures the conversation_id from the first response.created event + * and persists it on the session when it is new. + */ + const createResponsesApiTransform = ( session: any, sessionId: string, userId: string, ) => { - const { Transform } = require('stream'); - let captured = false; let buffer = ''; + let conversationCaptured = !!session.metadata?.conversation_id; return new Transform({ transform(chunk: any, _encoding: any, callback: any) { - this.push(chunk); + buffer += chunk.toString(); - if (!captured) { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = buffer.endsWith('\n') ? '' : lines.pop() || ''; + const blocks = buffer.split('\n\n'); + buffer = blocks.pop()!; + + for (const block of blocks) { + if (!block.trim()) continue; + + const lines = block.split('\n'); + let eventType = ''; + let dataLine = ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + dataLine = line.slice(6).trim(); + } + } + + if (dataLine === '[DONE]') { + this.push('data: [DONE]\n\n'); + continue; + } + + if (!dataLine) continue; + + let parsed: any; + try { + parsed = JSON.parse(dataLine); + } catch { + continue; + } + + if (eventType === 'response.created') { + const convId = parsed?.response?.conversation; + const requestId = parsed?.response?.id; + + if (convId && !conversationCaptured) { + conversationCaptured = true; + logger.info(`Captured conversation ID: ${convId}`); + sessionService + .updateSession(sessionId, userId, undefined, undefined, { + ...session.metadata, + conversation_id: convId, + }) + .catch((err: any) => + logger.error(`Failed to update session: ${err}`), + ); + } + + const legacy = { + event: 'start', + data: { conversation_id: convId, request_id: requestId }, + }; + this.push(`data: ${JSON.stringify(legacy)}\n\n`); + } else if (eventType === 'response.output_text.delta') { + const legacy = { + event: 'token', + data: { token: parsed?.delta ?? '' }, + }; + this.push(`data: ${JSON.stringify(legacy)}\n\n`); + } else if (eventType === 'response.completed') { + const usage = parsed?.response?.usage; + const legacy = { + event: 'end', + data: { + referenced_documents: [], + input_tokens: usage?.input_tokens, + output_tokens: usage?.output_tokens, + }, + }; + this.push(`data: ${JSON.stringify(legacy)}\n\n`); + } + } + callback(); + }, + + flush(callback: any) { + if (buffer.trim()) { + const lines = buffer.split('\n'); + let dataLine = ''; for (const line of lines) { - if ( - line.startsWith('data: ') && - line.slice(6).trim() !== '[DONE]' - ) { - try { - const conversationId = JSON.parse(line.slice(6))?.response - ?.conversation; - if (conversationId) { - captured = true; - buffer = ''; - logger.info(`Captured conversation ID: ${conversationId}`); - - sessionService - .updateSession(sessionId, userId, undefined, undefined, { - ...session.metadata, - conversation_id: conversationId, - }) - .catch((err: any) => - logger.error(`Failed to update session: ${err}`), - ); - break; - } - } catch { - // Ignore parse errors for non-JSON SSE markers - } + if (line.startsWith('data: ')) { + dataLine = line.slice(6).trim(); } } + if (dataLine === '[DONE]') { + this.push('data: [DONE]\n\n'); + } } callback(); }, @@ -445,16 +508,9 @@ export async function createNotebooksRouter( if (response.body) { const body = Readable.fromWeb(response.body as any); - const stream = conversationId - ? body - : body.pipe( - createConversationIdCaptureTransform( - session, - sessionId, - userId, - ), - ); - stream.pipe(res); + body + .pipe(createResponsesApiTransform(session, sessionId, userId)) + .pipe(res); } break; } diff --git a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md index 23f17119801..f954c7c2b76 100644 --- a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md +++ b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md @@ -247,6 +247,7 @@ export const lightspeedTranslationRef: TranslationRef< readonly 'notebook.overwrite.modal.title': string; readonly 'notebook.overwrite.modal.description': string; readonly 'notebook.overwrite.modal.action': string; + readonly 'notebook.document.delete': string; readonly 'conversation.delete.confirm.title': string; readonly 'conversation.delete.confirm.message': string; readonly 'conversation.delete.confirm.action': string; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts index 16e718ff378..c93d4047fa8 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts @@ -205,4 +205,37 @@ export class NotebooksApiClient implements NotebooksAPI { `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents/${encodeURIComponent(documentId)}/status`, ); } + + async querySession( + sessionId: string, + query: string, + ): Promise> { + const baseUrl = await this.getBaseUrl(); + const response = await this.fetchApi.fetch( + `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }, + ); + + if (!response.body) { + throw new Error('Readable stream is not supported or there is no body.'); + } + + if (!response.ok) { + const reader = response.body.getReader(); + const { done, value } = await reader.read(); + const text = done ? '' : new TextDecoder('utf-8').decode(value); + const errorMessage = JSON.parse(text); + if (errorMessage?.error) { + throw new Error( + `failed to query notebook session: ${errorMessage.error}`, + ); + } + } + + return response.body.getReader(); + } } diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts index 7ba87ccf194..3b95a63f1d1 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts @@ -48,6 +48,10 @@ export type NotebooksAPI = { sessionId: string, documentId: string, ) => Promise; + querySession: ( + sessionId: string, + query: string, + ) => Promise>; }; /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 43a2a1be549..96de6e83c53 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -486,7 +486,7 @@ export const LightspeedChat = ({ const notebooksPermissionResolved = !notebooksPermissionLoading && hasNotebooksAccess; const { data: notebooks = [], refetch: refetchNotebooks } = - useNotebookSessions(activeTab === 1 && notebooksPermissionResolved); + useNotebookSessions(notebooksPermissionResolved); const hasNotebooks = notebooks.length > 0; const [openNotebookMenuId, setOpenNotebookMenuId] = useState( null, @@ -731,6 +731,7 @@ export const LightspeedChat = ({ avatar, onComplete, onStart, + undefined, onRequestIdReady, ); @@ -860,16 +861,40 @@ export const LightspeedChat = ({ ], ); + const notebookConversationIds = useMemo( + () => + new Set( + notebooks + .map(n => n.metadata?.conversation_id) + .filter((id): id is string => !!id), + ), + [notebooks], + ); + + const chatOnlyConversations = useMemo( + () => + conversations.filter( + c => !notebookConversationIds.has(c.conversation_id), + ), + [conversations, notebookConversationIds], + ); + const categorizedMessages = useMemo( () => getCategorizeMessages( - conversations, + chatOnlyConversations, pinnedChats, additionalMessageProps, t, selectedSort, ), - [additionalMessageProps, conversations, pinnedChats, t, selectedSort], + [ + additionalMessageProps, + chatOnlyConversations, + pinnedChats, + t, + selectedSort, + ], ); const filterConversations = useCallback( @@ -1517,6 +1542,7 @@ export const LightspeedChat = ({ models={models} isPinningChatsEnabled={isPinningChatsEnabled} isModelSelectorDisabled={isSendButtonDisabled} + hideModelSelector={showNotebooksPanel} setDisplayMode={setDisplayMode} displayMode={displayMode} onPinnedChatsToggle={handlePinningChatsToggle} @@ -1617,6 +1643,18 @@ export const LightspeedChat = ({ sessionId={activeNotebook.session_id} notebookName={activeNotebook.name} documents={notebookDocuments} + metadata={activeNotebook.metadata} + topicSummary={ + conversations.find( + c => + c.conversation_id === + activeNotebook.metadata?.conversation_id, + )?.topic_summary ?? undefined + } + userName={userName} + avatar={avatar} + profileLoading={profileLoading} + topicRestrictionEnabled={topicRestrictionEnabled} onClose={handleCloseNotebook} /> )} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx index 4e07c40d204..41dac085d43 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -51,6 +51,7 @@ type LightspeedChatBoxHeaderProps = { onPinnedChatsToggle: (state: boolean) => void; onMcpSettingsClick: () => void; isModelSelectorDisabled?: boolean; + hideModelSelector?: boolean; setDisplayMode: (mode: ChatbotDisplayMode) => void; }; @@ -85,6 +86,7 @@ export const LightspeedChatBoxHeader = ({ onPinnedChatsToggle, onMcpSettingsClick, isModelSelectorDisabled = false, + hideModelSelector = false, setDisplayMode, }: LightspeedChatBoxHeaderProps) => { const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false); @@ -136,35 +138,37 @@ export const LightspeedChatBoxHeader = ({ return ( - { - handleSelectedModel(value as string); - setIsOptionsMenuOpen(false); - }} - onOpenChange={isOpen => setIsOptionsMenuOpen(isOpen)} - popperProps={{ position: 'right' }} - shouldFocusToggleOnSelect - shouldFocusFirstItemOnOpen={false} - toggle={toggle} - isScrollable={isModelDropdownScrollable} - maxMenuHeight={isModelDropdownScrollable ? '240px' : undefined} - > - - {models.map(model => ( - - - {model.label} - - - ))} - - + {!hideModelSelector && ( + { + handleSelectedModel(value as string); + setIsOptionsMenuOpen(false); + }} + onOpenChange={isOpen => setIsOptionsMenuOpen(isOpen)} + popperProps={{ position: 'right' }} + shouldFocusToggleOnSelect + shouldFocusFirstItemOnOpen={false} + toggle={toggle} + isScrollable={isModelDropdownScrollable} + maxMenuHeight={isModelDropdownScrollable ? '240px' : undefined} + > + + {models.map(model => ( + + + {model.label} + + + ))} + + + )} ( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx index 41e82e8275e..4f641a66858 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx @@ -14,9 +14,19 @@ * limitations under the License. */ +import { useState } from 'react'; + import { makeStyles, Typography } from '@material-ui/core'; -import { Button, Spinner, Tooltip } from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons'; +import { + Button, + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + Spinner, + Tooltip, +} from '@patternfly/react-core'; +import { EllipsisVIcon, PlusCircleIcon } from '@patternfly/react-icons'; import { useTranslation } from '../../hooks/useTranslation'; import { SessionDocument } from '../../types'; @@ -98,6 +108,17 @@ const useStyles = makeStyles(theme => ({ spinnerContainer: { flexShrink: 0, }, + kebabToggle: { + padding: 0, + flexShrink: 0, + }, + kebabDropdownMenu: { + '& .pf-v6-c-menu__list': { + paddingInlineStart: 0, + marginBlockStart: 0, + marginBlockEnd: 0, + }, + }, })); type DocumentSidebarProps = { @@ -105,9 +126,11 @@ type DocumentSidebarProps = { documents: SessionDocument[]; uploadingFileNames: string[]; completedFileNames?: Set; + deletingDocumentIds?: Set; collapsed: boolean; onToggleCollapse: () => void; onAddDocument: () => void; + onDeleteDocument?: (documentId: string) => void; }; export const DocumentSidebar = ({ @@ -115,12 +138,15 @@ export const DocumentSidebar = ({ documents, uploadingFileNames, completedFileNames, + deletingDocumentIds, collapsed, onToggleCollapse, onAddDocument, + onDeleteDocument, }: DocumentSidebarProps) => { const classes = useStyles(); const { t } = useTranslation(); + const [openMenuDocId, setOpenMenuDocId] = useState(null); if (collapsed) { return null; @@ -170,6 +196,56 @@ export const DocumentSidebar = ({
{doc.title} + {deletingDocumentIds?.has(doc.document_id) ? ( +
+ +
+ ) : ( + + setOpenMenuDocId(isOpen ? doc.document_id : null) + } + toggle={toggleRef => ( + { + event.stopPropagation(); + setOpenMenuDocId(current => + current === doc.document_id ? null : doc.document_id, + ); + }} + aria-label={t('notebook.document.delete')} + > + + + )} + > + + { + event.stopPropagation(); + setOpenMenuDocId(null); + onDeleteDocument?.(doc.document_id); + }} + > + {t('notebook.document.delete')} + + + + )}
))} {activePending.map(fileName => ( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index 2e668441190..b498d20cd65 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -14,13 +14,18 @@ * limitations under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -import { makeStyles } from '@material-ui/core'; +import { useApi } from '@backstage/core-plugin-api'; + +import { makeStyles, Typography } from '@material-ui/core'; import { + ChatbotContent, ChatbotFooter, ChatbotFootnote, + ChatbotWelcomePrompt, MessageBar, + MessageProps, } from '@patternfly/chatbot'; import { Alert, @@ -38,14 +43,20 @@ import { import { TimesIcon } from '@patternfly/react-icons'; import { useQueryClient } from '@tanstack/react-query'; -import { UNTITLED_NOTEBOOK_NAME } from '../../const'; +import { notebooksApiRef } from '../../api/notebooksApi'; +import { TEMP_CONVERSATION_ID, UNTITLED_NOTEBOOK_NAME } from '../../const'; +import { useCreateNotebookMessage } from '../../hooks/notebooks/useCreateNotebookMessage'; import { useDocumentStatusPolling, type PendingUpload, } from '../../hooks/notebooks/useDocumentStatusPolling'; import { useUploadDocument } from '../../hooks/notebooks/useUploadDocument'; +import { useConversationMessages } from '../../hooks/useConversationMessages'; +import { CreateMessageVariables } from '../../hooks/useCreateCoversationMessage'; import { useTranslation } from '../../hooks/useTranslation'; -import { SessionDocument } from '../../types'; +import { useWelcomePrompts } from '../../hooks/useWelcomePrompts'; +import { NotebookSessionMetadata, SessionDocument } from '../../types'; +import { LightspeedChatBox } from '../LightspeedChatBox'; import { AddDocumentModal } from './AddDocumentModal'; import { DocumentSidebar } from './DocumentSidebar'; import { OverwriteConfirmModal } from './OverwriteConfirmModal'; @@ -56,6 +67,8 @@ const useStyles = makeStyles(theme => ({ root: { display: 'flex', flexDirection: 'column', + flex: 1, + minHeight: 0, height: '100%', backgroundColor: 'var(--pf-t--global--background--color--primary--default)', }, @@ -99,18 +112,21 @@ const useStyles = makeStyles(theme => ({ drawerContentBody: { backgroundColor: 'var(--pf-t--global--background--color--secondary--default)', + height: '100%', }, contentColumn: { display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, + minHeight: 0, + overflow: 'hidden', }, alertContainer: { - width: '100%', - maxWidth: 816, + width: '95%', + maxWidth: 'unset', margin: '0 auto', - padding: `0 ${theme.spacing(3)}px ${theme.spacing(1)}px`, + padding: `0 0 ${theme.spacing(1)}px`, }, toastAlertGroup: { '--pf-v6-c-alert-group--m-toast--InsetInlineEnd': `${theme.spacing(2.5)}px`, @@ -123,12 +139,67 @@ const useStyles = makeStyles(theme => ({ margin: 0, }, }, + welcomeContainer: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'auto', + }, + notebookContentArea: { + width: '95%', + maxWidth: 'unset', + margin: `${theme.spacing(3)}px auto 0 auto`, + padding: 0, + }, + notebookHeading: { + fontSize: '2rem', + fontWeight: 500, + lineHeight: 1.25, + padding: `${theme.spacing(1)}px 0`, + }, + notebookSummary: { + fontSize: '1rem', + lineHeight: 2, + color: 'var(--pf-t--global--text--color--regular)', + paddingTop: theme.spacing(0.5), + }, + promptSuggestions: { + width: '95%', + maxWidth: 'unset', + margin: '0 auto', + }, + footerAlignedAlert: { + maxWidth: 'unset', + width: '95%', + margin: '0 auto', + padding: `0 0 ${theme.spacing(1)}px`, + }, + footer: { + '&>.pf-chatbot__footer-container': { + width: '95% !important', + maxWidth: 'unset !important', + }, + }, + chatContent: { + minHeight: 0, + display: 'flex', + flexDirection: 'column', + flex: 1, + overflow: 'auto', + }, })); type NotebookViewProps = { sessionId: string; notebookName?: string; documents?: SessionDocument[]; + metadata?: NotebookSessionMetadata; + topicSummary?: string; + userName?: string; + avatar?: string; + profileLoading: boolean; + topicRestrictionEnabled: boolean; onClose: () => void; }; @@ -136,12 +207,119 @@ export const NotebookView = ({ sessionId, notebookName = UNTITLED_NOTEBOOK_NAME, documents = [], + metadata, + topicSummary, + userName, + avatar, + profileLoading, + topicRestrictionEnabled, onClose, }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); const queryClient = useQueryClient(); + const notebooksApi = useApi(notebooksApiRef); const uploadMutation = useUploadDocument(); + const { mutateAsync: notebookCreateMessage } = useCreateNotebookMessage(); + + const [conversationId, setConversationId] = useState( + metadata?.conversation_id ?? TEMP_CONVERSATION_ID, + ); + const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); + const [announcement, setAnnouncement] = useState( + undefined, + ); + const [deletingDocumentIds, setDeletingDocumentIds] = useState>( + new Set(), + ); + + const handleDeleteDocument = useCallback( + async (documentId: string) => { + setDeletingDocumentIds(prev => new Set(prev).add(documentId)); + try { + await notebooksApi.deleteDocument(sessionId, documentId); + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'documents', sessionId], + }); + } finally { + setDeletingDocumentIds(prev => { + const next = new Set(prev); + next.delete(documentId); + return next; + }); + } + }, + [notebooksApi, sessionId, queryClient], + ); + + const onComplete = useCallback( + (message: string) => { + setIsSendButtonDisabled(false); + setAnnouncement(`Message from Bot: ${message}`); + queryClient.invalidateQueries({ + queryKey: ['conversationMessages', conversationId], + }); + }, + [queryClient, conversationId], + ); + + const onStart = useCallback((conv_id: string) => { + setConversationId(conv_id); + }, []); + + const createMessageAdapter = useCallback( + async (vars: CreateMessageVariables) => { + return notebookCreateMessage({ + prompt: vars.prompt, + sessionId, + }); + }, + [notebookCreateMessage, sessionId], + ); + + const { conversationMessages, handleInputPrompt, scrollToBottomRef } = + useConversationMessages( + conversationId, + userName, + '', + '', + avatar, + onComplete, + onStart, + createMessageAdapter, + ); + + const [messages, setMessages] = + useState(conversationMessages); + + useEffect(() => { + setMessages(conversationMessages); + }, [conversationMessages]); + + const sendMessage = useCallback( + (message: string | number) => { + setAnnouncement( + t('conversation.announcement.userMessage' as any, { + prompt: message.toString(), + }), + ); + handleInputPrompt(message.toString(), []); + setIsSendButtonDisabled(true); + }, + [handleInputPrompt, t], + ); + + const samplePrompts = useWelcomePrompts(); + const welcomePrompts = + samplePrompts?.map(prompt => { + const p = prompt as { title: string; message: string }; + return { + title: p.title, + message: p.message, + onClick: () => sendMessage(p.message), + }; + }) ?? []; + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const [uploadingFileNames, setUploadingFileNames] = useState([]); @@ -298,13 +476,66 @@ export const NotebookView = ({ documents={documents} uploadingFileNames={uploadingFileNames} completedFileNames={completedFileNames} + deletingDocumentIds={deletingDocumentIds} collapsed={sidebarCollapsed} onToggleCollapse={() => setSidebarCollapsed(prev => !prev)} onAddDocument={handleOpenUploadModal} + onDeleteDocument={handleDeleteDocument} /> ); + const renderMainContent = () => { + if (!hasDocuments && messages.length === 0) { + return ; + } + if (messages.length > 0) { + return ( + + + + ); + } + return ( +
+
+ + {t('disclaimer.withoutValidation')} + +
+
+ + {notebookName} + + {topicSummary && ( + + {topicSummary} + + )} +
+ {welcomePrompts.length > 0 && ( +
+ +
+ )} +
+ ); + }; + return (
{toastAlerts.length > 0 && ( @@ -320,8 +551,6 @@ export const NotebookView = ({ variant={AlertVariant[variant ?? 'success']} title={title} className={classes.toastAlert} - timeout={8000} - onTimeout={() => handleRemoveToastAlert(key as React.Key)} actionClose={
-
- {!hasDocuments && ( - - )} -
+
{renderMainContent()}
-
- - {t('disclaimer.withoutValidation')} - -
+ {!hasDocuments && messages.length === 0 && ( +
+ + {t('disclaimer.withoutValidation')} + +
+ )} - + {}} + isSendButtonDisabled={isSendButtonDisabled} + onSendMessage={sendMessage} placeholder={t('notebook.view.input.placeholder')} /> diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebookMessage.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebookMessage.ts new file mode 100644 index 00000000000..2765540b06c --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebookMessage.ts @@ -0,0 +1,51 @@ +/* + * 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 { useMutation, type UseMutationResult } from '@tanstack/react-query'; + +import { notebooksApiRef } from '../../api/notebooksApi'; + +export type CreateNotebookMessageVariables = { + prompt: string; + sessionId: string; +}; + +export const useCreateNotebookMessage = (): UseMutationResult< + ReadableStreamDefaultReader, + Error, + CreateNotebookMessageVariables +> => { + const notebooksApi = useApi(notebooksApiRef); + + return useMutation({ + mutationFn: async ({ + prompt, + sessionId, + }: CreateNotebookMessageVariables) => { + if (!sessionId) { + throw new Error('Failed to generate AI response'); + } + + return await notebooksApi.querySession(sessionId, prompt); + }, + onError: error => { + // eslint-disable-next-line + console.warn(error); + }, + }); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts index 9c70fbbd63a..7f2b00b07ec 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts @@ -31,6 +31,6 @@ export const useNotebookDocuments = ( return await notebooksApi.listDocuments(sessionId!); }, enabled: Boolean(sessionId), - staleTime: 1000 * 60, + staleTime: 0, }); }; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts index 1e6d568617f..c85d2fbe4bc 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts @@ -40,7 +40,10 @@ import { getTimestamp, transformDocumentsToSources, } from '../utils/lightspeed-chatbox-utils'; -import { useCreateConversationMessage } from './useCreateCoversationMessage'; +import { + CreateMessageVariables, + useCreateConversationMessage, +} from './useCreateCoversationMessage'; const toolCallIdKey = (id: string | number): string => { return String(id); @@ -145,9 +148,13 @@ export const useConversationMessages = ( avatar: string = userAvatar, onComplete?: (message: string) => void, onStart?: (conversation_id: string) => void, + createMessageOverride?: ( + vars: CreateMessageVariables, + ) => Promise>, onRequestIdReady?: (request_id: string) => void, ): UseConversationMessagesReturn => { - const { mutateAsync: createMessage } = useCreateConversationMessage(); + const { mutateAsync: defaultCreateMessage } = useCreateConversationMessage(); + const createMessage = createMessageOverride ?? defaultCreateMessage; const scrollToBottomRef = useRef(null); const [currentConversation, setCurrentConversation] = diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts index 12a31473838..0e0f34d47c3 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts @@ -21,7 +21,7 @@ import { useMutation, type UseMutationResult } from '@tanstack/react-query'; import { lightspeedApiRef } from '../api/api'; import { Attachment } from '../types'; -type CreateMessageVariables = { +export type CreateMessageVariables = { prompt: string; selectedModel: string; selectedProvider: string; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index 66c763ddf05..05018362c92 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -90,6 +90,7 @@ const lightspeedTranslationDe = createTranslationMessages({ 'notebook.overwrite.modal.description': 'Die folgenden Dateien existieren bereits in diesem Notizbuch. Möchten Sie sie mit den neuen Versionen überschreiben?', 'notebook.overwrite.modal.action': 'Überschreiben', + 'notebook.document.delete': 'Löschen', 'prompts.codeReadability.title': 'Hilfe zur Code-Lesbarkeit erhalten', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index adf2589942d..0a5f85bd796 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -90,6 +90,7 @@ const lightspeedTranslationEs = createTranslationMessages({ 'notebook.overwrite.modal.description': 'Los siguientes archivos ya existen en este cuaderno. ¿Desea sobrescribirlos con las nuevas versiones?', 'notebook.overwrite.modal.action': 'Sobrescribir', + 'notebook.document.delete': 'Eliminar', 'prompts.codeReadability.title': 'Obtener ayuda sobre la legibilidad del código', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index 36915727806..f10cf7a18f1 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -90,6 +90,7 @@ const lightspeedTranslationFr = createTranslationMessages({ 'notebook.overwrite.modal.description': 'Les fichiers suivants existent déjà dans ce carnet. Voulez-vous les écraser avec les nouvelles versions ?', 'notebook.overwrite.modal.action': 'Écraser', + 'notebook.document.delete': 'Supprimer', 'prompts.codeReadability.title': 'Obtenir de l’aide pour Décrypter le Code', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts index 9dab0704cc0..f8390d281c0 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts @@ -91,6 +91,7 @@ const lightspeedTranslationIt = createTranslationMessages({ 'notebook.overwrite.modal.description': 'I seguenti file esistono già in questo quaderno. Vuoi sovrascriverli con le nuove versioni?', 'notebook.overwrite.modal.action': 'Sovrascrivi', + 'notebook.document.delete': 'Elimina', 'prompts.codeReadability.title': 'Ottenere aiuto sulla leggibilità del codice', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts index 13f71bd8f16..c265c627a7b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts @@ -90,6 +90,7 @@ const lightspeedTranslationJa = createTranslationMessages({ 'notebook.overwrite.modal.description': '以下のファイルはこのノートブックに既に存在します。新しいバージョンで上書きしますか?', 'notebook.overwrite.modal.action': '上書き', + 'notebook.document.delete': '削除', 'prompts.codeReadability.title': 'コードの可読性に関するヘルプを利用する', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index af15672b374..5f494fc6dbd 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -87,6 +87,7 @@ export const lightspeedMessages = { 'notebook.overwrite.modal.description': 'The following files already exist in this notebook. Do you want to overwrite them with the new versions?', 'notebook.overwrite.modal.action': 'Overwrite', + 'notebook.document.delete': 'Delete', // Sample prompts - General Development 'prompts.codeReadability.title': 'Get Help On Code Readability', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/types.ts b/workspaces/lightspeed/plugins/lightspeed/src/types.ts index 45eb0e3fb7c..e6f126f7fad 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/types.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/types.ts @@ -204,6 +204,7 @@ export type NotebookSessionMetadata = { tags?: string[]; project?: string; document_ids?: string[]; + conversation_id?: string; }; /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx b/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx index 8b5629d24f8..497718f682f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx @@ -164,7 +164,7 @@ export const transformDocumentsToSources = ( body: doc.doc_description, title: doc.doc_title, link: doc?.doc_url, - isExternal: true, + isExternal: !!doc?.doc_url, })), }; };