Skip to content
Merged
102 changes: 99 additions & 3 deletions backend/companion/companionRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const router = express.Router();

router.use(express.json());

async function handleAIChatRequest(req, res) {
async function handlePromptSuggestions(req, res) {
const { namespace, resourceType, groupVersion, resourceName } = JSON.parse(
req.body.toString(),
);
Expand Down Expand Up @@ -62,11 +62,107 @@ async function handleAIChatRequest(req, res) {
conversationId: data?.conversation_id,
});
} catch (error) {
console.error('Error in AI Chat proxy:', error);
res.status(500).json({ error: 'Failed to fetch AI chat data' });
}
}

router.post('/suggestions', handleAIChatRequest);
async function handleChatMessage(req, res) {
const {
query,
namespace,
resourceType,
groupVersion,
resourceName,
} = JSON.parse(req.body.toString());
const clusterUrl = req.headers['x-cluster-url'];
const certificateAuthorityData =
req.headers['x-cluster-certificate-authority-data'];
const clusterToken = req.headers['x-k8s-authorization']?.replace(
/^Bearer\s+/i,
'',
);
const clientCertificateData = req.headers['x-client-certificate-data'];
const clientKeyData = req.headers['x-client-key-data'];
const sessionId = req.headers['session-id'];
const conversationId = sessionId;

try {
const uuidPattern = /^[a-f0-9]{32}$/i;
if (!uuidPattern.test(conversationId)) {
throw new Error('Invalid session ID ');
}
const baseUrl =
'https://companion.cp.dev.kyma.cloud.sap/api/conversations/';
const targetUrl = new URL(
`${encodeURIComponent(conversationId)}/messages`,
baseUrl,
);

const payload = {
query,
resource_kind: resourceType,
resource_api_version: groupVersion,
resource_name: resourceName,
namespace: namespace,
};

const AUTH_TOKEN = await tokenManager.getToken();

// Set up headers for streaming response
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

const headers = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
Authorization: `Bearer ${AUTH_TOKEN}`,
'X-Cluster-Certificate-Authority-Data': certificateAuthorityData,
'X-Cluster-Url': clusterUrl,
'Session-Id': sessionId,
};

if (clusterToken) {
headers['X-K8s-Authorization'] = clusterToken;
} else if (clientCertificateData && clientKeyData) {
headers['X-Client-Certificate-Data'] = clientCertificateData;
headers['X-Client-Key-Data'] = clientKeyData;
} else {
throw new Error('Missing authentication credentials');
}

const response = await fetch(targetUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});

if (!response.body) {
throw new Error('Response body is null');
}

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();

if (done) {
break;
}

const chunk = decoder.decode(value, { stream: true });
res.write(chunk);
}

res.end();
} catch (error) {
res.status(500).json({ error: 'Failed to fetch AI chat data' });
}
}

router.post('/suggestions', handlePromptSuggestions);
router.post('/messages', handleChatMessage);

export default router;
4 changes: 4 additions & 0 deletions backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ if (gzipEnabled)
// compression interferes with ReadableStreams. Small chunks are not transmitted for unknown reason
return false;
}
// Skip compression for streaming endpoint
if (req.originalUrl.startsWith('/backend/ai-chat/messages')) {
return false;
}
// fallback to standard filter function
return compression.filter(req, res);
},
Expand Down
85 changes: 55 additions & 30 deletions src/components/KymaCompanion/api/getChatResponse.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,68 @@
import { getClusterConfig } from 'state/utils/getBackendInfo';
import { parseWithNestedBrackets } from '../utils/parseNestedBrackets';
import { MessageChunk } from '../components/Chat/messages/Message';

interface ClusterAuth {
token?: string;
clientCertificateData?: string;
clientKeyData?: string;
}

type GetChatResponseArgs = {
prompt: string;
handleChatResponse: (chunk: any) => void;
handleError: () => void;
query: string;
namespace?: string;
resourceType: string;
groupVersion?: string;
resourceName?: string;
sessionID: string;
handleChatResponse: (chunk: MessageChunk) => void;
handleError: (error?: Error) => void;
clusterUrl: string;
token: string;
clusterAuth: ClusterAuth;
certificateAuthorityData: string;
};

export default async function getChatResponse({
prompt,
query,
namespace = '',
resourceType,
groupVersion = '',
resourceName = '',
sessionID,
handleChatResponse,
handleError,
sessionID,
clusterUrl,
token,
clusterAuth,
certificateAuthorityData,
}: GetChatResponseArgs): Promise<void> {
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}`;
const url = `${backendAddress}/ai-chat/messages`;
const payload = {
query,
namespace,
resourceType,
groupVersion,
resourceName,
};

const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'X-Cluster-Certificate-Authority-Data': certificateAuthorityData,
'X-Cluster-Url': clusterUrl,
'Session-Id': sessionID,
};

if (clusterAuth?.token) {
headers['X-K8s-Authorization'] = clusterAuth?.token;
} else if (clusterAuth?.clientCertificateData && clusterAuth?.clientKeyData) {
headers['X-Client-Certificate-Data'] = clusterAuth?.clientCertificateData;
headers['X-Client-Key-Data'] = clusterAuth?.clientKeyData;
} else {
throw new Error('Missing authentication credentials');
}

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,
},
headers,
body: JSON.stringify(payload),
method: 'POST',
})
Expand All @@ -58,7 +87,7 @@ function readChunk(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: TextDecoder,
handleChatResponse: (chunk: any) => void,
handleError: () => void,
handleError: (error?: Error) => void,
sessionID: string,
) {
reader
Expand All @@ -67,17 +96,13 @@ function readChunk(
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);
});
const chunk = JSON.parse(receivedString);
if (chunk?.data?.error) {
handleError(chunk.data.error);
return;
}
handleChatResponse(chunk);
readChunk(reader, decoder, handleChatResponse, handleError, sessionID);
})
.catch(error => {
Expand Down
Loading
Loading