From e630aa759e6259e542be6a4841c493b1eaf81ec6 Mon Sep 17 00:00:00 2001 From: chriskari Date: Mon, 2 Dec 2024 13:25:39 +0100 Subject: [PATCH] feat: reuse basic framework of the POC --- public/i18n/en.yaml | 17 ++ src/components/App/App.scss | 8 + src/components/App/App.tsx | 90 ++++++--- .../KymaCompanion/api/getChatResponse.ts | 87 +++++++++ .../KymaCompanion/api/getFollowUpQuestions.ts | 40 ++++ .../KymaCompanion/api/getPromptSuggestions.ts | 55 ++++++ .../KymaCompanion/components/Chat/Chat.scss | 31 +++ .../KymaCompanion/components/Chat/Chat.tsx | 179 ++++++++++++++++++ .../components/Chat/messages/Bubbles.scss | 14 ++ .../components/Chat/messages/Bubbles.tsx | 29 +++ .../components/Chat/messages/CodePanel.scss | 28 +++ .../components/Chat/messages/CodePanel.tsx | 24 +++ .../components/Chat/messages/ErrorMessage.tsx | 31 +++ .../components/Chat/messages/Message.scss | 42 ++++ .../components/Chat/messages/Message.tsx | 79 ++++++++ .../components/CompanionOpener.scss | 35 ++++ .../components/CompanionOpener.tsx | 170 +++++++++++++++++ .../components/KymaCompanion.scss | 45 +++++ .../components/KymaCompanion.tsx | 74 ++++++++ .../KymaCompanion/state/initalPromptAtom.ts | 12 ++ .../KymaCompanion/state/sessionIDAtom.ts | 10 + .../state/showKymaCompanionAtom.ts | 18 ++ .../KymaCompanion/utils/formatMarkdown.ts | 56 ++++++ .../utils/parseNestedBrackets.ts | 24 +++ src/header/Header.tsx | 15 ++ 25 files changed, 1183 insertions(+), 30 deletions(-) create mode 100644 src/components/KymaCompanion/api/getChatResponse.ts create mode 100644 src/components/KymaCompanion/api/getFollowUpQuestions.ts create mode 100644 src/components/KymaCompanion/api/getPromptSuggestions.ts create mode 100644 src/components/KymaCompanion/components/Chat/Chat.scss create mode 100644 src/components/KymaCompanion/components/Chat/Chat.tsx create mode 100644 src/components/KymaCompanion/components/Chat/messages/Bubbles.scss create mode 100644 src/components/KymaCompanion/components/Chat/messages/Bubbles.tsx create mode 100644 src/components/KymaCompanion/components/Chat/messages/CodePanel.scss create mode 100644 src/components/KymaCompanion/components/Chat/messages/CodePanel.tsx create mode 100644 src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx create mode 100644 src/components/KymaCompanion/components/Chat/messages/Message.scss create mode 100644 src/components/KymaCompanion/components/Chat/messages/Message.tsx create mode 100644 src/components/KymaCompanion/components/CompanionOpener.scss create mode 100644 src/components/KymaCompanion/components/CompanionOpener.tsx create mode 100644 src/components/KymaCompanion/components/KymaCompanion.scss create mode 100644 src/components/KymaCompanion/components/KymaCompanion.tsx create mode 100644 src/components/KymaCompanion/state/initalPromptAtom.ts create mode 100644 src/components/KymaCompanion/state/sessionIDAtom.ts create mode 100644 src/components/KymaCompanion/state/showKymaCompanionAtom.ts create mode 100644 src/components/KymaCompanion/utils/formatMarkdown.ts create mode 100644 src/components/KymaCompanion/utils/parseNestedBrackets.ts diff --git a/public/i18n/en.yaml b/public/i18n/en.yaml index a1aa4b7362..8e3f021d34 100644 --- a/public/i18n/en.yaml +++ b/public/i18n/en.yaml @@ -239,6 +239,7 @@ common: remove-all: Remove all reset: Reset restart: Restart + retry: Retry save: Save submit: Submit update: Update @@ -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: 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. unmanaged-modules-save-warning: Before proceeding, be aware that disabling module management may impact the stability and data integrity of your cluster. Once the management is disabled, reverting back may not be possible. Are you sure you want to continue? 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..152ca97543 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -36,6 +36,10 @@ 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'; + export default function App() { const language = useRecoilValue(languageAtom); const cluster = useRecoilValue(clusterState); @@ -72,37 +76,63 @@ export default function App() { useAfterInitHook(kubeconfigIdState); useGetKymaResources(); + const showCompanion = useRecoilValue(showKymaCompanionState); + return ( -
-
-
- - - - + +
+
+
+ + + + + } + /> + + } /> + } + /> + } /> - } - /> - - } /> - } - /> - } - /> - {makeGardenerLoginRoute()} - - - -
-
+ {makeGardenerLoginRoute()} +
+ +
+
+
+ + {showCompanion.show ? ( + + + + ) : ( + <> + )} + ); } diff --git a/src/components/KymaCompanion/api/getChatResponse.ts b/src/components/KymaCompanion/api/getChatResponse.ts new file mode 100644 index 0000000000..1455e50845 --- /dev/null +++ b/src/components/KymaCompanion/api/getChatResponse.ts @@ -0,0 +1,87 @@ +import { getClusterConfig } from 'state/utils/getBackendInfo'; +import { parseWithNestedBrackets } from '../utils/parseNestedBrackets'; + +type GetChatResponseArgs = { + prompt: string; + handleChatResponse: (chunk: any) => void; + handleError: () => void; + sessionID: string; + clusterUrl: string; + token: string; + certificateAuthorityData: string; +}; + +export default async function getChatResponse({ + prompt, + handleChatResponse, + handleError, + sessionID, + clusterUrl, + token, + 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}`; + + 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(); + if (!reader) { + throw new Error('Failed to get reader from response body'); + } + const decoder = new TextDecoder(); + readChunk(reader, decoder, handleChatResponse, handleError, sessionID); + }) + .catch(error => { + handleError(); + console.error('Error fetching data:', error); + }); +} + +function readChunk( + reader: ReadableStreamDefaultReader, + decoder: TextDecoder, + handleChatResponse: (chunk: any) => void, + handleError: () => void, + sessionID: string, +) { + 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/KymaCompanion/api/getFollowUpQuestions.ts b/src/components/KymaCompanion/api/getFollowUpQuestions.ts new file mode 100644 index 0000000000..826329cc5d --- /dev/null +++ b/src/components/KymaCompanion/api/getFollowUpQuestions.ts @@ -0,0 +1,40 @@ +import { getClusterConfig } from 'state/utils/getBackendInfo'; + +interface GetFollowUpQuestionsParams { + sessionID?: string; + handleFollowUpQuestions: (results: any) => void; + clusterUrl: string; + token: string; + certificateAuthorityData: string; +} + +export default async function getFollowUpQuestions({ + sessionID = '', + handleFollowUpQuestions, + clusterUrl, + token, + certificateAuthorityData, +}: GetFollowUpQuestionsParams): Promise { + 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/KymaCompanion/api/getPromptSuggestions.ts b/src/components/KymaCompanion/api/getPromptSuggestions.ts new file mode 100644 index 0000000000..540ab83898 --- /dev/null +++ b/src/components/KymaCompanion/api/getPromptSuggestions.ts @@ -0,0 +1,55 @@ +import { getClusterConfig } from 'state/utils/getBackendInfo'; +import { extractApiGroup } from 'resources/Roles/helpers'; + +interface GetPromptSuggestionsParams { + namespace?: string; + resourceType?: string; + groupVersion?: string; + resourceName?: string; + sessionID?: string; + clusterUrl: string; + token: string; + certificateAuthorityData: string; +} + +// TODO add return type + +export default async function getPromptSuggestions({ + namespace = '', + resourceType = '', + groupVersion = '', + resourceName = '', + sessionID = '', + clusterUrl, + token, + certificateAuthorityData, +}: GetPromptSuggestionsParams): Promise { + 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/KymaCompanion/components/Chat/Chat.scss b/src/components/KymaCompanion/components/Chat/Chat.scss new file mode 100644 index 0000000000..a0e7395d36 --- /dev/null +++ b/src/components/KymaCompanion/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/KymaCompanion/components/Chat/Chat.tsx b/src/components/KymaCompanion/components/Chat/Chat.tsx new file mode 100644 index 0000000000..0694f53ba3 --- /dev/null +++ b/src/components/KymaCompanion/components/Chat/Chat.tsx @@ -0,0 +1,179 @@ +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/KymaCompanion/state/initalPromptAtom'; +import Message from './messages/Message'; +import Bubbles from './messages/Bubbles'; +import ErrorMessage from './messages/ErrorMessage'; +import getChatResponse from 'components/KymaCompanion/api/getChatResponse'; +import { sessionIDState } from 'components/KymaCompanion/state/sessionIDAtom'; +import getFollowUpQuestions from 'components/KymaCompanion/api/getFollowUpQuestions'; +import { clusterState } from 'state/clusterAtom'; +import { authDataState } from 'state/authDataAtom'; +import './Chat.scss'; + +interface MessageType { + author: 'user' | 'ai'; + messageChunks: { step: string; result: string }[]; // Example structure for message chunks + isLoading: boolean; + suggestions?: any[]; +} + +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 }: MessageType) => { + setChatHistory(prevItems => + prevItems.concat({ author, messageChunks, isLoading }), + ); + }; + + const handleChatResponse = (response: any) => { + 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: 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, + }); + 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 ( + +
+ {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/KymaCompanion/components/Chat/messages/Bubbles.scss b/src/components/KymaCompanion/components/Chat/messages/Bubbles.scss new file mode 100644 index 0000000000..b3f64f53ec --- /dev/null +++ b/src/components/KymaCompanion/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/KymaCompanion/components/Chat/messages/Bubbles.tsx b/src/components/KymaCompanion/components/Chat/messages/Bubbles.tsx new file mode 100644 index 0000000000..19718042ff --- /dev/null +++ b/src/components/KymaCompanion/components/Chat/messages/Bubbles.tsx @@ -0,0 +1,29 @@ +import { Button, FlexBox } from '@ui5/webcomponents-react'; +import './Bubbles.scss'; + +interface BubblesProps { + suggestions: any[] | undefined; + onClick: (suggestion: string) => void; +} + +export default function Bubbles({ + suggestions, + onClick, +}: BubblesProps): JSX.Element { + return suggestions ? ( + + {suggestions.map((suggestion, index) => ( + + ))} + + ) : ( + <> + ); +} diff --git a/src/components/KymaCompanion/components/Chat/messages/CodePanel.scss b/src/components/KymaCompanion/components/Chat/messages/CodePanel.scss new file mode 100644 index 0000000000..c4b3a7b5f4 --- /dev/null +++ b/src/components/KymaCompanion/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/KymaCompanion/components/Chat/messages/CodePanel.tsx b/src/components/KymaCompanion/components/Chat/messages/CodePanel.tsx new file mode 100644 index 0000000000..3e979af972 --- /dev/null +++ b/src/components/KymaCompanion/components/Chat/messages/CodePanel.tsx @@ -0,0 +1,24 @@ +import { Text, Panel } from '@ui5/webcomponents-react'; +import { formatCodeSegment } from 'components/KymaCompanion/utils/formatMarkdown'; +import './CodePanel.scss'; + +interface CodePanelProps { + text: string; +} + +export default function CodePanel({ text }: CodePanelProps): JSX.Element { + const { language, code } = formatCodeSegment(text); + return !language ? ( +
+ + {code} + +
+ ) : ( + + + {code} + + + ); +} diff --git a/src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx b/src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx new file mode 100644 index 0000000000..a33a9a5c17 --- /dev/null +++ b/src/components/KymaCompanion/components/Chat/messages/ErrorMessage.tsx @@ -0,0 +1,31 @@ +import { Button, IllustratedMessage } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import { spacing } from '@ui5/webcomponents-react-base'; + +interface ErrorMessageProps { + errorOnInitialMessage: boolean; + resendInitialPrompt: () => void; +} + +export default function ErrorMessage({ + errorOnInitialMessage, + resendInitialPrompt, +}: ErrorMessageProps): JSX.Element { + const { t } = useTranslation(); + + return ( + + {errorOnInitialMessage && ( + + )} + + ); +} diff --git a/src/components/KymaCompanion/components/Chat/messages/Message.scss b/src/components/KymaCompanion/components/Chat/messages/Message.scss new file mode 100644 index 0000000000..57f2d2c5e2 --- /dev/null +++ b/src/components/KymaCompanion/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/KymaCompanion/components/Chat/messages/Message.tsx b/src/components/KymaCompanion/components/Chat/messages/Message.tsx new file mode 100644 index 0000000000..b6a2c35ec7 --- /dev/null +++ b/src/components/KymaCompanion/components/Chat/messages/Message.tsx @@ -0,0 +1,79 @@ +import { + BusyIndicator, + FlexBox, + Link, + ObjectStatus, + Text, +} from '@ui5/webcomponents-react'; +import { segmentMarkdownText } from 'components/KymaCompanion/utils/formatMarkdown'; +import CodePanel from './CodePanel'; +import './Message.scss'; + +interface MessageProps { + className: string; + messageChunks: Array<{ result: string }>; // Adjust this type based on the structure of 'messageChunks' + isLoading: boolean; +} + +export default function Message({ + className, + messageChunks, + isLoading, +}: MessageProps): JSX.Element { + 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/KymaCompanion/components/CompanionOpener.scss b/src/components/KymaCompanion/components/CompanionOpener.scss new file mode 100644 index 0000000000..ed346246e3 --- /dev/null +++ b/src/components/KymaCompanion/components/CompanionOpener.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/KymaCompanion/components/CompanionOpener.tsx b/src/components/KymaCompanion/components/CompanionOpener.tsx new file mode 100644 index 0000000000..c5cff0adcb --- /dev/null +++ b/src/components/KymaCompanion/components/CompanionOpener.tsx @@ -0,0 +1,170 @@ +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 { showKymaCompanionState } from 'components/KymaCompanion/state/showKymaCompanionAtom'; +import { initialPromptState } from '../state/initalPromptAtom'; +import getPromptSuggestions from 'components/KymaCompanion/api/getPromptSuggestions'; +import { createPortal } from 'react-dom'; +import { sessionIDState } from '../state/sessionIDAtom'; +import { clusterState } from 'state/clusterAtom'; +import './CompanionOpener.scss'; + +interface AIOpenerProps { + namespace: string; + resourceType: string; + groupVersion: string; + resourceName: string; +} + +export default function CompanionOpener({ + namespace, + resourceType, + groupVersion, + resourceName, +}: AIOpenerProps) { + const { t } = useTranslation(); + const [showCompanion, setShowCompanion] = useRecoilState( + showKymaCompanionState, + ); + 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 setSessionID = useSetRecoilState(sessionIDState); + const cluster = useRecoilValue(clusterState); + + const fetchSuggestions = async () => { + setErrorOccured(false); + setPopoverOpen(true); + if (!isLoading && suggestions.length === 0) { + setIsLoading(true); + // TODO + const sessionID = ''; + setSessionID(sessionID); + const promptSuggestions = await getPromptSuggestions({ + namespace, + resourceType, + groupVersion, + resourceName, + sessionID, + clusterUrl: cluster?.currentContext.cluster.cluster.server ?? '', + // TODO + token: '', + certificateAuthorityData: + cluster?.currentContext.cluster.cluster[ + 'certificate-authority-data' + ] ?? '', + }); + setIsLoading(false); + if (!promptSuggestions) { + setErrorOccured(true); + } else { + setSuggestions(promptSuggestions); + } + } + }; + + const sendInitialPrompt = (prompt: string) => { + setInitialPrompt(prompt); + setPopoverOpen(false); + setShowCompanion({ + 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('kyma-companion.opener.input-placeholder')} + className="popover-input" + /> + + {t('kyma-companion.opener.suggestions')} + + {errorOccured || (!isLoading && suggestions.length === 0) ? ( + + + {t('kyma-companion.opener.error-message')} + + + + ) : isLoading ? ( +
+ +
+ ) : ( + + {suggestions.map((suggestion, index) => ( + sendInitialPrompt(suggestion)} + className="custom-list-item" + > + {suggestion} + + + ))} + + )} +
, + document.body, + )} + + ); +} diff --git a/src/components/KymaCompanion/components/KymaCompanion.scss b/src/components/KymaCompanion/components/KymaCompanion.scss new file mode 100644 index 0000000000..0c027626ef --- /dev/null +++ b/src/components/KymaCompanion/components/KymaCompanion.scss @@ -0,0 +1,45 @@ +#companion_wrapper { + width: calc(100% - 1rem); + + .kyma-companion { + 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/KymaCompanion/components/KymaCompanion.tsx b/src/components/KymaCompanion/components/KymaCompanion.tsx new file mode 100644 index 0000000000..88b9a340f9 --- /dev/null +++ b/src/components/KymaCompanion/components/KymaCompanion.tsx @@ -0,0 +1,74 @@ +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 { + ShowKymaCompanion, + showKymaCompanionState, +} from 'components/KymaCompanion/state/showKymaCompanionAtom'; +import Chat from './Chat/Chat'; +import './KymaCompanion.scss'; + +export default function KymaCompanion() { + const { t } = useTranslation(); + const [showCompanion, setShowCompanion] = useRecoilState( + showKymaCompanionState, + ); + + return ( +
+ + + {t('kyma-companion.name')} + + +
+
+ + } + > + + + + + +
+
+ ); +} diff --git a/src/components/KymaCompanion/state/initalPromptAtom.ts b/src/components/KymaCompanion/state/initalPromptAtom.ts new file mode 100644 index 0000000000..863e3e63c6 --- /dev/null +++ b/src/components/KymaCompanion/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/KymaCompanion/state/sessionIDAtom.ts b/src/components/KymaCompanion/state/sessionIDAtom.ts new file mode 100644 index 0000000000..6ad48d75bb --- /dev/null +++ b/src/components/KymaCompanion/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/KymaCompanion/state/showKymaCompanionAtom.ts b/src/components/KymaCompanion/state/showKymaCompanionAtom.ts new file mode 100644 index 0000000000..1cbe4e0918 --- /dev/null +++ b/src/components/KymaCompanion/state/showKymaCompanionAtom.ts @@ -0,0 +1,18 @@ +import { atom, RecoilState } from 'recoil'; + +export type ShowKymaCompanion = { + show: boolean; + fullScreen: boolean; +}; + +const DEFAULT_SHOW_KYMA_COMPANION: ShowKymaCompanion = { + show: false, + fullScreen: false, +}; + +export const showKymaCompanionState: RecoilState = atom< + ShowKymaCompanion +>({ + key: 'showKymaCompanionState', + default: DEFAULT_SHOW_KYMA_COMPANION, +}); diff --git a/src/components/KymaCompanion/utils/formatMarkdown.ts b/src/components/KymaCompanion/utils/formatMarkdown.ts new file mode 100644 index 0000000000..80f1527f32 --- /dev/null +++ b/src/components/KymaCompanion/utils/formatMarkdown.ts @@ -0,0 +1,56 @@ +export type Segment = + | { type: 'bold'; content: string } + | { type: 'code'; content: string } + | { type: 'highlighted'; content: string } + | { type: 'link'; content: { name: string; address: string } } + | { type: 'normal'; content: string }; + +export function segmentMarkdownText(text: string): Segment[] { + 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(')')) { + const nameMatch = segment.match(/\[(.*?)\]/); + const addressMatch = segment.match(/\((.*?)\)/); + return { + type: 'link', + content: { + name: nameMatch ? nameMatch[1] : '', + address: addressMatch ? addressMatch[1] : '', + }, + }; + } else { + return { + type: 'normal', + content: segment, + }; + } + }) ?? [] + ); +} + +export function formatCodeSegment( + text: string, +): { language: string | undefined; code: string } { + 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/KymaCompanion/utils/parseNestedBrackets.ts b/src/components/KymaCompanion/utils/parseNestedBrackets.ts new file mode 100644 index 0000000000..2f22e9e95d --- /dev/null +++ b/src/components/KymaCompanion/utils/parseNestedBrackets.ts @@ -0,0 +1,24 @@ +// 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; +} diff --git a/src/header/Header.tsx b/src/header/Header.tsx index 08c54b2dea..c9a21ccbc2 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -32,6 +32,7 @@ import './Header.scss'; import { isResourceEditedState } from 'state/resourceEditedAtom'; import { isFormOpenState } from 'state/formOpenAtom'; import { handleActionIfFormOpen } from 'shared/components/UnsavedMessageBox/helpers'; +import { showKymaCompanionState } from 'components/KymaCompanion/state/showKymaCompanionAtom'; export function Header() { useAvailableNamespaces(); @@ -54,6 +55,7 @@ export function Header() { isResourceEditedState, ); const [isFormOpen, setIsFormOpen] = useRecoilState(isFormOpenState); + const setShowCompanion = useSetRecoilState(showKymaCompanionState); const shellbarRef = useRef(null); useEffect(() => { @@ -176,6 +178,19 @@ export function Header() { title={t('navigation.feedback')} /> )} + {isFeedbackEnabled && ( + + setShowCompanion({ + show: true, + fullScreen: false, + }) + } + icon="da" + text={t('kyma-companion.name')} + title={t('kyma-companion.name')} + /> + )}