Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Reuse POC's UI and state #3512

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions kyma/environments/dev/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ config:
isEnabled: false
EXTENSIBILITY_WIZARD:
isEnabled: true
KYMA_COMPANION:
isEnabled: false
TRACKING:
isEnabled: false
EVENTING:
Expand Down
2 changes: 2 additions & 0 deletions kyma/environments/prod/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ config:
isEnabled: false
EXTENSIBILITY_WIZARD:
isEnabled: true
KYMA_COMPANION:
isEnabled: false
EVENTING:
isEnabled: true
selectors:
Expand Down
2 changes: 2 additions & 0 deletions kyma/environments/stage/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ config:
isEnabled: false
EXTENSIBILITY_WIZARD:
isEnabled: true
KYMA_COMPANION:
isEnabled: false
EVENTING:
isEnabled: true
selectors:
Expand Down
2 changes: 2 additions & 0 deletions public/defaultConfig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ config:
isEnabled: false
EXTENSIBILITY_WIZARD:
isEnabled: true
KYMA_COMPANION:
isEnabled: false
TRACKING:
isEnabled: false
EVENTING:
Expand Down
17 changes: 17 additions & 0 deletions public/i18n/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ common:
remove-all: Remove all
reset: Reset
restart: Restart
retry: Retry
save: Save
submit: Submit
update: Update
Expand Down Expand Up @@ -748,6 +749,22 @@ jobs:
kubeconfig-id:
error: "Couldn't load kubeconfig ID; configuration not changed (Error: ${{error}})"
must-be-an-object: Kubeconfig must be a JSON or YAML object.
kyma-companion:
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
kyma-modules:
associated-resources: Associated Resources
unmanaged-modules-info: One of the modules is not managed and may not work properly. We cannot guarantee any service level agreement (SLA) or provide updates and maintenance for the module.
Expand Down
8 changes: 8 additions & 0 deletions src/components/App/App.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
#splitter-layout {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}

#html-wrap {
position: absolute;
top: 0;
Expand Down
89 changes: 59 additions & 30 deletions src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import useSidebarCondensed from 'sidebar/useSidebarCondensed';
import { useGetValidationEnabledSchemas } from 'state/validationEnabledSchemasAtom';
import { useGetKymaResources } from 'state/kymaResourcesAtom';

import { SplitterElement, SplitterLayout } from '@ui5/webcomponents-react';
import { showKymaCompanionState } from 'components/KymaCompanion/state/showKymaCompanionAtom';
import KymaCompanion from 'components/KymaCompanion/components/KymaCompanion';
import { Preferences } from 'components/Preferences/Preferences';
import { Header } from 'header/Header';
import { ContentWrapper } from './ContentWrapper/ContentWrapper';
Expand Down Expand Up @@ -77,43 +80,69 @@ export default function App() {
useAfterInitHook(kubeconfigIdState);
useGetKymaResources();

const showCompanion = useRecoilValue(showKymaCompanionState);

if (isLoading) {
return <Spinner />;
}

initTheme(theme);

return (
<div id="html-wrap">
<Header />
<div id="page-wrap">
<Sidebar key={cluster?.name} />
<ContentWrapper>
<Routes key={cluster?.name}>
<Route
path="*"
element={
<IncorrectPath
to="clusters"
message={t('components.incorrect-path.message.clusters')}
<SplitterLayout id="splitter-layout">
<SplitterElement
resizable={showCompanion.show}
size={
showCompanion.show
? showCompanion.fullScreen
? '0%'
: '70%'
: '100%'
}
>
<div id="html-wrap">
<Header />
<div id="page-wrap">
<Sidebar key={cluster?.name} />
<ContentWrapper>
<Routes key={cluster?.name}>
<Route
path="*"
element={
<IncorrectPath
to="clusters"
message={t('components.incorrect-path.message.clusters')}
/>
}
/>
<Route path="/" />
<Route path="clusters" element={<ClusterList />} />
<Route
path="cluster/:currentClusterName"
element={<Navigate to="overview" />}
/>
<Route
path="cluster/:currentClusterName/*"
element={<ClusterRoutes />}
/>
}
/>
<Route path="/" />
<Route path="clusters" element={<ClusterList />} />
<Route
path="cluster/:currentClusterName"
element={<Navigate to="overview" />}
/>
<Route
path="cluster/:currentClusterName/*"
element={<ClusterRoutes />}
/>
{makeGardenerLoginRoute()}
</Routes>
<Preferences />
</ContentWrapper>
</div>
</div>
{makeGardenerLoginRoute()}
</Routes>
<Preferences />
</ContentWrapper>
</div>
</div>
</SplitterElement>
{showCompanion.show ? (
<SplitterElement
resizable={!showCompanion.fullScreen}
size={showCompanion.fullScreen ? '100%' : '30%'}
minSize={350}
>
<KymaCompanion />
</SplitterElement>
) : (
<></>
)}
</SplitterLayout>
);
}
31 changes: 31 additions & 0 deletions src/components/KymaCompanion/components/Chat/Chat.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
160 changes: 160 additions & 0 deletions src/components/KymaCompanion/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// TODO: uncomment when API changes are added
/* eslint-disable @typescript-eslint/no-unused-vars */
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 { initialPromptState } from 'components/KymaCompanion/state/initalPromptAtom';
import Message from './messages/Message';
import Bubbles from './messages/Bubbles';
import ErrorMessage from './messages/ErrorMessage';
import { sessionIDState } from 'components/KymaCompanion/state/sessionIDAtom';
import { clusterState } from 'state/clusterAtom';
import { authDataState } from 'state/authDataAtom';
import './Chat.scss';

interface MessageType {
author: 'user' | 'ai';
messageChunks: { step: string; result: string }[];
isLoading: boolean;
suggestions?: any[];
}

export default function Chat() {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement | null>(null);
const [inputValue, setInputValue] = useState<string>('');
const [chatHistory, setChatHistory] = useState<MessageType[]>([]);
const [errorOccured, setErrorOccured] = useState<boolean>(false);
const initialPrompt = useRecoilValue<string>(initialPromptState);
const sessionID = useRecoilValue<string>(sessionIDState);
const cluster = useRecoilValue<any>(clusterState);
const authData = useRecoilValue<any>(authDataState);

const addMessage = ({ author, messageChunks, isLoading }: MessageType) => {
setChatHistory(prevItems =>
prevItems.concat({ author, messageChunks, isLoading }),
);
};

const handleChatResponse = (response: any) => {
const isLoading = response?.step !== 'output';
if (!isLoading) {
// TODO: uncomment when API changes are added
/*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: any) => {
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: string) => {
setErrorOccured(false);
addMessage({
author: 'user',
messageChunks: [{ step: 'output', result: prompt }],
isLoading: false,
});
// TODO: uncomment when API changes are added
/*getChatResponse({ prompt, handleChatResponse, handleError, sessionID, clusterUrl: cluster.currentContext.cluster.cluster.server, token: authData.token, certificateAuthorityData: cluster.currentContext.cluster.cluster['certificate-authority-data'], });*/
addMessage({ author: 'ai', messageChunks: [], isLoading: true });
};

const onSubmitInput = () => {
if (inputValue.length === 0) return;
const prompt = inputValue;
setInputValue('');
sendPrompt(prompt);
};

const scrollToBottom = () => {
if (containerRef?.current?.lastChild)
(containerRef.current.lastChild as HTMLElement).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 (
<FlexBox
direction="Column"
justifyContent="SpaceBetween"
className="chat-container"
>
<div className="chat-list sap-margin-tiny" ref={containerRef}>
{chatHistory.map((message, index) => {
return message.author === 'ai' ? (
<React.Fragment key={index}>
<Message
className="left-aligned"
messageChunks={message.messageChunks}
isLoading={message.isLoading}
/>
{index === chatHistory.length - 1 && !message.isLoading && (
<Bubbles
onClick={sendPrompt}
suggestions={message.suggestions}
/>
)}
</React.Fragment>
) : (
<Message
key={index}
className="right-aligned"
messageChunks={message.messageChunks}
isLoading={message.isLoading}
/>
);
})}
{errorOccured && (
<ErrorMessage
errorOnInitialMessage={chatHistory.length === 0}
resendInitialPrompt={() => sendPrompt(initialPrompt)}
/>
)}
</div>
<div className="sap-margin-x-tiny">
<Input
className="full-width"
disabled={chatHistory[chatHistory.length - 1]?.isLoading}
placeholder={t('kyma-companion.placeholder')}
value={inputValue}
icon={<Icon name="paper-plane" onClick={onSubmitInput} />}
onKeyDown={e => e.key === 'Enter' && onSubmitInput()}
onInput={e => setInputValue(e.target.value)}
/>
</div>
</FlexBox>
);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading