Skip to content

Commit c45aa19

Browse files
feat(Lightspeed): Add stop button to interrupt a streaming conversation (#2587)
* add stop button to interrupt streaming query * add changeset * set interrupted announcement and show the last used query in the message bar
1 parent 85b60e4 commit c45aa19

27 files changed

+603
-28
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': patch
3+
'@red-hat-developer-hub/backstage-plugin-lightspeed': patch
4+
---
5+
6+
Add stop button to interrupt a streaming conversation

workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/lcsHandlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ export const lcsHandlers: HttpHandler[] = [
126126
return HttpResponse.json(response);
127127
}),
128128

129+
http.post(`${LOCAL_LCS_ADDR}/v1/streaming_query/interrupt`, () => {
130+
return HttpResponse.json({ success: true });
131+
}),
132+
129133
http.get(
130134
`${LOCAL_LCS_ADDR}/v2/conversations/:conversation_id`,
131135
({ params }) => {

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,4 +705,30 @@ describe('lightspeed router tests', () => {
705705
expect(response.statusCode).toEqual(500);
706706
});
707707
});
708+
709+
describe('POST /v1/query/interrupt', () => {
710+
it('returns success when interrupt succeeds', async () => {
711+
const backendServer = await startBackendServer();
712+
713+
const response = await request(backendServer)
714+
.post('/api/lightspeed/v1/query/interrupt')
715+
.send({ request_id: 'req-123' });
716+
717+
expect(response.statusCode).toEqual(200);
718+
expect(response.body).toEqual({ success: true });
719+
});
720+
721+
it('returns 403 when user lacks permission', async () => {
722+
const backendServer = await startBackendServer(
723+
undefined,
724+
AuthorizeResult.DENY,
725+
);
726+
727+
const response = await request(backendServer)
728+
.post('/api/lightspeed/v1/query/interrupt')
729+
.send({ request_id: 'req-123' });
730+
731+
expect(response.statusCode).toEqual(403);
732+
});
733+
});
708734
});

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,11 @@ export async function createRouter(
390390
// ─── Proxy Middleware (existing) ────────────────────────────────────
391391

392392
router.use('/', async (req, res, next) => {
393-
const passthroughPaths = ['/v1/query', '/v1/feedback'];
393+
const passthroughPaths = [
394+
'/v1/query',
395+
'/v1/query/interrupt',
396+
'/v1/feedback',
397+
];
394398
// Skip middleware for ai-notebooks routes and specific paths
395399
if (
396400
req.path.startsWith('/ai-notebooks') ||
@@ -512,6 +516,47 @@ export async function createRouter(
512516
}
513517
}
514518
});
519+
520+
router.post('/v1/query/interrupt', async (request, response) => {
521+
try {
522+
const credentials = await httpAuth.credentials(request);
523+
const userEntity = await userInfo.getUserInfo(credentials);
524+
const user_id = userEntity.userEntityRef;
525+
await authorizer.authorizeUser(
526+
lightspeedChatCreatePermission,
527+
credentials,
528+
);
529+
const userQueryParam = `user_id=${encodeURIComponent(user_id)}`;
530+
const requestBody = JSON.stringify(request.body);
531+
const fetchResponse = await fetch(
532+
`http://0.0.0.0:${port}/v1/streaming_query/interrupt?${userQueryParam}`,
533+
{
534+
method: 'POST',
535+
headers: {
536+
'Content-Type': 'application/json',
537+
},
538+
body: requestBody,
539+
},
540+
);
541+
if (!fetchResponse.ok) {
542+
const errorBody = await fetchResponse.json();
543+
const errormsg = `Error from lightspeed-core server: ${errorBody.error?.message || errorBody?.detail?.cause || 'Unknown error'}`;
544+
logger.error(errormsg);
545+
response.status(500).json({ error: errormsg });
546+
return;
547+
}
548+
response.status(fetchResponse.status).json(await fetchResponse.json());
549+
} catch (error) {
550+
const errormsg = `Error while interrupting query: ${error}`;
551+
logger.error(errormsg);
552+
if (error instanceof NotAllowedError) {
553+
response.status(403).json({ error: error.message });
554+
} else {
555+
response.status(500).json({ error: error });
556+
}
557+
}
558+
});
559+
515560
router.post(
516561
'/v1/query',
517562
validateCompletionsRequest,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export const lightspeedTranslationRef: TranslationRef<
112112
readonly 'conversation.addToPinnedChats': string;
113113
readonly 'conversation.removeFromPinnedChats': string;
114114
readonly 'conversation.announcement.userMessage': string;
115+
readonly 'conversation.announcement.responseStopped': string;
115116
readonly 'user.guest': string;
116117
readonly 'user.loading': string;
117118
readonly 'tooltip.attach': string;

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,25 @@ export class LightspeedApiClient implements LightspeedAPI {
183183
return response.conversations ?? [];
184184
}
185185

186+
async stopMessage(requestId: string): Promise<{ success: boolean }> {
187+
const baseUrl = await this.getBaseUrl();
188+
const response = await this.fetchApi.fetch(
189+
`${baseUrl}/v1/query/interrupt`,
190+
{
191+
method: 'POST',
192+
headers: {
193+
'Content-Type': 'application/json',
194+
},
195+
body: JSON.stringify({ request_id: requestId }),
196+
},
197+
);
198+
if (!response.ok) {
199+
throw new Error(
200+
`failed to stop message, status ${response.status}: ${response.statusText}`,
201+
);
202+
}
203+
return await response.json();
204+
}
186205
async deleteConversation(conversation_id: string) {
187206
const baseUrl = await this.getBaseUrl();
188207

workspaces/lightspeed/plugins/lightspeed/src/api/__tests__/LightspeedApiClient.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,38 @@ describe('LightspeedApiClient', () => {
399399
});
400400
});
401401

402+
describe('stopMessage', () => {
403+
it('should return success when stop succeeds', async () => {
404+
mockFetchApi.fetch.mockResolvedValue({
405+
ok: true,
406+
json: jest.fn().mockResolvedValue({ success: true }),
407+
} as unknown as Response);
408+
409+
const result = await client.stopMessage('req-123');
410+
411+
expect(result).toEqual({ success: true });
412+
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
413+
'http://localhost:7007/api/lightspeed/v1/query/interrupt',
414+
expect.objectContaining({
415+
method: 'POST',
416+
body: JSON.stringify({ request_id: 'req-123' }),
417+
}),
418+
);
419+
});
420+
421+
it('should throw error when stop fails', async () => {
422+
mockFetchApi.fetch.mockResolvedValue({
423+
ok: false,
424+
status: 500,
425+
statusText: 'Internal Server Error',
426+
} as unknown as Response);
427+
428+
await expect(client.stopMessage('req-123')).rejects.toThrow(
429+
'failed to stop message, status 500: Internal Server Error',
430+
);
431+
});
432+
});
433+
402434
describe('createMessage', () => {
403435
it('should return readable stream reader when message is created', async () => {
404436
const mockReader = {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type LightspeedAPI = {
5050
getFeedbackStatus: () => Promise<boolean>;
5151
captureFeedback: (payload: CaptureFeedback) => Promise<{ response: string }>;
5252
isTopicRestrictionEnabled: () => Promise<boolean>;
53+
stopMessage: (requestId: string) => Promise<{ success: boolean }>;
5354
};
5455

5556
/**

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {
8484
useNotebookSessions,
8585
usePinnedChatsSettings,
8686
useSortSettings,
87+
useStopConversation,
8788
} from '../hooks';
8889
import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext';
8990
import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission';
@@ -376,6 +377,7 @@ export const LightspeedChat = ({
376377
[],
377378
);
378379
const [conversationId, setConversationId] = useState<string>('');
380+
const [requestId, setRequestId] = useState<string>('');
379381
const [newChatCreated, setNewChatCreated] = useState<boolean>(false);
380382
const [isSendButtonDisabled, setIsSendButtonDisabled] =
381383
useState<boolean>(false);
@@ -385,6 +387,8 @@ export const LightspeedChat = ({
385387
const [isSortSelectOpen, setIsSortSelectOpen] = useState<boolean>(false);
386388
const contentScrollRef = useRef<HTMLDivElement>(null);
387389
const bottomSentinelRef = useRef<HTMLDivElement>(null);
390+
const [messageBarKey, setMessageBarKey] = useState(0);
391+
const wasStoppedByUserRef = useRef(false);
388392
const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } =
389393
useLastOpenedConversation(user);
390394
const {
@@ -534,9 +538,16 @@ export const LightspeedChat = ({
534538
setCurrentConversationId(conv_id);
535539
};
536540

541+
const onRequestIdReady = (request_id: string) => {
542+
setRequestId(request_id);
543+
};
544+
537545
const onComplete = (message: string) => {
538546
setIsSendButtonDisabled(false);
539-
setAnnouncement(`Message from Bot: ${message}`);
547+
if (!wasStoppedByUserRef.current) {
548+
setAnnouncement(`Message from Bot: ${message}`);
549+
}
550+
wasStoppedByUserRef.current = false;
540551
queryClient.invalidateQueries({
541552
queryKey: ['conversations'],
542553
});
@@ -555,12 +566,16 @@ export const LightspeedChat = ({
555566
avatar,
556567
onComplete,
557568
onStart,
569+
onRequestIdReady,
558570
);
559571

560572
const [messages, setMessages] =
561573
useState<MessageProps[]>(conversationMessages);
562574

563575
const sendMessage = (message: string | number) => {
576+
if (!message.toString().trim()) return;
577+
578+
wasStoppedByUserRef.current = false;
564579
if (conversationId !== TEMP_CONVERSATION_ID) {
565580
setNewChatCreated(false);
566581
}
@@ -699,7 +714,7 @@ export const LightspeedChat = ({
699714
const filteredConversations = Object.entries(categorizedMessages).reduce(
700715
(acc, [key, items]) => {
701716
const filteredItems = items.filter(item =>
702-
item.text
717+
(item.text ?? '')
703718
.toLocaleLowerCase('en-US')
704719
.includes(targetValue.toLocaleLowerCase('en-US')),
705720
);
@@ -958,6 +973,26 @@ export const LightspeedChat = ({
958973
handleFileUpload(data);
959974
};
960975

976+
const { mutate: stopConversation } = useStopConversation();
977+
978+
const handleStopButton = () => {
979+
wasStoppedByUserRef.current = true;
980+
if (requestId) {
981+
stopConversation(requestId);
982+
setRequestId('');
983+
}
984+
setIsSendButtonDisabled(false);
985+
setAnnouncement(t('conversation.announcement.responseStopped'));
986+
const lastUserMessage = [...conversationMessages]
987+
.reverse()
988+
.find((m: { role?: string }) => m.role === 'user');
989+
const restoredPrompt = (lastUserMessage?.content as string) ?? '';
990+
setDraftMessage(restoredPrompt.trim());
991+
if (restoredPrompt) setMessageBarKey(k => k + 1);
992+
setFileContents([]);
993+
setUploadError({ message: null });
994+
};
995+
961996
const handleDraftMessage = (
962997
_e: ChangeEvent<HTMLTextAreaElement>,
963998
value: string | number,
@@ -1192,13 +1227,18 @@ export const LightspeedChat = ({
11921227
<ChatbotFooter className={classes.footer}>
11931228
<FilePreview />
11941229
<MessageBar
1230+
key={messageBarKey}
11951231
onSendMessage={sendMessage}
11961232
isSendButtonDisabled={isSendButtonDisabled}
11971233
hasAttachButton
11981234
handleAttach={handleAttach}
11991235
hasMicrophoneButton
12001236
value={draftMessage}
12011237
onChange={handleDraftMessage}
1238+
hasStopButton={isSendButtonDisabled}
1239+
handleStopButton={
1240+
isSendButtonDisabled ? handleStopButton : undefined
1241+
}
12021242
buttonProps={{
12031243
attach: {
12041244
inputTestId: 'attachment-input',

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ export const RenameConversationModal = ({
5858
c => c.conversation_id === conversationId,
5959
);
6060
if (conversation) {
61-
setChatName(conversation.topic_summary);
62-
setOriginalChatName(conversation.topic_summary);
61+
setChatName(conversation.topic_summary ?? '');
62+
setOriginalChatName(conversation.topic_summary ?? '');
6363
} else {
6464
setChatName('');
6565
setOriginalChatName('');

0 commit comments

Comments
 (0)