diff --git a/backend/companion/companionRouter.js b/backend/companion/companionRouter.js index b363bf3172..0b7c432045 100644 --- a/backend/companion/companionRouter.js +++ b/backend/companion/companionRouter.js @@ -7,7 +7,7 @@ const router = express.Router(); router.use(express.json()); -async function handleAIChatRequest(req, res) { +async function handlePromptSuggestions(req, res) { const { namespace, resourceType, groupVersion, resourceName } = JSON.parse( req.body.toString(), ); @@ -62,11 +62,107 @@ async function handleAIChatRequest(req, res) { conversationId: data?.conversation_id, }); } catch (error) { - console.error('Error in AI Chat proxy:', error); res.status(500).json({ error: 'Failed to fetch AI chat data' }); } } -router.post('/suggestions', handleAIChatRequest); +async function handleChatMessage(req, res) { + const { + query, + namespace, + resourceType, + groupVersion, + resourceName, + } = JSON.parse(req.body.toString()); + const clusterUrl = req.headers['x-cluster-url']; + const certificateAuthorityData = + req.headers['x-cluster-certificate-authority-data']; + const clusterToken = req.headers['x-k8s-authorization']?.replace( + /^Bearer\s+/i, + '', + ); + const clientCertificateData = req.headers['x-client-certificate-data']; + const clientKeyData = req.headers['x-client-key-data']; + const sessionId = req.headers['session-id']; + const conversationId = sessionId; + + try { + const uuidPattern = /^[a-f0-9]{32}$/i; + if (!uuidPattern.test(conversationId)) { + throw new Error('Invalid session ID '); + } + const baseUrl = + 'https://companion.cp.dev.kyma.cloud.sap/api/conversations/'; + const targetUrl = new URL( + `${encodeURIComponent(conversationId)}/messages`, + baseUrl, + ); + + const payload = { + query, + resource_kind: resourceType, + resource_api_version: groupVersion, + resource_name: resourceName, + namespace: namespace, + }; + + const AUTH_TOKEN = await tokenManager.getToken(); + + // Set up headers for streaming response + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Transfer-Encoding', 'chunked'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const headers = { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + Authorization: `Bearer ${AUTH_TOKEN}`, + 'X-Cluster-Certificate-Authority-Data': certificateAuthorityData, + 'X-Cluster-Url': clusterUrl, + 'Session-Id': sessionId, + }; + + if (clusterToken) { + headers['X-K8s-Authorization'] = clusterToken; + } else if (clientCertificateData && clientKeyData) { + headers['X-Client-Certificate-Data'] = clientCertificateData; + headers['X-Client-Key-Data'] = clientKeyData; + } else { + throw new Error('Missing authentication credentials'); + } + + const response = await fetch(targetUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.body) { + throw new Error('Response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + const chunk = decoder.decode(value, { stream: true }); + res.write(chunk); + } + + res.end(); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch AI chat data' }); + } +} + +router.post('/suggestions', handlePromptSuggestions); +router.post('/messages', handleChatMessage); export default router; diff --git a/backend/index.js b/backend/index.js index a6a3b82e81..6b5c8dc85f 100644 --- a/backend/index.js +++ b/backend/index.js @@ -44,6 +44,10 @@ if (gzipEnabled) // compression interferes with ReadableStreams. Small chunks are not transmitted for unknown reason return false; } + // Skip compression for streaming endpoint + if (req.originalUrl.startsWith('/backend/ai-chat/messages')) { + return false; + } // fallback to standard filter function return compression.filter(req, res); }, diff --git a/src/components/KymaCompanion/api/getChatResponse.ts b/src/components/KymaCompanion/api/getChatResponse.ts index 1455e50845..6b1c957291 100644 --- a/src/components/KymaCompanion/api/getChatResponse.ts +++ b/src/components/KymaCompanion/api/getChatResponse.ts @@ -1,39 +1,68 @@ import { getClusterConfig } from 'state/utils/getBackendInfo'; -import { parseWithNestedBrackets } from '../utils/parseNestedBrackets'; +import { MessageChunk } from '../components/Chat/messages/Message'; + +interface ClusterAuth { + token?: string; + clientCertificateData?: string; + clientKeyData?: string; +} type GetChatResponseArgs = { - prompt: string; - handleChatResponse: (chunk: any) => void; - handleError: () => void; + query: string; + namespace?: string; + resourceType: string; + groupVersion?: string; + resourceName?: string; sessionID: string; + handleChatResponse: (chunk: MessageChunk) => void; + handleError: (error?: Error) => void; clusterUrl: string; - token: string; + clusterAuth: ClusterAuth; certificateAuthorityData: string; }; export default async function getChatResponse({ - prompt, + query, + namespace = '', + resourceType, + groupVersion = '', + resourceName = '', + sessionID, handleChatResponse, handleError, - sessionID, clusterUrl, - token, + clusterAuth, certificateAuthorityData, }: GetChatResponseArgs): Promise { const { backendAddress } = getClusterConfig(); - const url = `${backendAddress}/api/v1/namespaces/ai-core/services/http:ai-backend-clusterip:5000/proxy/api/v1/chat`; - const payload = { question: prompt, session_id: sessionID }; - const k8sAuthorization = `Bearer ${token}`; + const url = `${backendAddress}/ai-chat/messages`; + const payload = { + query, + namespace, + resourceType, + groupVersion, + resourceName, + }; + + const headers: Record = { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + 'X-Cluster-Certificate-Authority-Data': certificateAuthorityData, + 'X-Cluster-Url': clusterUrl, + 'Session-Id': sessionID, + }; + + if (clusterAuth?.token) { + headers['X-K8s-Authorization'] = clusterAuth?.token; + } else if (clusterAuth?.clientCertificateData && clusterAuth?.clientKeyData) { + headers['X-Client-Certificate-Data'] = clusterAuth?.clientCertificateData; + headers['X-Client-Key-Data'] = clusterAuth?.clientKeyData; + } else { + throw new Error('Missing authentication credentials'); + } fetch(url, { - headers: { - accept: 'application/json', - 'content-type': 'application/json', - 'X-Cluster-Certificate-Authority-Data': certificateAuthorityData, - 'X-Cluster-Url': clusterUrl, - 'X-K8s-Authorization': k8sAuthorization, - 'X-User': sessionID, - }, + headers, body: JSON.stringify(payload), method: 'POST', }) @@ -58,7 +87,7 @@ function readChunk( reader: ReadableStreamDefaultReader, decoder: TextDecoder, handleChatResponse: (chunk: any) => void, - handleError: () => void, + handleError: (error?: Error) => void, sessionID: string, ) { reader @@ -67,17 +96,13 @@ function readChunk( if (done) { return; } - // Also handles the rare case of two chunks being sent at once const receivedString = decoder.decode(value, { stream: true }); - const chunks = parseWithNestedBrackets(receivedString).map(chunk => { - return JSON.parse(chunk); - }); - chunks.forEach(chunk => { - if ('error' in chunk) { - throw new Error(chunk.error); - } - handleChatResponse(chunk); - }); + const chunk = JSON.parse(receivedString); + if (chunk?.data?.error) { + handleError(chunk.data.error); + return; + } + handleChatResponse(chunk); readChunk(reader, decoder, handleChatResponse, handleError, sessionID); }) .catch(error => { diff --git a/src/components/KymaCompanion/components/Chat/Chat.tsx b/src/components/KymaCompanion/components/Chat/Chat.tsx index 702cf89ec2..7ddb984c21 100644 --- a/src/components/KymaCompanion/components/Chat/Chat.tsx +++ b/src/components/KymaCompanion/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import React, { useEffect, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { FlexBox, Icon, Text, TextArea } from '@ui5/webcomponents-react'; -import Message from './messages/Message'; +import Message, { MessageChunk } from './messages/Message'; import Bubbles from './messages/Bubbles'; import ErrorMessage from './messages/ErrorMessage'; import { sessionIDState } from 'state/companion/sessionIDAtom'; @@ -15,7 +15,7 @@ import './Chat.scss'; interface MessageType { author: 'user' | 'ai'; - messageChunks: { step: string; result: string }[]; + messageChunks: MessageChunk[]; isLoading: boolean; suggestions?: string[]; suggestionsLoading?: boolean; @@ -29,13 +29,20 @@ export default function Chat() { { author: 'ai', messageChunks: [ - { step: 'output', result: t('kyma-companion.introduction') }, + { + data: { + answer: { + content: t('kyma-companion.introduction'), + next: '__end__', + }, + }, + }, ], isLoading: false, suggestionsLoading: true, }, ]); - const [errorOccured, setErrorOccured] = useState(false); + const [error, setError] = useState(null); const sessionID = useRecoilValue(sessionIDState); const cluster = useRecoilValue(clusterState); const authData = useRecoilValue(authDataState); @@ -43,6 +50,7 @@ export default function Chat() { const { initialSuggestions, initialSuggestionsLoading, + currentResource, } = usePromptSuggestions({ skip: chatHistory.length > 1 }); const addMessage = ({ author, messageChunks, isLoading }: MessageType) => { @@ -63,8 +71,8 @@ export default function Chat() { }); }; - const handleChatResponse = (response: any) => { - const isLoading = response?.step !== 'output'; + const handleChatResponse = (response: MessageChunk) => { + const isLoading = response?.data?.answer?.next !== '__end__'; if (!isLoading) { setFollowUpLoading(); getFollowUpQuestions({ @@ -79,7 +87,7 @@ export default function Chat() { setChatHistory(prevMessages => { const [latestMessage] = prevMessages.slice(-1); return prevMessages.slice(0, -1).concat({ - author: 'ai', + ...latestMessage, messageChunks: latestMessage.messageChunks.concat(response), isLoading, }); @@ -87,7 +95,7 @@ export default function Chat() { }; const setFollowUpLoading = () => { - setErrorOccured(false); + setError(null); updateLatestMessage({ suggestionsLoading: true }); }; @@ -95,25 +103,42 @@ export default function Chat() { updateLatestMessage({ suggestions: questions, suggestionsLoading: false }); }; - const handleError = () => { - setErrorOccured(true); + const handleError = (error?: Error) => { + setError(error?.message ?? t('kyma-companion.error.subtitle')); setChatHistory(prevItems => prevItems.slice(0, -2)); }; - const sendPrompt = (prompt: string) => { - setErrorOccured(false); + const sendPrompt = (query: string) => { + setError(null); addMessage({ author: 'user', - messageChunks: [{ step: 'output', result: prompt }], + messageChunks: [ + { + data: { + answer: { + content: query, + next: '__end__', + }, + }, + }, + ], isLoading: false, }); getChatResponse({ - prompt, + query, + namespace: currentResource?.namespace, + resourceType: currentResource?.resourceType, + groupVersion: currentResource?.groupVersion, + resourceName: currentResource?.resourceName, handleChatResponse, handleError, sessionID, clusterUrl: cluster.currentContext.cluster.cluster.server, - token: authData.token, + clusterAuth: { + token: authData?.token, + clientCertificateData: authData['client-certificate-data'], + clientKeyData: authData['client-key-data'], + }, certificateAuthorityData: cluster.currentContext.cluster.cluster['certificate-authority-data'], }); @@ -140,7 +165,14 @@ export default function Chat() { if (initialSuggestionsLoading) { updateLatestMessage({ messageChunks: [ - { step: 'output', result: t('kyma-companion.introduction') }, + { + data: { + answer: { + content: t('kyma-companion.introduction'), + next: '__end__', + }, + }, + }, ], }); setFollowUpLoading(); @@ -150,8 +182,12 @@ export default function Chat() { updateLatestMessage({ messageChunks: [ { - step: 'output', - result: t('kyma-companion.introduction-no-suggestions'), + data: { + answer: { + content: t('kyma-companion.introduction-no-suggestions'), + next: '__end__', + }, + }, }, ], }); @@ -162,11 +198,11 @@ export default function Chat() { }, [initialSuggestions, initialSuggestionsLoading]); useEffect(() => { - const delay = errorOccured ? 500 : 0; + const delay = error ? 500 : 0; setTimeout(() => { scrollToBottom(); }, delay); - }, [chatHistory, errorOccured]); + }, [chatHistory, error]); return ( ); })} - {errorOccured && ( + {error && ( {}} /> diff --git a/src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx b/src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx index ef39a0e181..c92725f92e 100644 --- a/src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx +++ b/src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx @@ -2,11 +2,13 @@ import { Button, Card, IllustratedMessage } from '@ui5/webcomponents-react'; import { useTranslation } from 'react-i18next'; interface ErrorMessageProps { + errorMessage: string; errorOnInitialMessage: boolean; retryPrompt: () => void; } export default function ErrorMessage({ + errorMessage, errorOnInitialMessage, retryPrompt, }: ErrorMessageProps): JSX.Element { @@ -19,7 +21,7 @@ export default function ErrorMessage({ name="Connection" key="error-message" titleText={t('kyma-companion.error.title')} - subtitleText={t('kyma-companion.error.subtitle')} + subtitleText={errorMessage} className="sap-margin-top-small no-padding" > {errorOnInitialMessage && ( diff --git a/src/components/KymaCompanion/components/Chat/messages/Message.scss b/src/components/KymaCompanion/components/Chat/messages/Message.scss index e74e412563..7d01eb72ae 100644 --- a/src/components/KymaCompanion/components/Chat/messages/Message.scss +++ b/src/components/KymaCompanion/components/Chat/messages/Message.scss @@ -25,13 +25,13 @@ .bold { font-weight: bold; - font-size: 1rem; + font-size: 0.925rem; } .highlighted { background-color: var(--sapContent_LabelColor); color: white; - padding: 0.2rem 0.25rem; + padding: 0.1rem 0.2rem; margin: 0.1rem 0; border-radius: 4px; } @@ -39,5 +39,6 @@ .text { line-height: 1.35; white-space: pre-line; + display: inline; } } diff --git a/src/components/KymaCompanion/components/Chat/messages/Message.tsx b/src/components/KymaCompanion/components/Chat/messages/Message.tsx index 9c79a1259a..d07dce12b2 100644 --- a/src/components/KymaCompanion/components/Chat/messages/Message.tsx +++ b/src/components/KymaCompanion/components/Chat/messages/Message.tsx @@ -11,36 +11,58 @@ import './Message.scss'; interface MessageProps { className: string; - messageChunks: Array<{ result: string }>; // Adjust this type based on the structure of 'messageChunks' + messageChunks: MessageChunk[]; isLoading: boolean; } +export interface MessageChunk { + event?: string; + data: { + agent?: string; + answer: { + content: string; + tasks?: { + task_id: number; + task_name: string; + status: string; + agent: string; + }[]; + next: string; + }; + }; +} + export default function Message({ className, messageChunks, isLoading, }: MessageProps): JSX.Element { if (isLoading) { + const chunksLength = messageChunks.length; return (
- {messageChunks.length > 0 ? ( - messageChunks.map((chunk, index) => ( - - {chunk?.result} -
- {index !== messageChunks.length - 1 ? ( - - ) : ( - - )} -
-
- )) + {chunksLength > 0 ? ( + messageChunks[chunksLength - 1].data?.answer?.tasks?.map( + (task, index) => { + return ( + + {task?.task_name} +
+ {task?.status === 'completed' ? ( + + ) : ( + + )} +
+
+ ); + }, + ) ) : ( )} @@ -48,7 +70,9 @@ export default function Message({ ); } - const segmentedText = segmentMarkdownText(messageChunks.slice(-1)[0]?.result); + const segmentedText = segmentMarkdownText( + messageChunks.slice(-1)[0]?.data?.answer?.content, + ); return (
{segmentedText && ( diff --git a/src/components/KymaCompanion/hooks/usePromptSuggestions.ts b/src/components/KymaCompanion/hooks/usePromptSuggestions.ts index 5141c20fbf..e4aaa5f3a4 100644 --- a/src/components/KymaCompanion/hooks/usePromptSuggestions.ts +++ b/src/components/KymaCompanion/hooks/usePromptSuggestions.ts @@ -6,7 +6,16 @@ import { ColumnLayoutState, columnLayoutState } from 'state/columnLayoutAtom'; import { prettifyNameSingular } from 'shared/utils/helpers'; import { usePost } from 'shared/hooks/BackendAPI/usePost'; -const getResourceFromColumnnLayout = (columnLayout: ColumnLayoutState) => { +interface CurrentResource { + resourceName: string; + resourceType: string; + namespace: string; + groupVersion: string; +} + +const getResourceFromColumnnLayout = ( + columnLayout: ColumnLayoutState, +): CurrentResource => { const column = columnLayout?.endColumn ?? columnLayout?.midColumn ?? @@ -14,8 +23,9 @@ const getResourceFromColumnnLayout = (columnLayout: ColumnLayoutState) => { return { namespace: column?.namespaceId ?? '', resourceType: prettifyNameSingular(column?.resourceType ?? ''), - apiGroup: column?.apiGroup ?? '', - apiVersion: column?.apiVersion ?? '', + groupVersion: column?.apiGroup + ? `${column?.apiGroup}/${column?.apiVersion}` + : column?.apiVersion ?? '', resourceName: column?.resourceName ?? '', }; }; @@ -27,6 +37,28 @@ export function usePromptSuggestions(options?: { skip?: boolean }) { const columnLayout = useRecoilValue(columnLayoutState); const [loading, setLoading] = useState(true); const fetchedResourceRef = useRef(''); + const [currentResource, setCurrentResource] = useState({ + namespace: '', + resourceName: '', + resourceType: '', + groupVersion: '', + }); + + useEffect(() => { + const { + namespace, + resourceType, + groupVersion, + resourceName, + } = getResourceFromColumnnLayout(columnLayout); + + setCurrentResource({ + namespace, + resourceName, + resourceType, + groupVersion, + }); + }, [columnLayout]); useEffect(() => { if (options?.skip) { @@ -36,12 +68,9 @@ export function usePromptSuggestions(options?: { skip?: boolean }) { const { namespace, resourceType, - apiGroup, - apiVersion, resourceName, - } = getResourceFromColumnnLayout(columnLayout); - const groupVersion = apiGroup ? `${apiGroup}/${apiVersion}` : apiVersion; - + groupVersion, + } = currentResource; const resourceKey = `${namespace}|${resourceType}|${groupVersion}|${resourceName}`.toLocaleLowerCase(); async function fetchSuggestions() { @@ -68,7 +97,11 @@ export function usePromptSuggestions(options?: { skip?: boolean }) { fetchedResourceRef.current = resourceKey; fetchSuggestions(); } - }, [columnLayout, options?.skip, post, setSessionID]); + }, [currentResource, options?.skip, post, setSessionID]); - return { initialSuggestions, initialSuggestionsLoading: loading }; + return { + initialSuggestions, + initialSuggestionsLoading: loading, + currentResource, + }; } diff --git a/src/components/KymaCompanion/utils/parseNestedBrackets.ts b/src/components/KymaCompanion/utils/parseNestedBrackets.ts deleted file mode 100644 index 2f22e9e95d..0000000000 --- a/src/components/KymaCompanion/utils/parseNestedBrackets.ts +++ /dev/null @@ -1,24 +0,0 @@ -// input: "{sample}{string {with}}{multiple {nested{braces}}}" -// output: ["{sample}", "{string {with}}", "{multiple {nested{braces}}}"] -export function parseWithNestedBrackets(text: string): string[] { - const output: string[] = []; - let openBraces = 0; - let startIndex = 0; - - for (let i = 0; i < text.length; i++) { - const char = text[i]; - if (char === '{') { - if (openBraces === 0) { - startIndex = i; - } - openBraces++; - } - if (char === '}') { - openBraces--; - if (openBraces === 0) { - output.push(text.substring(startIndex, i + 1)); - } - } - } - return output; -}