From 01397e1c3e01cda633e5a77caed9f2add1eb9bc7 Mon Sep 17 00:00:00 2001 From: chriskari Date: Tue, 4 Mar 2025 11:53:19 +0100 Subject: [PATCH 1/9] feat: adjust api requests to POST messages endpoint --- backend/companion/companionRouter.js | 98 ++++++++++++++++++- backend/index.js | 4 + .../KymaCompanion/api/getChatResponse.ts | 66 +++++++++---- .../KymaCompanion/components/Chat/Chat.tsx | 60 +++++++++--- .../components/Chat/messages/Message.tsx | 61 ++++++++---- .../hooks/usePromptSuggestions.ts | 53 ++++++++-- 6 files changed, 281 insertions(+), 61 deletions(-) diff --git a/backend/companion/companionRouter.js b/backend/companion/companionRouter.js index b363bf3172..888a9a2e35 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(), ); @@ -67,6 +67,100 @@ async function handleAIChatRequest(req, res) { } } -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 url = `https://companion.cp.dev.kyma.cloud.sap/api/conversations/${conversationId}/messages`; + 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(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + console.log(response); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + 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) { + console.error('Error in AI Chat proxy:', 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..763b78be32 100644 --- a/src/components/KymaCompanion/api/getChatResponse.ts +++ b/src/components/KymaCompanion/api/getChatResponse.ts @@ -1,39 +1,69 @@ 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: () => 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', }) diff --git a/src/components/KymaCompanion/components/Chat/Chat.tsx b/src/components/KymaCompanion/components/Chat/Chat.tsx index 702cf89ec2..46d7b2bdcd 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,7 +29,14 @@ 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, @@ -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({ @@ -100,20 +108,37 @@ export default function Chat() { setChatHistory(prevItems => prevItems.slice(0, -2)); }; - const sendPrompt = (prompt: string) => { + const sendPrompt = (query: string) => { setErrorOccured(false); 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__', + }, + }, }, ], }); diff --git a/src/components/KymaCompanion/components/Chat/messages/Message.tsx b/src/components/KymaCompanion/components/Chat/messages/Message.tsx index 9c79a1259a..01970fdea7 100644 --- a/src/components/KymaCompanion/components/Chat/messages/Message.tsx +++ b/src/components/KymaCompanion/components/Chat/messages/Message.tsx @@ -11,10 +11,27 @@ 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, @@ -24,23 +41,27 @@ export default function Message({ return (
{messageChunks.length > 0 ? ( - messageChunks.map((chunk, index) => ( - - {chunk?.result} -
- {index !== messageChunks.length - 1 ? ( - - ) : ( - - )} -
-
- )) + messageChunks.map((chunk, index) => { + const taskName = + chunk?.data?.answer?.tasks?.[index]?.task_name || ''; + return ( + + {taskName} +
+ {index !== messageChunks.length - 1 ? ( + + ) : ( + + )} +
+
+ ); + }) ) : ( )} @@ -48,7 +69,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, + }; } From 014d19e4700a13426bbd8a87d7e4381af3ecfd3c Mon Sep 17 00:00:00 2001 From: chriskari Date: Tue, 4 Mar 2025 13:55:38 +0100 Subject: [PATCH 2/9] fix: address security warning --- backend/companion/companionRouter.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/companion/companionRouter.js b/backend/companion/companionRouter.js index 888a9a2e35..e2274f7c35 100644 --- a/backend/companion/companionRouter.js +++ b/backend/companion/companionRouter.js @@ -88,7 +88,20 @@ async function handleChatMessage(req, res) { const conversationId = sessionId; try { - const url = `https://companion.cp.dev.kyma.cloud.sap/api/conversations/${conversationId}/messages`; + if (!conversationId || typeof conversationId !== 'string') { + return res.status(400).json({ error: 'Invalid conversation ID' }); + } + + const baseUrl = + 'https://companion.cp.dev.kyma.cloud.sap/api/conversations/'; + let targetUrl; + try { + targetUrl = new URL(`${conversationId}/messages`, baseUrl); + } catch (urlError) { + console.error('Invalid URL construction:', urlError); + return res.status(400).json({ error: 'Invalid conversation ID' }); + } + const payload = { query, resource_kind: resourceType, @@ -123,14 +136,12 @@ async function handleChatMessage(req, res) { throw new Error('Missing authentication credentials'); } - const response = await fetch(url, { + const response = await fetch(targetUrl, { method: 'POST', headers, body: JSON.stringify(payload), }); - console.log(response); - if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } From d72d1008ae4b30469b5342e7f71d54ac716ba035 Mon Sep 17 00:00:00 2001 From: chriskari Date: Wed, 5 Mar 2025 09:08:19 +0100 Subject: [PATCH 3/9] feat: validate given sessionID to be a UUID --- backend/companion/companionRouter.js | 7 +++++-- backend/utils/isValidUUID.js | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 backend/utils/isValidUUID.js diff --git a/backend/companion/companionRouter.js b/backend/companion/companionRouter.js index e2274f7c35..2382e58ec6 100644 --- a/backend/companion/companionRouter.js +++ b/backend/companion/companionRouter.js @@ -1,5 +1,6 @@ import express from 'express'; import { TokenManager } from './TokenManager'; +import { isValidUUID } from '../utils/isValidUUID'; const tokenManager = new TokenManager(); @@ -88,8 +89,10 @@ async function handleChatMessage(req, res) { const conversationId = sessionId; try { - if (!conversationId || typeof conversationId !== 'string') { - return res.status(400).json({ error: 'Invalid conversation ID' }); + if (!isValidUUID(sessionId)) { + return res.status(400).json({ + error: 'Invalid session ID. Must be a valid UUID v4.', + }); } const baseUrl = diff --git a/backend/utils/isValidUUID.js b/backend/utils/isValidUUID.js new file mode 100644 index 0000000000..983c1c769c --- /dev/null +++ b/backend/utils/isValidUUID.js @@ -0,0 +1,12 @@ +export function isValidUUID(uuid) { + // Standard UUID length + const UUIDlength = 36; + // UUID v4 regex pattern + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + return ( + typeof uuid === 'string' && + uuid.length === UUIDlength && + uuidRegex.test(uuid) + ); +} From b2b3e3a720a3a29057a1f3004b5d513077954bcb Mon Sep 17 00:00:00 2001 From: chriskari Date: Wed, 5 Mar 2025 09:54:43 +0100 Subject: [PATCH 4/9] feat: validate sessionID to be a UUID and use encoding --- backend/companion/companionRouter.js | 26 +++++++------------------- backend/utils/isValidUUID.js | 12 ------------ 2 files changed, 7 insertions(+), 31 deletions(-) delete mode 100644 backend/utils/isValidUUID.js diff --git a/backend/companion/companionRouter.js b/backend/companion/companionRouter.js index 2382e58ec6..0b7c432045 100644 --- a/backend/companion/companionRouter.js +++ b/backend/companion/companionRouter.js @@ -1,6 +1,5 @@ import express from 'express'; import { TokenManager } from './TokenManager'; -import { isValidUUID } from '../utils/isValidUUID'; const tokenManager = new TokenManager(); @@ -63,7 +62,6 @@ async function handlePromptSuggestions(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' }); } } @@ -89,21 +87,16 @@ async function handleChatMessage(req, res) { const conversationId = sessionId; try { - if (!isValidUUID(sessionId)) { - return res.status(400).json({ - error: 'Invalid session ID. Must be a valid UUID v4.', - }); + 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/'; - let targetUrl; - try { - targetUrl = new URL(`${conversationId}/messages`, baseUrl); - } catch (urlError) { - console.error('Invalid URL construction:', urlError); - return res.status(400).json({ error: 'Invalid conversation ID' }); - } + const targetUrl = new URL( + `${encodeURIComponent(conversationId)}/messages`, + baseUrl, + ); const payload = { query, @@ -145,10 +138,6 @@ async function handleChatMessage(req, res) { body: JSON.stringify(payload), }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - if (!response.body) { throw new Error('Response body is null'); } @@ -169,7 +158,6 @@ async function handleChatMessage(req, res) { res.end(); } catch (error) { - console.error('Error in AI Chat proxy:', error); res.status(500).json({ error: 'Failed to fetch AI chat data' }); } } diff --git a/backend/utils/isValidUUID.js b/backend/utils/isValidUUID.js deleted file mode 100644 index 983c1c769c..0000000000 --- a/backend/utils/isValidUUID.js +++ /dev/null @@ -1,12 +0,0 @@ -export function isValidUUID(uuid) { - // Standard UUID length - const UUIDlength = 36; - // UUID v4 regex pattern - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - - return ( - typeof uuid === 'string' && - uuid.length === UUIDlength && - uuidRegex.test(uuid) - ); -} From 731a3bb342a9032a2493953c9c1a43901b022a6c Mon Sep 17 00:00:00 2001 From: chriskari Date: Wed, 5 Mar 2025 11:17:46 +0100 Subject: [PATCH 5/9] feat: remove obsolote method for parsing streamed response --- .../KymaCompanion/api/getChatResponse.ts | 16 ++++--------- .../utils/parseNestedBrackets.ts | 24 ------------------- 2 files changed, 5 insertions(+), 35 deletions(-) delete mode 100644 src/components/KymaCompanion/utils/parseNestedBrackets.ts diff --git a/src/components/KymaCompanion/api/getChatResponse.ts b/src/components/KymaCompanion/api/getChatResponse.ts index 763b78be32..a44200eb07 100644 --- a/src/components/KymaCompanion/api/getChatResponse.ts +++ b/src/components/KymaCompanion/api/getChatResponse.ts @@ -1,5 +1,4 @@ import { getClusterConfig } from 'state/utils/getBackendInfo'; -import { parseWithNestedBrackets } from '../utils/parseNestedBrackets'; import { MessageChunk } from '../components/Chat/messages/Message'; interface ClusterAuth { @@ -97,17 +96,12 @@ 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 ('error' in chunk) { + throw new Error(chunk.error); + } + handleChatResponse(chunk); readChunk(reader, decoder, handleChatResponse, handleError, sessionID); }) .catch(error => { 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; -} From b2160682408a563d1758c4494efae446d2e1d231 Mon Sep 17 00:00:00 2001 From: chriskari Date: Wed, 5 Mar 2025 13:13:13 +0100 Subject: [PATCH 6/9] feat: small css improvements of messages --- .../KymaCompanion/components/Chat/messages/Message.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/KymaCompanion/components/Chat/messages/Message.scss b/src/components/KymaCompanion/components/Chat/messages/Message.scss index e74e412563..9976c2502e 100644 --- a/src/components/KymaCompanion/components/Chat/messages/Message.scss +++ b/src/components/KymaCompanion/components/Chat/messages/Message.scss @@ -31,7 +31,7 @@ .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; } } From 3e674c75ec1531b46949996e87920f0ef8dc167b Mon Sep 17 00:00:00 2001 From: chriskari Date: Wed, 5 Mar 2025 13:54:58 +0100 Subject: [PATCH 7/9] fix: small fix for chatHistory update after receiving message has finished --- src/components/KymaCompanion/components/Chat/Chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/KymaCompanion/components/Chat/Chat.tsx b/src/components/KymaCompanion/components/Chat/Chat.tsx index 46d7b2bdcd..aea69da9cc 100644 --- a/src/components/KymaCompanion/components/Chat/Chat.tsx +++ b/src/components/KymaCompanion/components/Chat/Chat.tsx @@ -87,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, }); From 887b78708475a3a74b42b1e3c1ff2fceb4465f79 Mon Sep 17 00:00:00 2001 From: chriskari Date: Wed, 5 Mar 2025 15:28:36 +0100 Subject: [PATCH 8/9] feat: adjust displaying streamed chunks --- .../KymaCompanion/components/Chat/Chat.tsx | 2 +- .../components/Chat/messages/Message.scss | 2 +- .../components/Chat/messages/Message.tsx | 45 ++++++++++--------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/components/KymaCompanion/components/Chat/Chat.tsx b/src/components/KymaCompanion/components/Chat/Chat.tsx index aea69da9cc..62ad97fb7e 100644 --- a/src/components/KymaCompanion/components/Chat/Chat.tsx +++ b/src/components/KymaCompanion/components/Chat/Chat.tsx @@ -72,7 +72,7 @@ export default function Chat() { }; const handleChatResponse = (response: MessageChunk) => { - const isLoading = response?.data?.answer.next !== '__end__'; + const isLoading = response?.data?.answer?.next !== '__end__'; if (!isLoading) { setFollowUpLoading(); getFollowUpQuestions({ diff --git a/src/components/KymaCompanion/components/Chat/messages/Message.scss b/src/components/KymaCompanion/components/Chat/messages/Message.scss index 9976c2502e..7d01eb72ae 100644 --- a/src/components/KymaCompanion/components/Chat/messages/Message.scss +++ b/src/components/KymaCompanion/components/Chat/messages/Message.scss @@ -25,7 +25,7 @@ .bold { font-weight: bold; - font-size: 1rem; + font-size: 0.925rem; } .highlighted { diff --git a/src/components/KymaCompanion/components/Chat/messages/Message.tsx b/src/components/KymaCompanion/components/Chat/messages/Message.tsx index 01970fdea7..d07dce12b2 100644 --- a/src/components/KymaCompanion/components/Chat/messages/Message.tsx +++ b/src/components/KymaCompanion/components/Chat/messages/Message.tsx @@ -38,30 +38,31 @@ export default function Message({ isLoading, }: MessageProps): JSX.Element { if (isLoading) { + const chunksLength = messageChunks.length; return (
- {messageChunks.length > 0 ? ( - messageChunks.map((chunk, index) => { - const taskName = - chunk?.data?.answer?.tasks?.[index]?.task_name || ''; - return ( - - {taskName} -
- {index !== messageChunks.length - 1 ? ( - - ) : ( - - )} -
-
- ); - }) + {chunksLength > 0 ? ( + messageChunks[chunksLength - 1].data?.answer?.tasks?.map( + (task, index) => { + return ( + + {task?.task_name} +
+ {task?.status === 'completed' ? ( + + ) : ( + + )} +
+
+ ); + }, + ) ) : ( )} From 8e05f2b7854f50fe9e5a4ee1dcfb166be5c92a61 Mon Sep 17 00:00:00 2001 From: chriskari Date: Wed, 5 Mar 2025 16:02:51 +0100 Subject: [PATCH 9/9] feat: update error handling for future custom errors from companion backend --- .../KymaCompanion/api/getChatResponse.ts | 9 +++++---- .../KymaCompanion/components/Chat/Chat.tsx | 17 +++++++++-------- .../components/Chat/messages/ErrorMessage.tsx | 4 +++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/components/KymaCompanion/api/getChatResponse.ts b/src/components/KymaCompanion/api/getChatResponse.ts index a44200eb07..6b1c957291 100644 --- a/src/components/KymaCompanion/api/getChatResponse.ts +++ b/src/components/KymaCompanion/api/getChatResponse.ts @@ -15,7 +15,7 @@ type GetChatResponseArgs = { resourceName?: string; sessionID: string; handleChatResponse: (chunk: MessageChunk) => void; - handleError: () => void; + handleError: (error?: Error) => void; clusterUrl: string; clusterAuth: ClusterAuth; certificateAuthorityData: string; @@ -87,7 +87,7 @@ function readChunk( reader: ReadableStreamDefaultReader, decoder: TextDecoder, handleChatResponse: (chunk: any) => void, - handleError: () => void, + handleError: (error?: Error) => void, sessionID: string, ) { reader @@ -98,8 +98,9 @@ function readChunk( } const receivedString = decoder.decode(value, { stream: true }); const chunk = JSON.parse(receivedString); - if ('error' in chunk) { - throw new Error(chunk.error); + if (chunk?.data?.error) { + handleError(chunk.data.error); + return; } handleChatResponse(chunk); readChunk(reader, decoder, handleChatResponse, handleError, sessionID); diff --git a/src/components/KymaCompanion/components/Chat/Chat.tsx b/src/components/KymaCompanion/components/Chat/Chat.tsx index 62ad97fb7e..7ddb984c21 100644 --- a/src/components/KymaCompanion/components/Chat/Chat.tsx +++ b/src/components/KymaCompanion/components/Chat/Chat.tsx @@ -42,7 +42,7 @@ export default function Chat() { suggestionsLoading: true, }, ]); - const [errorOccured, setErrorOccured] = useState(false); + const [error, setError] = useState(null); const sessionID = useRecoilValue(sessionIDState); const cluster = useRecoilValue(clusterState); const authData = useRecoilValue(authDataState); @@ -95,7 +95,7 @@ export default function Chat() { }; const setFollowUpLoading = () => { - setErrorOccured(false); + setError(null); updateLatestMessage({ suggestionsLoading: true }); }; @@ -103,13 +103,13 @@ 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 = (query: string) => { - setErrorOccured(false); + setError(null); addMessage({ author: 'user', messageChunks: [ @@ -198,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 && (