diff --git a/package-lock.json b/package-lock.json index a99b8f1b3f..801f6ee1d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "cronstrue": "^1.114.0", "didyoumean": "^1.2.2", "file-saver": "^2.0.2", + "fingerprintjs2": "^2.1.4", "graphviz-react": "^1.2.5", "http-status-codes": "^2.2.0", "i18next": "^22.0.4", @@ -11581,6 +11582,12 @@ "node": ">=6" } }, + "node_modules/fingerprintjs2": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/fingerprintjs2/-/fingerprintjs2-2.1.4.tgz", + "integrity": "sha512-veP2yVsnYvjDVkzZMyIEwpqCAQfsBLH+U4PK5MlFAnLjZrttbdRqEArE1fPcnJFz5oS5CrdONbsV7J6FGpIJEQ==", + "deprecated": "Package has been renamed to @fingerprintjs/fingerprintjs. Install @fingerprintjs/fingerprintjs to get updates." + }, "node_modules/flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -36370,6 +36377,11 @@ "locate-path": "^3.0.0" } }, + "fingerprintjs2": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/fingerprintjs2/-/fingerprintjs2-2.1.4.tgz", + "integrity": "sha512-veP2yVsnYvjDVkzZMyIEwpqCAQfsBLH+U4PK5MlFAnLjZrttbdRqEArE1fPcnJFz5oS5CrdONbsV7J6FGpIJEQ==" + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", diff --git a/package.json b/package.json index fceda75e3d..88fb5ed91a 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "cronstrue": "^1.114.0", "didyoumean": "^1.2.2", "file-saver": "^2.0.2", + "fingerprintjs2": "^2.1.4", "graphviz-react": "^1.2.5", "http-status-codes": "^2.2.0", "i18next": "^22.0.4", diff --git a/public/i18n/en.yaml b/public/i18n/en.yaml index 25706597bb..2fa3d8e239 100644 --- a/public/i18n/en.yaml +++ b/public/i18n/en.yaml @@ -3,6 +3,22 @@ horizontal-pod-autoscalers: Horizontal Pod Autoscalers subscriptions: Subscriptions apps: title: Apps +ai-assistant: + name: Joule + opener: + use-ai: AI Companion + suggestions: Suggestions + input-placeholder: Ask about this resource + error-message: Couldn't fetch suggestions. Please try again. + error: + title: Service is interrupted + subtitle: A temporary interruption occured. Please try again. + introduction1: Hello there, + introduction2: How can I help you? + placeholder: Type something + tabs: + chat: Chat + page-insights: Page Insights cluster-overview: headers: cpu: CPU @@ -264,6 +280,7 @@ common: remove-all: Remove all reset: Reset restart: Restart + retry: Retry save: Save submit: Submit update: Update diff --git a/src/components/AIassistant/api/getChatResponse.js b/src/components/AIassistant/api/getChatResponse.js new file mode 100644 index 0000000000..22bc2b4080 --- /dev/null +++ b/src/components/AIassistant/api/getChatResponse.js @@ -0,0 +1,74 @@ +import { getClusterConfig } from 'state/utils/getBackendInfo'; +import { parseWithNestedBrackets } from '../utils/parseNestedBrackets'; + +export default async function getChatResponse({ + prompt, + handleChatResponse, + handleError, + sessionID, + clusterUrl, + token, + certificateAuthorityData, +}) { + 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}`; + + 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, + }, + body: JSON.stringify(payload), + method: 'POST', + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + readChunk(reader, decoder, handleChatResponse, handleError, sessionID); + }) + .catch(error => { + handleError(); + console.error('Error fetching data:', error); + }); +} + +function readChunk( + reader, + decoder, + handleChatResponse, + handleError, + sessionID, +) { + reader + .read() + .then(({ done, value }) => { + 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); + }); + readChunk(reader, decoder, handleChatResponse, handleError, sessionID); + }) + .catch(error => { + handleError(); + console.error('Error reading stream:', error); + }); +} diff --git a/src/components/AIassistant/api/getFollowUpQuestions.js b/src/components/AIassistant/api/getFollowUpQuestions.js new file mode 100644 index 0000000000..8d8ccea53d --- /dev/null +++ b/src/components/AIassistant/api/getFollowUpQuestions.js @@ -0,0 +1,32 @@ +import { getClusterConfig } from 'state/utils/getBackendInfo'; + +export default async function getFollowUpQuestions({ + sessionID = '', + handleFollowUpQuestions, + clusterUrl, + token, + certificateAuthorityData, +}) { + try { + const { backendAddress } = getClusterConfig(); + const url = `${backendAddress}/api/v1/namespaces/ai-core/services/http:ai-backend-clusterip:5000/proxy/api/v1/llm/followup`; + const payload = JSON.parse(`{"session_id":"${sessionID}"}`); + const k8sAuthorization = `Bearer ${token}`; + + let { results } = await 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, + }, + body: JSON.stringify(payload), + method: 'POST', + }).then(result => result.json()); + handleFollowUpQuestions(results); + } catch (error) { + console.error('Error fetching data:', error); + } +} diff --git a/src/components/AIassistant/api/getPromptSuggestions.js b/src/components/AIassistant/api/getPromptSuggestions.js new file mode 100644 index 0000000000..b2448f4392 --- /dev/null +++ b/src/components/AIassistant/api/getPromptSuggestions.js @@ -0,0 +1,42 @@ +import { getClusterConfig } from 'state/utils/getBackendInfo'; +import { extractApiGroup } from 'resources/Roles/helpers'; + +export default async function getPromptSuggestions({ + namespace = '', + resourceType = '', + groupVersion = '', + resourceName = '', + sessionID = '', + clusterUrl, + token, + certificateAuthorityData, +}) { + try { + const { backendAddress } = getClusterConfig(); + const url = `${backendAddress}/api/v1/namespaces/ai-core/services/http:ai-backend-clusterip:5000/proxy/api/v1/llm/init`; + const apiGroup = extractApiGroup(groupVersion); + const payload = JSON.parse( + `{"resource_type":"${resourceType.toLowerCase()}${ + apiGroup.length ? `.${apiGroup}` : '' + }","resource_name":"${resourceName}","namespace":"${namespace}","session_id":"${sessionID}"}`, + ); + const k8sAuthorization = `Bearer ${token}`; + + let { results } = await 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, + }, + body: JSON.stringify(payload), + method: 'POST', + }).then(result => result.json()); + return results; + } catch (error) { + console.error('Error fetching data:', error); + return false; + } +} diff --git a/src/components/AIassistant/components/AIOpener.js b/src/components/AIassistant/components/AIOpener.js new file mode 100644 index 0000000000..0aa821bc41 --- /dev/null +++ b/src/components/AIassistant/components/AIOpener.js @@ -0,0 +1,162 @@ +import { + Button, + CustomListItem, + FlexBox, + Icon, + Input, + List, + Loader, + Popover, + Text, + Title, +} from '@ui5/webcomponents-react'; +import { spacing } from '@ui5/webcomponents-react-base'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { showAIassistantState } from 'components/AIassistant/state/showAIassistantAtom'; +import { initialPromptState } from '../state/initalPromptAtom'; +import getPromptSuggestions from 'components/AIassistant/api/getPromptSuggestions'; +import { createPortal } from 'react-dom'; +import { authDataState } from 'state/authDataAtom'; +import { sessionIDState } from '../state/sessionIDAtom'; +import generateSessionID from '../utils/generateSesssionID'; +import './AIOpener.scss'; +import { clusterState } from 'state/clusterAtom'; + +export default function AIOpener({ + namespace, + resourceType, + groupVersion, + resourceName, +}) { + const { t } = useTranslation(); + const [showAssistant, setShowAssistant] = useRecoilState( + showAIassistantState, + ); + const setInitialPrompt = useSetRecoilState(initialPromptState); + const [popoverOpen, setPopoverOpen] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [errorOccured, setErrorOccured] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const authData = useRecoilValue(authDataState); + const setSessionID = useSetRecoilState(sessionIDState); + const cluster = useRecoilValue(clusterState); + + const fetchSuggestions = async () => { + setErrorOccured(false); + setPopoverOpen(true); + if (!isLoading && suggestions.length === 0) { + setIsLoading(true); + const sessionID = await generateSessionID(authData); + setSessionID(sessionID); + const promptSuggestions = await getPromptSuggestions({ + namespace, + resourceType, + groupVersion, + resourceName, + sessionID, + clusterUrl: cluster.currentContext.cluster.cluster.server, + token: authData.token, + certificateAuthorityData: + cluster.currentContext.cluster.cluster['certificate-authority-data'], + }); + setIsLoading(false); + if (!promptSuggestions) { + setErrorOccured(true); + } else { + setSuggestions(promptSuggestions); + } + } + }; + + const sendInitialPrompt = prompt => { + setInitialPrompt(prompt); + setPopoverOpen(false); + setShowAssistant({ + show: true, + fullScreen: false, + }); + }; + + const onSubmitInput = () => { + if (inputValue.length === 0) return; + const prompt = inputValue; + setInputValue(''); + sendInitialPrompt(prompt); + }; + + return ( + <> + + {createPortal( + setPopoverOpen(false)} + opener="openPopoverBtn" + placementType="Bottom" + horizontalAlign="Right" + > + } + value={inputValue} + onKeyDown={e => e.key === 'Enter' && onSubmitInput()} + onInput={e => setInputValue(e.target.value)} + placeholder={t('ai-assistant.opener.input-placeholder')} + className="popover-input" + /> + + {t('ai-assistant.opener.suggestions')} + + {errorOccured || (!isLoading && suggestions.length === 0) ? ( + + + {t('ai-assistant.opener.error-message')} + + + + ) : isLoading ? ( +
+ +
+ ) : ( + + {suggestions.map((suggestion, index) => ( + sendInitialPrompt(suggestion)} + className="custom-list-item" + > + {suggestion} + + + ))} + + )} +
, + document.body, + )} + + ); +} diff --git a/src/components/AIassistant/components/AIOpener.scss b/src/components/AIassistant/components/AIOpener.scss new file mode 100644 index 0000000000..ed346246e3 --- /dev/null +++ b/src/components/AIassistant/components/AIOpener.scss @@ -0,0 +1,35 @@ +.ai-button { + color: var(--sapChart_OrderedColor_5); +} + +.suggestions-popover::part(content) { + padding: 0.5rem; +} + +.suggestions-popover { + .popover-input { + min-width: 225px; + width: 100%; + } + + .custom-list-item::part(native-li) { + padding: 0.5rem; + } + + .custom-list-item { + .text { + width: 90%; + } + } + + .custom-list-item::part(content) { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + + .text { + width: 90%; + } + } +} diff --git a/src/components/AIassistant/components/AIassistant.js b/src/components/AIassistant/components/AIassistant.js new file mode 100644 index 0000000000..b4194df4b3 --- /dev/null +++ b/src/components/AIassistant/components/AIassistant.js @@ -0,0 +1,75 @@ +import { useTranslation } from 'react-i18next'; +import { + Button, + Card, + Tab, + TabContainer, + Title, + Toolbar, + ToolbarSpacer, +} from '@ui5/webcomponents-react'; +import { spacing } from '@ui5/webcomponents-react-base'; +import { useRecoilState } from 'recoil'; +import { showAIassistantState } from 'components/AIassistant/state/showAIassistantAtom'; +import Chat from './Chat/Chat'; +//import PageInsights from './PageInsights/PageInsights'; +import './AIassistant.scss'; + +export default function AIassistant() { + const { t } = useTranslation(); + const [showAssistant, setShowAssistant] = useRecoilState( + showAIassistantState, + ); + + return ( +
+ + + {t('ai-assistant.name')} + + +
+
+ + } + > + + + + + {/* + + */} + +
+
+ ); +} diff --git a/src/components/AIassistant/components/AIassistant.scss b/src/components/AIassistant/components/AIassistant.scss new file mode 100644 index 0000000000..d23dd95abc --- /dev/null +++ b/src/components/AIassistant/components/AIassistant.scss @@ -0,0 +1,45 @@ +#assistant_wrapper { + width: calc(100% - 1rem); + + .ai_assistant { + height: 100%; + width: 100%; + + &__header { + background-color: var(--sapContent_Illustrative_Color1); + min-height: 60px; + padding: 0.5rem; + + .title { + color: white; + text-shadow: none; + } + + .action { + color: white; + background: transparent; + } + } + + .tab-container { + height: calc(100vh - 60px - 1.4rem); + container-type: inline-size; + } + } +} + +@container (max-width: 950px) { + .tab-container::part(content) { + width: 100%; + margin-left: 0; + margin-right: 0; + } +} + +@container (min-width: 950px) { + .tab-container::part(content) { + width: 70%; + margin-left: 15%; + margin-right: 15%; + } +} diff --git a/src/components/AIassistant/components/Chat/Chat.js b/src/components/AIassistant/components/Chat/Chat.js new file mode 100644 index 0000000000..c64f64dbe5 --- /dev/null +++ b/src/components/AIassistant/components/Chat/Chat.js @@ -0,0 +1,167 @@ +import { useTranslation } from 'react-i18next'; +import React, { useEffect, useRef, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { FlexBox, Icon, Input } from '@ui5/webcomponents-react'; +import { spacing } from '@ui5/webcomponents-react-base'; +import { initialPromptState } from 'components/AIassistant/state/initalPromptAtom'; +import Message from './messages/Message'; +import Bubbles from './messages/Bubbles'; +import ErrorMessage from './messages/ErrorMessage'; +import getChatResponse from 'components/AIassistant/api/getChatResponse'; +import { sessionIDState } from 'components/AIassistant/state/sessionIDAtom'; +import getFollowUpQuestions from 'components/AIassistant/api/getFollowUpQuestions'; +import { clusterState } from 'state/clusterAtom'; +import { authDataState } from 'state/authDataAtom'; +import './Chat.scss'; + +export default function Chat() { + const { t } = useTranslation(); + const containerRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + const [chatHistory, setChatHistory] = useState([]); + const [errorOccured, setErrorOccured] = useState(false); + const initialPrompt = useRecoilValue(initialPromptState); + const sessionID = useRecoilValue(sessionIDState); + const cluster = useRecoilValue(clusterState); + const authData = useRecoilValue(authDataState); + + const addMessage = (author, messageChunks, isLoading) => { + setChatHistory(prevItems => + prevItems.concat({ author, messageChunks, isLoading }), + ); + }; + + const handleChatResponse = response => { + const isLoading = response?.step !== 'output'; + if (!isLoading) { + getFollowUpQuestions({ + sessionID, + handleFollowUpQuestions, + clusterUrl: cluster.currentContext.cluster.cluster.server, + token: authData.token, + certificateAuthorityData: + cluster.currentContext.cluster.cluster['certificate-authority-data'], + }); + } + setChatHistory(prevMessages => { + const [latestMessage] = prevMessages.slice(-1); + return prevMessages.slice(0, -1).concat({ + author: 'ai', + messageChunks: latestMessage.messageChunks.concat(response), + isLoading, + }); + }); + }; + + const handleFollowUpQuestions = questions => { + setChatHistory(prevMessages => { + const [latestMessage] = prevMessages.slice(-1); + return prevMessages + .slice(0, -1) + .concat({ ...latestMessage, suggestions: questions }); + }); + }; + + const handleError = () => { + setErrorOccured(true); + setChatHistory(prevItems => prevItems.slice(0, -2)); + }; + + const sendPrompt = prompt => { + setErrorOccured(false); + addMessage('user', [{ step: 'output', result: prompt }], false); + getChatResponse({ + prompt, + handleChatResponse, + handleError, + sessionID, + clusterUrl: cluster.currentContext.cluster.cluster.server, + token: authData.token, + certificateAuthorityData: + cluster.currentContext.cluster.cluster['certificate-authority-data'], + }); + addMessage('ai', [], true); + }; + + const onSubmitInput = () => { + if (inputValue.length === 0) return; + const prompt = inputValue; + setInputValue(''); + sendPrompt(prompt); + }; + + const scrollToBottom = () => { + if (containerRef?.current?.lastChild) + containerRef.current.lastChild.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }; + + useEffect(() => { + if (chatHistory.length === 0) sendPrompt(initialPrompt); + // eslint-disable-next-line + }, []); + + useEffect(() => { + const delay = errorOccured ? 500 : 0; + setTimeout(() => { + scrollToBottom(); + }, delay); + }, [chatHistory, errorOccured]); + + return ( + +
+ {chatHistory.map((message, index) => { + return message.author === 'ai' ? ( + + + {index === chatHistory.length - 1 && !message.isLoading && ( + + )} + + ) : ( + + ); + })} + {errorOccured && ( + sendPrompt(initialPrompt)} + /> + )} +
+
+ } + onKeyDown={e => e.key === 'Enter' && onSubmitInput()} + onInput={e => setInputValue(e.target.value)} + /> +
+
+ ); +} diff --git a/src/components/AIassistant/components/Chat/Chat.scss b/src/components/AIassistant/components/Chat/Chat.scss new file mode 100644 index 0000000000..a0e7395d36 --- /dev/null +++ b/src/components/AIassistant/components/Chat/Chat.scss @@ -0,0 +1,31 @@ +.chat-container { + height: 100%; + overflow: hidden; + + .chat-list { + display: flex; + flex-direction: column; + overflow: auto; + gap: 8px; + + &::-webkit-scrollbar { + display: none; + } + + .left-aligned { + align-self: flex-start; + background-color: var(--sapBackgroundColor); + border-radius: 8px 8px 8px 0; + } + + .right-aligned { + align-self: flex-end; + background-color: var(--sapContent_Illustrative_Color1); + border-radius: 8px 8px 0 8px; + + .text { + color: white; + } + } + } +} diff --git a/src/components/AIassistant/components/Chat/messages/Bubbles.js b/src/components/AIassistant/components/Chat/messages/Bubbles.js new file mode 100644 index 0000000000..1057e1171d --- /dev/null +++ b/src/components/AIassistant/components/Chat/messages/Bubbles.js @@ -0,0 +1,21 @@ +import { Button, FlexBox } from '@ui5/webcomponents-react'; +import './Bubbles.scss'; + +export default function Bubbles({ suggestions, onClick }) { + return suggestions ? ( + + {suggestions.map((suggestion, index) => ( + + ))} + + ) : ( + <> + ); +} diff --git a/src/components/AIassistant/components/Chat/messages/Bubbles.scss b/src/components/AIassistant/components/Chat/messages/Bubbles.scss new file mode 100644 index 0000000000..b3f64f53ec --- /dev/null +++ b/src/components/AIassistant/components/Chat/messages/Bubbles.scss @@ -0,0 +1,14 @@ +.bubbles-container { + max-width: 90%; + gap: 8px; + + .bubble-button { + align-self: flex-start; + color: var(--sapChart_OrderedColor_5); + } + + .bubble-button:hover { + background-color: var(--sapBackgroundColor1); + border-color: var(--sapChart_OrderedColor_5); + } +} diff --git a/src/components/AIassistant/components/Chat/messages/CodePanel.js b/src/components/AIassistant/components/Chat/messages/CodePanel.js new file mode 100644 index 0000000000..78c18285af --- /dev/null +++ b/src/components/AIassistant/components/Chat/messages/CodePanel.js @@ -0,0 +1,20 @@ +import { Text, Panel } from '@ui5/webcomponents-react'; +import { formatCodeSegment } from 'components/AIassistant/utils/formatMarkdown'; +import './CodePanel.scss'; + +export default function CodePanel({ text }) { + const { language, code } = formatCodeSegment(text); + return !language ? ( +
+ + {code} + +
+ ) : ( + + + {code} + + + ); +} diff --git a/src/components/AIassistant/components/Chat/messages/CodePanel.scss b/src/components/AIassistant/components/Chat/messages/CodePanel.scss new file mode 100644 index 0000000000..c4b3a7b5f4 --- /dev/null +++ b/src/components/AIassistant/components/Chat/messages/CodePanel.scss @@ -0,0 +1,28 @@ +.code-response { + background-color: #484848; + color: white; + padding: 0.75rem; + border-radius: 4px; + + .text { + color: white; + } +} + +.code-panel::part(header) { + background-color: #484848; + color: white; + border-radius: 4px 4px 0 0; + font-size: 0.9rem; +} + +.code-panel::part(content) { + background-color: #484848; + border-radius: 0 0 4px 4px; +} + +.code-panel { + .text { + color: white; + } +} diff --git a/src/components/AIassistant/components/Chat/messages/ErrorMessage.js b/src/components/AIassistant/components/Chat/messages/ErrorMessage.js new file mode 100644 index 0000000000..20c0021f21 --- /dev/null +++ b/src/components/AIassistant/components/Chat/messages/ErrorMessage.js @@ -0,0 +1,26 @@ +import { Button, IllustratedMessage } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import { spacing } from '@ui5/webcomponents-react-base'; + +export default function ErrorMessage({ + errorOnInitialMessage, + resendInitialPrompt, +}) { + const { t } = useTranslation(); + + return ( + + {errorOnInitialMessage && ( + + )} + + ); +} diff --git a/src/components/AIassistant/components/Chat/messages/Message.js b/src/components/AIassistant/components/Chat/messages/Message.js new file mode 100644 index 0000000000..dca76c0123 --- /dev/null +++ b/src/components/AIassistant/components/Chat/messages/Message.js @@ -0,0 +1,69 @@ +import { + BusyIndicator, + FlexBox, + Link, + ObjectStatus, + Text, +} from '@ui5/webcomponents-react'; +import { segmentMarkdownText } from 'components/AIassistant/utils/formatMarkdown'; +import CodePanel from './CodePanel'; +import './Message.scss'; + +export default function Message({ className, messageChunks, isLoading }) { + if (isLoading) { + return ( +
+ {messageChunks.length > 0 ? ( + messageChunks.map((chunk, index) => ( + + {chunk?.result} +
+ {index !== messageChunks.length - 1 ? ( + + ) : ( + + )} +
+
+ )) + ) : ( + + )} +
+ ); + } + + const segmentedText = segmentMarkdownText(messageChunks.slice(-1)[0]?.result); + return ( +
+ {segmentedText && ( + + {segmentedText.map((segment, index) => + segment.type === 'bold' ? ( + + {segment.content} + + ) : segment.type === 'code' ? ( + + ) : segment.type === 'highlighted' ? ( + + {segment.content} + + ) : segment.type === 'link' ? ( + + {segment.content.name} + + ) : ( + segment.content + ), + )} + + )} +
+ ); +} diff --git a/src/components/AIassistant/components/Chat/messages/Message.scss b/src/components/AIassistant/components/Chat/messages/Message.scss new file mode 100644 index 0000000000..57f2d2c5e2 --- /dev/null +++ b/src/components/AIassistant/components/Chat/messages/Message.scss @@ -0,0 +1,42 @@ +.message { + max-width: 80%; + padding: 12px; + + &.loading { + display: flex; + flex-direction: column; + gap: 16px; + + .loading-item { + gap: 8px; + + .text { + flex-grow: 1; + } + + .loading-status { + display: flex; + align-items: center; + justify-content: center; + min-width: 35px; + } + } + } + + .bold { + font-weight: bold; + font-size: 1rem; + } + + .highlighted { + background-color: var(--sapContent_LabelColor); + color: white; + padding: 0.2rem 0.25rem; + margin: 0.1rem 0; + border-radius: 4px; + } + + .text { + line-height: 1.35; + } +} diff --git a/src/components/AIassistant/components/PageInsights/InsightPanel.js b/src/components/AIassistant/components/PageInsights/InsightPanel.js new file mode 100644 index 0000000000..36be621e17 --- /dev/null +++ b/src/components/AIassistant/components/PageInsights/InsightPanel.js @@ -0,0 +1,51 @@ +import { + ObjectStatus, + Panel, + Text, + Title, + Toolbar, + ToolbarSpacer, +} from '@ui5/webcomponents-react'; +import { useState } from 'react'; +import './InsightPanel.scss'; + +export default function InsightPanel({ resourceType, resourceName, status }) { + const [open, setOpen] = useState(true); + const toggle = () => { + setOpen(!open); + }; + + return ( + + + {resourceType + ' ' + resourceName} + + {status && ( + <> + + + + )} + + } + > + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet + clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit + amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam + nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua. At vero eos et accusam et justo duo dolores et ea + rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem + ipsum dolor sit amet. + + + ); +} diff --git a/src/components/AIassistant/components/PageInsights/InsightPanel.scss b/src/components/AIassistant/components/PageInsights/InsightPanel.scss new file mode 100644 index 0000000000..fa9e24059f --- /dev/null +++ b/src/components/AIassistant/components/PageInsights/InsightPanel.scss @@ -0,0 +1,8 @@ +.insight-panel { + .toolbar-title { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/components/AIassistant/components/PageInsights/PageInsights.js b/src/components/AIassistant/components/PageInsights/PageInsights.js new file mode 100644 index 0000000000..eb4f8bba99 --- /dev/null +++ b/src/components/AIassistant/components/PageInsights/PageInsights.js @@ -0,0 +1,25 @@ +import { spacing } from '@ui5/webcomponents-react-base'; +import InsightPanel from './InsightPanel'; +import './PageInsights.scss'; + +export default function PageInsights() { + return ( +
+ + + +
+ ); +} diff --git a/src/components/AIassistant/components/PageInsights/PageInsights.scss b/src/components/AIassistant/components/PageInsights/PageInsights.scss new file mode 100644 index 0000000000..685c99598b --- /dev/null +++ b/src/components/AIassistant/components/PageInsights/PageInsights.scss @@ -0,0 +1,6 @@ +.page-insights-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0.5rem 0 0.5rem 0; +} diff --git a/src/components/AIassistant/state/initalPromptAtom.ts b/src/components/AIassistant/state/initalPromptAtom.ts new file mode 100644 index 0000000000..863e3e63c6 --- /dev/null +++ b/src/components/AIassistant/state/initalPromptAtom.ts @@ -0,0 +1,12 @@ +import { atom, RecoilState } from 'recoil'; + +type InitalPrompt = string; + +const DEFAULT_INITIAL_PROMPT = ''; + +export const initialPromptState: RecoilState = atom( + { + key: 'initialPromptState', + default: DEFAULT_INITIAL_PROMPT, + }, +); diff --git a/src/components/AIassistant/state/sessionIDAtom.ts b/src/components/AIassistant/state/sessionIDAtom.ts new file mode 100644 index 0000000000..6ad48d75bb --- /dev/null +++ b/src/components/AIassistant/state/sessionIDAtom.ts @@ -0,0 +1,10 @@ +import { atom, RecoilState } from 'recoil'; + +type SessionID = string; + +const DEFAULT_SESSION_ID = ''; + +export const sessionIDState: RecoilState = atom({ + key: 'sessionIDState', + default: DEFAULT_SESSION_ID, +}); diff --git a/src/components/AIassistant/state/showAIassistantAtom.ts b/src/components/AIassistant/state/showAIassistantAtom.ts new file mode 100644 index 0000000000..92baec53e1 --- /dev/null +++ b/src/components/AIassistant/state/showAIassistantAtom.ts @@ -0,0 +1,18 @@ +import { atom, RecoilState } from 'recoil'; + +type ShowAIassistant = { + show: boolean; + fullScreen: boolean; +}; + +const DEFAULT_SHOW_AI_ASSISTANT: ShowAIassistant = { + show: false, + fullScreen: false, +}; + +export const showAIassistantState: RecoilState = atom< + ShowAIassistant +>({ + key: 'showAIassistantState', + default: DEFAULT_SHOW_AI_ASSISTANT, +}); diff --git a/src/components/AIassistant/utils/formatMarkdown.js b/src/components/AIassistant/utils/formatMarkdown.js new file mode 100644 index 0000000000..944f783ec1 --- /dev/null +++ b/src/components/AIassistant/utils/formatMarkdown.js @@ -0,0 +1,43 @@ +export function segmentMarkdownText(text) { + if (!text) return []; + const regex = /(\*\*(.*?)\*\*)|(```([\s\S]*?)```\s)|(`(.*?)`)|\[(.*?)\]\((.*?)\)|[^[\]*`]+/g; + return text.match(regex).map(segment => { + if (segment.startsWith('**')) { + return { + type: 'bold', + content: segment.replace(/\*\*/g, ''), + }; + } else if (segment.startsWith('```')) { + return { + type: 'code', + content: segment.replace(/```/g, ''), + }; + } else if (segment.startsWith('`')) { + return { + type: 'highlighted', + content: segment.replace(/`/g, ''), + }; + } else if (segment.startsWith('[') && segment.endsWith(')')) { + return { + type: 'link', + content: { + name: segment.match(/\[(.*?)\]/)[0].replace(/^\[|\]$/g, ''), + address: segment.match(/\((.*?)\)/)[0].replace(/^\(|\)$/g, ''), + }, + }; + } else { + return { + type: 'normal', + content: segment, + }; + } + }); +} + +export function formatCodeSegment(text) { + const lines = text.split('\n'); + const language = lines.shift(); + const nonEmptyLines = lines.filter(line => line.trim() !== ''); + const code = nonEmptyLines.join('\n'); + return { language, code }; +} diff --git a/src/components/AIassistant/utils/generateSesssionID.js b/src/components/AIassistant/utils/generateSesssionID.js new file mode 100644 index 0000000000..028f0b8298 --- /dev/null +++ b/src/components/AIassistant/utils/generateSesssionID.js @@ -0,0 +1,19 @@ +import CryptoJS from 'crypto-js'; +import Fingerprint2 from 'fingerprintjs2'; + +export default async function generateSessionID(authData) { + const uuid = await generateBrowserFingerprint(); + return CryptoJS.SHA256(uuid + JSON.stringify(authData)).toString( + CryptoJS.enc.Hex, + ); +} + +const generateBrowserFingerprint = async () => { + return await new Promise(resolve => { + Fingerprint2.get(components => { + const values = components.map(component => component.value); + const fingerprint = Fingerprint2.x64hash128(values.join(''), 31); + resolve(fingerprint); + }); + }); +}; diff --git a/src/components/AIassistant/utils/parseNestedBrackets.js b/src/components/AIassistant/utils/parseNestedBrackets.js new file mode 100644 index 0000000000..c78d68cded --- /dev/null +++ b/src/components/AIassistant/utils/parseNestedBrackets.js @@ -0,0 +1,24 @@ +// input: "{sample}{string {with}}{multiple {nested{braces}}}" +// output: ["{sample}", "{string {with}}", "{multiple {nested{braces}}}"] +export function parseWithNestedBrackets(text) { + const output = []; + 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; +} diff --git a/src/components/App/App.scss b/src/components/App/App.scss index 23c3596ddd..f72c0f17d6 100644 --- a/src/components/App/App.scss +++ b/src/components/App/App.scss @@ -1,3 +1,11 @@ +#splitter-layout { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + #html-wrap { position: absolute; top: 0; diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 8fd1c779bc..60396fbe24 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -34,6 +34,9 @@ import './App.scss'; import { useAfterInitHook } from 'state/useAfterInitHook'; import useSidebarCondensed from 'sidebar/useSidebarCondensed'; import { useGetValidationEnabledSchemas } from 'state/validationEnabledSchemasAtom'; +import { SplitterElement, SplitterLayout } from '@ui5/webcomponents-react'; +import { showAIassistantState } from 'components/AIassistant/state/showAIassistantAtom'; +import AIassistant from 'components/AIassistant/components/AIassistant'; import { useGetKymaResources } from 'state/kymaResourcesAtom'; export default function App() { @@ -72,37 +75,63 @@ export default function App() { useAfterInitHook(kubeconfigIdState); useGetKymaResources(); + const showAssistant = useRecoilValue(showAIassistantState); + return ( -
-
-
- - - - + +
+
+
+ + + + + } + /> + + } /> + } + /> + } /> - } - /> - - } /> - } - /> - } - /> - {makeGardenerLoginRoute()} - - - -
-
+ {makeGardenerLoginRoute()} +
+ +
+
+
+ + {showAssistant.show ? ( + + + + ) : ( + <> + )} + ); } diff --git a/src/components/Clusters/views/ClusterOverview/ClusterOverview.js b/src/components/Clusters/views/ClusterOverview/ClusterOverview.js index e81e631684..6a5e8d63a2 100644 --- a/src/components/Clusters/views/ClusterOverview/ClusterOverview.js +++ b/src/components/Clusters/views/ClusterOverview/ClusterOverview.js @@ -1,5 +1,5 @@ -import React from 'react'; -import { Button, Title } from '@ui5/webcomponents-react'; +import React, { useEffect } from 'react'; +import { Button, FlexBox, Title } from '@ui5/webcomponents-react'; import { ClusterNodes } from './ClusterNodes'; import { ClusterValidation } from './ClusterValidation/ClusterValidation'; import { useFeature } from 'hooks/useFeature'; @@ -16,9 +16,11 @@ import { useNotification } from 'shared/contexts/NotificationContext'; import { useNavigate } from 'react-router-dom'; import { deleteCluster } from 'components/Clusters/shared'; import { spacing } from '@ui5/webcomponents-react-base'; -import './ClusterOverview.scss'; +import AIOpener from 'components/AIassistant/components/AIOpener'; import { useSetRecoilState } from 'recoil'; import { showYamlUploadDialogState } from 'state/showYamlUploadDialogAtom'; +import { showAIassistantState } from 'components/AIassistant/state/showAIassistantAtom'; +import './ClusterOverview.scss'; import BannerCarousel from 'components/Extensibility/components/FeaturedCard/BannerCarousel'; const Injections = React.lazy(() => @@ -37,6 +39,14 @@ export function ClusterOverview() { resourceType: t('clusters.labels.name'), }); const setShowAdd = useSetRecoilState(showYamlUploadDialogState); + const setShowAssistant = useSetRecoilState(showAIassistantState); + + useEffect(() => { + return () => { + setShowAssistant({ show: false, fullScreen: false }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const actions = [