Skip to content
Open
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
6 changes: 6 additions & 0 deletions workspaces/lightspeed/.changeset/many-toys-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': patch
'@red-hat-developer-hub/backstage-plugin-lightspeed': patch
---

Add stop button to interrupt a streaming conversation
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export const lcsHandlers: HttpHandler[] = [
return HttpResponse.json(response);
}),

http.post(`${LOCAL_LCS_ADDR}/v1/streaming_query/interrupt`, () => {
return HttpResponse.json({ success: true });
}),

http.get(
`${LOCAL_LCS_ADDR}/v2/conversations/:conversation_id`,
({ params }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -705,4 +705,30 @@ describe('lightspeed router tests', () => {
expect(response.statusCode).toEqual(500);
});
});

describe('POST /v1/query/interrupt', () => {
it('returns success when interrupt succeeds', async () => {
const backendServer = await startBackendServer();

const response = await request(backendServer)
.post('/api/lightspeed/v1/query/interrupt')
.send({ request_id: 'req-123' });

expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({ success: true });
});

it('returns 403 when user lacks permission', async () => {
const backendServer = await startBackendServer(
undefined,
AuthorizeResult.DENY,
);

const response = await request(backendServer)
.post('/api/lightspeed/v1/query/interrupt')
.send({ request_id: 'req-123' });

expect(response.statusCode).toEqual(403);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,11 @@ export async function createRouter(
// ─── Proxy Middleware (existing) ────────────────────────────────────

router.use('/', async (req, res, next) => {
const passthroughPaths = ['/v1/query', '/v1/feedback'];
const passthroughPaths = [
'/v1/query',
'/v1/query/interrupt',
'/v1/feedback',
];
// Skip middleware for ai-notebooks routes and specific paths
if (
req.path.startsWith('/ai-notebooks') ||
Expand Down Expand Up @@ -512,6 +516,47 @@ export async function createRouter(
}
}
});

router.post('/v1/query/interrupt', async (request, response) => {
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.

Why do we need a separate API for stopping the streaming response? Why not use an AbortController?

try {
const credentials = await httpAuth.credentials(request);
const userEntity = await userInfo.getUserInfo(credentials);
const user_id = userEntity.userEntityRef;
await authorizer.authorizeUser(
lightspeedChatCreatePermission,
credentials,
);
const userQueryParam = `user_id=${encodeURIComponent(user_id)}`;
const requestBody = JSON.stringify(request.body);
const fetchResponse = await fetch(
`http://0.0.0.0:${port}/v1/streaming_query/interrupt?${userQueryParam}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: requestBody,
},
);
if (!fetchResponse.ok) {
const errorBody = await fetchResponse.json();
const errormsg = `Error from lightspeed-core server: ${errorBody.error?.message || errorBody?.detail?.cause || 'Unknown error'}`;
logger.error(errormsg);
response.status(500).json({ error: errormsg });
return;
}
response.status(fetchResponse.status).json(await fetchResponse.json());
} catch (error) {
const errormsg = `Error while interrupting query: ${error}`;
logger.error(errormsg);
if (error instanceof NotAllowedError) {
response.status(403).json({ error: error.message });
} else {
response.status(500).json({ error: error });
}
}
});

router.post(
'/v1/query',
validateCompletionsRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ readonly "conversation.rename": string;
readonly "conversation.addToPinnedChats": string;
readonly "conversation.removeFromPinnedChats": string;
readonly "conversation.announcement.userMessage": string;
readonly "conversation.announcement.responseStopped": string;
readonly "user.guest": string;
readonly "user.loading": string;
readonly "tooltip.attach": string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,25 @@ export class LightspeedApiClient implements LightspeedAPI {
return response.conversations ?? [];
}

async stopMessage(requestId: string): Promise<{ success: boolean }> {
const baseUrl = await this.getBaseUrl();
const response = await this.fetchApi.fetch(
`${baseUrl}/v1/query/interrupt`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ request_id: requestId }),
},
);
if (!response.ok) {
throw new Error(
`failed to stop message, status ${response.status}: ${response.statusText}`,
);
}
return await response.json();
}
async deleteConversation(conversation_id: string) {
const baseUrl = await this.getBaseUrl();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,38 @@ describe('LightspeedApiClient', () => {
});
});

describe('stopMessage', () => {
it('should return success when stop succeeds', async () => {
mockFetchApi.fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ success: true }),
} as unknown as Response);

const result = await client.stopMessage('req-123');

expect(result).toEqual({ success: true });
expect(mockFetchApi.fetch).toHaveBeenCalledWith(
'http://localhost:7007/api/lightspeed/v1/query/interrupt',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ request_id: 'req-123' }),
}),
);
});

it('should throw error when stop fails', async () => {
mockFetchApi.fetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as unknown as Response);

await expect(client.stopMessage('req-123')).rejects.toThrow(
'failed to stop message, status 500: Internal Server Error',
);
});
});

describe('createMessage', () => {
it('should return readable stream reader when message is created', async () => {
const mockReader = {
Expand Down
1 change: 1 addition & 0 deletions workspaces/lightspeed/plugins/lightspeed/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type LightspeedAPI = {
getFeedbackStatus: () => Promise<boolean>;
captureFeedback: (payload: CaptureFeedback) => Promise<{ response: string }>;
isTopicRestrictionEnabled: () => Promise<boolean>;
stopMessage: (requestId: string) => Promise<{ success: boolean }>;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
useNotebookSessions,
usePinnedChatsSettings,
useSortSettings,
useStopConversation,
} from '../hooks';
import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext';
import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission';
Expand Down Expand Up @@ -370,6 +371,7 @@ export const LightspeedChat = ({
[],
);
const [conversationId, setConversationId] = useState<string>('');
const [requestId, setRequestId] = useState<string>('');
const [newChatCreated, setNewChatCreated] = useState<boolean>(false);
const [isSendButtonDisabled, setIsSendButtonDisabled] =
useState<boolean>(false);
Expand All @@ -379,6 +381,8 @@ export const LightspeedChat = ({
const [isSortSelectOpen, setIsSortSelectOpen] = useState<boolean>(false);
const contentScrollRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const [messageBarKey, setMessageBarKey] = useState(0);
const wasStoppedByUserRef = useRef(false);
const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } =
useLastOpenedConversation(user);
const {
Expand Down Expand Up @@ -528,9 +532,16 @@ export const LightspeedChat = ({
setCurrentConversationId(conv_id);
};

const onRequestIdReady = (request_id: string) => {
setRequestId(request_id);
};

const onComplete = (message: string) => {
setIsSendButtonDisabled(false);
setAnnouncement(`Message from Bot: ${message}`);
if (!wasStoppedByUserRef.current) {
setAnnouncement(`Message from Bot: ${message}`);
}
wasStoppedByUserRef.current = false;
queryClient.invalidateQueries({
queryKey: ['conversations'],
});
Expand All @@ -549,12 +560,16 @@ export const LightspeedChat = ({
avatar,
onComplete,
onStart,
onRequestIdReady,
);

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

const sendMessage = (message: string | number) => {
if (!message.toString().trim()) return;

wasStoppedByUserRef.current = false;
if (conversationId !== TEMP_CONVERSATION_ID) {
setNewChatCreated(false);
}
Expand Down Expand Up @@ -693,7 +708,7 @@ export const LightspeedChat = ({
const filteredConversations = Object.entries(categorizedMessages).reduce(
(acc, [key, items]) => {
const filteredItems = items.filter(item =>
item.text
(item.text ?? '')
.toLocaleLowerCase('en-US')
.includes(targetValue.toLocaleLowerCase('en-US')),
);
Expand Down Expand Up @@ -952,6 +967,26 @@ export const LightspeedChat = ({
handleFileUpload(data);
};

const { mutate: stopConversation } = useStopConversation();

const handleStopButton = () => {
wasStoppedByUserRef.current = true;
if (requestId) {
stopConversation(requestId);
setRequestId('');
}
setIsSendButtonDisabled(false);
setAnnouncement(t('conversation.announcement.responseStopped'));
const lastUserMessage = [...conversationMessages]
.reverse()
.find((m: { role?: string }) => m.role === 'user');
const restoredPrompt = (lastUserMessage?.content as string) ?? '';
setDraftMessage(restoredPrompt.trim());
if (restoredPrompt) setMessageBarKey(k => k + 1);
setFileContents([]);
setUploadError({ message: null });
};

const handleDraftMessage = (
_e: ChangeEvent<HTMLTextAreaElement>,
value: string | number,
Expand Down Expand Up @@ -1186,13 +1221,18 @@ export const LightspeedChat = ({
<ChatbotFooter className={classes.footer}>
<FilePreview />
<MessageBar
key={messageBarKey}
onSendMessage={sendMessage}
isSendButtonDisabled={isSendButtonDisabled}
hasAttachButton
handleAttach={handleAttach}
hasMicrophoneButton
value={draftMessage}
onChange={handleDraftMessage}
hasStopButton={isSendButtonDisabled}
handleStopButton={
isSendButtonDisabled ? handleStopButton : undefined
}
buttonProps={{
attach: {
inputTestId: 'attachment-input',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export const RenameConversationModal = ({
c => c.conversation_id === conversationId,
);
if (conversation) {
setChatName(conversation.topic_summary);
setOriginalChatName(conversation.topic_summary);
setChatName(conversation.topic_summary ?? '');
setOriginalChatName(conversation.topic_summary ?? '');
} else {
setChatName('');
setOriginalChatName('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const mockLightspeedApi = {
getFeedbackStatus: jest.fn().mockResolvedValue(false),
captureFeedback: jest.fn().mockResolvedValue({ response: 'success' }),
isTopicRestrictionEnabled: jest.fn().mockResolvedValue(false),
stopMessage: jest.fn().mockResolvedValue({ success: true }),
};

const setupLightspeedChat = () => (
Expand Down
Loading
Loading