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) => (
+ onClick(suggestion)}
+ >
+ {suggestion}
+
+ ))}
+
+ ) : (
+ <>>
+ );
+}
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 && (
+
+ {t('common.buttons.retry')}
+
+ )}
+
+ );
+}
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 = [
-
- {t('cluster-overview.headers.cluster-details')}
-
+
+ {t('cluster-overview.headers.cluster-details')}
+
+
+
{data && }
diff --git a/src/components/HelmReleases/HelmReleasesDetails.js b/src/components/HelmReleases/HelmReleasesDetails.js
index 9cbb034f25..db8ebd7f26 100644
--- a/src/components/HelmReleases/HelmReleasesDetails.js
+++ b/src/components/HelmReleases/HelmReleasesDetails.js
@@ -11,16 +11,28 @@ import { findRecentRelease } from './findRecentRelease';
import { ResourceCreate } from 'shared/components/ResourceCreate/ResourceCreate';
import { useUrl } from 'hooks/useUrl';
import YamlUploadDialog from 'resources/Namespaces/YamlUpload/YamlUploadDialog';
+import AIOpener from 'components/AIassistant/components/AIOpener';
import { ResourceDescription } from 'components/HelmReleases';
import HelmReleasesYaml from './HelmReleasesYaml';
import { ErrorBoundary } from 'shared/components/ErrorBoundary/ErrorBoundary';
import { showYamlTab } from './index';
import { Link } from 'shared/components/Link/Link';
import { createPortal } from 'react-dom';
+import { useSetRecoilState } from 'recoil';
+import { showAIassistantState } from 'components/AIassistant/state/showAIassistantAtom';
+import { useEffect } from 'react';
function HelmReleasesDetails({ releaseName, namespace }) {
const { t } = useTranslation();
const { namespaceUrl } = useUrl();
+ const setShowAssistant = useSetRecoilState(showAIassistantState);
+
+ useEffect(() => {
+ return () => {
+ setShowAssistant({ show: false, fullScreen: false });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
const { data, loading } = useGetList(s => s.type === 'helm.sh/release.v1')(
namespace === '-all-'
@@ -94,6 +106,14 @@ function HelmReleasesDetails({ releaseName, namespace }) {
status={releaseSecret.metadata.labels.status}
/>
+
+
+
>
)}
{createPortal(, document.body)}
diff --git a/src/header/Header.tsx b/src/header/Header.tsx
index e9a9d0d87c..c8de96f1ff 100644
--- a/src/header/Header.tsx
+++ b/src/header/Header.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
Avatar,
diff --git a/src/shared/components/DynamicPageComponent/DynamicPageComponent.js b/src/shared/components/DynamicPageComponent/DynamicPageComponent.js
index 8d4b836c4d..96e9ff8fa7 100644
--- a/src/shared/components/DynamicPageComponent/DynamicPageComponent.js
+++ b/src/shared/components/DynamicPageComponent/DynamicPageComponent.js
@@ -25,7 +25,9 @@ const Column = ({ title, children, columnSpan, image, style = {} }) => {
{image &&
{image}
}
-
{title}:
+ {title && (
+
{title + ':'}
+ )}
{children}
diff --git a/src/shared/components/ResourceDetails/ResourceDetails.js b/src/shared/components/ResourceDetails/ResourceDetails.js
index 31b3155388..a8a22e8b9d 100644
--- a/src/shared/components/ResourceDetails/ResourceDetails.js
+++ b/src/shared/components/ResourceDetails/ResourceDetails.js
@@ -1,8 +1,8 @@
-import React, { createContext, Suspense, useState } from 'react';
+import React, { createContext, Suspense, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import pluralize from 'pluralize';
import { useTranslation } from 'react-i18next';
-import { Button, Title } from '@ui5/webcomponents-react';
+import { Button, FlexBox, Title } from '@ui5/webcomponents-react';
import { spacing } from '@ui5/webcomponents-react-base';
import { ResourceNotFound } from 'shared/components/ResourceNotFound/ResourceNotFound';
@@ -23,15 +23,17 @@ import { useVersionWarning } from 'hooks/useVersionWarning';
import { Tooltip } from '../Tooltip/Tooltip';
import YamlUploadDialog from 'resources/Namespaces/YamlUpload/YamlUploadDialog';
+import AIOpener from 'components/AIassistant/components/AIOpener';
import { createPortal } from 'react-dom';
import ResourceDetailsCard from './ResourceDetailsCard';
import { ResourceStatusCard } from '../ResourceStatusCard/ResourceStatusCard';
import { EMPTY_TEXT_PLACEHOLDER } from '../../constants';
import { ReadableElapsedTimeFromNow } from '../ReadableElapsedTimeFromNow/ReadableElapsedTimeFromNow';
import { HintButton } from '../DescriptionHint/DescriptionHint';
-import { useRecoilValue } from 'recoil';
+import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useFeature } from 'hooks/useFeature';
import { columnLayoutState } from 'state/columnLayoutAtom';
+import { showAIassistantState } from 'components/AIassistant/state/showAIassistantAtom';
import BannerCarousel from 'components/Extensibility/components/FeaturedCard/BannerCarousel';
// This component is loaded after the page mounts.
@@ -176,6 +178,7 @@ function Resource({
resource.kind,
);
const [showTitleDescription, setShowTitleDescription] = useState(false);
+ const setShowAssistant = useSetRecoilState(showAIassistantState);
const pluralizedResourceKind = pluralize(prettifiedResourceKind);
useWindowTitle(windowTitle || pluralizedResourceKind);
@@ -193,6 +196,13 @@ function Resource({
const protectedResource = isProtected(resource);
+ useEffect(() => {
+ return () => {
+ setShowAssistant({ show: false, fullScreen: false });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const deleteButtonWrapper = children => {
if (protectedResource) {
return (
@@ -377,15 +387,21 @@ function Resource({
)}
{!disableResourceDetailsCard && (
<>
-
- {title ?? t('common.headers.resource-details')}
-
+
+ {title ?? t('common.headers.resource-details')}
+
+
+