Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions workspaces/lightspeed/.changeset/bright-notebooks-stream.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": "<type>", "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');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JslYoon Please see if this changes looks good to you.
Notebook chat streaming: normalize SSE format to match chat tab

The notebook query endpoint proxies responses from Lightspeed Core, which uses the Responses API SSE format (event: response.output_text.delta / data: {...}). However, the frontend useConversationMessages hook expects the legacy streaming format (data: {"event": "token", "data": {"token": "..."}}), which is what the chat tab already uses.

Without this transform, the frontend received the entire response as a single blob instead of streaming token-by-token, because it couldn't parse the incoming SSE events.

Added a createResponsesApiTransform in notebooksRouters.ts that converts Responses API SSE events to the legacy format on the fly, so both chat and notebook tabs stream identically. The transform also captures the conversation_id from the first response.created event and persists it on the session.

@JslYoon JslYoon Apr 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine by me.

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();
},
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadableStreamDefaultReader<Uint8Array>> {
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();
}
Comment on lines +227 to +240

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Stream reader locked twice 🐞 Bug ≡ Correctness

NotebooksApiClient.querySession() calls response.body.getReader() in the error path and then calls
getReader() again to return a reader, which will throw because the stream is already locked and also
may let non-OK responses proceed without throwing.
Agent Prompt
### Issue description
`NotebooksApiClient.querySession()` locks the response stream by calling `response.body.getReader()` in the error branch and then calls `getReader()` again, which will throw at runtime. It also doesn't reliably throw for non-OK responses.

### Issue Context
This method is used for notebook chat streaming; failed requests should surface a clean error without breaking the stream API.

### Fix Focus Areas
- workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts[205-236]

### Suggested fix
- If `!response.ok`, read the error via `await response.text()` (or reuse a single reader and `releaseLock()`), then `throw` unconditionally.
- Only call `response.body.getReader()` once (in the success path) and return that reader.
- Wrap JSON parsing of error text in `try/catch` and fall back to raw text when not JSON.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export type NotebooksAPI = {
sessionId: string,
documentId: string,
) => Promise<DocumentStatus>;
querySession: (
sessionId: string,
query: string,
) => Promise<ReadableStreamDefaultReader<Uint8Array>>;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(
null,
Expand Down Expand Up @@ -731,6 +731,7 @@ export const LightspeedChat = ({
avatar,
onComplete,
onStart,
undefined,
onRequestIdReady,
);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1517,6 +1542,7 @@ export const LightspeedChat = ({
models={models}
isPinningChatsEnabled={isPinningChatsEnabled}
isModelSelectorDisabled={isSendButtonDisabled}
hideModelSelector={showNotebooksPanel}
setDisplayMode={setDisplayMode}
displayMode={displayMode}
onPinnedChatsToggle={handlePinningChatsToggle}
Expand Down Expand Up @@ -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}
/>
)}
Expand Down
Loading
Loading