From a17a53ab34394e4fde46a97e13f898467e980e29 Mon Sep 17 00:00:00 2001 From: Freda Lai <42527625+fredalai@users.noreply.github.com> Date: Mon, 18 Nov 2024 11:35:06 +0800 Subject: [PATCH] feat(wren-ui): support recommendation questions for home and follow-up (#910) --- .../apollo/client/graphql/home.generated.ts | 199 ++++++++++++++++-- wren-ui/src/apollo/client/graphql/home.ts | 60 +++++- .../pages/home/RecommendedQuestions.tsx | 40 +++- .../prompt/RecommendedQuestionsPrompt.tsx | 105 +++++++++ .../components/pages/home/prompt/Result.tsx | 27 +-- .../components/pages/home/prompt/index.tsx | 7 +- .../pages/home/promptThread/AnswerResult.tsx | 22 +- .../pages/home/promptThread/index.tsx | 56 +++-- wren-ui/src/hooks/useAskPrompt.tsx | 5 +- .../useRecommendedQuestionsInstruction.tsx | 157 ++++++++++++++ wren-ui/src/pages/home/[id].tsx | 72 ++++++- wren-ui/src/pages/home/index.tsx | 112 ++++++++-- wren-ui/src/utils/svgs/CopilotSVG.tsx | 21 ++ wren-ui/src/utils/svgs/index.ts | 1 + 14 files changed, 792 insertions(+), 92 deletions(-) create mode 100644 wren-ui/src/components/pages/home/prompt/RecommendedQuestionsPrompt.tsx create mode 100644 wren-ui/src/hooks/useRecommendedQuestionsInstruction.tsx create mode 100644 wren-ui/src/utils/svgs/CopilotSVG.tsx create mode 100644 wren-ui/src/utils/svgs/index.ts diff --git a/wren-ui/src/apollo/client/graphql/home.generated.ts b/wren-ui/src/apollo/client/graphql/home.generated.ts index 624d93651..ad2c1495c 100644 --- a/wren-ui/src/apollo/client/graphql/home.generated.ts +++ b/wren-ui/src/apollo/client/graphql/home.generated.ts @@ -7,6 +7,8 @@ export type CommonErrorFragment = { __typename?: 'Error', code?: string | null, export type CommonResponseFragment = { __typename?: 'ThreadResponse', id: number, question: string, status: Types.AskingTaskStatus, detail?: { __typename?: 'ThreadResponseDetail', sql?: string | null, description?: string | null, steps: Array<{ __typename?: 'DetailStep', summary: string, sql: string, cteName?: string | null }>, view?: { __typename?: 'ViewInfo', id: number, name: string, statement: string, displayName: string } | null } | null }; +export type CommonRecommendedQuestionsTaskFragment = { __typename?: 'RecommendedQuestionsTask', status: Types.RecommendedQuestionsTaskStatus, questions: Array<{ __typename?: 'ResultQuestion', question: string, category: string, sql: string }>, error?: { __typename?: 'Error', code?: string | null, shortMessage?: string | null, message?: string | null, stacktrace?: Array | null } | null }; + export type SuggestedQuestionsQueryVariables = Types.Exact<{ [key: string]: never; }>; @@ -110,14 +112,30 @@ export type InstantRecommendedQuestionsQueryVariables = Types.Exact<{ export type InstantRecommendedQuestionsQuery = { __typename?: 'Query', instantRecommendedQuestions: { __typename?: 'RecommendedQuestionsTask', status: Types.RecommendedQuestionsTaskStatus, questions: Array<{ __typename?: 'ResultQuestion', question: string, category: string, sql: string }>, error?: { __typename?: 'Error', code?: string | null, shortMessage?: string | null, message?: string | null, stacktrace?: Array | null } | null } }; -export const CommonErrorFragmentDoc = gql` - fragment CommonError on Error { - code - shortMessage - message - stacktrace -} - `; +export type GetThreadRecommendationQuestionsQueryVariables = Types.Exact<{ + threadId: Types.Scalars['Int']; +}>; + + +export type GetThreadRecommendationQuestionsQuery = { __typename?: 'Query', getThreadRecommendationQuestions: { __typename?: 'RecommendedQuestionsTask', status: Types.RecommendedQuestionsTaskStatus, questions: Array<{ __typename?: 'ResultQuestion', question: string, category: string, sql: string }>, error?: { __typename?: 'Error', code?: string | null, shortMessage?: string | null, message?: string | null, stacktrace?: Array | null } | null } }; + +export type GetProjectRecommendationQuestionsQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type GetProjectRecommendationQuestionsQuery = { __typename?: 'Query', getProjectRecommendationQuestions: { __typename?: 'RecommendedQuestionsTask', status: Types.RecommendedQuestionsTaskStatus, questions: Array<{ __typename?: 'ResultQuestion', question: string, category: string, sql: string }>, error?: { __typename?: 'Error', code?: string | null, shortMessage?: string | null, message?: string | null, stacktrace?: Array | null } | null } }; + +export type GenerateProjectRecommendationQuestionsMutationVariables = Types.Exact<{ [key: string]: never; }>; + + +export type GenerateProjectRecommendationQuestionsMutation = { __typename?: 'Mutation', generateProjectRecommendationQuestions: boolean }; + +export type GenerateThreadRecommendationQuestionsMutationVariables = Types.Exact<{ + threadId: Types.Scalars['Int']; +}>; + + +export type GenerateThreadRecommendationQuestionsMutation = { __typename?: 'Mutation', generateThreadRecommendationQuestions: boolean }; + export const CommonResponseFragmentDoc = gql` fragment CommonResponse on ThreadResponse { id @@ -140,6 +158,27 @@ export const CommonResponseFragmentDoc = gql` } } `; +export const CommonErrorFragmentDoc = gql` + fragment CommonError on Error { + code + shortMessage + message + stacktrace +} + `; +export const CommonRecommendedQuestionsTaskFragmentDoc = gql` + fragment CommonRecommendedQuestionsTask on RecommendedQuestionsTask { + status + questions { + question + category + sql + } + error { + ...CommonError + } +} + ${CommonErrorFragmentDoc}`; export const SuggestedQuestionsDocument = gql` query SuggestedQuestions { suggestedQuestions { @@ -648,18 +687,10 @@ export type CreateInstantRecommendedQuestionsMutationOptions = Apollo.BaseMutati export const InstantRecommendedQuestionsDocument = gql` query InstantRecommendedQuestions($taskId: String!) { instantRecommendedQuestions(taskId: $taskId) { - status - questions { - question - category - sql - } - error { - ...CommonError - } + ...CommonRecommendedQuestionsTask } } - ${CommonErrorFragmentDoc}`; + ${CommonRecommendedQuestionsTaskFragmentDoc}`; /** * __useInstantRecommendedQuestionsQuery__ @@ -687,4 +718,134 @@ export function useInstantRecommendedQuestionsLazyQuery(baseOptions?: Apollo.Laz } export type InstantRecommendedQuestionsQueryHookResult = ReturnType; export type InstantRecommendedQuestionsLazyQueryHookResult = ReturnType; -export type InstantRecommendedQuestionsQueryResult = Apollo.QueryResult; \ No newline at end of file +export type InstantRecommendedQuestionsQueryResult = Apollo.QueryResult; +export const GetThreadRecommendationQuestionsDocument = gql` + query GetThreadRecommendationQuestions($threadId: Int!) { + getThreadRecommendationQuestions(threadId: $threadId) { + ...CommonRecommendedQuestionsTask + } +} + ${CommonRecommendedQuestionsTaskFragmentDoc}`; + +/** + * __useGetThreadRecommendationQuestionsQuery__ + * + * To run a query within a React component, call `useGetThreadRecommendationQuestionsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetThreadRecommendationQuestionsQuery` 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 query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetThreadRecommendationQuestionsQuery({ + * variables: { + * threadId: // value for 'threadId' + * }, + * }); + */ +export function useGetThreadRecommendationQuestionsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetThreadRecommendationQuestionsDocument, options); + } +export function useGetThreadRecommendationQuestionsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetThreadRecommendationQuestionsDocument, options); + } +export type GetThreadRecommendationQuestionsQueryHookResult = ReturnType; +export type GetThreadRecommendationQuestionsLazyQueryHookResult = ReturnType; +export type GetThreadRecommendationQuestionsQueryResult = Apollo.QueryResult; +export const GetProjectRecommendationQuestionsDocument = gql` + query GetProjectRecommendationQuestions { + getProjectRecommendationQuestions { + ...CommonRecommendedQuestionsTask + } +} + ${CommonRecommendedQuestionsTaskFragmentDoc}`; + +/** + * __useGetProjectRecommendationQuestionsQuery__ + * + * To run a query within a React component, call `useGetProjectRecommendationQuestionsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetProjectRecommendationQuestionsQuery` 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 query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetProjectRecommendationQuestionsQuery({ + * variables: { + * }, + * }); + */ +export function useGetProjectRecommendationQuestionsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetProjectRecommendationQuestionsDocument, options); + } +export function useGetProjectRecommendationQuestionsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetProjectRecommendationQuestionsDocument, options); + } +export type GetProjectRecommendationQuestionsQueryHookResult = ReturnType; +export type GetProjectRecommendationQuestionsLazyQueryHookResult = ReturnType; +export type GetProjectRecommendationQuestionsQueryResult = Apollo.QueryResult; +export const GenerateProjectRecommendationQuestionsDocument = gql` + mutation GenerateProjectRecommendationQuestions { + generateProjectRecommendationQuestions +} + `; +export type GenerateProjectRecommendationQuestionsMutationFn = Apollo.MutationFunction; + +/** + * __useGenerateProjectRecommendationQuestionsMutation__ + * + * To run a mutation, you first call `useGenerateProjectRecommendationQuestionsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGenerateProjectRecommendationQuestionsMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [generateProjectRecommendationQuestionsMutation, { data, loading, error }] = useGenerateProjectRecommendationQuestionsMutation({ + * variables: { + * }, + * }); + */ +export function useGenerateProjectRecommendationQuestionsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GenerateProjectRecommendationQuestionsDocument, options); + } +export type GenerateProjectRecommendationQuestionsMutationHookResult = ReturnType; +export type GenerateProjectRecommendationQuestionsMutationResult = Apollo.MutationResult; +export type GenerateProjectRecommendationQuestionsMutationOptions = Apollo.BaseMutationOptions; +export const GenerateThreadRecommendationQuestionsDocument = gql` + mutation GenerateThreadRecommendationQuestions($threadId: Int!) { + generateThreadRecommendationQuestions(threadId: $threadId) +} + `; +export type GenerateThreadRecommendationQuestionsMutationFn = Apollo.MutationFunction; + +/** + * __useGenerateThreadRecommendationQuestionsMutation__ + * + * To run a mutation, you first call `useGenerateThreadRecommendationQuestionsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGenerateThreadRecommendationQuestionsMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [generateThreadRecommendationQuestionsMutation, { data, loading, error }] = useGenerateThreadRecommendationQuestionsMutation({ + * variables: { + * threadId: // value for 'threadId' + * }, + * }); + */ +export function useGenerateThreadRecommendationQuestionsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GenerateThreadRecommendationQuestionsDocument, options); + } +export type GenerateThreadRecommendationQuestionsMutationHookResult = ReturnType; +export type GenerateThreadRecommendationQuestionsMutationResult = Apollo.MutationResult; +export type GenerateThreadRecommendationQuestionsMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/wren-ui/src/apollo/client/graphql/home.ts b/wren-ui/src/apollo/client/graphql/home.ts index 231d2b3c7..eb6f6fa47 100644 --- a/wren-ui/src/apollo/client/graphql/home.ts +++ b/wren-ui/src/apollo/client/graphql/home.ts @@ -32,6 +32,22 @@ const COMMON_RESPONSE = gql` } `; +const COMMON_RECOMMENDED_QUESTIONS_TASK = gql` + fragment CommonRecommendedQuestionsTask on RecommendedQuestionsTask { + status + questions { + question + category + sql + } + error { + ...CommonError + } + } + + ${COMMON_ERROR} +`; + export const SUGGESTED_QUESTIONS = gql` query SuggestedQuestions { suggestedQuestions { @@ -191,16 +207,40 @@ export const CREATE_INSTANT_RECOMMENDED_QUESTIONS = gql` export const INSTANT_RECOMMENDED_QUESTIONS = gql` query InstantRecommendedQuestions($taskId: String!) { instantRecommendedQuestions(taskId: $taskId) { - status - questions { - question - category - sql - } - error { - ...CommonError - } + ...CommonRecommendedQuestionsTask } } - ${COMMON_ERROR} + ${COMMON_RECOMMENDED_QUESTIONS_TASK} +`; + +export const GET_THREAD_RECOMMENDATION_QUESTIONS = gql` + query GetThreadRecommendationQuestions($threadId: Int!) { + getThreadRecommendationQuestions(threadId: $threadId) { + ...CommonRecommendedQuestionsTask + } + } + + ${COMMON_RECOMMENDED_QUESTIONS_TASK} +`; + +export const GET_PROJECT_RECOMMENDATION_QUESTIONS = gql` + query GetProjectRecommendationQuestions { + getProjectRecommendationQuestions { + ...CommonRecommendedQuestionsTask + } + } + + ${COMMON_RECOMMENDED_QUESTIONS_TASK} +`; + +export const GENERATE_PROJECT_RECOMMENDATION_QUESTIONS = gql` + mutation GenerateProjectRecommendationQuestions { + generateProjectRecommendationQuestions + } +`; + +export const GENERATE_THREAD_RECOMMENDATION_QUESTIONS = gql` + mutation GenerateThreadRecommendationQuestions($threadId: Int!) { + generateThreadRecommendationQuestions(threadId: $threadId) + } `; diff --git a/wren-ui/src/components/pages/home/RecommendedQuestions.tsx b/wren-ui/src/components/pages/home/RecommendedQuestions.tsx index ad7777b13..7ec59fc3f 100644 --- a/wren-ui/src/components/pages/home/RecommendedQuestions.tsx +++ b/wren-ui/src/components/pages/home/RecommendedQuestions.tsx @@ -4,9 +4,13 @@ import { useMemo } from 'react'; import { Skeleton } from 'antd'; import BulbOutlined from '@ant-design/icons/BulbOutlined'; import { makeIterable } from '@/utils/iteration'; +import { + RecommendedQuestionsTask, + RecommendedQuestionsTaskStatus, +} from '@/apollo/client/graphql/__types__'; interface Props { - items: string[]; + items: { question: string; sql: string }[]; loading?: boolean; error?: { shortMessage?: string; @@ -15,7 +19,7 @@ interface Props { stacktrace?: string[]; }; className?: string; - onSelect?: (question: string) => void; + onSelect?: ({ question, sql }: { question: string; sql: string }) => void; } const StyledSkeleton = styled(Skeleton)` @@ -31,17 +35,38 @@ const StyledSkeleton = styled(Skeleton)` } `; +export const getRecommendedQuestionProps = ( + data: RecommendedQuestionsTask, + show = true, +) => { + if (!data || !show) return { show: false }; + const questions = (data?.questions || []).slice(0, 3).map((item) => ({ + question: item.question, + sql: item.sql, + })); + const loading = data?.status === RecommendedQuestionsTaskStatus.GENERATING; + return { + show: loading || questions.length > 0, + state: { + items: questions, + loading, + error: data?.error, + }, + }; +}; + const QuestionItem = (props: { index: number; question: string; - onSelect?: (question: string) => void; + sql: string; + onSelect?: ({ question, sql }: { question: string; sql: string }) => void; }) => { - const { index, question, onSelect } = props; + const { index, question, sql, onSelect } = props; return (
0 && 'mt-1')}> onSelect(question)} + onClick={() => onSelect({ question, sql })} > {question} @@ -53,7 +78,10 @@ const QuestionList = makeIterable(QuestionItem); export default function RecommendedQuestions(props: Props) { const { items, loading, className, onSelect } = props; - const data = useMemo(() => items.map((question) => ({ question })), [items]); + const data = useMemo( + () => items.map(({ question, sql }) => ({ question, sql })), + [items], + ); return (
diff --git a/wren-ui/src/components/pages/home/prompt/RecommendedQuestionsPrompt.tsx b/wren-ui/src/components/pages/home/prompt/RecommendedQuestionsPrompt.tsx new file mode 100644 index 000000000..1045d9392 --- /dev/null +++ b/wren-ui/src/components/pages/home/prompt/RecommendedQuestionsPrompt.tsx @@ -0,0 +1,105 @@ +import { useState, useMemo } from 'react'; +import styled from 'styled-components'; +import { Space, Button, Row, Col } from 'antd'; +import ColumnHeightOutlined from '@ant-design/icons/ColumnHeightOutlined'; +import MinusOutlined from '@ant-design/icons/MinusOutlined'; +import EllipsisWrapper from '@/components/EllipsisWrapper'; +import { Logo } from '@/components/Logo'; +import { makeIterable } from '@/utils/iteration'; + +const CategorySectionBlock = styled.div` + background: var(--gray-1); + border: 1px solid var(--gray-4); + border-radius: 4px; + padding: 16px; +`; + +const QuestionBlock = styled.div` + background: var(--gray-1); + user-select: none; + height: 150px; + transition: border-color ease 0.2s; + + &:hover { + border-color: var(--geekblue-6) !important; + } +`; + +const MAX_EXPANDED_QUESTIONS = 9; + +const QuestionTemplate = ({ category, sql, question, onSelect }) => { + return ( + + onSelect({ sql, question })} + > +
+
+ {category} +
+
+ +
+ + ); +}; + +const QuestionColumnIterator = makeIterable(QuestionTemplate); + +export default function RecommendedQuestionsPrompt(props) { + const { onSelect, recommendedQuestions, buttonProps } = props; + + const [isExpanded, setIsExpanded] = useState(false); + + const questionList = useMemo(() => { + return recommendedQuestions.slice( + 0, + isExpanded ? undefined : MAX_EXPANDED_QUESTIONS, + ); + }, [recommendedQuestions, isExpanded]); + + const onHandleToggle = () => setIsExpanded((prev) => !prev); + + const showExpandButton = recommendedQuestions.length > MAX_EXPANDED_QUESTIONS; + + return ( +
+
+ +
+ Know more about your data. +
+
+ Try asking some of the following questions +
+
+ + + + + + {showExpandButton && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/wren-ui/src/components/pages/home/prompt/Result.tsx b/wren-ui/src/components/pages/home/prompt/Result.tsx index 9b660c63f..3b1aa5fa2 100644 --- a/wren-ui/src/components/pages/home/prompt/Result.tsx +++ b/wren-ui/src/components/pages/home/prompt/Result.tsx @@ -11,13 +11,14 @@ import MessageOutlined from '@ant-design/icons/MessageOutlined'; import ErrorCollapse from '@/components/ErrorCollapse'; import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined'; import useAskProcessState from '@/hooks/useAskProcessState'; -import RecommendedQuestions from '@/components/pages/home/RecommendedQuestions'; +import RecommendedQuestions, { + getRecommendedQuestionProps, +} from '@/components/pages/home/RecommendedQuestions'; import MarkdownBlock from '@/components/editor/MarkdownBlock'; import { AskingTask, AskingTaskType, RecommendedQuestionsTask, - RecommendedQuestionsTaskStatus, } from '@/apollo/client/graphql/__types__'; const StyledResult = styled.div` @@ -42,7 +43,13 @@ interface Props { }; error?: any; onSelectResult: (payload: { sql: string }) => void; - onSelectQuestion: (question: string) => void; + onSelectQuestion: ({ + question, + sql, + }: { + question: string; + sql: string; + }) => void; onClose: () => void; onStop: () => void; loading?: boolean; @@ -56,20 +63,6 @@ const Wrapper = ({ children }) => { ); }; -const getRecommendedQuestionProps = (data: RecommendedQuestionsTask) => { - if (!data) return { show: false }; - const questions = (data?.questions || []).map((item) => item.question); - const loading = data?.status === RecommendedQuestionsTaskStatus.GENERATING; - return { - show: loading || questions.length > 0, - state: { - items: questions, - loading, - error: data?.error, - }, - }; -}; - const makeProcessing = (text: string) => (props: Props) => { const { onStop } = props; return ( diff --git a/wren-ui/src/components/pages/home/prompt/index.tsx b/wren-ui/src/components/pages/home/prompt/index.tsx index d7bc84ecc..cc1caec41 100644 --- a/wren-ui/src/components/pages/home/prompt/index.tsx +++ b/wren-ui/src/components/pages/home/prompt/index.tsx @@ -132,11 +132,10 @@ export default forwardRef(function Prompt(props, ref) { } }, [error]); - const selectQuestion = async (value: string) => { - setInputValue(value); - onStopStreaming && onStopStreaming(); + const selectQuestion = async (payload) => { + onSelect && (await onSelect(payload)); + closeResult(); askProcessState.resetState(); - onSubmit && (await onSubmit(value)); }; const selectResult = async (payload) => { diff --git a/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx b/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx index de3c33365..ebf2ddf76 100644 --- a/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx +++ b/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx @@ -9,6 +9,10 @@ import SaveOutlined from '@ant-design/icons/SaveOutlined'; import FileDoneOutlined from '@ant-design/icons/FileDoneOutlined'; import StepContent from '@/components/pages/home/promptThread/StepContent'; import { getIsFinished } from '@/hooks/useAskPrompt'; +import { RecommendedQuestionsProps } from '@/components/pages/home/promptThread'; +import RecommendedQuestions, { + getRecommendedQuestionProps, +} from '@/components/pages/home/RecommendedQuestions'; import { ThreadResponse } from '@/apollo/client/graphql/__types__'; const { Title, Text } = Typography; @@ -39,6 +43,9 @@ interface Props { isLastThreadResponse: boolean; onOpenSaveAsViewModal: (data: { sql: string; responseId: number }) => void; onInitPreviewDone: () => void; + + // recommended questions + recommendedQuestionsProps: RecommendedQuestionsProps; } const QuestionTitle = (props) => { @@ -61,6 +68,7 @@ export default function AnswerResult(props: Props) { isLastThreadResponse, onOpenSaveAsViewModal, onInitPreviewDone, + recommendedQuestionsProps, } = props; const { question, status, error } = threadResponse; @@ -75,8 +83,13 @@ export default function AnswerResult(props: Props) { ? { minHeight: 'calc(100vh - (194px))' } : null; + const recommendedQuestionProps = getRecommendedQuestionProps( + recommendedQuestionsProps.data, + recommendedQuestionsProps.show, + ); + return ( -
+
{error ? ( )} + {isLastThreadResponse && recommendedQuestionProps.show && ( + + )}
)} diff --git a/wren-ui/src/components/pages/home/promptThread/index.tsx b/wren-ui/src/components/pages/home/promptThread/index.tsx index 5f596d3d6..ae16ca839 100644 --- a/wren-ui/src/components/pages/home/promptThread/index.tsx +++ b/wren-ui/src/components/pages/home/promptThread/index.tsx @@ -4,15 +4,32 @@ import { Divider } from 'antd'; import styled from 'styled-components'; import AnswerResult from './AnswerResult'; import { makeIterable, IterableComponent } from '@/utils/iteration'; +import { getIsFinished } from '@/hooks/useAskPrompt'; import { DetailedThread, + RecommendedQuestionsTask, ThreadResponse, } from '@/apollo/client/graphql/__types__'; -import { getIsFinished } from '@/hooks/useAskPrompt'; + +interface SelectQuestionProps { + question: string; + sql: string; +} + +export interface RecommendedQuestionsProps { + data: RecommendedQuestionsTask; + show: boolean; + onSelect: ({ question, sql }: SelectQuestionProps) => void; +} interface Props { - data: DetailedThread; + data: { + thread: DetailedThread; + recommendedQuestions: RecommendedQuestionsTask; + showRecommendedQuestions: boolean; + }; onOpenSaveAsViewModal: (data: { sql: string; responseId: number }) => void; + onSelect: ({ question, sql }: SelectQuestionProps) => void; } const StyledPromptThread = styled.div` @@ -44,11 +61,17 @@ const AnswerResultTemplate: React.FC< motion: boolean; onOpenSaveAsViewModal: (data: { sql: string; responseId: number }) => void; onInitPreviewDone: () => void; + recommendedQuestionsProps: { + data: RecommendedQuestionsTask; + show: boolean; + onSelect: ({ question, sql }: SelectQuestionProps) => void; + }; } > = ({ data, index, motion, + recommendedQuestionsProps, onOpenSaveAsViewModal, onInitPreviewDone, ...threadResponse @@ -66,6 +89,7 @@ const AnswerResultTemplate: React.FC< onOpenSaveAsViewModal={onOpenSaveAsViewModal} onInitPreviewDone={onInitPreviewDone} threadResponse={threadResponse} + recommendedQuestionsProps={recommendedQuestionsProps} />
); @@ -77,26 +101,29 @@ export default function PromptThread(props: Props) { const router = useRouter(); const divRef = useRef(null); const motionResponsesRef = useRef>({}); - const { data, onOpenSaveAsViewModal } = props; + const { data, onOpenSaveAsViewModal, onSelect } = props; const responses = useMemo( () => - (data?.responses || []).map((response) => ({ + (data.thread?.responses || []).map((response) => ({ ...response, motion: motionResponsesRef.current[response.id], })), - [data?.responses], + [data.thread?.responses], ); const triggerScrollToBottom = (behavior?: ScrollBehavior) => { - if ((data?.responses || []).length <= 1) return; + if ((data.thread?.responses || []).length <= 1) return; const contentLayout = divRef.current?.parentElement; - const lastChild = divRef.current?.lastElementChild as HTMLElement; - const lastChildElement = lastChild?.lastElementChild as HTMLElement; + const allElements = (divRef.current?.querySelectorAll( + '.adm-answer-result', + ) || []) as HTMLElement[]; + const lastAnswerResult = allElements[allElements.length - 1]; + const dividerSpace = 48; - if (contentLayout && lastChildElement) { + if (contentLayout && lastAnswerResult) { contentLayout.scrollTo({ - top: lastChildElement.offsetTop - dividerSpace, + top: lastAnswerResult.offsetTop - dividerSpace, behavior, }); } @@ -109,7 +136,7 @@ export default function PromptThread(props: Props) { }, [router.query]); useEffect(() => { - motionResponsesRef.current = (data?.responses || []).reduce( + motionResponsesRef.current = (data.thread?.responses || []).reduce( (result, item) => { result[item.id] = !getIsFinished(item?.status); return result; @@ -118,7 +145,7 @@ export default function PromptThread(props: Props) { ); const lastResponseMotion = Object.values(motionResponsesRef.current).pop(); triggerScrollToBottom(lastResponseMotion ? 'smooth' : 'auto'); - }, [data?.responses]); + }, [data.thread?.responses]); const onInitPreviewDone = () => { triggerScrollToBottom(); @@ -130,6 +157,11 @@ export default function PromptThread(props: Props) { data={responses} onOpenSaveAsViewModal={onOpenSaveAsViewModal} onInitPreviewDone={onInitPreviewDone} + recommendedQuestionsProps={{ + data: data.recommendedQuestions, + show: data.showRecommendedQuestions, + onSelect, + }} /> ); diff --git a/wren-ui/src/hooks/useAskPrompt.tsx b/wren-ui/src/hooks/useAskPrompt.tsx index 6a8f87bd4..8acd79824 100644 --- a/wren-ui/src/hooks/useAskPrompt.tsx +++ b/wren-ui/src/hooks/useAskPrompt.tsx @@ -30,10 +30,13 @@ export const getIsFinished = (status: AskingTaskStatus) => AskingTaskStatus.STOPPED, ].includes(status); -const isRecommendedFinished = (status: RecommendedQuestionsTaskStatus) => +export const isRecommendedFinished = (status: RecommendedQuestionsTaskStatus) => [ RecommendedQuestionsTaskStatus.FINISHED, RecommendedQuestionsTaskStatus.FAILED, + + // for existing thread response & existing projects that are not executed to generate recommendation questions + RecommendedQuestionsTaskStatus.NOT_STARTED, ].includes(status); const isNeedRecommendedQuestions = (askingTask: AskingTask) => { diff --git a/wren-ui/src/hooks/useRecommendedQuestionsInstruction.tsx b/wren-ui/src/hooks/useRecommendedQuestionsInstruction.tsx new file mode 100644 index 000000000..1fbcd6730 --- /dev/null +++ b/wren-ui/src/hooks/useRecommendedQuestionsInstruction.tsx @@ -0,0 +1,157 @@ +import { useMemo, useState, useEffect } from 'react'; +import { groupBy, orderBy, flatMap } from 'lodash'; +import { message } from 'antd'; +import Icon from '@ant-design/icons'; +import ReloadOutlined from '@ant-design/icons/ReloadOutlined'; +import { CopilotSVG } from '@/utils/svgs'; +import { isRecommendedFinished } from '@/hooks/useAskPrompt'; +import { + ResultQuestion, + RecommendedQuestionsTaskStatus, +} from '@/apollo/client/graphql/__types__'; +import { + useGetProjectRecommendationQuestionsLazyQuery, + useGenerateProjectRecommendationQuestionsMutation, +} from '@/apollo/client/graphql/home.generated'; + +interface GroupedQuestion { + category: string; + question: string; + sql: string; +} + +const getGroupedQuestions = ( + questions: ResultQuestion[], +): GroupedQuestion[] => { + const groupedData = groupBy(questions, 'category'); + return orderBy( + flatMap(groupedData), + (item) => groupedData[item.category].length, // Sort by number of questions in each category + 'desc', + ); +}; + +export default function useRecommendedQuestionsInstruction() { + const [showRetry, setShowRetry] = useState(false); + const [generating, setGenerating] = useState(false); + const [isRegenerate, setIsRegenerate] = useState(false); + const [ + showRecommendedQuestionsPromptMode, + setShowRecommendedQuestionsPromptMode, + ] = useState(false); + const [recommendedQuestions, setRecommendedQuestions] = useState< + GroupedQuestion[] + >([]); + + const [fetchRecommendationQuestions, recommendationQuestionsResult] = + useGetProjectRecommendationQuestionsLazyQuery({ + pollInterval: 2000, + }); + + const [generateProjectRecommendationQuestions] = + useGenerateProjectRecommendationQuestionsMutation(); + + const recommendedQuestionsTask = useMemo( + () => + recommendationQuestionsResult.data?.getProjectRecommendationQuestions || + null, + [recommendationQuestionsResult.data], + ); + + useEffect(() => { + const fetchRecommendationQuestionsData = async () => { + const result = await fetchRecommendationQuestions(); + const data = result.data?.getProjectRecommendationQuestions; + + // for existing projects that do not have to generate recommended questions yet + if (isRecommendedFinished(data.status)) { + if (data.questions.length > 0) { + // for regenerate then leave and go back to the home page + setRecommendedQuestions(getGroupedQuestions(data.questions)); + + setShowRecommendedQuestionsPromptMode(true); + } + } + }; + + fetchRecommendationQuestionsData(); + }, []); + + useEffect(() => { + if (isRecommendedFinished(recommendedQuestionsTask?.status)) { + recommendationQuestionsResult.stopPolling(); + + if (recommendedQuestionsTask.questions.length === 0) { + isRegenerate && setShowRetry(true); + + if ( + showRecommendedQuestionsPromptMode && + recommendedQuestionsTask.status === + RecommendedQuestionsTaskStatus.FAILED + ) { + message.error( + `We couldn't regenerate questions right now. Let's try again later.`, + ); + } + } else { + setIsRegenerate(true); + + // update to recommendedQuestions + setRecommendedQuestions( + getGroupedQuestions(recommendedQuestionsTask.questions), + ); + setShowRecommendedQuestionsPromptMode(true); + } + + setGenerating(false); + } + }, [recommendedQuestionsTask]); + + const onGetRecommendationQuestions = async () => { + setGenerating(true); + setIsRegenerate(true); + try { + await generateProjectRecommendationQuestions(); + fetchRecommendationQuestions(); + } catch (error) { + console.error(error); + } + }; + + const buttonProps = useMemo(() => { + const baseProps = { + loading: generating, + onClick: onGetRecommendationQuestions, + }; + + if (showRecommendedQuestionsPromptMode && isRegenerate) { + return { + ...baseProps, + icon: , + children: 'Regenerate', + }; + } + + return { + ...baseProps, + icon: showRetry ? ( + + ) : ( + + ), + children: generating + ? 'Generating questions' + : showRetry + ? 'Retry' + : 'What could I ask?', + }; + }, [generating, isRegenerate, showRetry, showRecommendedQuestionsPromptMode]); + + return { + recommendedQuestions, + generating, + showRetry, + showRecommendedQuestionsPromptMode, + buttonProps, + }; +} diff --git a/wren-ui/src/pages/home/[id].tsx b/wren-ui/src/pages/home/[id].tsx index 34a8d22a3..184ea9bf6 100644 --- a/wren-ui/src/pages/home/[id].tsx +++ b/wren-ui/src/pages/home/[id].tsx @@ -1,20 +1,25 @@ import { useRouter } from 'next/router'; import { useParams } from 'next/navigation'; -import { ComponentRef, useEffect, useMemo, useRef } from 'react'; +import { ComponentRef, useEffect, useMemo, useRef, useState } from 'react'; import { message } from 'antd'; import { Path } from '@/utils/enum'; import useHomeSidebar from '@/hooks/useHomeSidebar'; import SiderLayout from '@/components/layouts/SiderLayout'; import Prompt from '@/components/pages/home/prompt'; +import useAskPrompt, { + getIsFinished, + isRecommendedFinished, +} from '@/hooks/useAskPrompt'; +import useModalAction from '@/hooks/useModalAction'; +import PromptThread from '@/components/pages/home/promptThread'; +import SaveAsViewModal from '@/components/modals/SaveAsViewModal'; import { useCreateThreadResponseMutation, useThreadQuery, useThreadResponseLazyQuery, + useGenerateThreadRecommendationQuestionsMutation, + useGetThreadRecommendationQuestionsLazyQuery, } from '@/apollo/client/graphql/home.generated'; -import useAskPrompt, { getIsFinished } from '@/hooks/useAskPrompt'; -import useModalAction from '@/hooks/useModalAction'; -import PromptThread from '@/components/pages/home/promptThread'; -import SaveAsViewModal from '@/components/modals/SaveAsViewModal'; import { useCreateViewMutation } from '@/apollo/client/graphql/view.generated'; export default function HomeThread() { @@ -25,6 +30,10 @@ export default function HomeThread() { const threadId = useMemo(() => Number(params?.id) || null, [params]); const askPrompt = useAskPrompt(threadId); const saveAsViewModal = useModalAction(); + + const [showRecommendedQuestions, setShowRecommendedQuestions] = + useState(false); + const [createViewMutation, { loading: creating }] = useCreateViewMutation({ onError: (error) => console.error(error), onCompleted: () => message.success('Successfully created view.'), @@ -67,6 +76,16 @@ export default function HomeThread() { }, }); + const [generateThreadRecommendationQuestions] = + useGenerateThreadRecommendationQuestionsMutation(); + + const [ + fetchThreadRecommendationQuestions, + threadRecommendationQuestionsResult, + ] = useGetThreadRecommendationQuestionsLazyQuery({ + pollInterval: 1000, + }); + const thread = useMemo(() => data?.thread || null, [data]); const threadResponse = useMemo( () => threadResponseResult.data?.threadResponse || null, @@ -81,7 +100,13 @@ export default function HomeThread() { useEffect(() => { askPrompt.onStopPolling(); threadResponseResult.stopPolling(); + threadRecommendationQuestionsResult.stopPolling(); $prompt.current?.close(); + + if (threadId !== null) { + fetchThreadRecommendationQuestions({ variables: { threadId } }); + setShowRecommendedQuestions(true); + } }, [threadId]); useEffect(() => { @@ -99,18 +124,50 @@ export default function HomeThread() { }, [thread]); useEffect(() => { - if (isFinished) threadResponseResult.stopPolling(); + if (isFinished) { + threadResponseResult.stopPolling(); + setShowRecommendedQuestions(true); + } }, [isFinished]); + const recommendedQuestions = useMemo( + () => + threadRecommendationQuestionsResult.data + ?.getThreadRecommendationQuestions || null, + [threadRecommendationQuestionsResult.data], + ); + + useEffect(() => { + if (isRecommendedFinished(recommendedQuestions?.status)) { + threadRecommendationQuestionsResult.stopPolling(); + } + }, [recommendedQuestions]); + + const result = useMemo( + () => ({ + thread, + recommendedQuestions, + showRecommendedQuestions, + }), + [thread, recommendedQuestions, showRecommendedQuestions], + ); + const onSelect = async (payload) => { try { askPrompt.onStopPolling(); const response = await createThreadResponse({ variables: { threadId: thread.id, data: payload }, }); + generateThreadRecommendationQuestions({ + variables: { threadId: thread.id }, + }); + setShowRecommendedQuestions(false); await fetchThreadResponse({ variables: { responseId: response.data.createThreadResponse.id }, }); + fetchThreadRecommendationQuestions({ + variables: { threadId: thread.id }, + }); } catch (error) { console.error(error); } @@ -119,8 +176,9 @@ export default function HomeThread() { return (
diff --git a/wren-ui/src/pages/home/index.tsx b/wren-ui/src/pages/home/index.tsx index 5e619e1ad..672d79d95 100644 --- a/wren-ui/src/pages/home/index.tsx +++ b/wren-ui/src/pages/home/index.tsx @@ -1,5 +1,6 @@ import { ComponentRef, useMemo, useRef } from 'react'; import { useRouter } from 'next/router'; +import { Button, Typography } from 'antd'; import { Logo } from '@/components/Logo'; import { Path } from '@/utils/enum'; import { nextTick } from '@/utils/time'; @@ -8,10 +9,83 @@ import Prompt from '@/components/pages/home/prompt'; import DemoPrompt from '@/components/pages/home/prompt/DemoPrompt'; import useHomeSidebar from '@/hooks/useHomeSidebar'; import useAskPrompt from '@/hooks/useAskPrompt'; +import useRecommendedQuestionsInstruction from '@/hooks/useRecommendedQuestionsInstruction'; +import RecommendedQuestionsPrompt from '@/components/pages/home/prompt/RecommendedQuestionsPrompt'; import { useSuggestedQuestionsQuery, useCreateThreadMutation, + useGenerateThreadRecommendationQuestionsMutation, } from '@/apollo/client/graphql/home.generated'; +import { useGetSettingsQuery } from '@/apollo/client/graphql/settings.generated'; + +const { Text } = Typography; + +const Wrapper = ({ children }) => { + return ( +
+ +
+ Know more about your data +
+ {children} +
+ ); +}; + +const SampleQuestionsInstruction = (props) => { + const { sampleQuestions, onSelect } = props; + + return ( + + + + ); +}; + +function RecommendedQuestionsInstruction(props) { + const { onSelect } = props; + + const { + buttonProps, + generating, + recommendedQuestions, + showRetry, + showRecommendedQuestionsPromptMode, + } = useRecommendedQuestionsInstruction(); + + return showRecommendedQuestionsPromptMode ? ( +
+ +
+
+ ) : ( + +