@stack-spot/ai-chat-widget 3.0.0-beta.1 → 3.0.2-beta.1

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +85 -19
  2. package/dist/app-metadata.json +6 -6
  3. package/dist/chat-interceptors/quick-commands.d.ts.map +1 -1
  4. package/dist/chat-interceptors/quick-commands.js +10 -3
  5. package/dist/chat-interceptors/quick-commands.js.map +1 -1
  6. package/dist/chat-interceptors/send-message.d.ts.map +1 -1
  7. package/dist/chat-interceptors/send-message.js +76 -15
  8. package/dist/chat-interceptors/send-message.js.map +1 -1
  9. package/dist/layout.css +6 -0
  10. package/dist/state/ChatEntry.d.ts +1 -1
  11. package/dist/state/ChatEntry.d.ts.map +1 -1
  12. package/dist/state/ChatEntry.js +2 -1
  13. package/dist/state/ChatEntry.js.map +1 -1
  14. package/dist/state/ChatState.d.ts +4 -0
  15. package/dist/state/ChatState.d.ts.map +1 -1
  16. package/dist/state/ChatState.js.map +1 -1
  17. package/dist/utils/chat.d.ts.map +1 -1
  18. package/dist/utils/chat.js +1 -0
  19. package/dist/utils/chat.js.map +1 -1
  20. package/dist/utils/knowledge-source.d.ts +2 -2
  21. package/dist/utils/planning-tool.d.ts +3 -0
  22. package/dist/utils/planning-tool.d.ts.map +1 -1
  23. package/dist/utils/planning-tool.js +7 -0
  24. package/dist/utils/planning-tool.js.map +1 -1
  25. package/dist/views/Agents/styled.d.ts.map +1 -1
  26. package/dist/views/Agents/styled.js +1 -2
  27. package/dist/views/Agents/styled.js.map +1 -1
  28. package/dist/views/Chat/ChatMessage.d.ts +1 -1
  29. package/dist/views/Chat/ChatMessage.d.ts.map +1 -1
  30. package/dist/views/Chat/ChatMessage.js +23 -6
  31. package/dist/views/Chat/ChatMessage.js.map +1 -1
  32. package/dist/views/Chat/StepsList.d.ts +8 -3
  33. package/dist/views/Chat/StepsList.d.ts.map +1 -1
  34. package/dist/views/Chat/StepsList.js +67 -34
  35. package/dist/views/Chat/StepsList.js.map +1 -1
  36. package/dist/views/Chat/styled.d.ts.map +1 -1
  37. package/dist/views/Chat/styled.js +8 -12
  38. package/dist/views/Chat/styled.js.map +1 -1
  39. package/dist/views/MessageInput/ButtonBar.d.ts.map +1 -1
  40. package/dist/views/MessageInput/ButtonBar.js +2 -1
  41. package/dist/views/MessageInput/ButtonBar.js.map +1 -1
  42. package/dist/views/MessageInput/ModelSwitcher/index.d.ts +2 -0
  43. package/dist/views/MessageInput/ModelSwitcher/index.d.ts.map +1 -0
  44. package/dist/views/MessageInput/ModelSwitcher/index.js +25 -0
  45. package/dist/views/MessageInput/ModelSwitcher/index.js.map +1 -0
  46. package/dist/views/MessageInput/ModelSwitcher/utils.d.ts +30 -0
  47. package/dist/views/MessageInput/ModelSwitcher/utils.d.ts.map +1 -0
  48. package/dist/views/MessageInput/ModelSwitcher/utils.js +91 -0
  49. package/dist/views/MessageInput/ModelSwitcher/utils.js.map +1 -0
  50. package/dist/views/MessageInput/SelectContent.js +1 -1
  51. package/dist/views/MessageInput/SelectContent.js.map +1 -1
  52. package/dist/views/MessageInput/dictionary.d.ts +1 -1
  53. package/dist/views/MessageInput/dictionary.d.ts.map +1 -1
  54. package/dist/views/MessageInput/dictionary.js +6 -0
  55. package/dist/views/MessageInput/dictionary.js.map +1 -1
  56. package/dist/views/MessageInput/styled.d.ts +12 -0
  57. package/dist/views/MessageInput/styled.d.ts.map +1 -1
  58. package/dist/views/MessageInput/styled.js +35 -0
  59. package/dist/views/MessageInput/styled.js.map +1 -1
  60. package/dist/views/Resources.js +12 -5
  61. package/dist/views/Resources.js.map +1 -1
  62. package/package.json +4 -4
  63. package/src/app-metadata.json +6 -6
  64. package/src/chat-interceptors/quick-commands.ts +18 -7
  65. package/src/chat-interceptors/send-message.ts +82 -18
  66. package/src/layout.css +6 -0
  67. package/src/state/ChatEntry.ts +2 -1
  68. package/src/state/ChatState.ts +4 -0
  69. package/src/utils/chat.ts +1 -0
  70. package/src/utils/knowledge-source.ts +2 -2
  71. package/src/utils/planning-tool.ts +9 -0
  72. package/src/views/Agents/styled.ts +1 -2
  73. package/src/views/Chat/ChatMessage.tsx +63 -57
  74. package/src/views/Chat/StepsList.tsx +115 -72
  75. package/src/views/Chat/styled.ts +8 -12
  76. package/src/views/MessageInput/ButtonBar.tsx +2 -0
  77. package/src/views/MessageInput/ModelSwitcher/index.tsx +68 -0
  78. package/src/views/MessageInput/ModelSwitcher/utils.tsx +143 -0
  79. package/src/views/MessageInput/SelectContent.tsx +1 -1
  80. package/src/views/MessageInput/dictionary.ts +6 -0
  81. package/src/views/MessageInput/styled.ts +37 -0
  82. package/src/views/Resources.tsx +18 -10
@@ -3,11 +3,11 @@ import { AnimatedHeight } from '@stack-spot/portal-components/AnimatedHeight'
3
3
  import { ChatStep, StepChatStep, ToolChatStep } from '@stack-spot/portal-network'
4
4
  import { theme } from '@stack-spot/portal-theme'
5
5
  import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
6
- import { findLastIndex } from 'lodash'
7
- import React, { useMemo } from 'react'
6
+ import { findLast, findLastIndex } from 'lodash'
7
+ import React, { useEffect, useMemo } from 'react'
8
8
  import styled from 'styled-components'
9
9
  import { Markdown } from '../../components/Markdown'
10
- import { useCurrentChat, useCurrentChatMessages, useWidget } from '../../context/hooks'
10
+ import { useChat, useChatMessages, useCurrentChat, useCurrentChatMessages, useWidget } from '../../context/hooks'
11
11
  import { ChatEntry } from '../../state/ChatEntry'
12
12
  import { planningToolDictionaryHelper } from '../../utils/planning-tool'
13
13
  import { updateToolStep } from '../../utils/update-tool-step'
@@ -17,6 +17,7 @@ interface Props {
17
17
  steps: ChatStep[],
18
18
  messageId: number,
19
19
  chatId: string,
20
+ userHasAlreadyAnswered?: boolean,
20
21
  }
21
22
 
22
23
  interface StepChatStepWithTarget extends Omit<StepChatStep, 'status' | 'id' | 'type'> {
@@ -52,14 +53,14 @@ const StepAccordionHeader = ({ step, index, expand }: Pick<StepProps, 'step' | '
52
53
  <Badge colorScheme="inverse" appearance="square">
53
54
  {t.step} {index}
54
55
  </Badge>}
55
- <Text className="step-title" appearance="body2" >
56
+ <Text className="step-title" appearance="body2">
56
57
  {step.input}
57
58
  </Text>
58
- {step.status === 'awaiting_approval' &&
59
- <Badge appearance="square" style={{ backgroundColor: theme.color.gray[800], color: theme.color.gray[50] }}>
60
- <Icon icon="Security" />
61
- {t.pendingReview}
62
- </Badge>}
59
+ {step.status === 'awaiting_approval' &&
60
+ <Badge appearance="square" style={{ backgroundColor: theme.color.gray[800], color: theme.color.gray[50] }}>
61
+ <Icon icon="Security" />
62
+ {t.pendingReview}
63
+ </Badge>}
63
64
  </Row>
64
65
  }
65
66
 
@@ -72,12 +73,13 @@ const StyledCard = styled(Card)`
72
73
  const Step = ({ step, index, onClick, total, totalTools, isAllDone }: StepProps) => {
73
74
  const t = useTranslate(dictionary)
74
75
  const status = getStatusIcon(step.status, isAllDone)
76
+ const hasTools = step.attempts?.[0]?.tools && step.attempts?.[0]?.tools?.length > 0
75
77
 
76
78
  return (
77
79
  <li tabIndex={onClick ? 0 : undefined} onClick={onClick} role={onClick ? 'button' : 'listitem'}>
78
80
  <Row gap="4px" alignItems="center">
79
81
  <div className="step-status-icon">{status}</div>
80
- <StyledCard p="8px" w="75%">
82
+ <StyledCard p="8px" w="80%">
81
83
  <Accordion header={expand => <StepAccordionHeader step={step} index={index} expand={expand} />}>
82
84
  <Column pt="12px">
83
85
  {total ?
@@ -90,27 +92,32 @@ const Step = ({ step, index, onClick, total, totalTools, isAllDone }: StepProps)
90
92
  <Row gap="4px">
91
93
  <Icon icon="BorderRadius" size="sm" color="light.700" />
92
94
  <Text color="light.700">{t.totalTools}</Text>
93
- {totalTools}
95
+ {totalTools ?? 0}
94
96
  </Row>
95
97
  </Row>
96
98
  : <>
97
- <Row pb="8px" gap="4px">
99
+ <Row pb="8px">
98
100
  <Icon icon="Target" size="sm" color="light.700" />
99
- <Text color="light.700">{t.stepGoal}:</Text>
100
- <Text>
101
+ <Text color="light.700" tag="span" style={{ margin: '0 4px' }}>{t.stepGoal}:</Text>
102
+ <Text tag="span">
101
103
  {step.input}
102
104
  </Text>
103
105
  </Row>
104
- <Row gap="4px">
106
+ {hasTools ? <>
107
+ <Row gap="4px">
108
+ <Icon icon="BorderRadius" size="sm" color="light.700" />
109
+ <Text color="light.700">{t.toolsToBeExecuted}:</Text>
110
+ </Row>
111
+ <ul className="tools-list">
112
+ {step.attempts?.[0]?.tools?.map((tool, index) => (<li key={`${tool.id}-${index}`}>
113
+ <Text>{tool.name}: {tool.goal}</Text>
114
+ </li>))}
115
+ </ul>
116
+ </> : <Row gap="4px">
105
117
  <Icon icon="BorderRadius" size="sm" color="light.700" />
106
- <Text color="light.700">{t.toolsToBeExecuted}:</Text>
118
+ <Text color="light.700">{t.noToolToBeUsed}</Text>
107
119
  </Row>
108
- <ul className="tools-list">
109
- {step.attempts?.[0]?.tools?.map((tool) => (<li key={tool.id}>
110
- <Text>{tool.name}: {tool.goal}</Text>
111
- </li>),
112
- )}
113
- </ul>
120
+ }
114
121
  </>
115
122
  }
116
123
  </Column>
@@ -140,12 +147,12 @@ export const StepsPlaceholder = () => {
140
147
  </Card>
141
148
  }
142
149
 
143
- const AwaitingApproval = ({ customApproveText }: { customApproveText?: string }) => {
150
+ const AwaitingApproval = ({ customApproveText, chatId }: { chatId: string, customApproveText?: string }) => {
144
151
  const t = useTranslate(dictionary)
145
- const chat = useCurrentChat()
152
+ const chat = useChat(chatId)
146
153
 
147
154
  const onAnswer = (response: string) => {
148
- chat.pushMessage(ChatEntry.createUserEntry(response))
155
+ chat.pushMessage(ChatEntry.createUserEntry('', false, undefined, undefined, response))
149
156
  }
150
157
 
151
158
  return <>
@@ -167,31 +174,30 @@ const AwaitingApproval = ({ customApproveText }: { customApproveText?: string })
167
174
  </>
168
175
  }
169
176
 
170
- export const ToolStepsList = ({ steps, messageId }: { steps: ToolChatStep, messageId: number }) => {
177
+ export const ToolStepsList = ({ toolStep, messageId, chatId }: { toolStep: ToolChatStep, messageId: number, chatId: string }) => {
171
178
  const t = useTranslate(dictionary)
172
- const toolsAwaiting = steps
173
179
  const chat = useCurrentChat()
174
180
  const messages = useCurrentChatMessages()
175
181
  const inputParsed = `\`\`\`json
176
- ${JSON.stringify(toolsAwaiting?.input, null, 2)}
182
+ ${JSON.stringify(toolStep?.input, null, 2)}
177
183
  \`\`\``
178
184
 
179
185
  const tool = useMemo(() => {
180
- if (!toolsAwaiting) return undefined
181
- return toolsAwaiting.attempts?.[0].tools?.[0]
182
- }, [toolsAwaiting])
186
+ if (!toolStep) return undefined
187
+ return toolStep.attempts?.[0].tools?.[0]
188
+ }, [toolStep])
183
189
 
184
- useMemo(() => {
185
- if (!toolsAwaiting) return undefined
186
- const executionId = toolsAwaiting.attempts?.[0].tools?.[0].executionId
190
+ useEffect(() => {
191
+ if (!toolStep) return undefined
192
+ const executionId = toolStep.attempts?.[0]?.tools?.[0]?.executionId
187
193
  if (!executionId) return
188
194
 
189
- updateToolStep(messages, executionId, steps.status)
190
-
191
- }, [messages, toolsAwaiting, steps.status])
195
+ updateToolStep(messages, executionId, toolStep.status)
196
+
197
+ }, [messages, toolStep, toolStep.status])
192
198
 
193
199
  return <>
194
- {toolsAwaiting && tool ? <AnimatedHeight>
200
+ {toolStep && tool ? <AnimatedHeight>
195
201
  <div className="steps">
196
202
  <Badge colorPalette="yellow" appearance="square">
197
203
  <Icon icon="StopWatch" />
@@ -204,7 +210,7 @@ export const ToolStepsList = ({ steps, messageId }: { steps: ToolChatStep, messa
204
210
  </Row>
205
211
 
206
212
  <Text>
207
- {toolsAwaiting.user_question}
213
+ {toolStep.user_question}
208
214
  </Text>
209
215
 
210
216
  <Accordion header={expand => <Row gap="8px" mb="4px">
@@ -216,54 +222,62 @@ export const ToolStepsList = ({ steps, messageId }: { steps: ToolChatStep, messa
216
222
  </Markdown>
217
223
  </Accordion>
218
224
 
219
- {toolsAwaiting.status === 'awaiting_approval' && <AwaitingApproval customApproveText={t.approveTool} />}
220
-
225
+ <AwaitingApproval customApproveText={t.approveTool} chatId={chatId} />
221
226
  </Card>
222
227
  </div>
223
228
  </AnimatedHeight> : null}
224
229
  </>
225
230
  }
226
231
 
227
- export const StepsList = ({ steps, chatId, messageId }: Props) => {
232
+ export const StepsList = ({ steps, messageId, chatId, userHasAlreadyAnswered }: Props) => {
228
233
  const t = useTranslate(dictionary)
229
- const actualSteps = steps.filter(s => s.type === 'step')
234
+
235
+ const filteredSteps = steps.filter(s => s.type === 'step')
236
+ const actualSteps = useMemo(() => filteredSteps.filter((item) => {
237
+ const messageIdForStep = planningToolDictionaryHelper.getMessageIdFromStepId(item.id)
238
+ if (!messageIdForStep) {
239
+ planningToolDictionaryHelper.setMessageIdForStepId(messageId, item.id)
240
+ return true
241
+ } else if (messageIdForStep === messageId) {
242
+ return true
243
+ }
244
+ // If a step is from a planning and it is already inserted in the planningToolDictionaryHelper it means the step is already in a
245
+ // previous message and we do not want to show it again (for instance, when required a approval in the planning, we will have
246
+ // two messages with the same step id one for the planning awaiting and one for the planning end, so we want to show only one)
247
+ return false
248
+ }), [filteredSteps])
249
+
230
250
  const planning = steps.filter(s => s.type === 'planning')
231
-
232
- useMemo(() => {
251
+
252
+ useEffect(() => {
233
253
  actualSteps.map((item) => {
234
- const executionId = item.attempts[0]?.tools?.[0].executionId
254
+ const executionId = item.attempts[0]?.tools?.[0]?.executionId
235
255
  if (executionId) {
236
256
  planningToolDictionaryHelper.setMessageIdPlanningStepToolExecutionId(`${messageId}`, executionId)
237
257
  }
238
258
  })
239
259
  }, [actualSteps, messageId])
240
260
 
241
- const toolsStep = steps.filter(s => s.type === 'tool')
242
-
243
- useMemo(() => {
244
- const executionId = toolsStep?.[0]?.attempts?.[0]?.tools?.[0]?.executionId
261
+ const toolsStep = findLast(steps, s => s.type === 'tool')
262
+
263
+ useEffect(() => {
264
+ const executionId = toolsStep?.attempts?.[0]?.tools?.[0]?.executionId
245
265
  if (executionId) {
246
266
  planningToolDictionaryHelper.setMessageIdToolStepToolExecutionId(`${messageId}`, executionId)
247
267
  }
248
268
  }, [toolsStep, messageId])
249
269
 
250
- const planningGoal = steps.filter(s => s.type === 'planning')?.[0]?.goal
270
+ const planningGoal = planning?.[0]?.goal
251
271
  const isLastStepDone = actualSteps[actualSteps.length - 1]?.status !== 'running' &&
252
272
  actualSteps[actualSteps.length - 1]?.status !== 'pending'
253
273
  const totalTools = useMemo(() => actualSteps?.reduce((sum, step) => {
254
- const firstAttempt = step.attempts && step.attempts[0]
255
- const toolsCount = firstAttempt.tools?.length ?? 0
274
+ const firstAttempt = step?.attempts && step.attempts[0]
275
+ const toolsCount = firstAttempt?.tools?.length ?? 0
256
276
  return sum + toolsCount
257
277
  }, 0), [steps])
258
278
 
259
279
  let currentStepIndex = findLastIndex(actualSteps, s => s.status === 'running' || s.status === 'success')
260
280
  if (currentStepIndex === -1) currentStepIndex = 0
261
- const widget = useWidget()
262
-
263
- function openToolsPanel() {
264
- widget.set('currentMessageInPanel', { chatId, messageId })
265
- widget.set('panel', 'steps')
266
- }
267
281
 
268
282
  return (<>
269
283
  {actualSteps.length > 0 ? <AnimatedHeight>
@@ -272,12 +286,10 @@ export const StepsList = ({ steps, chatId, messageId }: Props) => {
272
286
  <Icon icon="Target" size="sm" color="light.600" />
273
287
  <Text>{t.executionPlan}</Text>
274
288
  </Row>
289
+
275
290
  <ul className="steps-list">
276
- <Step
277
- step={actualSteps[currentStepIndex]}
278
- index={currentStepIndex + 1}
279
- />
280
-
291
+ {actualSteps.map((s, i) => <Step step={s} key={i} index={i + 1} />)}
292
+
281
293
  <Step
282
294
  step={{ status: 'target', input: planningGoal, attempts: [] }}
283
295
  index={currentStepIndex + 1}
@@ -290,23 +302,52 @@ export const StepsList = ({ steps, chatId, messageId }: Props) => {
290
302
  <Column gap="8px" mt="8px">
291
303
  <Divider colorScheme="light" />
292
304
  <Text color="light.700">{planning?.[0]?.user_question}</Text>
293
- {planning?.[0]?.status === 'awaiting_approval' && <AwaitingApproval />}
305
+ {!userHasAlreadyAnswered && planning?.[0]?.status === 'awaiting_approval' && <AwaitingApproval chatId={chatId} />}
294
306
  </Column>
295
307
 
296
- {isLastStepDone && <div className="step-actions">
297
- <Button colorScheme="light" size="sm" className="icon-button details" onClick={openToolsPanel}>
298
- <Icon group="fill" icon="Play" size="xs" />
299
- {t.detailed}
300
- </Button>
301
- </div>}
308
+ {userHasAlreadyAnswered && planning?.[0]?.status === 'success' && <ViewToolsDetails chatId={chatId} />}
309
+
302
310
  </div>
303
311
  </AnimatedHeight> : null}
304
312
 
305
- {toolsStep?.length > 0 && <ToolStepsList steps={toolsStep[0]} messageId={messageId} />}
313
+ {toolsStep && toolsStep.status === 'awaiting_approval' && !userHasAlreadyAnswered &&
314
+ <ToolStepsList toolStep={toolsStep} messageId={messageId} chatId={chatId} />}
306
315
  </>
307
316
  )
308
317
  }
309
318
 
319
+ export const ViewToolsDetails = ({ chatId }: { chatId: string }) => {
320
+ const t = useTranslate(dictionary)
321
+ const messages = useChatMessages(chatId)
322
+ const widget = useWidget()
323
+ const getPlanningMessageId = () => {
324
+ const messageWithPlanning = findLast(messages, (message) => {
325
+ const steps = message.getValue().steps
326
+ const planningStep = steps?.find((step) => step.type === 'planning' && step.status === 'success')
327
+ return planningStep ? true : false
328
+ })
329
+ return messageWithPlanning?.id
330
+ }
331
+
332
+ function openToolsPanel() {
333
+ const messageId = getPlanningMessageId()
334
+
335
+ if (messageId) {
336
+ widget.set('currentMessageInPanel', { chatId, messageId })
337
+ widget.set('panel', 'steps')
338
+ }
339
+ }
340
+
341
+ return <>
342
+ <div className="step-actions">
343
+ <Button colorScheme="light" size="sm" className="icon-button details" onClick={openToolsPanel}>
344
+ <Icon group="fill" icon="Play" size="xs" />
345
+ {t.detailed}
346
+ </Button>
347
+ </div>
348
+ </>
349
+ }
350
+
310
351
  const dictionary = {
311
352
  en: {
312
353
  step: 'Step',
@@ -326,6 +367,7 @@ const dictionary = {
326
367
  viewDetails: 'View details',
327
368
  approveTool: 'Approve execution',
328
369
  pendingReview: 'Pending review',
370
+ noToolToBeUsed: 'No tool will be needed',
329
371
  },
330
372
  pt: {
331
373
  step: 'Passo',
@@ -345,5 +387,6 @@ const dictionary = {
345
387
  viewDetails: 'Ver detalhes',
346
388
  approveTool: 'Aprovar execução',
347
389
  pendingReview: 'Revisão pendente',
390
+ noToolToBeUsed: 'Nenhuma tool será necessária',
348
391
  },
349
392
  } satisfies Dictionary
@@ -220,7 +220,7 @@ export const ChatList: IStyledComponentBase<
220
220
 
221
221
  .tools-box {
222
222
 
223
- > ul {
223
+ > button {
224
224
  display: flex;
225
225
  flex-direction: row;
226
226
  flex-wrap: wrap;
@@ -229,15 +229,11 @@ export const ChatList: IStyledComponentBase<
229
229
  margin-top: 8px;
230
230
  padding: 0;
231
231
  list-style: none;
232
- gap: 6px;
232
+ gap: 0px;
233
233
 
234
234
  &:hover{
235
- cursor: pointer;
236
-
237
- .agent-info-avatar-resource {
238
- transition: margin-left 0.3s ease-in;
239
- margin-left: 0px;
240
- }
235
+ transition: gap 0.3s ease-in;
236
+ gap: 6px;
241
237
  }
242
238
  }
243
239
  }
@@ -282,7 +278,6 @@ export const ChatList: IStyledComponentBase<
282
278
  }
283
279
 
284
280
  .step-title {
285
- line-height: 0.75rem;
286
281
  overflow: hidden;
287
282
  text-overflow: ellipsis;
288
283
  display: -webkit-box;
@@ -292,8 +287,10 @@ export const ChatList: IStyledComponentBase<
292
287
  }
293
288
  }
294
289
 
295
- .step-actions {
296
- margin-top: 8px;
290
+ }
291
+
292
+ .step-actions {
293
+ margin-top: 16px;
297
294
  display: flex;
298
295
  gap: 6px;
299
296
 
@@ -303,7 +300,6 @@ export const ChatList: IStyledComponentBase<
303
300
  align-items: center;
304
301
  }
305
302
  }
306
- }
307
303
 
308
304
  .markdown img {
309
305
  max-width: 70%;
@@ -1,6 +1,7 @@
1
1
  import { IconButton, Row } from '@stack-spot/citric-react'
2
2
  import { useCurrentChat, useCurrentChatState, useWidget } from '../../context/hooks'
3
3
  import { useMessageInputDictionary } from './dictionary'
4
+ import { ModelSwitcher } from './ModelSwitcher'
4
5
  import { SelectContent } from './SelectContent'
5
6
  import { SelectionBarWrapper } from './styled'
6
7
 
@@ -29,6 +30,7 @@ export const ButtonBar = ({ onSend, isLoading }: SelectionBarProps) => {
29
30
  <IconButton icon="Code" appearance="square" aria-label={t.code} title={t.code} onClick={() => widget.set('panel', 'editor')} />
30
31
  )}
31
32
  </Row>
33
+ <ModelSwitcher />
32
34
  {isLoading ? (
33
35
  <IconButton
34
36
  icon="Stop"
@@ -0,0 +1,68 @@
1
+ import { CitricIconOutline, CitricIconSocial } from '@stack-spot/citric-icons'
2
+ import { AsyncContent, Button, Column, FieldGroup, Icon, Input, Row } from '@stack-spot/citric-react'
3
+ import { SelectionList } from '@stack-spot/portal-components/SelectionList'
4
+ import { agentToolsClient, genAiInferenceClient } from '@stack-spot/portal-network'
5
+ import { useMemo, useState } from 'react'
6
+ import { CSSProperties } from 'styled-components'
7
+ import { useCurrentChat, useCurrentChatState } from '../../../context/hooks'
8
+ import { useMessageInputDictionary } from '../dictionary'
9
+ import { RowWrapperStyled, stylesModelSwitcher } from '../styled'
10
+ import { getListModelsData, handleFilterTypeModel, providerIcon } from './utils'
11
+
12
+ export const ModelSwitcher = () => {
13
+ const t = useMessageInputDictionary()
14
+ const agentCurrentChat = useCurrentChatState('agent')
15
+ const chat = useCurrentChat()
16
+ const [filter, setFilter] = useState('')
17
+ const [visibleMenu, setVisibleMenu] = useState(false)
18
+ const [agentData, isLoadingAgentData] = agentToolsClient.agent.useStatefulQuery({ agentId: agentCurrentChat?.id || '' })
19
+ const [models, isLoadingModels] =
20
+ genAiInferenceClient.listModels.useStatefulQuery({ pageSize: 999, active: true })
21
+
22
+ const { modelName, modelProviderType, listItemsData } =
23
+ getListModelsData(chat, setVisibleMenu, agentData, models)
24
+
25
+ const data = useMemo(() => {
26
+ const items = listItemsData ?? []
27
+ return handleFilterTypeModel(items, t, filter)
28
+ }, [agentCurrentChat?.id, listItemsData, filter])
29
+
30
+ return (
31
+ <RowWrapperStyled>
32
+ <AsyncContent loading={isLoadingAgentData && isLoadingModels}>
33
+ <Button
34
+ className="button-select-model"
35
+ colorScheme="light"
36
+ size="sm"
37
+ aria-label={t.agent}
38
+ title={t.agent}
39
+ onClick={() => setVisibleMenu(state => !state)}
40
+ >
41
+ <Icon
42
+ icon={providerIcon[modelProviderType as CitricIconOutline | CitricIconSocial]}
43
+ group={modelProviderType === 'stackspot' ? 'outline' : 'social'}
44
+ />
45
+ {modelName}
46
+ <Icon icon="ChevronDown" group="fill" size="sm" />
47
+ </Button>
48
+ </AsyncContent>
49
+ <SelectionList
50
+ id="menuModelSwitcher"
51
+ items={data || []}
52
+ visible={visibleMenu}
53
+ onHide={() => setVisibleMenu(false)}
54
+ showListAsCard
55
+ style={stylesModelSwitcher.selection as CSSProperties}
56
+ before={
57
+ <Column>
58
+ <FieldGroup fullWidth style={{ marginTop: '8px' }}>
59
+ <Icon icon="Search" />
60
+ <Input type="search" value={filter} onChange={(value) => (setFilter(value))} />
61
+ </FieldGroup>
62
+ {!data.length ? <Row m="16px 8px">{t.nothingFound}</Row> : undefined}
63
+ </Column>
64
+ }
65
+ />
66
+ </RowWrapperStyled>
67
+ )
68
+ }
@@ -0,0 +1,143 @@
1
+ import { CitricIconOutline, CitricIconSocial } from '@stack-spot/citric-icons'
2
+ import { IconBox } from '@stack-spot/citric-react'
3
+ import { AgentModel } from '@stack-spot/portal-network/api/agent-tools'
4
+ import { LlmModelsResponse, PaginatedResponseLlmModelsResponse } from '@stack-spot/portal-network/api/genAiInference'
5
+ import { theme } from '@stack-spot/portal-theme'
6
+ import { Dispatch, ReactElement } from 'react'
7
+ import { ChatState } from '../../../state/ChatState'
8
+
9
+ export interface ItemProps {
10
+ active?: boolean,
11
+ label?: string,
12
+ icon?: ReactElement,
13
+ self_hosted?: boolean,
14
+ onClick?: VoidFunction,
15
+ }
16
+
17
+ export const providerIcon: Record<string, CitricIconSocial | CitricIconOutline> = {
18
+ openai: 'OpenAI',
19
+ bedrock: 'AWSBedrock',
20
+ azure: 'Azure',
21
+ stackspot: 'StackSpot',
22
+ gemini: 'Gemini',
23
+ deepseek: 'DeepSeek',
24
+ anthropic: 'Anthropic',
25
+ }
26
+
27
+ export function getListModelsData(
28
+ chat: ChatState,
29
+ setVisibleMenu: Dispatch<React.SetStateAction<boolean>>,
30
+ agent?: AgentModel,
31
+ models?: PaginatedResponseLlmModelsResponse) {
32
+
33
+ const chatSelectedModelId = chat.get('selected_model_id')
34
+
35
+ const listModelsToShow = agent?.visibility_level !== 'built_in' && !!agent?.model_id ?
36
+ models?.items.filter((model) => agent?.available_llm_models?.find((modelAvailable) => modelAvailable.model_id === model.id)) :
37
+ models?.items
38
+
39
+ const modelAvailableDefault = agent?.available_llm_models?.find((model) => (model.is_default || model.model_id === agent.model_id))
40
+ const modelListData = parseModelList(chat, setVisibleMenu, listModelsToShow, modelAvailableDefault?.model_id)
41
+
42
+ if (agent?.visibility_level === 'built_in' || !agent?.model_id) {
43
+
44
+ const modelDefaultProviderType = models?.items.find((modelAccount) =>
45
+ chatSelectedModelId ? modelAccount.id === chatSelectedModelId :
46
+ modelAccount.resources.find((resource) => resource.is_default))?.model_configuration.provider.provider_type
47
+
48
+ const modelDefaultActive = modelListData.find((model) => model?.active)
49
+
50
+ return { modelName: modelDefaultActive?.label, modelProviderType: modelDefaultProviderType, listItemsData: modelListData }
51
+ }
52
+
53
+ const modelAccount = models?.items.find((modelAccount) => modelAccount.id === (chatSelectedModelId || agent.model_id))
54
+ const modelProviderType = modelAccount?.model_configuration.provider.provider_type
55
+ const modelSelectedName = modelAccount?.display_name || modelAvailableDefault?.model_name || agent?.model_name
56
+
57
+ const listItemsData =
58
+ modelListData && modelListData?.length > 0 ? modelListData :
59
+ [{
60
+ active: true,
61
+ label: modelSelectedName || '',
62
+ icon: <IconBox icon={providerIcon[modelProviderType || 'stackspot']}
63
+ appearance="square"
64
+ group={modelProviderType === 'stackspot' ? 'outline' : 'social'} />,
65
+ self_hosted: modelAccount?.self_hosted || false,
66
+ onClick: () => {
67
+ chat.set('selected_model_id', modelAvailableDefault?.model_id)
68
+ setVisibleMenu(false)
69
+ },
70
+ }]
71
+
72
+
73
+ return { modelName: modelSelectedName, modelProviderType, listItemsData }
74
+ }
75
+
76
+ function parseModelList(
77
+ chat: ChatState,
78
+ setVisibleMenu: Dispatch<React.SetStateAction<boolean>>,
79
+ listModel?: LlmModelsResponse[],
80
+ modelAvailableDefaultId?: string,
81
+ ) {
82
+
83
+ const data = Array<ItemProps>()
84
+ const chatModelId = chat.get('selected_model_id')
85
+
86
+ listModel?.forEach((model) => {
87
+ data.push({
88
+ active: chatModelId ? chatModelId === model.id : modelAvailableDefaultId === model.id ||
89
+ model.resources?.some((resource) => resource.is_default && resource.name === 'agents'),
90
+ label: model?.display_name || 'LLM',
91
+ icon: <IconBox
92
+ style={{ backgroundColor: theme.color.light[300] }}
93
+ appearance="square"
94
+ icon={providerIcon[model.model_configuration.provider.provider_type]}
95
+ group={model.model_configuration.provider.provider_type === 'stackspot' ? 'outline' : 'social'}
96
+ />,
97
+ self_hosted: model.self_hosted,
98
+ onClick: () => {
99
+ chat.set('selected_model_id', model.id)
100
+ setVisibleMenu(false)
101
+ },
102
+ })
103
+ })
104
+ return data
105
+ }
106
+
107
+ export function handleFilterTypeModel(items: ItemProps[], t: Record<string, string>, filter?: string) {
108
+ const filterLower = filter?.toLocaleLowerCase()
109
+ const filteredItens = items.filter((item) => filterLower ? item?.label?.toLocaleLowerCase().includes(filterLower) : item)
110
+
111
+ const { selfHosted, hosted } = filteredItens.reduce(
112
+ (acc, model) => {
113
+ if (model?.self_hosted) {
114
+ acc.selfHosted.push(model)
115
+ } else {
116
+ acc.hosted.push(model)
117
+ }
118
+ return acc
119
+ },
120
+ { selfHosted: [], hosted: [] } as { selfHosted: typeof items, hosted: typeof items },
121
+ )
122
+
123
+ const sections = [
124
+ {
125
+ label: t.hosted,
126
+ children: hosted,
127
+ },
128
+ {
129
+ label: t.selfHosted,
130
+ children: selfHosted,
131
+ },
132
+ ]
133
+
134
+ const filteredByHavingItens = sections
135
+ .filter(section => section.children.length > 0)
136
+ .map(section => ({
137
+ type: 'section',
138
+ label: section.label,
139
+ children: section.children,
140
+ }))
141
+
142
+ return filteredByHavingItens
143
+ }
@@ -83,7 +83,7 @@ export const SelectContent = () => {
83
83
  if (!hasFeatureButtons) return null
84
84
 
85
85
  return itemConfigs.length > 1 ? (
86
- <MenuOverlay items={listItems} position="top" bgLevel={500} spaced roundedItems>
86
+ <MenuOverlay items={listItems} position="right" bgLevel={500} roundedItems menuClass="menu-citric-hr" alignment="end">
87
87
  <IconButton icon="Plus" />
88
88
  </MenuOverlay>
89
89
  ) : (
@@ -35,6 +35,9 @@ const dictionary = {
35
35
  chatAgent: 'Agents',
36
36
  uploadSuccessStatus: 'File sent successfully',
37
37
  chatViewMenu: 'Chat options menu',
38
+ nothingFound: 'Nothing Found',
39
+ hosted: 'Hosted',
40
+ selfHosted: 'Self Hosted',
38
41
  },
39
42
  pt: {
40
43
  stack: 'Selecionar stack',
@@ -70,6 +73,9 @@ const dictionary = {
70
73
  chatAgent: 'Agentes',
71
74
  uploadSuccessStatus: 'Arquivo anexado com sucesso',
72
75
  chatViewMenu: 'Menu de opções do chat',
76
+ nothingFound: 'Nada encontrado',
77
+ hosted: 'Hospedado',
78
+ selfHosted: 'Auto-hospedado',
73
79
  },
74
80
  } satisfies Dictionary
75
81