Skip to content

Commit 7733af6

Browse files
authored
feat: POST /conversations to companion backend (kyma-project#3691)
* feat: track currently opened resource in ResourceDetails * feat: use columnLayoutState to track currentResource * feat: update structure of api call * feat: proxy API request through our backend * feat: automate token fetching in the backend * feat: enhance columnLayouState to also track the start column resource * feat: adjust resourcetype of cluster overview * feat: improve initial layout * feat: adjust column state for cluster overview * feat: add loading indicator, and refresh when navigate to different resource * feat: handle different auth method and fix errors regarding this * feat: skip request if conversation has started * feat: add properly cased resourceType to navigation nodes * feat: adjustments regarding previous commit * feat: adjust introductory message based on existence of suggestions
1 parent 08cd244 commit 7733af6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+547
-131
lines changed

backend/companion/TokenManager.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { getKcpToken } from './getKcpToken';
2+
3+
export class TokenManager {
4+
constructor() {
5+
this.currentToken = null;
6+
this.tokenExpirationTime = null;
7+
// Add buffer time (e.g., 5 minutes) before actual expiration to prevent edge cases
8+
this.expirationBuffer = 5 * 60 * 1000;
9+
}
10+
11+
async getToken() {
12+
// Check if token exists and is not near expiration
13+
if (
14+
this.currentToken &&
15+
this.tokenExpirationTime &&
16+
Date.now() < this.tokenExpirationTime - this.expirationBuffer
17+
) {
18+
return this.currentToken;
19+
}
20+
21+
// If token doesn't exist or is expired/near expiration, fetch new token
22+
try {
23+
const newToken = await getKcpToken();
24+
this.currentToken = newToken;
25+
// Set expiration time based on JWT expiry
26+
// You'll need to decode the JWT to get the actual expiration
27+
this.tokenExpirationTime = this.getExpirationFromJWT(newToken);
28+
return newToken;
29+
} catch (error) {
30+
console.error('Failed to refresh token:', error);
31+
throw error;
32+
}
33+
}
34+
35+
getExpirationFromJWT(token) {
36+
try {
37+
// Split the token and get the payload
38+
const PAYLOAD_INDEX = 1;
39+
const payload = JSON.parse(
40+
Buffer.from(token.split('.')[PAYLOAD_INDEX], 'base64').toString(),
41+
);
42+
// exp is in seconds, convert to milliseconds
43+
return payload.exp * 1000;
44+
} catch (error) {
45+
console.error('Error parsing JWT:', error);
46+
// If we can't parse the expiration, set a default (e.g., 1 hour from now)
47+
return Date.now() + 60 * 60 * 1000;
48+
}
49+
}
50+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import express from 'express';
2+
import { TokenManager } from './TokenManager';
3+
4+
const tokenManager = new TokenManager();
5+
6+
const router = express.Router();
7+
8+
router.use(express.json());
9+
10+
async function handleAIChatRequest(req, res) {
11+
const { namespace, resourceType, groupVersion, resourceName } = JSON.parse(
12+
req.body.toString(),
13+
);
14+
const clusterUrl = req.headers['x-cluster-url'];
15+
const certificateAuthorityData =
16+
req.headers['x-cluster-certificate-authority-data'];
17+
const clusterToken = req.headers['x-k8s-authorization']?.replace(
18+
/^Bearer\s+/i,
19+
'',
20+
);
21+
const clientCertificateData = req.headers['x-client-certificate-data'];
22+
const clientKeyData = req.headers['x-client-key-data'];
23+
24+
try {
25+
const url = 'https://companion.cp.dev.kyma.cloud.sap/api/conversations/';
26+
const payload = {
27+
resource_kind: resourceType,
28+
resource_api_version: groupVersion,
29+
resource_name: resourceName,
30+
namespace: namespace,
31+
};
32+
33+
const AUTH_TOKEN = await tokenManager.getToken();
34+
35+
const headers = {
36+
Accept: 'application/json',
37+
'Content-Type': 'application/json',
38+
Authorization: `Bearer ${AUTH_TOKEN}`,
39+
'X-Cluster-Certificate-Authority-Data': certificateAuthorityData,
40+
'X-Cluster-Url': clusterUrl,
41+
};
42+
43+
if (clusterToken) {
44+
headers['X-K8s-Authorization'] = clusterToken;
45+
} else if (clientCertificateData && clientKeyData) {
46+
headers['X-Client-Certificate-Data'] = clientCertificateData;
47+
headers['X-Client-Key-Data'] = clientKeyData;
48+
} else {
49+
throw new Error('Missing authentication credentials');
50+
}
51+
52+
const response = await fetch(url, {
53+
method: 'POST',
54+
headers,
55+
body: JSON.stringify(payload),
56+
});
57+
58+
const data = await response.json();
59+
60+
res.json({
61+
promptSuggestions: data?.initial_questions,
62+
conversationId: data?.conversation_id,
63+
});
64+
} catch (error) {
65+
console.error('Error in AI Chat proxy:', error);
66+
res.status(500).json({ error: 'Failed to fetch AI chat data' });
67+
}
68+
}
69+
70+
router.post('/suggestions', handleAIChatRequest);
71+
72+
export default router;

backend/companion/getKcpToken.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export async function getKcpToken() {
2+
const tokenUrl = 'https://kymatest.accounts400.ondemand.com/oauth2/token';
3+
const grantType = 'client_credentials';
4+
const clientId = process.env.COMPANION_KCP_AUTH_CLIENT_ID;
5+
const clientSecret = process.env.COMPANION_KCP_AUTH_CLIENT_SECRET;
6+
7+
if (!clientId) {
8+
throw new Error('COMPANION_KCP_AUTH_CLIENT_ID is not set');
9+
}
10+
if (!clientSecret) {
11+
throw new Error('COMPANION_KCP_AUTH_CLIENT_SECRET is not set');
12+
}
13+
14+
// Prepare request data
15+
const requestBody = new URLSearchParams();
16+
requestBody.append('grant_type', grantType);
17+
18+
// Prepare authorization header
19+
const authHeader = Buffer.from(`${clientId}:${clientSecret}`).toString(
20+
'base64',
21+
);
22+
23+
try {
24+
const response = await fetch(tokenUrl, {
25+
method: 'POST',
26+
headers: {
27+
Authorization: `Basic ${authHeader}`,
28+
'Content-Type': 'application/x-www-form-urlencoded',
29+
},
30+
body: requestBody.toString(),
31+
});
32+
33+
if (!response.ok) {
34+
throw new Error(`HTTP error! status: ${response.status}`);
35+
}
36+
37+
const data = await response.json();
38+
return data.access_token;
39+
} catch (error) {
40+
throw new Error(`Failed to fetch token: ${error.message}`);
41+
}
42+
}

backend/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { makeHandleRequest, serveStaticApp, serveMonaco } from './common';
22
import { handleTracking } from './tracking.js';
33
import jsyaml from 'js-yaml';
4+
import companionRouter from './companion/companionRouter';
45
//import { requestLogger } from './utils/other'; //uncomment this to log the outgoing traffic
56

67
const express = require('express');
@@ -88,11 +89,13 @@ if (isDocker) {
8889
// Running in dev mode
8990
// yup, order matters here
9091
serveMonaco(app);
92+
app.use('/backend/ai-chat', companionRouter);
9193
app.use('/backend', handleRequest);
9294
serveStaticApp(app, '/', '/core-ui');
9395
} else {
9496
// Running in prod mode
9597
handleTracking(app);
98+
app.use('/backend/ai-chat', companionRouter);
9699
app.use('/backend', handleRequest);
97100
}
98101

public/i18n/en.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,7 @@ kyma-companion:
766766
title: Service is interrupted
767767
subtitle: A temporary interruption occured. Please try again.
768768
introduction: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. Meanwhile, you can check the suggested questions below; you may find them helpful!
769+
introduction-no-suggestions: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. While I don't have any initial suggestions for this resource, feel free to ask me anything you'd like!
769770
placeholder: Message Joule
770771
disclaimer: Joule is powered by generative AI, and the output must be reviewed before use. Do not enter any sensitive personal data, and avoid entering any other data you do not wish to be processed.
771772
tabs:

src/command-pallette/CommandPalletteUI/useSearchResults.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function useSearchResults({
5656
const navigateAndCloseColumns = (to: To) => {
5757
setLayoutColumn({
5858
layout: 'OneColumn',
59+
startColumn: null,
5960
midColumn: null,
6061
endColumn: null,
6162
});

src/components/App/ExtensibilityRoutes.js

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,37 @@ const ColumnWrapper = ({
3636
const { namespaceId, resourceName } = useParams();
3737
const initialLayoutState = layout
3838
? {
39-
layout: layout ?? layoutState?.layout,
39+
layout: layout,
40+
startColumn: {
41+
resourceName: null,
42+
resourceType: urlPath ?? resourceType,
43+
namespaceId: namespaceId,
44+
apiGroup: extension?.general.resource.group,
45+
apiVersion: extension?.general.resource.version,
46+
},
4047
midColumn: {
4148
resourceName: resourceName,
4249
resourceType: urlPath ?? resourceType,
4350
namespaceId: namespaceId,
51+
apiGroup: extension?.general.resource.group,
52+
apiVersion: extension?.general.resource.version,
4453
},
4554
endColumn: null,
4655
}
47-
: null;
56+
: {
57+
layout: layoutState?.layout,
58+
startColumn: {
59+
resourceType: urlPath ?? resourceType,
60+
namespaceId: namespaceId,
61+
apiGroup: extension?.general.resource.group,
62+
apiVersion: extension?.general.resource.version,
63+
},
64+
midColumn: null,
65+
endColumn: null,
66+
};
4867

4968
useEffect(() => {
50-
if (layout && resourceName && resourceType) {
51-
setLayoutColumn(initialLayoutState);
52-
}
69+
setLayoutColumn(initialLayoutState);
5370
// eslint-disable-next-line react-hooks/exhaustive-deps
5471
}, [layout, namespaceId, resourceName, resourceType]);
5572

src/components/BusolaExtensions/BusolaExtensionCreate.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ export default function BusolaExtensionCreate({
6363
setLayoutColumn({
6464
layout: nextLayout,
6565
showCreate: null,
66+
startColumn: {
67+
resourceName: null,
68+
resourceType: 'Extensions',
69+
namespaceId: 'kube-public',
70+
},
6671
midColumn: {
6772
resourceName: crd.metadata.name,
6873
resourceType: 'Extensions',

src/components/BusolaExtensions/extensionsNode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { configFeaturesNames, NavNode } from 'state/types';
33
export const extensionsNavNode: NavNode = {
44
category: 'Configuration',
55
resourceType: 'configmaps',
6+
resourceTypeCased: 'ConfigMap',
67
pathSegment: 'busolaextensions',
78
label: 'Extensions',
89
namespaced: false,

src/components/Clusters/views/ClusterList.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function ClusterList() {
5050
useEffect(() => {
5151
setLayoutColumn({
5252
layout: 'OneColumn',
53+
startColumn: null,
5354
midColumn: null,
5455
endColumn: null,
5556
});

0 commit comments

Comments
 (0)