diff --git a/assets/src/components/ai/AIPanel.tsx b/assets/src/components/ai/AIPanel.tsx index 1367ca037..e517b569a 100644 --- a/assets/src/components/ai/AIPanel.tsx +++ b/assets/src/components/ai/AIPanel.tsx @@ -6,109 +6,122 @@ import { IconFrame, } from '@pluralsh/design-system' import { useTheme } from 'styled-components' -import { ReactNode } from 'react' +import { forwardRef, Ref, ReactNode } from 'react' import { AIPanelOverlay } from './AIPanelOverlay.tsx' -export default function AIPanel({ - open, - onClose, - showCloseIcon = false, - showClosePanel = false, - header, - subheader, - footer, - children, - ...props -}: { - open: boolean - onClose: () => void - showCloseIcon?: boolean - showClosePanel?: boolean - header: string - subheader: string - footer?: ReactNode - children: ReactNode -} & CardProps) { - const theme = useTheme() +const AIPanel = forwardRef( + ( + { + open, + onClose, + showCloseIcon = false, + showClosePanel = false, + header, + subheader, + footer, + children, + ...props + }: { + open: boolean + onClose: () => void + CloseIcon?: boolean + showClosePanel?: boolean + showCloseIcon?: boolean + header: string + subheader: string + footer?: ReactNode + children: ReactNode + } & CardProps, + ref: Ref + ) => { + const theme = useTheme() - return ( - - -
-
-
{header}
-
- {subheader} -
-
- {showCloseIcon && ( - } - onClick={onClose} - tooltip="Close" - /> - )} -
-
- {children} -
- {showClosePanel && (
*': { - flexGrow: 1, - }, }} > - - {footer && footer} +
+
{header}
+
+ {subheader} +
+
+ {showCloseIcon && ( + } + onClick={onClose} + tooltip="Close" + /> + )} +
+
+ {children}
- )} -
-
- ) -} + {showClosePanel && ( +
*': { + flexGrow: 1, + }, + }} + > + + {footer && footer} +
+ )} + + + ) + } +) + +export default AIPanel diff --git a/assets/src/components/ai/chatbot/AISuggestFix.tsx b/assets/src/components/ai/chatbot/AISuggestFix.tsx index f1adc9a5d..cdeca56f9 100644 --- a/assets/src/components/ai/chatbot/AISuggestFix.tsx +++ b/assets/src/components/ai/chatbot/AISuggestFix.tsx @@ -1,9 +1,18 @@ import { Markdown } from '@pluralsh/design-system' -import { ReactNode, useCallback, useState } from 'react' import { + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useRef, + useState, +} from 'react' +import { + AiDelta, AiInsightFragment, AiRole, ChatMessage, + useAiChatStreamSubscription, useAiSuggestedFixLazyQuery, } from '../../../generated/graphql.ts' import { GqlError } from '../../utils/Alert.tsx' @@ -23,9 +32,58 @@ function fixMessage(fix: string): ChatMessage { } } +function Loading({ + insightId, + scrollToBottom, + setStreaming, +}: { + insightId: string + scrollToBottom: () => void + setStreaming: Dispatch> +}): ReactNode { + const [streamedMessage, setStreamedMessage] = useState([]) + useAiChatStreamSubscription({ + variables: { insightId }, + onData: ({ data: { data } }) => { + setStreaming(true) + if ((data?.aiStream?.seq ?? 1) % 120 === 0) scrollToBottom() + setStreamedMessage((streamedMessage) => [ + ...streamedMessage, + { + seq: data?.aiStream?.seq ?? 0, + content: data?.aiStream?.content ?? '', + }, + ]) + }, + }) + + if (!streamedMessage.length) { + return + } + + return ( + a.seq - b.seq) + .map((delta) => delta.content) + .join('')} + /> + ) +} + function AISuggestFix({ insight }: AISuggestFixProps): ReactNode { + const ref = useRef(null) + const [streaming, setStreaming] = useState(false) + const scrollToBottom = useCallback(() => { + ref.current?.scrollTo({ + top: ref.current.scrollHeight, + behavior: 'smooth', + }) + }, [ref]) + const [getSuggestion, { loading, data, error }] = useAiSuggestedFixLazyQuery({ variables: { insightID: insight?.id ?? '' }, + onCompleted: () => streaming && scrollToBottom(), fetchPolicy: 'network-only', }) @@ -47,6 +105,7 @@ function AISuggestFix({ insight }: AISuggestFixProps): ReactNode { > setOpen(false)} showCloseIcon @@ -65,7 +124,13 @@ function AISuggestFix({ insight }: AISuggestFixProps): ReactNode { } > {data?.aiSuggestedFix && } - {loading && !data && } + {loading && !data && ( + + )} {!loading && error && } diff --git a/assets/src/components/ai/chatbot/ChatbotPanelThread.tsx b/assets/src/components/ai/chatbot/ChatbotPanelThread.tsx index ca46069a8..5c918efaa 100644 --- a/assets/src/components/ai/chatbot/ChatbotPanelThread.tsx +++ b/assets/src/components/ai/chatbot/ChatbotPanelThread.tsx @@ -1,17 +1,19 @@ import { EmptyState, usePrevious } from '@pluralsh/design-system' -import { ReactNode, Ref, useCallback, useEffect, useRef } from 'react' +import { ReactNode, Ref, useCallback, useEffect, useRef, useState } from 'react' import styled, { useTheme } from 'styled-components' import { useCanScroll } from 'components/hooks/useCanScroll.ts' import { GqlError } from 'components/utils/Alert.tsx' import LoadingIndicator from 'components/utils/LoadingIndicator.tsx' import { + AiDelta, AiRole, ChatFragment, ChatThreadDetailsDocument, ChatThreadDetailsQuery, ChatThreadFragment, + useAiChatStreamSubscription, useChatMutation, useChatThreadDetailsQuery, } from 'generated/graphql' @@ -32,6 +34,7 @@ export function ChatbotPanelThread({ fullscreen: boolean }) { const theme = useTheme() + const [streaming, setStreaming] = useState(false) const messageListRef = useRef(null) const scrollToBottom = useCallback(() => { messageListRef.current?.scrollTo({ @@ -40,6 +43,22 @@ export function ChatbotPanelThread({ }) }, [messageListRef]) + const [streamedMessage, setStreamedMessage] = useState([]) + useAiChatStreamSubscription({ + variables: { threadId: currentThread.id }, + onData: ({ data: { data } }) => { + setStreaming(true) + if ((data?.aiStream?.seq ?? 1) % 120 === 0) scrollToBottom() + setStreamedMessage((streamedMessage) => [ + ...streamedMessage, + { + seq: data?.aiStream?.seq ?? 0, + content: data?.aiStream?.content ?? '', + }, + ]) + }, + }) + const { data } = useChatThreadDetailsQuery({ variables: { id: currentThread.id }, }) @@ -59,6 +78,7 @@ export function ChatbotPanelThread({ updatedAt: new Date().toISOString(), }, }), + onCompleted: () => streaming && scrollToBottom(), update: (cache, { data }) => { updateCache(cache, { query: ChatThreadDetailsDocument, @@ -78,7 +98,10 @@ export function ChatbotPanelThread({ const length = data?.chatThread?.chats?.edges?.length ?? 0 const prevLength = usePrevious(length) ?? 0 useEffect(() => { - if (length > prevLength) scrollToBottom() + if (length > prevLength) { + scrollToBottom() + setStreamedMessage([]) + } }, [length, prevLength, scrollToBottom]) const sendMessage = useCallback( @@ -95,7 +118,6 @@ export function ChatbotPanelThread({ if (!data?.chatThread?.chats?.edges) return - const messages = data.chatThread.chats.edges .map((edge) => edge?.node) .filter((msg): msg is ChatFragment => Boolean(msg)) @@ -113,7 +135,19 @@ export function ChatbotPanelThread({ {...msg} /> ))} - {sendingMessage && } + {sendingMessage && + (streamedMessage.length ? ( + a.seq - b.seq) + .map((delta) => delta.content) + .join('')} + /> + ) : ( + + ))} | null } | null, insight?: { __typename?: 'AiInsight', id: string, text?: string | null, summary?: string | null, sha?: string | null, freshness?: InsightFreshness | null, updatedAt?: string | null, insertedAt?: string | null, error?: Array<{ __typename?: 'ServiceError', message: string, source: string } | null> | null, cluster?: { __typename?: 'Cluster', id: string, name: string, distro?: ClusterDistro | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null, clusterInsightComponent?: { __typename?: 'ClusterInsightComponent', id: string, name: string } | null, service?: { __typename?: 'ServiceDeployment', id: string, name: string, cluster?: { __typename?: 'Cluster', id: string, name: string, distro?: ClusterDistro | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null, serviceComponent?: { __typename?: 'ServiceComponent', id: string, name: string, service?: { __typename?: 'ServiceDeployment', id: string, name: string, cluster?: { __typename?: 'Cluster', id: string, name: string, distro?: ClusterDistro | null, provider?: { __typename?: 'ClusterProvider', cloud: string } | null } | null } | null } | null, stack?: { __typename?: 'InfrastructureStack', id?: string | null, name: string, type: StackType } | null, stackRun?: { __typename?: 'StackRun', id: string, message?: string | null, type: StackType, stack?: { __typename?: 'InfrastructureStack', id?: string | null, name: string } | null } | null } | null } | null }; +export type AiChatStreamSubscriptionVariables = Exact<{ + threadId?: InputMaybe; + insightId?: InputMaybe; +}>; + + +export type AiChatStreamSubscription = { __typename?: 'RootSubscriptionType', aiStream?: { __typename?: 'AiDelta', seq: number, content: string } | null }; + export type CostAnalysisFragment = { __typename?: 'CostAnalysis', minutes?: number | null, cpuCost?: number | null, pvCost?: number | null, ramCost?: number | null, totalCost?: number | null }; export type FileContentFragment = { __typename?: 'FileContent', content?: string | null, path?: string | null }; @@ -15440,6 +15448,38 @@ export function useDeleteChatThreadMutation(baseOptions?: Apollo.MutationHookOpt export type DeleteChatThreadMutationHookResult = ReturnType; export type DeleteChatThreadMutationResult = Apollo.MutationResult; export type DeleteChatThreadMutationOptions = Apollo.BaseMutationOptions; +export const AiChatStreamDocument = gql` + subscription AIChatStream($threadId: ID, $insightId: ID) { + aiStream(threadId: $threadId, insightId: $insightId) { + seq + content + } +} + `; + +/** + * __useAiChatStreamSubscription__ + * + * To run a query within a React component, call `useAiChatStreamSubscription` and pass it any options that fit your needs. + * When your component renders, `useAiChatStreamSubscription` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useAiChatStreamSubscription({ + * variables: { + * threadId: // value for 'threadId' + * insightId: // value for 'insightId' + * }, + * }); + */ +export function useAiChatStreamSubscription(baseOptions?: Apollo.SubscriptionHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSubscription(AiChatStreamDocument, options); + } +export type AiChatStreamSubscriptionHookResult = ReturnType; +export type AiChatStreamSubscriptionResult = Apollo.SubscriptionResult; export const AppDocument = gql` query App($name: String!) { application(name: $name) { @@ -24785,6 +24825,7 @@ export const namedOperations = { Logout: 'Logout' }, Subscription: { + AIChatStream: 'AIChatStream', LogsDelta: 'LogsDelta' }, Fragment: { diff --git a/assets/src/graph/ai.graphql b/assets/src/graph/ai.graphql index a27311f92..0d1da02be 100644 --- a/assets/src/graph/ai.graphql +++ b/assets/src/graph/ai.graphql @@ -229,3 +229,10 @@ mutation DeleteChatThread($id: ID!) { ...ChatThread } } + +subscription AIChatStream($threadId: ID, $insightId: ID) { + aiStream(threadId: $threadId, insightId: $insightId) { + seq + content + } +} diff --git a/lib/console/ai/stream.ex b/lib/console/ai/stream.ex index c0ecc6ff6..f9f2c9065 100644 --- a/lib/console/ai/stream.ex +++ b/lib/console/ai/stream.ex @@ -9,10 +9,10 @@ defmodule Console.AI.Stream do def stream(), do: Process.get(@stream) - def publish(%__MODULE__{topic: topic}, delta) when is_binary(topic) do + def publish(%__MODULE__{topic: topic}, c, ind) when is_binary(topic) do Absinthe.Subscription.publish( ConsoleWeb.Endpoint, - delta, + %{content: c, seq: ind}, [ai_stream: topic] ) end diff --git a/lib/console/ai/stream/exec.ex b/lib/console/ai/stream/exec.ex index 9a0763afa..b0f34a62e 100644 --- a/lib/console/ai/stream/exec.ex +++ b/lib/console/ai/stream/exec.ex @@ -14,7 +14,7 @@ defmodule Console.AI.Stream.Exec do {%AIStream.SSE.Event{data: data}, ind}, acc -> case reducer.(data) do c when is_binary(c) -> - AIStream.publish(stream, %{content: c, seq: ind}) + AIStream.publish(stream, c, ind) {:cont, [c | acc]} _ -> {:cont, acc} end diff --git a/test/console_web/channels/graphql/ai_subscription_test.exs b/test/console_web/channels/graphql/ai_subscription_test.exs index ebba32a43..a58a0cc88 100644 --- a/test/console_web/channels/graphql/ai_subscription_test.exs +++ b/test/console_web/channels/graphql/ai_subscription_test.exs @@ -17,7 +17,7 @@ defmodule ConsoleWeb.GraphQl.AISubscriptionTest do assert_reply(ref, :ok, %{subscriptionId: _}) stream = %Stream{topic: Stream.topic(:thread, thread.id, user)} - Stream.publish(stream, %{content: "something", seq: 1}) + Stream.publish(stream, "something", 1) assert_push("subscription:data", %{result: %{data: %{"aiStream" => %{"content" => "something"}}}}) end end