@cognizant-ai-lab/ui-common 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { Dispatch, Ref, SetStateAction } from "react";
2
+ import { LLMProvider } from "../../../state/Settings.js";
2
3
  import { CombinedAgentType } from "../Common/Types.js";
3
4
  export interface ChatCommonProps {
4
5
  /**
@@ -9,10 +10,6 @@ export interface ChatCommonProps {
9
10
  * The current username of the logged-in user. Used for fetching things from APIs mainly
10
11
  */
11
12
  readonly currentUser: string;
12
- /**
13
- * Path to image for user avatar
14
- */
15
- readonly userImage: string;
16
13
  /**
17
14
  * Function to set the state of the component to indicate whether we are awaiting a response from the LLM
18
15
  */
@@ -22,9 +19,9 @@ export interface ChatCommonProps {
22
19
  */
23
20
  readonly isAwaitingLlm: boolean;
24
21
  /**
25
- * The agent to send the request to.
22
+ * The network to send the request to.
26
23
  */
27
- readonly targetAgent: string;
24
+ readonly selectedNetwork: string | null;
28
25
  /**
29
26
  * Special endpoint for legacy agents since they do not have a single unified endpoint like Neuro-san agents.
30
27
  */
@@ -94,6 +91,10 @@ export interface ChatCommonProps {
94
91
  * Sample queries for the current network that the user can "click to send"
95
92
  */
96
93
  readonly sampleQueries?: string[];
94
+ /**
95
+ * Array of LLM providers for which API keys are required but missing. Only applies to BYOK networks.
96
+ */
97
+ readonly missingApiKeys?: LLMProvider[];
97
98
  }
98
99
  export type ChatCommonHandle = {
99
100
  handleStop: () => void;
@@ -32,7 +32,6 @@ import ListItemText from "@mui/material/ListItemText";
32
32
  import Menu from "@mui/material/Menu";
33
33
  import MenuItem from "@mui/material/MenuItem";
34
34
  import { useTheme } from "@mui/material/styles";
35
- import Tooltip from "@mui/material/Tooltip";
36
35
  import Typography from "@mui/material/Typography";
37
36
  import { isEmpty } from "lodash-es";
38
37
  import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
@@ -72,7 +71,7 @@ export const MAX_TURNS = 50;
72
71
  * agent responses. Customization for inputs and outputs is provided via event handlers-like props.
73
72
  */
74
73
  export const ChatCommon = ({ ref, ...props }) => {
75
- const { customAgentGreetings = EMPTY, agentPlaceholders = EMPTY, backgroundColor, currentUser, extraParams, extraSlyData, id, isAwaitingLlm, legacyAgentEndpoint, networkDescription, neuroSanURL, onChunkReceived, onClose, onSend, onStreamingComplete, onStreamingStarted, sampleQueries, setIsAwaitingLlm, setPreviousResponse, targetAgent, title, } = props;
74
+ const { customAgentGreetings = EMPTY, agentPlaceholders = EMPTY, backgroundColor, currentUser, extraParams, extraSlyData, id, isAwaitingLlm, legacyAgentEndpoint, missingApiKeys = [], networkDescription, neuroSanURL, onChunkReceived, onClose, onSend, onStreamingComplete, onStreamingStarted, sampleQueries, setIsAwaitingLlm, setPreviousResponse, selectedNetwork, title, } = props;
76
75
  // MUI theme
77
76
  const theme = useTheme();
78
77
  // User LLM chat input
@@ -99,7 +98,7 @@ export const ChatCommon = ({ ref, ...props }) => {
99
98
  const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
100
99
  // Persistent agent chat history store, which is where we store both kinds of chat histories
101
100
  // (see store implementation for details)
102
- const storedChatHistory = useAgentChatHistoryStore((state) => state?.history?.[targetAgent]);
101
+ const storedChatHistory = useAgentChatHistoryStore((state) => selectedNetwork ? state?.history?.[selectedNetwork] : undefined);
103
102
  const agentChatHistory = useMemo(() => storedChatHistory ?? { chatHistory: [], chatContext: null, slyData: {} }, [storedChatHistory]);
104
103
  // Access store for context items
105
104
  const updateChatContext = useAgentChatHistoryStore((state) => state.updateChatContext);
@@ -135,7 +134,7 @@ export const ChatCommon = ({ ref, ...props }) => {
135
134
  };
136
135
  // Keeps track of whether the agent completed its task
137
136
  const succeeded = useRef(false);
138
- const agentDisplayName = useMemo(() => cleanUpAgentName(removeTrailingUuid(targetAgent)), [targetAgent]);
137
+ const agentDisplayName = useMemo(() => cleanUpAgentName(removeTrailingUuid(selectedNetwork)), [selectedNetwork]);
139
138
  useEffect(() => {
140
139
  // Set up speech recognition
141
140
  const handlers = setupSpeechRecognition(setChatInput, setVoiceInputState, speechRecognitionRef);
@@ -196,13 +195,13 @@ export const ChatCommon = ({ ref, ...props }) => {
196
195
  }
197
196
  // Shallow merge existing slyData with incoming chatMessage.sly_data
198
197
  if (chatMessage.sly_data) {
199
- updateSlyData(targetAgent, chatMessage.sly_data);
198
+ updateSlyData(selectedNetwork, chatMessage.sly_data);
200
199
  }
201
200
  // It's a ChatMessage. Does it have chat context? Only AGENT_FRAMEWORK messages can have chat context.
202
201
  if (chatMessage.type === ChatMessageType.AGENT_FRAMEWORK && chatMessage.chat_context) {
203
202
  // Save the chat context, potentially overwriting any previous ones we received during this session.
204
203
  // We only care about the last one received.
205
- updateChatContext(targetAgent, chatMessage.chat_context);
204
+ updateChatContext(selectedNetwork, chatMessage.chat_context);
206
205
  }
207
206
  // Check if there is an error block in the "structure" field of the chat message.
208
207
  const errorMessage = checkError(chatMessage.structure);
@@ -215,7 +214,7 @@ export const ChatCommon = ({ ref, ...props }) => {
215
214
  });
216
215
  succeeded.current = false;
217
216
  }
218
- else if (chatMessage?.text?.trim().length > 0 || chatMessage.structure) {
217
+ else if (chatMessage?.text?.trim().length > 0 || !isEmpty(chatMessage.structure)) {
219
218
  // Not an error, so output it if it has text or a structure.
220
219
  // This is the normal happy path for an incoming message.
221
220
  // The backend sometimes sends messages with no text content, and we don't want to display those to the
@@ -236,7 +235,7 @@ export const ChatCommon = ({ ref, ...props }) => {
236
235
  currentResponse.current += chatMessage.text;
237
236
  }
238
237
  }
239
- }, [addTurn, targetAgent, updateChatContext, updateSlyData]);
238
+ }, [addTurn, selectedNetwork, updateChatContext, updateSlyData]);
240
239
  /**
241
240
  * Handle a chunk of response from the server. Called each time the server streams a chunk.
242
241
  */
@@ -244,14 +243,14 @@ export const ChatCommon = ({ ref, ...props }) => {
244
243
  // Give container a chance to process the chunk first
245
244
  const onChunkReceivedResult = onChunkReceived?.(chunk) ?? true;
246
245
  succeeded.current = succeeded.current || onChunkReceivedResult;
247
- if (isLegacyAgentType(targetAgent)) {
246
+ if (isLegacyAgentType(selectedNetwork)) {
248
247
  // For legacy agents, we either get plain text or Markdown. Just output it as-is.
249
248
  handleLegacyAgentChunk(chunk);
250
249
  }
251
250
  else {
252
251
  handleNeuroSanAgentChunk(chunk);
253
252
  }
254
- }, [onChunkReceived, targetAgent, handleNeuroSanAgentChunk, handleLegacyAgentChunk]);
253
+ }, [onChunkReceived, selectedNetwork, handleNeuroSanAgentChunk, handleLegacyAgentChunk]);
255
254
  /**
256
255
  * Reset the state of the component. This is called after a request is completed, regardless of success or failure.
257
256
  */
@@ -259,10 +258,10 @@ export const ChatCommon = ({ ref, ...props }) => {
259
258
  // Reset state, whatever happened during request
260
259
  setIsAwaitingLlm(false);
261
260
  setChatInput("");
262
- setPreviousResponse?.(targetAgent, currentResponse.current);
261
+ setPreviousResponse?.(selectedNetwork, currentResponse.current);
263
262
  currentResponse.current = "";
264
263
  legacyTurnIdRef.current = null;
265
- }, [setIsAwaitingLlm, setPreviousResponse, targetAgent]);
264
+ }, [setIsAwaitingLlm, setPreviousResponse, selectedNetwork]);
266
265
  /*
267
266
  * The main logic for sending a query to the server, with retries on errors.
268
267
  */
@@ -275,7 +274,7 @@ export const ChatCommon = ({ ref, ...props }) => {
275
274
  // Increment the attempt number and set the state to indicate we're awaiting a response
276
275
  attemptNumber += 1;
277
276
  // Check which agent type we are dealing with
278
- if (isLegacyAgentType(targetAgent)) {
277
+ if (isLegacyAgentType(selectedNetwork)) {
279
278
  // It's a legacy agent (these go directly to the LLM and are different from
280
279
  // the Neuro-san agents).
281
280
  // Send the chat query to the server. This will block until the stream ends from the server
@@ -285,7 +284,7 @@ export const ChatCommon = ({ ref, ...props }) => {
285
284
  // It's a Neuro-san agent.
286
285
  // Some coded tools (data generator...) expect the username provided in slyData.
287
286
  const slyDataWithUserName = { ...agentChatHistory?.slyData, ...extraSlyData, login: currentUser };
288
- await sendChatQuery(neuroSanURL, controller?.current.signal, query, targetAgent, handleChunk, agentChatHistory.chatContext, slyDataWithUserName, currentUser, StreamingUnit.Line);
287
+ await sendChatQuery(neuroSanURL, controller?.current.signal, query, selectedNetwork, handleChunk, agentChatHistory.chatContext, slyDataWithUserName, currentUser, StreamingUnit.Line);
289
288
  }
290
289
  }
291
290
  catch (error) {
@@ -317,7 +316,7 @@ export const ChatCommon = ({ ref, ...props }) => {
317
316
  handleChunk,
318
317
  legacyAgentEndpoint,
319
318
  neuroSanURL,
320
- targetAgent,
319
+ selectedNetwork,
321
320
  ]);
322
321
  const getFinalAnswerErrorTurn = () => ({
323
322
  id: uuid(),
@@ -329,7 +328,7 @@ export const ChatCommon = ({ ref, ...props }) => {
329
328
  // Prefer the most recent matching turn
330
329
  const idx = currentTurns.reduceRight((foundIndex, turn, i) => foundIndex !== -1 || extractFinalAnswer(turn.text) === undefined ? foundIndex : i, -1);
331
330
  if (idx === -1) {
332
- if (givesFinalAnswer(targetAgent)) {
331
+ if (givesFinalAnswer(selectedNetwork)) {
333
332
  // This agent is supposed to give final answers, but didn't this time. An error.
334
333
  setTurns((prev) => [...prev, getFinalAnswerErrorTurn()]);
335
334
  return;
@@ -342,13 +341,13 @@ export const ChatCommon = ({ ref, ...props }) => {
342
341
  // Just set the last turn as the final answer
343
342
  setTurns((prev) => prev.map((turn) => (turn.id === lastTurn.id ? { ...turn, role: MessageRole.FinalAnswer } : turn)));
344
343
  // Save it to chat history
345
- updateChatHistory(targetAgent, [new AIMessage({ content: lastTurn.text, id: uuid() })]);
344
+ updateChatHistory(selectedNetwork, [new AIMessage({ content: lastTurn.text, id: uuid() })]);
346
345
  return;
347
346
  }
348
347
  }
349
348
  const sourceTurn = currentTurns[idx];
350
349
  // Save item to chat history (same as original behavior)
351
- updateChatHistory(targetAgent, [new AIMessage({ content: sourceTurn.text, id: uuid() })]);
350
+ updateChatHistory(selectedNetwork, [new AIMessage({ content: sourceTurn.text, id: uuid() })]);
352
351
  // Extract the final answer from the turn.
353
352
  const finalAnswer = extractFinalAnswer(sourceTurn.text)?.trim();
354
353
  // Update the turn to be a final answer turn, and add a new final answer turn with just the final answer text.
@@ -365,7 +364,7 @@ export const ChatCommon = ({ ref, ...props }) => {
365
364
  });
366
365
  return updated;
367
366
  });
368
- }, [targetAgent, updateChatHistory]);
367
+ }, [selectedNetwork, updateChatHistory]);
369
368
  /**
370
369
  * Extract the final answer from the turns for a Neuro-san agent. For Neuro-san agents, we expect the final answer
371
370
  * to be the most recent turn messageType === ChatMessageType.AGENT_FRAMEWORK.
@@ -384,23 +383,16 @@ export const ChatCommon = ({ ref, ...props }) => {
384
383
  }
385
384
  // Extract final answer from that turn
386
385
  const finalAnswerTurn = currentTurns[idx];
387
- const hasFinalAnswer = finalAnswerTurn.text?.trim().length > 0 || !isEmpty(finalAnswerTurn.structure);
388
- if (hasFinalAnswer) {
389
- // Update relevant turn to be the final answer
390
- setTurns((prev) => prev.map((turn, i) => (i === idx ? { ...turn, role: MessageRole.FinalAnswer } : turn)));
391
- // Save final answer to chat history
392
- const finalAnswerContent = finalAnswerTurn.text || JSON.stringify(finalAnswerTurn.structure, null, 2);
393
- updateChatHistory(targetAgent, [new AIMessage({ content: finalAnswerContent, id: uuid() })]);
394
- }
395
- else {
396
- // No final answer found, display error
397
- setTurns((prev) => [...prev, getFinalAnswerErrorTurn()]);
398
- }
399
- }, [targetAgent, updateChatHistory]);
386
+ // Update relevant turn to be the final answer
387
+ setTurns((prev) => prev.map((turn, i) => (i === idx ? { ...turn, role: MessageRole.FinalAnswer } : turn)));
388
+ // Save final answer to chat history
389
+ const finalAnswerContent = finalAnswerTurn.text || JSON.stringify(finalAnswerTurn.structure, null, 2);
390
+ updateChatHistory(selectedNetwork, [new AIMessage({ content: finalAnswerContent, id: uuid() })]);
391
+ }, [selectedNetwork, updateChatHistory]);
400
392
  const handleSend = useCallback(async (query) => {
401
393
  // Record user query in chat history. Discard anything beyond MAX_CHAT_HISTORY_ITEMS
402
394
  const userQueryMessage = new HumanMessage({ content: query, id: uuid() });
403
- updateChatHistory(targetAgent, [userQueryMessage]);
395
+ updateChatHistory(selectedNetwork, [userQueryMessage]);
404
396
  // Allow parent to intercept and modify the query before sending if needed
405
397
  const queryToSend = onSend?.(query) ?? query;
406
398
  // Save query for "regenerate" use. Again we save the real user input, not the modified query. It will again
@@ -428,7 +420,7 @@ export const ChatCommon = ({ ref, ...props }) => {
428
420
  if (!wasAborted) {
429
421
  if (succeeded.current) {
430
422
  // Success: infer final answer depending on agent type
431
- if (isLegacyAgentType(targetAgent)) {
423
+ if (isLegacyAgentType(selectedNetwork)) {
432
424
  handleFinalAnswerLegacyAgent();
433
425
  }
434
426
  else {
@@ -460,7 +452,7 @@ export const ChatCommon = ({ ref, ...props }) => {
460
452
  onStreamingStarted,
461
453
  resetState,
462
454
  setIsAwaitingLlm,
463
- targetAgent,
455
+ selectedNetwork,
464
456
  updateChatHistory,
465
457
  ]);
466
458
  const handleStop = useCallback(() => {
@@ -485,29 +477,34 @@ export const ChatCommon = ({ ref, ...props }) => {
485
477
  const shouldEnableRegenerateButton = previousUserQuery && !isAwaitingLlm;
486
478
  // Enable Clear Chat button if not awaiting response and there is chat output to clear
487
479
  const enableClearChatButton = !isAwaitingLlm && (turns.length > 0 || agentChatHistory?.chatHistory?.length > 0);
488
- const getPlaceholder = () => !targetAgent ? null : agentPlaceholders[targetAgent] || `Chat with ${agentDisplayName}`;
480
+ const getPlaceholder = () => selectedNetwork ? agentPlaceholders[selectedNetwork] || `Chat with ${agentDisplayName}` : null;
489
481
  const handleClearChat = useCallback(() => {
490
482
  setTurns([]);
491
- resetHistory(targetAgent);
483
+ resetHistory(selectedNetwork);
492
484
  setPreviousUserQuery("");
493
485
  currentResponse.current = "";
494
- }, [resetHistory, targetAgent]);
486
+ }, [resetHistory, selectedNetwork]);
495
487
  // Expose the handleStop and handleClearChat methods to parent components via ref for external control
496
488
  useImperativeHandle(ref, () => ({
497
489
  handleStop,
498
490
  handleClearChat,
499
491
  }), [handleStop, handleClearChat]);
500
- const getNoAgentOverlay = () => (_jsx(Tooltip, { title: "Please select a Network from the list to start the chat.", placement: "auto", children: _jsx(Box, { id: "chat-disabled-overlay", sx: {
492
+ const getErrorOverlay = (errorText) => (_jsx(Box, { id: "chat-disabled-overlay", sx: {
493
+ position: "absolute",
494
+ top: 0,
495
+ left: 0,
496
+ right: 0,
497
+ bottom: 0,
498
+ zIndex: theme.zIndex.modal - 1,
499
+ cursor: "not-allowed",
500
+ // Capture all pointer events to prevent interaction with the chat when no agent is selected
501
+ pointerEvents: "all",
502
+ }, children: _jsx(Typography, { sx: {
501
503
  position: "absolute",
502
- top: 0,
503
- left: 0,
504
- right: 0,
505
- bottom: 0,
506
- zIndex: theme.zIndex.modal - 1,
507
- cursor: "not-allowed",
508
- // Capture all pointer events to prevent interaction with the chat when no agent is selected
509
- pointerEvents: "all",
510
- } }) }));
504
+ top: "50%",
505
+ left: "50%",
506
+ transform: "translate(-50%, -50%)",
507
+ }, children: errorText }) }));
511
508
  const getTitle = () => (_jsxs(Box, { id: `llm-chat-title-container-${id}`, sx: {
512
509
  alignItems: "center",
513
510
  borderTopLeftRadius: "var(--bs-border-radius)",
@@ -527,7 +524,7 @@ export const ChatCommon = ({ ref, ...props }) => {
527
524
  setOptionsMenuAnchorEl(e.currentTarget);
528
525
  setOptionsMenuOpen(true);
529
526
  }, children: _jsx(TuneIcon, { sx: { fontSize: "1.2rem" } }) }) }));
530
- const agentGreeting = customAgentGreetings[targetAgent] ?? "Hi, how can I help?";
527
+ const agentGreeting = customAgentGreetings[selectedNetwork] ?? "Hi, how can I help?";
531
528
  const handleOptionsMenuClose = () => {
532
529
  setOptionsMenuAnchorEl(null);
533
530
  setOptionsMenuOpen(false);
@@ -569,7 +566,7 @@ export const ChatCommon = ({ ref, ...props }) => {
569
566
  paddingTop: "7.5px",
570
567
  scrollbarGutter: "stable",
571
568
  width: "100%",
572
- }, tabIndex: -1, children: [getOptionsMenu(), getOptionsMenuButton(), agentChatHistory?.chatHistory?.length > 0 && (_jsx(ChatHistory, { id: id, messages: agentChatHistory.chatHistory })), _jsxs(Box, { sx: { marginBottom: "0.5rem", marginTop: "1rem", color: "var(--bs-gray)" }, children: [_jsxs(Typography, { component: "span", sx: { fontWeight: 700 }, variant: "inherit", children: [targetAgent, networkDescription && ":"] }), networkDescription && (_jsxs(Typography, { component: "span", sx: { ml: 0.5 }, variant: "inherit", children: [" ", networkDescription] }))] }), _jsx(Box, { sx: { marginBottom: "0.5rem", marginTop: "1rem" }, children: agentGreeting }), _jsx(SampleQueries, { disabled: isAwaitingLlm, handleSend: handleSend, sampleQueries: sampleQueries }), _jsx(Conversation, { id: id, includeAgentMessages: !givesFinalAnswer(targetAgent), shouldWrapOutput: shouldWrapOutput, turns: turns }), !isAwaitingLlm && turns.length > 0 && (
569
+ }, tabIndex: -1, children: [getOptionsMenu(), getOptionsMenuButton(), agentChatHistory?.chatHistory?.length > 0 && (_jsx(ChatHistory, { id: id, messages: agentChatHistory.chatHistory })), _jsxs(Box, { sx: { marginBottom: "0.5rem", marginTop: "1rem", color: "var(--bs-gray)" }, children: [_jsxs(Typography, { component: "span", sx: { fontWeight: 700 }, variant: "inherit", children: [selectedNetwork, networkDescription && ":"] }), networkDescription && (_jsxs(Typography, { component: "span", sx: { ml: 0.5 }, variant: "inherit", children: [" ", networkDescription] }))] }), _jsx(Box, { sx: { marginBottom: "0.5rem", marginTop: "1rem" }, children: agentGreeting }), _jsx(SampleQueries, { disabled: isAwaitingLlm, handleSend: handleSend, sampleQueries: sampleQueries }), _jsx(Conversation, { id: id, includeAgentMessages: !givesFinalAnswer(selectedNetwork), shouldWrapOutput: shouldWrapOutput, turns: turns }), !isAwaitingLlm && turns.length > 0 && (
573
570
  // Only show thinking once streaming is complete
574
571
  _jsx(Thinking, { id: id, turns: turns })), isAwaitingLlm && (_jsxs(Box, { id: "awaitingOutputContainer", sx: { display: "flex", alignItems: "center", fontSize: "smaller" }, children: [_jsx("span", { id: "working-span", style: { marginRight: "1rem" }, children: "Working..." }), _jsx(CircularProgress, { id: "awaitingOutputSpinner", sx: {
575
572
  color: "var(--bs-primary)",
@@ -613,8 +610,8 @@ export const ChatCommon = ({ ref, ...props }) => {
613
610
  flexDirection: "column",
614
611
  flexGrow: 1,
615
612
  height: "100%",
616
- opacity: targetAgent ? 1 : 0.4,
617
- pointerEvents: targetAgent ? "auto" : "none",
613
+ opacity: selectedNetwork ? 1 : 0.4,
614
+ pointerEvents: selectedNetwork ? "auto" : "none",
618
615
  position: "relative",
619
616
  }, children: [title && getTitle(), getResponseBox(), getUserInputBox()] }));
620
617
  return (_jsx(Box, { id: `llm-chat-${id}`, sx: {
@@ -623,5 +620,10 @@ export const ChatCommon = ({ ref, ...props }) => {
623
620
  flexGrow: 1,
624
621
  height: "100%",
625
622
  position: "relative",
626
- }, children: targetAgent ? getChatBox() : getNoAgentOverlay() }));
623
+ }, children: selectedNetwork
624
+ ? missingApiKeys?.length === 0
625
+ ? getChatBox()
626
+ : getErrorOverlay(`API key(s) required for: ${missingApiKeys.join(", ")}. ` +
627
+ "Please add the required key(s) in Settings to use this Network.")
628
+ : getErrorOverlay("Please select a Network from the list to start the chat.") }));
627
629
  };
@@ -4,10 +4,6 @@ interface ChatBotProps {
4
4
  * id HTML id to use for the outer component
5
5
  */
6
6
  readonly id: string;
7
- /**
8
- * Path to image for user avatar
9
- */
10
- readonly userAvatar: string;
11
7
  /**
12
8
  * Text about current page to help the LLM respond intelligently
13
9
  */
@@ -29,7 +29,7 @@ import { LegacyAgentType } from "../AgentChat/Common/Types.js";
29
29
  * Site-wide Chatbot component.
30
30
  */
31
31
  // Temporarily disabled but will be used once we migrated the backend to use Neuro-san RAG.
32
- export const ChatBot = ({ id, userAvatar, pageContext }) => {
32
+ export const ChatBot = ({ id, pageContext }) => {
33
33
  const [chatOpen, setChatOpen] = useState(false);
34
34
  const [isAwaitingLlm, setIsAwaitingLlm] = useState(false);
35
35
  const { user: { name: currentUser }, } = useAuthentication().data;
@@ -51,7 +51,7 @@ export const ChatBot = ({ id, userAvatar, pageContext }) => {
51
51
  borderWidth: 1,
52
52
  borderColor: darkMode ? "var(--bs-white)" : "var(--bs-gray-light)",
53
53
  zIndex: getZIndex(2, theme),
54
- }, children: _jsx(ChatCommon, { id: "chatbot-window", currentUser: currentUser, setIsAwaitingLlm: setIsAwaitingLlm, isAwaitingLlm: isAwaitingLlm, targetAgent: LegacyAgentType.ChatBot, userImage: userAvatar, legacyAgentEndpoint: CHATBOT_ENDPOINT, extraParams: { pageContext }, backgroundColor: darkMode ? "var(--bs-gray-dark)" : "var(--bs-tertiary-blue)", title: "Cognizant Neuro AI Assistant", onClose: () => setChatOpen(false) }) }) }), !chatOpen && (_jsx(Box, { id: `chatbot-icon-${id}`, sx: {
54
+ }, children: _jsx(ChatCommon, { id: "chatbot-window", currentUser: currentUser, setIsAwaitingLlm: setIsAwaitingLlm, isAwaitingLlm: isAwaitingLlm, selectedNetwork: LegacyAgentType.ChatBot, legacyAgentEndpoint: CHATBOT_ENDPOINT, extraParams: { pageContext }, backgroundColor: darkMode ? "var(--bs-gray-dark)" : "var(--bs-tertiary-blue)", title: "Cognizant Neuro AI Assistant", onClose: () => setChatOpen(false) }) }) }), !chatOpen && (_jsx(Box, { id: `chatbot-icon-${id}`, sx: {
55
55
  display: "flex",
56
56
  alignItems: "center",
57
57
  backgroundColor: darkMode ? "var(--bs-dark-mode-dim)" : "var(--bs-white)",
@@ -43,7 +43,5 @@ export const CustomerLogo = ({ fallbackElement, logoServiceToken }) => {
43
43
  const logoUrl = logoServiceToken && customer?.trim().length > 0
44
44
  ? `https://img.logo.dev/name/${encodeURIComponent(customer)}?token=${logoServiceToken}&theme=dark&format=png&size=75`
45
45
  : null;
46
- return logoUrl ? (
47
- // eslint-disable-next-line @next/next/no-img-element
48
- _jsx("img", { src: logoUrl, alt: `${customer} Logo`, width: 40, height: 40, style: { borderRadius: "50%" } })) : (fallbackElement);
46
+ return logoUrl ? (_jsx("img", { src: logoUrl, alt: `${customer} Logo`, width: 40, height: 40, style: { borderRadius: "50%" } })) : (fallbackElement);
49
47
  };
@@ -97,6 +97,7 @@ export const MultiAgentAccelerator = ({ backendNeuroSanApiUrl, userInfo, }) => {
97
97
  // MUI theme
98
98
  const theme = useTheme();
99
99
  const enableZenMode = useSettingsStore((state) => state.settings.behavior.enableZenMode);
100
+ const apiKeys = useSettingsStore((state) => state.settings.apiKeys);
100
101
  // Stores whether are currently awaiting LLM response (for knowing when to show spinners)
101
102
  const [isAwaitingLlm, setIsAwaitingLlm] = useState(false);
102
103
  const [isEditingNetwork, setIsEditingNetwork] = useState(false);
@@ -118,12 +119,13 @@ export const MultiAgentAccelerator = ({ backendNeuroSanApiUrl, userInfo, }) => {
118
119
  const [selectedNetwork, setSelectedNetwork] = useState(null);
119
120
  const [networkDescription, setNetworkDescription] = useState("");
120
121
  const networkDisplayName = useMemo(() => cleanUpAgentName(removeTrailingUuid(selectedNetwork)), [selectedNetwork]);
122
+ const [providerKeysRequired, setProviderKeysRequired] = useState(new Set());
121
123
  const [customURLLocalStorage, setCustomURLLocalStorage] = useLocalStorage("customAgentNetworkURL", null);
122
124
  // An extra set of quotes is making it in the string in local storage.
123
125
  const [neuroSanURL, setNeuroSanURL] = useState(customURLLocalStorage?.replaceAll('"', "") || backendNeuroSanApiUrl);
124
126
  // Tracks how many times each agent has been involved in the conversation
125
127
  const [agentCounts, setAgentCounts] = useState(new Map());
126
- //common function to change the selected network and reset related state
128
+ // Common function to change the selected network and reset related state
127
129
  const changeSelectedNetwork = useCallback((next) => {
128
130
  setSelectedNetwork(next);
129
131
  setAgentCounts(new Map());
@@ -203,23 +205,62 @@ export const MultiAgentAccelerator = ({ backendNeuroSanApiUrl, userInfo, }) => {
203
205
  const designerTempNetwork = designerNetworkName
204
206
  ? temporaryNetworks.find((n) => n.agentNetworkName === designerNetworkName)
205
207
  : undefined;
206
- const extraSlyData = currentTempNetwork
208
+ /**
209
+ * Builds the API keys object to be sent as extraSlyData with each request, based on the LLM providers required
210
+ * by the currently selected network. Only includes keys for providers that are required.
211
+ */
212
+ const getApiKeys = () => {
213
+ const llmConfig = {};
214
+ if (providerKeysRequired.has("OpenAI")) {
215
+ llmConfig["openai_api_key"] = apiKeys["OpenAI"];
216
+ }
217
+ if (providerKeysRequired.has("Anthropic")) {
218
+ llmConfig["anthropic_api_key"] = apiKeys["Anthropic"];
219
+ }
220
+ return { llm_config: llmConfig };
221
+ };
222
+ /**
223
+ * Builds the extraSlyData object to be sent with each request, including information for Agent Network Designer
224
+ * and (if required) API keys for LLM providers.
225
+ */
226
+ const buildExtraSlyData = () => {
227
+ if (currentTempNetwork) {
228
+ const result = {
229
+ [AGENT_NETWORK_DEFINITION_KEY]: currentTempNetwork.agentNetworkDefinition,
230
+ };
231
+ // Use agentNetworkName, not reservation_id
232
+ if (currentTempNetwork.agentNetworkName) {
233
+ result[AGENT_NETWORK_NAME_KEY] = currentTempNetwork.agentNetworkName;
234
+ }
235
+ if (currentTempNetwork.networkHocon) {
236
+ result[AGENT_NETWORK_HOCON] = currentTempNetwork.networkHocon;
237
+ }
238
+ return result;
239
+ }
240
+ if (designerTempNetwork) {
241
+ return {
242
+ [AGENT_NETWORK_DEFINITION_KEY]: designerTempNetwork.agentNetworkDefinition,
243
+ };
244
+ }
245
+ return undefined;
246
+ };
247
+ // Whether any API keys are required
248
+ const anyApiKeysRequired = providerKeysRequired.size > 0;
249
+ // Build base extraSlyData
250
+ const baseExtraSlyData = buildExtraSlyData();
251
+ // Add API keys to extraSlyData if needed, merging with baseExtraSlyData if it exists
252
+ const extraSlyData = anyApiKeysRequired || baseExtraSlyData
207
253
  ? {
208
- [AGENT_NETWORK_DEFINITION_KEY]: currentTempNetwork.agentNetworkDefinition,
209
- // Use the agentNetworkName, not reservation_id
210
- ...(currentTempNetwork.agentNetworkName
211
- ? { [AGENT_NETWORK_NAME_KEY]: currentTempNetwork.agentNetworkName }
212
- : {}),
213
- ...(currentTempNetwork.networkHocon ? { [AGENT_NETWORK_HOCON]: currentTempNetwork.networkHocon } : {}),
254
+ ...baseExtraSlyData,
255
+ ...(anyApiKeysRequired ? getApiKeys() : {}),
214
256
  }
215
- : designerTempNetwork
216
- ? { [AGENT_NETWORK_DEFINITION_KEY]: designerTempNetwork.agentNetworkDefinition }
217
- : undefined;
257
+ : undefined;
218
258
  // Handle external stop button click - stops streaming and exits zen mode
219
259
  const handleExternalStop = useCallback(() => {
220
260
  chatRef.current?.handleStop();
221
261
  resetState();
222
262
  }, [resetState]);
263
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
223
264
  useEffect(() => {
224
265
  ;
225
266
  (async () => {
@@ -242,9 +283,24 @@ export const MultiAgentAccelerator = ({ backendNeuroSanApiUrl, userInfo, }) => {
242
283
  try {
243
284
  const agentFunction = await getAgentFunction(neuroSanURL, selectedNetwork, userInfo.userName);
244
285
  setNetworkDescription(agentFunction?.function?.description || "");
286
+ const schema = agentFunction?.function?.sly_data_schema;
287
+ const schemaProperties = isRecord(schema) ? schema["properties"] : undefined;
288
+ const llmConfig = isRecord(schemaProperties) ? schemaProperties["llm_config"] : undefined;
289
+ const llmConfigRequired = isRecord(llmConfig) ? llmConfig["required"] : undefined;
290
+ setProviderKeysRequired(new Set((Array.isArray(llmConfigRequired) ? llmConfigRequired : []).flatMap((key) => {
291
+ if (key === "openai_api_key") {
292
+ return ["OpenAI"];
293
+ }
294
+ if (key === "anthropic_api_key") {
295
+ return ["Anthropic"];
296
+ }
297
+ console.warn(`Unknown API key requirement "${key}" for network ${selectedNetwork}. Skipping.`);
298
+ // Will get dropped by flatMap
299
+ return [];
300
+ })));
245
301
  }
246
- catch {
247
- // Ignore. May be a legacy agent without a functional description in Neuro-san.
302
+ catch (e) {
303
+ console.warn(`Unable to get agent details for network ${selectedNetwork}:`, e);
248
304
  }
249
305
  };
250
306
  // Clear out existing
@@ -506,6 +562,9 @@ export const MultiAgentAccelerator = ({ backendNeuroSanApiUrl, userInfo, }) => {
506
562
  setTourRequested(false);
507
563
  }
508
564
  }, [tourRequested, selectedNetwork, agentsInNetwork, networks, controls, setTourStatus]);
565
+ const getMissingApiKeys = () => {
566
+ return providerKeysRequired.size > 0 ? [...providerKeysRequired].filter((provider) => !apiKeys?.[provider]) : [];
567
+ };
509
568
  const getLeftPanel = () => {
510
569
  return (_jsx(Slide, { id: "multi-agent-accelerator-grid-sidebar-slide", in: !enableZenMode || !isAwaitingLlm, direction: "right", timeout: GROW_ANIMATION_TIME_MS, onExited: () => {
511
570
  setIsStreaming(true);
@@ -531,11 +590,11 @@ export const MultiAgentAccelerator = ({ backendNeuroSanApiUrl, userInfo, }) => {
531
590
  setIsStreaming(true);
532
591
  }, children: _jsx(Grid, { id: "multi-agent-accelerator-grid-agent-chat-common", size: enableZenMode && isStreaming ? 0 : 6.5, sx: {
533
592
  height: "100%",
534
- }, children: _jsx(ChatCommon, { customAgentGreetings: {
535
- [AGENT_NETWORK_DESIGNER_ID]: "Let's build a network together!",
536
- }, agentPlaceholders: {
593
+ }, children: _jsx(ChatCommon, { agentPlaceholders: {
537
594
  [AGENT_NETWORK_DESIGNER_ID]: "Describe in plain language the network you would like to build.",
538
- }, currentUser: userInfo.userName, extraSlyData: extraSlyData, id: "agent-network-ui", isAwaitingLlm: isAwaitingLlm, networkDescription: networkDescription, neuroSanURL: neuroSanURL, onChunkReceived: onChunkReceived, onStreamingComplete: onStreamingComplete, onStreamingStarted: onStreamingStarted, ref: chatRef, sampleQueries: sampleQueries, setIsAwaitingLlm: setIsAwaitingLlm, targetAgent: selectedNetwork, userImage: userInfo.userImage }, selectedNetwork ?? "no-network") }) }));
595
+ }, currentUser: userInfo.userName, customAgentGreetings: {
596
+ [AGENT_NETWORK_DESIGNER_ID]: "Let's build a network together!",
597
+ }, extraSlyData: extraSlyData, id: "agent-network-ui", isAwaitingLlm: isAwaitingLlm, missingApiKeys: getMissingApiKeys(), networkDescription: networkDescription, neuroSanURL: neuroSanURL, onChunkReceived: onChunkReceived, onStreamingComplete: onStreamingComplete, onStreamingStarted: onStreamingStarted, ref: chatRef, sampleQueries: sampleQueries, setIsAwaitingLlm: setIsAwaitingLlm, selectedNetwork: selectedNetwork }, selectedNetwork ?? "no-network") }) }));
539
598
  };
540
599
  const getStopButton = () => {
541
600
  return (_jsx(_Fragment, { children: isAwaitingLlm && enableZenMode && (_jsx(Box, { id: "stop-button-container", sx: {
@@ -0,0 +1,16 @@
1
+ import { FC } from "react";
2
+ interface ApiKeyInputProps {
3
+ readonly forgetKey: () => void;
4
+ readonly id: string;
5
+ readonly logo: string;
6
+ readonly onSave: (key: string) => void;
7
+ readonly onTest: (key: string) => Promise<boolean>;
8
+ readonly persistedValue: string;
9
+ readonly placeholder: string;
10
+ readonly vendor: string;
11
+ }
12
+ /**
13
+ * Component for inputting an API key for a given vendor, with the ability to test the key and forget the saved key.
14
+ */
15
+ export declare const ApiKeyInput: FC<ApiKeyInputProps>;
16
+ export {};
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import CheckIcon from "@mui/icons-material/Check";
3
+ import ClearIcon from "@mui/icons-material/Clear";
4
+ import ErrorIcon from "@mui/icons-material/Error";
5
+ import Visibility from "@mui/icons-material/Visibility";
6
+ import VisibilityOff from "@mui/icons-material/VisibilityOff";
7
+ import Box from "@mui/material/Box";
8
+ import Button from "@mui/material/Button";
9
+ import FormLabel from "@mui/material/FormLabel";
10
+ import IconButton from "@mui/material/IconButton";
11
+ import InputAdornment from "@mui/material/InputAdornment";
12
+ import TextField from "@mui/material/TextField";
13
+ import Tooltip from "@mui/material/Tooltip";
14
+ import { useEffect, useState } from "react";
15
+ import { ConfirmationModal } from "../Common/ConfirmationModal.js";
16
+ /**
17
+ * Component for inputting an API key for a given vendor, with the ability to test the key and forget the saved key.
18
+ */
19
+ export const ApiKeyInput = ({ forgetKey, id, logo, onSave, onTest, persistedValue, placeholder, vendor, }) => {
20
+ const [inputValue, setInputValue] = useState(persistedValue ?? "");
21
+ const [keyValidated, setKeyValidated] = useState(null);
22
+ const [isValidating, setIsValidating] = useState(false);
23
+ const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
24
+ const [showKey, setShowKey] = useState(false);
25
+ const handleValueChange = (e) => {
26
+ setKeyValidated(null);
27
+ setInputValue(e.target.value);
28
+ };
29
+ const handleOnTest = async () => {
30
+ setIsValidating(true);
31
+ setKeyValidated(null);
32
+ try {
33
+ const isValid = await onTest(inputValue);
34
+ setKeyValidated(isValid);
35
+ }
36
+ finally {
37
+ setIsValidating(false);
38
+ }
39
+ };
40
+ const disableActions = !inputValue || inputValue === persistedValue || isValidating;
41
+ // Sync with persisted value changes - if the persisted value changes from outside
42
+ useEffect(() => {
43
+ setInputValue(persistedValue ?? "");
44
+ setKeyValidated(null);
45
+ }, [persistedValue]);
46
+ return (_jsxs(Box, { "data-testid": `${id}-input`, sx: { display: "flex", alignItems: "center", width: "100%", gap: 2 }, children: [confirmationDialogOpen ? (_jsx(ConfirmationModal, { id: `${id}-forget-key-confirmation-modal`, content: `This will forget the currently saved API key for ${vendor} and you will need to enter ` +
47
+ " the key again to use networks that require it. Are you sure you want to continue?", handleCancel: () => {
48
+ setConfirmationDialogOpen(false);
49
+ }, handleOk: () => {
50
+ setConfirmationDialogOpen(false);
51
+ setInputValue("");
52
+ forgetKey();
53
+ }, okBtnLabel: "Yes, forget key", title: `Forget ${vendor} API key?` })) : null, _jsx(Box, { sx: {
54
+ width: 25,
55
+ height: 25,
56
+ display: "flex",
57
+ alignItems: "center",
58
+ justifyContent: "center",
59
+ }, children: _jsx("img", { src: logo, alt: `${vendor} logo`, style: { maxWidth: "100%", maxHeight: "100%", objectFit: "contain" } }) }), _jsx(FormLabel, { id: `${id}-label`, sx: { width: 90, flexShrink: 0 }, children: vendor }), _jsx(TextField, { "aria-labelledby": `${id}-label`, autoComplete: "off", onChange: handleValueChange, placeholder: placeholder, size: "small", slotProps: {
60
+ input: {
61
+ endAdornment: (_jsxs(InputAdornment, { position: "end", children: [_jsx(Tooltip, { title: showKey ? "Hide API key" : "Show API key", children: _jsx("span", { children: _jsx(IconButton, { "aria-label": "toggle key visibility", disabled: !inputValue, onClick: () => setShowKey(!showKey), onMouseDown: (e) => e.preventDefault(), size: "small", children: showKey ? (_jsx(VisibilityOff, { fontSize: "small" })) : (_jsx(Visibility, { fontSize: "small" })) }) }) }), _jsx(IconButton, { "aria-label": "Clear input", edge: "end", onClick: () => setInputValue(""), size: "small", children: _jsx(ClearIcon, { fontSize: "small" }) })] })),
62
+ },
63
+ }, sx: { flex: 1 },
64
+ // Type depends on whether we're showing or hiding the key
65
+ type: showKey ? "text" : "password", value: inputValue, variant: "outlined" }), _jsx(Box, { sx: {
66
+ width: 24,
67
+ height: 24,
68
+ color: (theme) => (keyValidated ? theme.palette.success.main : theme.palette.error.main),
69
+ }, children: keyValidated === null ? null : keyValidated ? (_jsx(CheckIcon, { fontSize: "small" })) : (_jsx(ErrorIcon, { fontSize: "small" })) }), _jsx(Button, { disabled: disableActions, loading: isValidating, onClick: handleOnTest, size: "small", variant: "contained", children: "Test" }), _jsx(Button, { onClick: () => onSave(inputValue), size: "small", variant: "contained", disabled: disableActions, children: "Save" }), _jsx(Button, { onClick: () => setConfirmationDialogOpen(true), size: "small", variant: "contained", disabled: !persistedValue || isValidating, children: "Forget" })] }));
70
+ };