From 1864096ac022eac50eae1a63f4d6b1c4e1736525 Mon Sep 17 00:00:00 2001 From: Jake Laderman Date: Sat, 2 Nov 2024 12:09:29 -0400 Subject: [PATCH] WIP: add ai chat UI (#1534) Co-authored-by: michaeljguarino Co-authored-by: Sebastian Florek --- AGENT_VERSION | 2 +- assets/package.json | 3 +- assets/src/components/ai/AI.tsx | 169 ++++ ...ExplainWithAIContext.tsx => AIContext.tsx} | 127 ++- assets/src/components/ai/AIPanel.tsx | 9 +- assets/src/components/ai/AIPanelOverlay.tsx | 8 +- assets/src/components/ai/AIPinsTable.tsx | 55 ++ assets/src/components/ai/AIThreadsTable.tsx | 267 ++++++ .../components/ai/AiThreadsTableActions.tsx | 168 ++++ .../ai/{ => chatbot}/AISuggestFix.tsx | 36 +- .../ai/{ => chatbot}/AISuggestFixButton.tsx | 0 assets/src/components/ai/chatbot/Chatbot.tsx | 217 +++++ .../components/ai/chatbot/ChatbotButton.tsx | 76 ++ .../{help => ai/chatbot}/ChatbotMarkdown.tsx | 66 +- .../ai/chatbot/ChatbotPanelThread.tsx | 435 ++++++++++ .../ai/{ => explain}/ExplainWithAI.tsx | 8 +- .../ai/{ => explain}/ExplainWithAIButton.tsx | 21 +- .../ai/{ => explain}/ExplainWithAIPanel.tsx | 30 +- .../services/component/ServiceComponent.tsx | 2 +- .../cd/services/service/ServiceInsights.tsx | 11 +- .../component/ComponentInsights.tsx | 11 +- assets/src/components/help/Chatbot.tsx | 497 ----------- assets/src/components/help/HelpMenu.tsx | 2 +- .../kubernetes/cluster/Namespace.tsx | 2 +- .../components/kubernetes/cluster/Node.tsx | 2 +- .../kubernetes/configuration/ConfigMap.tsx | 2 +- .../customresources/CustomResource.tsx | 2 +- .../CustomResourceDefinition.tsx | 2 +- .../PinCustomResourceDefinition.tsx | 4 +- .../components/kubernetes/network/Ingress.tsx | 2 +- .../kubernetes/network/IngressClass.tsx | 2 +- .../kubernetes/network/NetworkPolicy.tsx | 2 +- .../components/kubernetes/network/Service.tsx | 2 +- .../kubernetes/rbac/ClusterRole.tsx | 2 +- .../kubernetes/rbac/ClusterRoleBinding.tsx | 2 +- .../src/components/kubernetes/rbac/Role.tsx | 2 +- .../kubernetes/rbac/RoleBinding.tsx | 2 +- .../kubernetes/rbac/ServiceAccount.tsx | 2 +- .../kubernetes/storage/PersistentVolume.tsx | 2 +- .../storage/PersistentVolumeClaim.tsx | 2 +- .../kubernetes/storage/StorageClass.tsx | 2 +- .../kubernetes/workloads/CronJob.tsx | 2 +- .../kubernetes/workloads/DaemonSet.tsx | 2 +- .../kubernetes/workloads/Deployment.tsx | 2 +- .../components/kubernetes/workloads/Job.tsx | 2 +- .../components/kubernetes/workloads/Pod.tsx | 2 +- .../kubernetes/workloads/ReplicaSet.tsx | 2 +- .../workloads/ReplicationController.tsx | 2 +- .../kubernetes/workloads/StatefulSet.tsx | 2 +- assets/src/components/layout/Console.tsx | 6 +- assets/src/components/layout/Sidebar.tsx | 8 + assets/src/components/layout/Subheader.tsx | 4 +- .../global/GlobalSettingsAiProvider.tsx | 9 +- assets/src/components/stacks/Stacks.tsx | 3 +- .../stacks/customrun/StackCustomRun.tsx | 2 +- .../stacks/insights/StackInsights.tsx | 11 +- .../stacks/run/insights/StackRunInsights.tsx | 12 +- .../src/components/utils/LoadingIndicator.tsx | 7 +- .../src/components/utils/RefreshIconFrame.tsx | 2 +- assets/src/components/utils/hooks.ts | 18 + .../components/utils/table/StackedText.tsx | 39 +- .../utils/table/useFetchPaginatedData.tsx | 20 +- .../components/utils/tableFetchHelpers.tsx | 30 +- .../src/components/utils/typography/Text.tsx | 24 +- assets/src/generated/graphql-kubernetes.ts | 2 +- assets/src/generated/graphql-plural.ts | 2 +- assets/src/generated/graphql.ts | 818 ++++++++++++++++-- assets/src/graph/ai.graphql | 204 +++++ assets/src/graph/aiInsights.graphql | 36 - assets/src/routes/aiRoutes.tsx | 11 + assets/src/routes/consoleRoutes.tsx | 38 +- assets/src/routes/kubernetesRoute.tsx | 2 +- assets/yarn.lock | 13 +- ...loyments.plural.sh_deploymentsettings.yaml | 12 +- config/prod.exs | 2 + go/client/models_gen.go | 61 +- .../api/v1alpha1/deploymentsettings_types.go | 16 +- .../api/v1alpha1/zz_generated.deepcopy.go | 10 + ...loyments.plural.sh_deploymentsettings.yaml | 12 +- lib/console.ex | 11 + lib/console/ai/chat.ex | 72 +- lib/console/ai/cron.ex | 7 +- lib/console/ai/evidence/stack.ex | 11 +- lib/console/ai/evidence/stack_run.ex | 12 + lib/console/ai/fixer/base.ex | 9 +- lib/console/ai/fixer/stack.ex | 12 +- lib/console/ai/policy.ex | 5 +- lib/console/ai/provider/openai.ex | 11 +- lib/console/ai/pubsub/consumer.ex | 31 + lib/console/ai/pubsub/protocol.ex | 39 + lib/console/deployments/notifications.ex | 1 - lib/console/graphql/ai.ex | 69 +- lib/console/graphql/deployments/settings.ex | 4 +- lib/console/graphql/resolvers/ai.ex | 26 +- lib/console/schema/ai_insight.ex | 18 +- lib/console/schema/ai_pin.ex | 34 + lib/console/schema/chat_thread.ex | 28 +- lib/console/schema/deployment_settings.ex | 4 +- lib/console/schema/user.ex | 6 +- lib/helm/interface/http.ex | 2 +- ...loyments.plural.sh_deploymentsettings.yaml | 12 +- priv/notifications/slack/stack.run.json.eex | 4 +- .../20241030002604_uniq_default_thread.exs | 8 + ...41031120245_add_insight_chat_reference.exs | 9 + .../migrations/20241031153931_add_ai_pins.exs | 17 + ...1031193909_add_last_message_at_threads.exs | 9 + schema/schema.graphql | 77 +- test/console/ai/chat_test.exs | 65 +- test/console/ai/cron_test.exs | 24 +- test/console/ai/pubsub/consumer_test.exs | 87 ++ test/console/commands/command_test.exs | 1 + test/console/deployments/helm/agent_test.exs | 14 + .../graphql/mutations/ai_mutations_test.exs | 54 +- .../graphql/queries/ai_queries_test.exs | 61 ++ test/support/factory.ex | 8 + test/test_helper.exs | 1 + 116 files changed, 3721 insertions(+), 850 deletions(-) create mode 100644 assets/src/components/ai/AI.tsx rename assets/src/components/ai/{ExplainWithAIContext.tsx => AIContext.tsx} (59%) create mode 100644 assets/src/components/ai/AIPinsTable.tsx create mode 100644 assets/src/components/ai/AIThreadsTable.tsx create mode 100644 assets/src/components/ai/AiThreadsTableActions.tsx rename assets/src/components/ai/{ => chatbot}/AISuggestFix.tsx (62%) rename assets/src/components/ai/{ => chatbot}/AISuggestFixButton.tsx (100%) create mode 100644 assets/src/components/ai/chatbot/Chatbot.tsx create mode 100644 assets/src/components/ai/chatbot/ChatbotButton.tsx rename assets/src/components/{help => ai/chatbot}/ChatbotMarkdown.tsx (70%) create mode 100644 assets/src/components/ai/chatbot/ChatbotPanelThread.tsx rename assets/src/components/ai/{ => explain}/ExplainWithAI.tsx (84%) rename assets/src/components/ai/{ => explain}/ExplainWithAIButton.tsx (66%) rename assets/src/components/ai/{ => explain}/ExplainWithAIPanel.tsx (53%) delete mode 100644 assets/src/components/help/Chatbot.tsx create mode 100644 assets/src/components/utils/hooks.ts create mode 100644 assets/src/graph/ai.graphql delete mode 100644 assets/src/graph/aiInsights.graphql create mode 100644 assets/src/routes/aiRoutes.tsx create mode 100644 lib/console/ai/pubsub/consumer.ex create mode 100644 lib/console/ai/pubsub/protocol.ex create mode 100644 lib/console/schema/ai_pin.ex create mode 100644 priv/repo/migrations/20241030002604_uniq_default_thread.exs create mode 100644 priv/repo/migrations/20241031120245_add_insight_chat_reference.exs create mode 100644 priv/repo/migrations/20241031153931_add_ai_pins.exs create mode 100644 priv/repo/migrations/20241031193909_add_last_message_at_threads.exs create mode 100644 test/console/ai/pubsub/consumer_test.exs diff --git a/AGENT_VERSION b/AGENT_VERSION index a02de3e9c5..43a36aba9e 100644 --- a/AGENT_VERSION +++ b/AGENT_VERSION @@ -1 +1 @@ -v0.4.52 \ No newline at end of file +v0.4.53 \ No newline at end of file diff --git a/assets/package.json b/assets/package.json index 795e053ae3..6b462c9025 100644 --- a/assets/package.json +++ b/assets/package.json @@ -48,7 +48,7 @@ "@nivo/pie": "0.87.0", "@nivo/radial-bar": "0.87.0", "@nivo/tooltip": "0.87.0", - "@pluralsh/design-system": "3.76.0", + "@pluralsh/design-system": "3.77.0", "@react-hooks-library/core": "0.6.0", "@saas-ui/use-hotkeys": "1.1.3", "@tanstack/react-table": "8.20.5", @@ -63,6 +63,7 @@ "classnames": "2.3.2", "cmdk": "1.0.0", "country-code-lookup": "0.0.23", + "dayjs": "1.11.13", "emoji-mart": "5.5.2", "encrypt-storage": "2.13.2", "escape-carriage": "1.3.1", diff --git a/assets/src/components/ai/AI.tsx b/assets/src/components/ai/AI.tsx new file mode 100644 index 0000000000..6cd8f1a18e --- /dev/null +++ b/assets/src/components/ai/AI.tsx @@ -0,0 +1,169 @@ +import { Button, Flex, GearTrainIcon } from '@pluralsh/design-system' +import { StackedText } from 'components/utils/table/StackedText.tsx' +import { + FetchPaginatedDataResult, + useFetchPaginatedData, +} from 'components/utils/table/useFetchPaginatedData.tsx' +import { + AiPinFragment, + AiPinsQuery, + ChatThreadTinyFragment, + ChatThreadsQuery, + useAiPinsQuery, + useChatThreadsQuery, +} from 'generated/graphql.ts' +import { useCallback, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { GLOBAL_SETTINGS_ABS_PATH } from '../../routes/settingsRoutesConst.tsx' +import { AIPinsTable } from './AIPinsTable.tsx' +import { AIThreadsTable } from './AIThreadsTable.tsx' + +export default function AI() { + const threadsQuery = useFetchPaginatedData({ + queryHook: useChatThreadsQuery, + keyPath: ['chatThreads'], + }) + + const pinsQuery = useFetchPaginatedData({ + queryHook: useAiPinsQuery, + keyPath: ['aiPins'], + }) + + const refetchAll = useCallback(() => { + threadsQuery.refetch().then(() => pinsQuery.refetch()) + }, [threadsQuery, pinsQuery]) + + const filteredPins = useMemo( + () => + pinsQuery.data?.aiPins?.edges + ?.map((edge) => edge?.node) + ?.filter((pin): pin is AiPinFragment => Boolean(pin)) ?? [], + [pinsQuery.data?.aiPins?.edges] + ) + + const filteredThreads = useMemo( + () => + threadsQuery.data?.chatThreads?.edges + ?.map((edge) => edge?.node) + ?.filter( + (thread) => !filteredPins.some((pin) => pin.thread?.id === thread?.id) + ) + ?.filter((thread): thread is ChatThreadTinyFragment => + Boolean(thread) + ) ?? [], + [filteredPins, threadsQuery.data?.chatThreads?.edges] + ) + + return ( + +
+ + + + + + ) +} + +function Header() { + const navigate = useNavigate() + return ( + + + + + ) +} + +function PinnedSection({ + filteredPins, + pinsQuery, + refetch, +}: { + filteredPins: AiPinFragment[] + pinsQuery: FetchPaginatedDataResult + refetch: () => void +}) { + return ( + + + + + ) +} + +function AllThreadsSection({ + filteredThreads, + threadsQuery, + refetch, +}: { + filteredThreads: ChatThreadTinyFragment[] + threadsQuery: FetchPaginatedDataResult + refetch: () => void +}) { + return ( + + + + + ) +} diff --git a/assets/src/components/ai/ExplainWithAIContext.tsx b/assets/src/components/ai/AIContext.tsx similarity index 59% rename from assets/src/components/ai/ExplainWithAIContext.tsx rename to assets/src/components/ai/AIContext.tsx index 0a249f795b..a80fdf75f8 100644 --- a/assets/src/components/ai/ExplainWithAIContext.tsx +++ b/assets/src/components/ai/AIContext.tsx @@ -1,3 +1,8 @@ +import { + ChatThreadAttributes, + ChatThreadFragment, + useCreateChatThreadMutation, +} from 'generated/graphql.ts' import { createContext, Dispatch, @@ -24,33 +29,55 @@ type ExplainWithAIContextT = { system: string } -export const verbosityLevelToSystem = { - [AIVerbosityLevel.Low]: `You're a seasoned DevOps engineer with experience in Kubernetes, GitOps and Infrastructure As Code, -and need to give a concise but clear explanation of an infrastructure problem that will likely involve either Kubernetes or Terraform. -The user is not necessarily an expert in the domain, so please documentation and evidence to explain what issue they're facing. -Give a short overview of the resource they are mentioning and any guidance on how they can learn more about how it works. -Keep your response to 1-2 sections.`, - [AIVerbosityLevel.Medium]: `You're a seasoned DevOps engineer with experience in Kubernetes, GitOps and Infrastructure As Code, -and need to give a concise but clear explanation of an infrastructure problem that will likely involve either Kubernetes or Terraform. -The user is not necessarily an expert in the domain, so please documentation and evidence to explain what issue they're facing. -Give a short overview of the resource they are mentioning and any guidance on how they can learn more about how it works. -Keep your response to 3-5 sections.`, - [AIVerbosityLevel.High]: `You're a seasoned DevOps engineer with experience in Kubernetes, GitOps and Infrastructure As Code, -and need to give a concise but clear explanation of an infrastructure problem that will likely involve either Kubernetes or Terraform. -The user is not necessarily an expert in the domain, so please provide as much documentation -and evidence as is necessary to explain what issue they're facing. Give a descriptive overview of the resource they are mentioning -and any guidance on how they can learn more about how it works.`, -} as const satisfies Record +type ChatbotContextT = { + open: boolean + setOpen: (open: boolean) => void + fullscreen: boolean + setFullscreen: Dispatch> + currentThread: Nullable + setCurrentThread: (thread: Nullable) => void +} const ExplainWithAIContext = createContext( undefined ) -export function ExplainWithAIContextProvider({ - children, -}: { - children: ReactNode -}) { +const ChatbotContext = createContext(undefined) + +export function AIContextProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function ChatbotContextProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false) + const [fullscreen, setFullscreen] = useState(false) + const [currentThread, setCurrentThread] = + useState>() + + const context = useMemo( + () => ({ + open, + setOpen, + currentThread, + setCurrentThread, + fullscreen, + setFullscreen, + }), + [open, currentThread, fullscreen] + ) + + return ( + + {children} + + ) +} + +function ExplainWithAIContextProvider({ children }: { children: ReactNode }) { const [prompt, setPrompt] = useState() const [verbosityLevel, setVerbosityLevel] = usePersistedState( 'plural-ai-verbosity-level', @@ -75,6 +102,44 @@ export function ExplainWithAIContextProvider({ ) } +export function useChatbotContext() { + const context = useContext(ChatbotContext) + if (!context) { + throw new Error('useChatbot must be used within a ChatbotProvider') + } + return context +} + +export function useChatbot() { + const { setOpen, setCurrentThread, fullscreen, setFullscreen } = + useChatbotContext() + const [mutation, { loading, error }] = useCreateChatThreadMutation() + + return { + createNewThread: (attributes: ChatThreadAttributes) => { + mutation({ + variables: { attributes }, + onCompleted: (data) => { + setCurrentThread(data.createThread) + setOpen(true) + }, + }) + }, + goToThread: (thread: ChatThreadFragment) => { + setCurrentThread(thread) + setOpen(true) + }, + goToThreadList: () => { + setCurrentThread(null) + setOpen(true) + }, + fullscreen, + setFullscreen, + loading, + error, + } +} + export const useExplainWithAIContext = () => { const ctx = useContext(ExplainWithAIContext) @@ -98,3 +163,21 @@ export const useExplainWithAI = (prompt?: string) => { return () => setPrompt?.(undefined) }, [setPrompt, prompt]) } + +const verbosityLevelToSystem = { + [AIVerbosityLevel.Low]: `You're a seasoned DevOps engineer with experience in Kubernetes, GitOps and Infrastructure As Code, +and need to give a concise but clear explanation of an infrastructure problem that will likely involve either Kubernetes or Terraform. +The user is not necessarily an expert in the domain, so please documentation and evidence to explain what issue they're facing. +Give a short overview of the resource they are mentioning and any guidance on how they can learn more about how it works. +Keep your response to 1-2 sections.`, + [AIVerbosityLevel.Medium]: `You're a seasoned DevOps engineer with experience in Kubernetes, GitOps and Infrastructure As Code, +and need to give a concise but clear explanation of an infrastructure problem that will likely involve either Kubernetes or Terraform. +The user is not necessarily an expert in the domain, so please documentation and evidence to explain what issue they're facing. +Give a short overview of the resource they are mentioning and any guidance on how they can learn more about how it works. +Keep your response to 3-5 sections.`, + [AIVerbosityLevel.High]: `You're a seasoned DevOps engineer with experience in Kubernetes, GitOps and Infrastructure As Code, +and need to give a concise but clear explanation of an infrastructure problem that will likely involve either Kubernetes or Terraform. +The user is not necessarily an expert in the domain, so please provide as much documentation +and evidence as is necessary to explain what issue they're facing. Give a descriptive overview of the resource they are mentioning +and any guidance on how they can learn more about how it works.`, +} as const satisfies Record diff --git a/assets/src/components/ai/AIPanel.tsx b/assets/src/components/ai/AIPanel.tsx index b3b9d36580..1367ca0376 100644 --- a/assets/src/components/ai/AIPanel.tsx +++ b/assets/src/components/ai/AIPanel.tsx @@ -16,6 +16,7 @@ export default function AIPanel({ showClosePanel = false, header, subheader, + footer, children, ...props }: { @@ -25,6 +26,7 @@ export default function AIPanel({ showClosePanel?: boolean header: string subheader: string + footer?: ReactNode children: ReactNode } & CardProps) { const theme = useTheme() @@ -91,14 +93,19 @@ export default function AIPanel({ display: 'flex', gap: theme.spacing.small, padding: theme.spacing.large, + '> *': { + flexGrow: 1, + }, }} > + {footer && footer} )} diff --git a/assets/src/components/ai/AIPanelOverlay.tsx b/assets/src/components/ai/AIPanelOverlay.tsx index 591a0791d8..7ff36c61a8 100644 --- a/assets/src/components/ai/AIPanelOverlay.tsx +++ b/assets/src/components/ai/AIPanelOverlay.tsx @@ -17,10 +17,12 @@ const getTransitionProps = (open: boolean) => ({ export function AIPanelOverlay({ open, onClose, + alwaysGrow = false, children, }: { open: boolean onClose: () => void + alwaysGrow?: boolean children: ReactNode }) { const theme = useTheme() @@ -37,17 +39,17 @@ export function AIPanelOverlay({ ref={ref} css={{ display: 'flex', - transition: 'max-height 0.2s ease-in-out', + transition: 'max-height 0.2s ease-in-out, height 0.2s ease-in-out', pointerEvents: 'none', position: 'absolute', right: 0, top: 32 + theme.spacing.small, - width: 650, + width: 600, zIndex: theme.zIndexes.modal, '& > *': { pointerEvents: 'auto' }, }} style={{ - maxHeight, + ...(alwaysGrow ? { height: maxHeight } : { maxHeight }), transformOrigin: 'top right', ...styles, }} diff --git a/assets/src/components/ai/AIPinsTable.tsx b/assets/src/components/ai/AIPinsTable.tsx new file mode 100644 index 0000000000..c4e98644a8 --- /dev/null +++ b/assets/src/components/ai/AIPinsTable.tsx @@ -0,0 +1,55 @@ +import { Table } from '@pluralsh/design-system' +import { GqlError } from 'components/utils/Alert' +import { FullHeightTableWrap } from 'components/utils/layout/FullHeightTableWrap' +import { TableSkeleton } from 'components/utils/SkeletonLoaders' +import { + DEFAULT_REACT_VIRTUAL_OPTIONS, + FetchPaginatedDataResult, +} from 'components/utils/table/useFetchPaginatedData' +import { AiPinFragment, AiPinsQuery } from 'generated/graphql' +import { useTheme } from 'styled-components' +import { pinTableColumns } from './AIThreadsTable' + +export function AIPinsTable({ + filteredPins, + pinsQuery, + refetch, +}: { + filteredPins: AiPinFragment[] + pinsQuery: FetchPaginatedDataResult + refetch: () => void +}) { + const theme = useTheme() + const { data, loading, error, pageInfo, fetchNextPage, setVirtualSlice } = + pinsQuery + const reactTableOptions = { meta: { refetch } } + + if (error) return + if (!data?.aiPins?.edges) return + + return ( + + + + ) +} diff --git a/assets/src/components/ai/AIThreadsTable.tsx b/assets/src/components/ai/AIThreadsTable.tsx new file mode 100644 index 0000000000..d47a8ae47f --- /dev/null +++ b/assets/src/components/ai/AIThreadsTable.tsx @@ -0,0 +1,267 @@ +import { + ChatFilledIcon, + ChatOutlineIcon, + Chip, + Flex, + IconFrame, + PushPinFilledIcon, + PushPinOutlineIcon, + Spinner, + Table, +} from '@pluralsh/design-system' +import { createColumnHelper } from '@tanstack/react-table' +import { GqlError } from 'components/utils/Alert' +import { FullHeightTableWrap } from 'components/utils/layout/FullHeightTableWrap' +import { TableSkeleton } from 'components/utils/SkeletonLoaders' +import { StackedText } from 'components/utils/table/StackedText' +import { + DEFAULT_REACT_VIRTUAL_OPTIONS, + FetchPaginatedDataResult, + useFetchPaginatedData, +} from 'components/utils/table/useFetchPaginatedData' +import { CaptionP } from 'components/utils/typography/Text' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { + AiInsightSummaryFragment, + AiPinFragment, + ChatThreadsQuery, + ChatThreadTinyFragment, + useChatThreadsQuery, + useCreateAiPinMutation, + useDeleteAiPinMutation, +} from 'generated/graphql' +import styled, { useTheme } from 'styled-components' +import { useChatbot } from './AIContext' +import { AiThreadsTableActions } from './AiThreadsTableActions' +import { useMemo } from 'react' + +dayjs.extend(relativeTime) + +export function AllThreadsTable() { + const threadsQuery = useFetchPaginatedData({ + queryHook: useChatThreadsQuery, + keyPath: ['chatThreads'], + }) + + const filteredThreads = useMemo( + () => + threadsQuery.data?.chatThreads?.edges + ?.map((edge) => edge?.node) + ?.filter((thread): thread is ChatThreadTinyFragment => + Boolean(thread) + ) ?? [], + [threadsQuery.data?.chatThreads?.edges] + ) + + return ( + + ) +} + +export function AIThreadsTable({ + filteredThreads, + threadsQuery, + refetch, + modal, +}: { + filteredThreads: ChatThreadTinyFragment[] + threadsQuery: FetchPaginatedDataResult + refetch: () => void + modal?: boolean | null +}) { + const theme = useTheme() + const { data, loading, error, pageInfo, fetchNextPage, setVirtualSlice } = + threadsQuery + const reactTableOptions = { meta: { refetch, modal } } + + if (error) return + if (!data?.chatThreads?.edges) return + + return ( + +
+ + ) +} + +const threadColumnHelper = createColumnHelper() +const pinColumnHelper = createColumnHelper() + +// putting the whole row into a single column, easier to customize +const ThreadRow = threadColumnHelper.accessor((thread) => thread, { + id: 'thread', + cell: function Cell({ getValue, table }) { + const thread = getValue() + const [pinThread, { loading: pinLoading }] = useCreateAiPinMutation({ + variables: { + attributes: { + threadId: thread.id, + insightId: thread.insight?.id, + name: thread.summary.substring(0, 250), + }, + }, + onCompleted: () => table.options.meta?.refetch?.(), + }) + return ( + pinThread()} + pinLoading={pinLoading} + modal={table.options.meta?.modal || false} + /> + ) + }, +}) + +const PinRow = pinColumnHelper.accessor((pin) => pin, { + id: 'pin', + cell: function Cell({ getValue, table }) { + const pin = getValue() + const [unpinThread, { loading: unpinLoading }] = useDeleteAiPinMutation({ + variables: { + id: pin.id, + }, + onCompleted: () => table.options.meta?.refetch?.(), + }) + return ( + unpinThread()} + pinLoading={unpinLoading} + /> + ) + }, +}) + +function AITableRowBase({ + item, + onClickPin, + pinLoading, + modal, +}: { + item: ChatThreadTinyFragment | AiPinFragment + onClickPin?: () => void + pinLoading?: boolean + modal?: boolean | null +}) { + const theme = useTheme() + const { goToThread } = useChatbot() + const isPin = item.__typename === 'AiPin' + const thread = isPin + ? (item as AiPinFragment).thread + : (item as ChatThreadTinyFragment) + if (!thread) return
handle insight pins
+ + const isStale = dayjs().isAfter(dayjs(thread.updatedAt).add(24, 'hours')) + + return ( + goToThread(thread)}> + + + ) : ( + + ) + } + /> + + + + {dayjs( + thread.lastMessageAt || thread.updatedAt || thread.insertedAt + ).fromNow()} + + {!modal && ( + + {isStale ? 'Stale' : 'Active'} + + )} + {!modal && ( + { + e.stopPropagation() + onClickPin?.() + }} + icon={ + pinLoading ? ( + + ) : isPin ? ( + + ) : ( + + ) + } + /> + )} + + + ) +} + +const ThreadEntrySC = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing.xlarge, + height: '100%', + width: '100%', + background: theme.colors['fill-one'], + padding: theme.spacing.medium, + '&:not(:has(button:hover))': { + '&:hover': { + background: theme.colors['fill-two-selected'], + cursor: 'pointer', + }, + }, +})) + +export const getInsightResourceName = ( + insight: Nullable +): Nullable => + insight?.cluster?.name || + insight?.clusterInsightComponent?.name || + insight?.service?.name || + insight?.serviceComponent?.name || + insight?.stack?.name || + insight?.stackRun?.message + +export const threadTableColumns = [ThreadRow] +export const pinTableColumns = [PinRow] diff --git a/assets/src/components/ai/AiThreadsTableActions.tsx b/assets/src/components/ai/AiThreadsTableActions.tsx new file mode 100644 index 0000000000..09e2ca0c28 --- /dev/null +++ b/assets/src/components/ai/AiThreadsTableActions.tsx @@ -0,0 +1,168 @@ +import { + Button, + ChatOutlineIcon, + Flex, + FormField, + Input, + ListBoxItem, + Modal, + PencilIcon, + TrashCanIcon, +} from '@pluralsh/design-system' +import { Confirm } from 'components/utils/Confirm' +import { MoreMenu } from 'components/utils/MoreMenu' +import { + ChatThreadTinyFragment, + useDeleteChatThreadMutation, + useUpdateChatThreadMutation, +} from 'generated/graphql' +import { useState } from 'react' +import { useChatbot } from './AIContext' +import { GqlError } from 'components/utils/Alert' + +enum MenuItemKey { + OpenChat = 'open-chat', + Rename = 'rename', + Delete = 'delete', +} + +export function AiThreadsTableActions({ + thread, +}: { + thread: ChatThreadTinyFragment +}) { + const [menuKey, setMenuKey] = useState>('') + const { goToThread } = useChatbot() + + const onSelectionChange = (newKey: string) => { + if (newKey === MenuItemKey.OpenChat) goToThread(thread) + else setMenuKey(newKey) + } + + return ( +
e.stopPropagation()}> + + } + label="Open Chat" + textValue="Open Chat" + /> + } + label="Rename thread" + textValue="Rename thread" + /> + } + label="Delete thread" + textValue="Delete thread" + /> + + {/* Modals */} + setMenuKey('')} + > + setMenuKey('')} + /> + + setMenuKey('')} + /> +
+ ) +} + +export function RenameAiThread({ + thread, + onClose, +}: { + thread: ChatThreadTinyFragment + onClose: () => void +}) { + const [name, setName] = useState(thread.summary) + const [mutation, { loading, error }] = useUpdateChatThreadMutation({ + variables: { id: thread.id, attributes: { summary: name } }, + onCompleted: onClose, + }) + + return ( +
{ + e.preventDefault() + mutation() + }} + > + + {error && } + + setName(e.target.value)} + /> + + + + + + + + ) +} + +export function DeleteAiThreadModal({ + thread, + open, + onClose, +}: { + thread: ChatThreadTinyFragment + open: boolean + onClose: Nullable<() => void> +}) { + const [mutation, { loading, error }] = useDeleteChatThreadMutation({ + variables: { id: thread.id }, + awaitRefetchQueries: true, + refetchQueries: ['ChatThreads', 'AiPins'], + }) + + return ( + onClose?.()} + destructive + label="Delete" + loading={loading} + error={error} + open={open} + submit={() => mutation()} + title="Delete thread" + text={<>Are you sure you want to delete this thread?} + /> + ) +} diff --git a/assets/src/components/ai/AISuggestFix.tsx b/assets/src/components/ai/chatbot/AISuggestFix.tsx similarity index 62% rename from assets/src/components/ai/AISuggestFix.tsx rename to assets/src/components/ai/chatbot/AISuggestFix.tsx index 127f7a580f..f1adc9a5d6 100644 --- a/assets/src/components/ai/AISuggestFix.tsx +++ b/assets/src/components/ai/chatbot/AISuggestFix.tsx @@ -1,23 +1,32 @@ import { Markdown } from '@pluralsh/design-system' import { ReactNode, useCallback, useState } from 'react' -import { useTheme } from 'styled-components' import { - AiInsight, + AiInsightFragment, + AiRole, + ChatMessage, useAiSuggestedFixLazyQuery, -} from '../../generated/graphql.ts' -import { GqlError } from '../utils/Alert.tsx' -import LoadingIndicator from '../utils/LoadingIndicator.tsx' -import AIPanel from './AIPanel.tsx' +} from '../../../generated/graphql.ts' +import { GqlError } from '../../utils/Alert.tsx' +import LoadingIndicator from '../../utils/LoadingIndicator.tsx' +import AIPanel from '../AIPanel.tsx' import { AISuggestFixButton } from './AISuggestFixButton.tsx' +import { ChatWithAIButton, insightMessage } from './ChatbotButton.tsx' interface AISuggestFixProps { - insight: Nullable + insight: Nullable +} + +function fixMessage(fix: string): ChatMessage { + return { + content: `Here is the fix we've come up with so far:\n\n${fix}`, + role: AiRole.Assistant, + } } function AISuggestFix({ insight }: AISuggestFixProps): ReactNode { - const theme = useTheme() const [getSuggestion, { loading, data, error }] = useAiSuggestedFixLazyQuery({ variables: { insightID: insight?.id ?? '' }, + fetchPolicy: 'network-only', }) const [open, setOpen] = useState(false) @@ -34,7 +43,6 @@ function AISuggestFix({ insight }: AISuggestFixProps): ReactNode {
@@ -45,6 +53,16 @@ function AISuggestFix({ insight }: AISuggestFixProps): ReactNode { showClosePanel={!!data?.aiSuggestedFix} header="Suggest a fix" subheader="Get a suggested fix based on the insight. AI is prone to mistakes, always test changes before application." + footer={ + + } > {data?.aiSuggestedFix && } {loading && !data && } diff --git a/assets/src/components/ai/AISuggestFixButton.tsx b/assets/src/components/ai/chatbot/AISuggestFixButton.tsx similarity index 100% rename from assets/src/components/ai/AISuggestFixButton.tsx rename to assets/src/components/ai/chatbot/AISuggestFixButton.tsx diff --git a/assets/src/components/ai/chatbot/Chatbot.tsx b/assets/src/components/ai/chatbot/Chatbot.tsx new file mode 100644 index 0000000000..29b9dd2760 --- /dev/null +++ b/assets/src/components/ai/chatbot/Chatbot.tsx @@ -0,0 +1,217 @@ +import { + ChatOutlineIcon, + ExpandIcon, + Flex, + GearTrainIcon, + HistoryIcon, + IconFrame, + ModalWrapper, + ShrinkIcon, +} from '@pluralsh/design-system' + +import * as Dialog from '@radix-ui/react-dialog' + +import { Body2BoldP, CaptionP } from 'components/utils/typography/Text' +import { ChatThreadFragment } from 'generated/graphql' +import { ComponentPropsWithRef } from 'react' +import { VisuallyHidden } from 'react-aria' +import { useNavigate } from 'react-router-dom' +import { GLOBAL_SETTINGS_ABS_PATH } from 'routes/settingsRoutesConst' +import styled, { useTheme } from 'styled-components' +import { useChatbot, useChatbotContext } from '../AIContext.tsx' +import { ChatbotIconButton } from './ChatbotButton.tsx' +import { ChatbotPanelThread } from './ChatbotPanelThread.tsx' +import { AllThreadsTable } from '../AIThreadsTable.tsx' + +type ChatbotPanelInnerProps = ComponentPropsWithRef & { + fullscreen: boolean + onClose: () => void + currentThread?: Nullable +} + +export function Chatbot() { + const { open, setOpen, fullscreen, currentThread } = useChatbotContext() + + return ( +
+ setOpen(true)} + > + + + setOpen(false)} + currentThread={currentThread} + /> +
+ ) +} + +export function ChatbotPanel({ + open, + fullscreen = false, + onClose, + ...props +}: { + open: boolean +} & ChatbotPanelInnerProps) { + const theme = useTheme() + return ( + + + {/* required for accessibility */} + + Ask Plural AI + + + ) +} + +function ChatbotPanelInner({ + fullscreen, + onClose, + currentThread, + ...props +}: ChatbotPanelInnerProps) { + return ( + + + {currentThread && ( + + )} + {!currentThread && } + + ) +} + +function ChatbotHeader({ + onClose, + fullscreen, +}: { + onClose: () => void + fullscreen: boolean +}) { + const theme = useTheme() + const navigate = useNavigate() + const { goToThreadList, setFullscreen } = useChatbot() + return ( + + + + Ask AI + } + /> + { + onClose() + navigate(`${GLOBAL_SETTINGS_ABS_PATH}/ai-provider`) + }} + size="small" + icon={} + /> + setFullscreen((prev) => !prev)} + size="small" + icon={fullscreen ? : } + /> + + + + AI is prone to mistakes, always test changes before application. + + + ) +} + +const ChatbotHeaderSC = styled.div<{ $fullscreen: boolean }>( + ({ $fullscreen, theme }) => ({ + ...($fullscreen && { + border: theme.borders.input, + borderRadius: theme.borderRadiuses.large, + }), + backgroundColor: $fullscreen + ? theme.colors['fill-one'] + : theme.colors['fill-two'], + padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing.xxsmall, + }) +) + +const ChatbotFrameSC = styled.div<{ $fullscreen?: boolean }>( + ({ $fullscreen, theme }) => ({ + ...($fullscreen + ? { + gap: theme.spacing.medium, + } + : { + border: theme.borders['fill-two'], + borderRadius: theme.borderRadiuses.large, + }), + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + height: '100%', + width: $fullscreen ? '75vw' : 768, + }) +) + +const LineIcon = ( + + + +) diff --git a/assets/src/components/ai/chatbot/ChatbotButton.tsx b/assets/src/components/ai/chatbot/ChatbotButton.tsx new file mode 100644 index 0000000000..a83a20a904 --- /dev/null +++ b/assets/src/components/ai/chatbot/ChatbotButton.tsx @@ -0,0 +1,76 @@ +import { Button, ChatOutlineIcon } from '@pluralsh/design-system' +import { AiInsightFragment, AiRole, ChatMessage } from 'generated/graphql.ts' +import { ButtonProps } from 'honorable' +import { Dispatch, ReactNode } from 'react' +import { useChatbot } from '../AIContext.tsx' +import AIButton from '../explain/ExplainWithAIButton.tsx' + +const FIX_PREFACE = + "The following is an insight into an issue on the user's infrastructure we'd like to learn more about:" + +interface ChatbotButtonProps { + active: boolean + onClick: Dispatch +} + +export function ChatbotIconButton({ + onClick, + active, + ...props +}: ChatbotButtonProps & ButtonProps): ReactNode { + return ( + + + + ) +} + +export function insightMessage( + insight: Nullable +): ChatMessage { + return { + content: `${FIX_PREFACE}\n\n${insight?.text ?? ''}`, + role: AiRole.Assistant, + } +} + +export function ChatWithAIButton({ + messages, + insightId, + ...props +}: { + messages?: Nullable + insightId?: Nullable +} & ButtonProps) { + const { createNewThread, loading } = useChatbot() + + const handleClick = () => { + createNewThread({ + insightId, + // TODO: update this + summary: 'Further questions about an insight from Plural AI', + summarized: false, + messages: messages || [], + }) + } + return ( + + ) +} diff --git a/assets/src/components/help/ChatbotMarkdown.tsx b/assets/src/components/ai/chatbot/ChatbotMarkdown.tsx similarity index 70% rename from assets/src/components/help/ChatbotMarkdown.tsx rename to assets/src/components/ai/chatbot/ChatbotMarkdown.tsx index 2ec63b3d17..fa417aadaf 100644 --- a/assets/src/components/help/ChatbotMarkdown.tsx +++ b/assets/src/components/ai/chatbot/ChatbotMarkdown.tsx @@ -98,6 +98,49 @@ function MdPre({ } const commonCfg = { shouldForwardProp: () => true } +const MdH1 = styled.h1.withConfig(commonCfg)(({ theme }) => ({ + ...theme.partials.text.title2, + color: theme.colors.text, + marginTop: theme.spacing.large, + marginBottom: theme.spacing.small, + '&:first-of-type': { marginTop: 0 }, +})) +const MdH2 = styled.h2.withConfig(commonCfg)(({ theme }) => ({ + ...theme.partials.text.subtitle1, + color: theme.colors.text, + marginTop: theme.spacing.large, + marginBottom: theme.spacing.small, + '&:first-of-type': { marginTop: 0 }, +})) +const MdH3 = styled.h3.withConfig(commonCfg)(({ theme }) => ({ + ...theme.partials.text.subtitle2, + color: theme.colors.text, + marginTop: theme.spacing.large, + marginBottom: theme.spacing.small, + '&:first-of-type': { marginTop: 0 }, +})) +const MdH4 = styled.h4.withConfig(commonCfg)(({ theme }) => ({ + ...theme.partials.text.body1Bold, + color: theme.colors.text, + marginTop: theme.spacing.large, + marginBottom: theme.spacing.small, + '&:first-of-type': { marginTop: 0 }, +})) +const MdH5 = styled.h5.withConfig(commonCfg)(({ theme }) => ({ + ...theme.partials.text.body1Bold, + color: theme.colors.text, + marginTop: theme.spacing.large, + marginBottom: theme.spacing.small, + '&:first-of-type': { marginTop: 0 }, +})) +const MdH6 = styled.h6.withConfig(commonCfg)(({ theme }) => ({ + ...theme.partials.text.body1Bold, + color: theme.colors.text, + marginTop: theme.spacing.large, + marginBottom: theme.spacing.small, + '&:first-of-type': { marginTop: 0 }, +})) + const MdListSC = styled.ul.withConfig(commonCfg)(({ theme }) => ({ ...theme.partials.reset.list, marginBottom: theme.partials.text.code.lineHeight, @@ -177,9 +220,30 @@ function ChatbotMarkdown({ text }: MarkdownProps) { return useMemo( () => ( (null) + const inputRef = useRef(null) + const lastMsgRef = useRef(null) + const [newMessage, setNewMessage] = usePersistedSessionState( + 'currentAiChatMessage', + '' + ) + const scrollToBottom = useCallback(() => { + historyScrollRef.current?.scrollTo({ top: 9999999999999 }) + }, [historyScrollRef]) + + const { data, refetch } = useChatThreadDetailsQuery({ + variables: { id: currentThread.id }, + }) + + const [mutate, { loading: sendingMessage, error: messageError }] = + useChatMutation({ + onCompleted: async () => { + setNewMessage('') + await refetch() + scrollToBottom() + }, + }) + + // scroll to bottom and focus input on initial mount + useLayoutEffect(() => { + inputRef.current?.focus() + }, []) + + useEffect(() => { + if (data?.chatThread && !loaded) { + scrollToBottom() + setLoaded(true) + } + }, [data, loaded, scrollToBottom]) + + const sendMessage = useCallback( + (e: FormEvent) => { + e.preventDefault() + if (!newMessage) return + mutate({ + variables: { + messages: [{ role: AiRole.User, content: newMessage }], + threadId: currentThread.id, + }, + }) + }, + [mutate, newMessage, currentThread] + ) + + if (!data?.chatThread?.chats?.edges) + return + + const messages = data.chatThread.chats.edges + .map((edge) => edge?.node) + .filter((msg): msg is ChatFragment => Boolean(msg)) + + return ( + <> + {messageError && } + {isEmpty(messages) && } + + {messages.map((msg, i) => { + const len = messages.length + const ref = i === len - 1 ? lastMsgRef : undefined + return ( + + ) + })} + + + { + setNewMessage(e.currentTarget.value) + }} + fullscreen={fullscreen} + /> + + + + ) +} + +const ChatMessage = forwardRef( + ( + { + id, + content, + role, + ...props + }: { + content: string + role: AiRole + } & ComponentProps, + ref: Ref + ) => { + const theme = useTheme() + const [showActions, setShowActions] = useState(false) + let finalContent: ReactNode + + if (role === AiRole.Assistant || role === AiRole.System) { + finalContent = + } else { + finalContent = content.split('\n\n').map((str, i) => ( + + {str.split('\n').map((line, i, arr) => ( +
+ {line} + {i !== arr.length - 1 ?
: null} +
+ ))} +
+ )) + } + + return ( + setShowActions(true)} + onMouseLeave={() => setShowActions(false)} + ref={ref} + {...props} + > + + + {role !== AiRole.User && } +
{finalContent}
+
+
+ ) + } +) + +function ChatMessageActions({ + id, + content, + show, +}: { + id: string + content: string + show: boolean +}) { + const [copied, setCopied] = useState(false) + + const showCopied = () => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const [deleteMessage, { loading: deleteLoading }] = useDeleteChatMutation({ + awaitRefetchQueries: true, + refetchQueries: ['ChatThreadDetails'], + }) + + return ( + + + } + > + : } + /> + + deleteMessage({ variables: { id } })} + icon={ + deleteLoading ? : + } + /> + + ) +} + +const ChatbotTextArea = forwardRef( + ( + { + onKeyDown: onKeydownProp, + fullscreen, + ...props + }: ComponentProps<'textarea'> & { fullscreen: boolean }, + ref: Ref + ) => { + const { isMac } = usePlatform() + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (onKeydownProp) { + onKeydownProp(e) + } + if (e.key === 'Enter') { + e.preventDefault() + let modKeyPressed = e.shiftKey || e.ctrlKey || e.altKey + + if (isMac) { + modKeyPressed = modKeyPressed || e.metaKey + } + if (modKeyPressed) { + textAreaInsert(e.currentTarget, '\n') + } else { + submitForm(e.currentTarget?.form) + } + } + }, + [isMac, onKeydownProp] + ) + + return ( + + + + + + + ) + } +) + +const ActionsWrapperSC = styled.div<{ $show: boolean }>(({ theme, $show }) => ({ + position: 'absolute', + top: theme.spacing.small, + right: theme.spacing.small, + display: 'flex', + gap: theme.spacing.xsmall, + opacity: $show ? 1 : 0, + transition: '0.2s opacity ease', + pointerEvents: $show ? 'auto' : 'none', +})) + +const ChatMessageSC = styled.li(({ theme }) => ({ + ...theme.partials.reset.li, + position: 'relative', + padding: theme.spacing.small, +})) + +const ChatbotLoadingBarSC = styled(ProgressBar)<{ $show: boolean }>( + ({ theme, $show }) => ({ + position: 'absolute', + top: -theme.borderWidths.default, + left: 0, + right: 0, + height: theme.borderWidths.default, + transition: '0.2s opacity ease', + opacity: $show ? 1 : 0, + }) +) + +const ChatbotMessagesSC = styled.ul<{ $fullscreen: boolean }>( + ({ theme, $fullscreen }) => ({ + ...($fullscreen && { + borderRadius: theme.borderRadiuses.large, + border: theme.borders.input, + }), + ...theme.partials.reset.list, + backgroundColor: theme.colors['fill-one'], + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + padding: theme.spacing.xsmall, + flexGrow: 1, + }) +) + +const ChatbotFormSC = styled.form<{ $fullscreen: boolean }>( + ({ theme, $fullscreen }) => ({ + ...($fullscreen && { + border: theme.borders.input, + }), + position: 'relative', + borderRadius: theme.borderRadiuses.large, + backgroundColor: $fullscreen + ? theme.colors['fill-one'] + : theme.colors['fill-two'], + padding: theme.spacing.medium, + }) +) + +const ChatbotTextAreaWrapperSC = styled.div<{ $fullscreen: boolean }>( + ({ theme, $fullscreen }) => ({ + display: 'flex', + gap: theme.spacing.medium, + borderRadius: theme.borderRadiuses.large, + backgroundColor: $fullscreen + ? theme.colors['fill-two'] + : theme.colors['fill-three'], + '&:has(textarea:focus)': { + outline: theme.borders['outline-focused'], + }, + }) +) + +const ChatbotTextAreaSC = styled.textarea(({ theme }) => ({ + ...theme.partials.text.body2, + flex: 1, + padding: `${theme.spacing.medium}px 0 0 ${theme.spacing.small}px`, + backgroundColor: 'transparent', + border: 'none', + outline: 'none', + resize: 'none', + color: theme.colors.text, +})) + +const SendMessageButtonSC = styled.button(({ theme }) => ({ + ...theme.partials.reset.button, + padding: theme.spacing.small, + '&:hover': { + backgroundColor: theme.colors['fill-three-selected'], + }, +})) + +function PluralAssistantIcon() { + return ( + + + + ) +} + +const AssistantIconWrapperSC = styled.div(({ theme }) => ({ + ...aiGradientBorderStyles(theme, 'fill-two'), + width: theme.spacing.xlarge, + height: theme.spacing.xlarge, + borderRadius: theme.borderRadiuses.large, + padding: theme.spacing.xsmall, + svg: { + transform: 'translateY(-1px) translateX(-1px)', + }, +})) diff --git a/assets/src/components/ai/ExplainWithAI.tsx b/assets/src/components/ai/explain/ExplainWithAI.tsx similarity index 84% rename from assets/src/components/ai/ExplainWithAI.tsx rename to assets/src/components/ai/explain/ExplainWithAI.tsx index 478d453ecb..fedc9b954f 100644 --- a/assets/src/components/ai/ExplainWithAI.tsx +++ b/assets/src/components/ai/explain/ExplainWithAI.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' -import AIButton from './ExplainWithAIButton.tsx' -import { useExplainWithAIContext } from './ExplainWithAIContext.tsx' import { useTheme } from 'styled-components' +import AIButton from './ExplainWithAIButton.tsx' +import { useExplainWithAIContext } from '../AIContext.tsx' import ExplainWithAIPanel from './ExplainWithAIPanel.tsx' export default function ExplainWithAI() { @@ -12,9 +12,9 @@ export default function ExplainWithAI() { return (
, visible, children, ...props }: { active?: boolean + startIcon?: ReactNode visible: boolean children: ReactNode } & ButtonProps) { @@ -30,13 +32,10 @@ export default function ExplainWithAIButton({