Skip to content

Commit d9df5b8

Browse files
feat(lightspeed): notebook chat (#2754)
* feat(lightspeed): create notebook flow Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * feat(lightspeed): creating new notebook Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * updating status of documents Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * updating the unit tests Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * handling overwrite in create flow Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * adding changeset Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * feat(lightspeed): adding chat Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * delete of document Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * formatting the query response Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * adding changeset Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * updating delete functionality Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * fixing width Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * improving spacing Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> * update doc_url Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> --------- Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com>
1 parent f384d58 commit d9df5b8

22 files changed

Lines changed: 623 additions & 106 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed': minor
3+
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': patch
4+
---
5+
6+
Add notebook chat with streaming support, document management, and UI improvements.
7+
8+
- Backend: add SSE transform to normalize Responses API format to legacy streaming format so notebook chat streams token-by-token like the chat tab.
9+
- Frontend: add notebook chat view with conversation messages, document sidebar with per-document delete, and topic summary display.
10+
- Fix stale document list when re-opening a notebook by setting query staleTime to 0.
11+
- Hide model selector on the Notebooks tab while keeping the settings ellipsis menu visible.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts

Lines changed: 100 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import express, { Router } from 'express';
2626

2727
import { lightspeedNotebooksUsePermission } from '@red-hat-developer-hub/backstage-plugin-lightspeed-common';
2828

29-
import { Readable } from 'stream';
29+
import { Readable, Transform } from 'stream';
3030

3131
import {
3232
DEFAULT_LIGHTSPEED_SERVICE_HOST,
@@ -149,52 +149,115 @@ export async function createNotebooksRouter(
149149
}
150150
};
151151

152-
const createConversationIdCaptureTransform = (
152+
/**
153+
* Transforms Responses API SSE (event:/data: lines) into the legacy
154+
* streaming format that the frontend useConversationMessages hook expects:
155+
* data: {"event": "<type>", "data": {...}}\n\n
156+
*
157+
* Also captures the conversation_id from the first response.created event
158+
* and persists it on the session when it is new.
159+
*/
160+
const createResponsesApiTransform = (
153161
session: any,
154162
sessionId: string,
155163
userId: string,
156164
) => {
157-
const { Transform } = require('stream');
158-
let captured = false;
159165
let buffer = '';
166+
let conversationCaptured = !!session.metadata?.conversation_id;
160167

161168
return new Transform({
162169
transform(chunk: any, _encoding: any, callback: any) {
163-
this.push(chunk);
170+
buffer += chunk.toString();
164171

165-
if (!captured) {
166-
buffer += chunk.toString();
167-
const lines = buffer.split('\n');
168-
buffer = buffer.endsWith('\n') ? '' : lines.pop() || '';
172+
const blocks = buffer.split('\n\n');
173+
buffer = blocks.pop()!;
174+
175+
for (const block of blocks) {
176+
if (!block.trim()) continue;
177+
178+
const lines = block.split('\n');
179+
let eventType = '';
180+
let dataLine = '';
181+
182+
for (const line of lines) {
183+
if (line.startsWith('event: ')) {
184+
eventType = line.slice(7).trim();
185+
} else if (line.startsWith('data: ')) {
186+
dataLine = line.slice(6).trim();
187+
}
188+
}
189+
190+
if (dataLine === '[DONE]') {
191+
this.push('data: [DONE]\n\n');
192+
continue;
193+
}
194+
195+
if (!dataLine) continue;
196+
197+
let parsed: any;
198+
try {
199+
parsed = JSON.parse(dataLine);
200+
} catch {
201+
continue;
202+
}
203+
204+
if (eventType === 'response.created') {
205+
const convId = parsed?.response?.conversation;
206+
const requestId = parsed?.response?.id;
207+
208+
if (convId && !conversationCaptured) {
209+
conversationCaptured = true;
210+
logger.info(`Captured conversation ID: ${convId}`);
211+
sessionService
212+
.updateSession(sessionId, userId, undefined, undefined, {
213+
...session.metadata,
214+
conversation_id: convId,
215+
})
216+
.catch((err: any) =>
217+
logger.error(`Failed to update session: ${err}`),
218+
);
219+
}
220+
221+
const legacy = {
222+
event: 'start',
223+
data: { conversation_id: convId, request_id: requestId },
224+
};
225+
this.push(`data: ${JSON.stringify(legacy)}\n\n`);
226+
} else if (eventType === 'response.output_text.delta') {
227+
const legacy = {
228+
event: 'token',
229+
data: { token: parsed?.delta ?? '' },
230+
};
231+
this.push(`data: ${JSON.stringify(legacy)}\n\n`);
232+
} else if (eventType === 'response.completed') {
233+
const usage = parsed?.response?.usage;
234+
const legacy = {
235+
event: 'end',
236+
data: {
237+
referenced_documents: [],
238+
input_tokens: usage?.input_tokens,
239+
output_tokens: usage?.output_tokens,
240+
},
241+
};
242+
this.push(`data: ${JSON.stringify(legacy)}\n\n`);
243+
}
244+
}
169245

246+
callback();
247+
},
248+
249+
flush(callback: any) {
250+
if (buffer.trim()) {
251+
const lines = buffer.split('\n');
252+
let dataLine = '';
170253
for (const line of lines) {
171-
if (
172-
line.startsWith('data: ') &&
173-
line.slice(6).trim() !== '[DONE]'
174-
) {
175-
try {
176-
const conversationId = JSON.parse(line.slice(6))?.response
177-
?.conversation;
178-
if (conversationId) {
179-
captured = true;
180-
buffer = '';
181-
logger.info(`Captured conversation ID: ${conversationId}`);
182-
183-
sessionService
184-
.updateSession(sessionId, userId, undefined, undefined, {
185-
...session.metadata,
186-
conversation_id: conversationId,
187-
})
188-
.catch((err: any) =>
189-
logger.error(`Failed to update session: ${err}`),
190-
);
191-
break;
192-
}
193-
} catch {
194-
// Ignore parse errors for non-JSON SSE markers
195-
}
254+
if (line.startsWith('data: ')) {
255+
dataLine = line.slice(6).trim();
196256
}
197257
}
258+
if (dataLine === '[DONE]') {
259+
this.push('data: [DONE]\n\n');
260+
}
198261
}
199262
callback();
200263
},
@@ -451,16 +514,9 @@ export async function createNotebooksRouter(
451514

452515
if (response.body) {
453516
const body = Readable.fromWeb(response.body as any);
454-
const stream = conversationId
455-
? body
456-
: body.pipe(
457-
createConversationIdCaptureTransform(
458-
session,
459-
sessionId,
460-
userId,
461-
),
462-
);
463-
stream.pipe(res);
517+
body
518+
.pipe(createResponsesApiTransform(session, sessionId, userId))
519+
.pipe(res);
464520
}
465521
break;
466522
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export const lightspeedTranslationRef: TranslationRef<
247247
readonly 'notebook.overwrite.modal.title': string;
248248
readonly 'notebook.overwrite.modal.description': string;
249249
readonly 'notebook.overwrite.modal.action': string;
250+
readonly 'notebook.document.delete': string;
250251
readonly 'conversation.delete.confirm.title': string;
251252
readonly 'conversation.delete.confirm.message': string;
252253
readonly 'conversation.delete.confirm.action': string;

workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,37 @@ export class NotebooksApiClient implements NotebooksAPI {
205205
`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents/${encodeURIComponent(documentId)}/status`,
206206
);
207207
}
208+
209+
async querySession(
210+
sessionId: string,
211+
query: string,
212+
): Promise<ReadableStreamDefaultReader<Uint8Array>> {
213+
const baseUrl = await this.getBaseUrl();
214+
const response = await this.fetchApi.fetch(
215+
`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/query`,
216+
{
217+
method: 'POST',
218+
headers: { 'Content-Type': 'application/json' },
219+
body: JSON.stringify({ query }),
220+
},
221+
);
222+
223+
if (!response.body) {
224+
throw new Error('Readable stream is not supported or there is no body.');
225+
}
226+
227+
if (!response.ok) {
228+
const reader = response.body.getReader();
229+
const { done, value } = await reader.read();
230+
const text = done ? '' : new TextDecoder('utf-8').decode(value);
231+
const errorMessage = JSON.parse(text);
232+
if (errorMessage?.error) {
233+
throw new Error(
234+
`failed to query notebook session: ${errorMessage.error}`,
235+
);
236+
}
237+
}
238+
239+
return response.body.getReader();
240+
}
208241
}

workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export type NotebooksAPI = {
4848
sessionId: string,
4949
documentId: string,
5050
) => Promise<DocumentStatus>;
51+
querySession: (
52+
sessionId: string,
53+
query: string,
54+
) => Promise<ReadableStreamDefaultReader<Uint8Array>>;
5155
};
5256

5357
/**

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

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ export const LightspeedChat = ({
486486
const notebooksPermissionResolved =
487487
!notebooksPermissionLoading && hasNotebooksAccess;
488488
const { data: notebooks = [], refetch: refetchNotebooks } =
489-
useNotebookSessions(activeTab === 1 && notebooksPermissionResolved);
489+
useNotebookSessions(notebooksPermissionResolved);
490490
const hasNotebooks = notebooks.length > 0;
491491
const [openNotebookMenuId, setOpenNotebookMenuId] = useState<string | null>(
492492
null,
@@ -731,6 +731,7 @@ export const LightspeedChat = ({
731731
avatar,
732732
onComplete,
733733
onStart,
734+
undefined,
734735
onRequestIdReady,
735736
);
736737

@@ -860,16 +861,40 @@ export const LightspeedChat = ({
860861
],
861862
);
862863

864+
const notebookConversationIds = useMemo(
865+
() =>
866+
new Set(
867+
notebooks
868+
.map(n => n.metadata?.conversation_id)
869+
.filter((id): id is string => !!id),
870+
),
871+
[notebooks],
872+
);
873+
874+
const chatOnlyConversations = useMemo(
875+
() =>
876+
conversations.filter(
877+
c => !notebookConversationIds.has(c.conversation_id),
878+
),
879+
[conversations, notebookConversationIds],
880+
);
881+
863882
const categorizedMessages = useMemo(
864883
() =>
865884
getCategorizeMessages(
866-
conversations,
885+
chatOnlyConversations,
867886
pinnedChats,
868887
additionalMessageProps,
869888
t,
870889
selectedSort,
871890
),
872-
[additionalMessageProps, conversations, pinnedChats, t, selectedSort],
891+
[
892+
additionalMessageProps,
893+
chatOnlyConversations,
894+
pinnedChats,
895+
t,
896+
selectedSort,
897+
],
873898
);
874899

875900
const filterConversations = useCallback(
@@ -1517,6 +1542,7 @@ export const LightspeedChat = ({
15171542
models={models}
15181543
isPinningChatsEnabled={isPinningChatsEnabled}
15191544
isModelSelectorDisabled={isSendButtonDisabled}
1545+
hideModelSelector={showNotebooksPanel}
15201546
setDisplayMode={setDisplayMode}
15211547
displayMode={displayMode}
15221548
onPinnedChatsToggle={handlePinningChatsToggle}
@@ -1617,6 +1643,18 @@ export const LightspeedChat = ({
16171643
sessionId={activeNotebook.session_id}
16181644
notebookName={activeNotebook.name}
16191645
documents={notebookDocuments}
1646+
metadata={activeNotebook.metadata}
1647+
topicSummary={
1648+
conversations.find(
1649+
c =>
1650+
c.conversation_id ===
1651+
activeNotebook.metadata?.conversation_id,
1652+
)?.topic_summary ?? undefined
1653+
}
1654+
userName={userName}
1655+
avatar={avatar}
1656+
profileLoading={profileLoading}
1657+
topicRestrictionEnabled={topicRestrictionEnabled}
16201658
onClose={handleCloseNotebook}
16211659
/>
16221660
)}

0 commit comments

Comments
 (0)