diff --git a/wren-ui/public/images/learning/question-sql-pairs.png b/wren-ui/public/images/learning/question-sql-pairs.png new file mode 100644 index 0000000000..5420b7a49b Binary files /dev/null and b/wren-ui/public/images/learning/question-sql-pairs.png differ diff --git a/wren-ui/src/components/learning/guide/stories.tsx b/wren-ui/src/components/learning/guide/stories.tsx index 010c774002..4f49ac66a1 100644 --- a/wren-ui/src/components/learning/guide/stories.tsx +++ b/wren-ui/src/components/learning/guide/stories.tsx @@ -2,7 +2,9 @@ import { useState } from 'react'; import { createRoot } from 'react-dom/client'; import { NextRouter } from 'next/router'; import { Select } from 'antd'; +import styled from 'styled-components'; import { ModelIcon, TranslateIcon } from '@/utils/icons'; +import { RobotSVG } from '@/utils/svgs'; import { renderToString } from 'react-dom/server'; import { Dispatcher, @@ -19,6 +21,13 @@ import { import { TEMPLATE_OPTIONS as SAMPLE_DATASET_INFO } from '@/components/pages/setup/utils'; import { getLanguageText } from '@/utils/language'; import * as events from '@/utils/events'; +import { nextTick } from '@/utils/time'; + +const RobotIcon = styled(RobotSVG)` + width: 24px; + height: 24px; +`; + const defaultConfigs: DriverConfig = { progressText: '{{current}} / {{total}}', nextBtnText: 'Next', @@ -41,6 +50,10 @@ export const makeStoriesPlayer = playDataModelingGuide(...args, dispatcher), [LEARNING.SWITCH_PROJECT_LANGUAGE]: () => playSwitchProjectLanguageGuide(...args, dispatcher), + [LEARNING.QUESTION_SQL_PAIRS_GUIDE]: () => + playQuestionSQLPairsGuide(...args, dispatcher), + [LEARNING.SAVE_TO_KNOWLEDGE]: () => + playSaveToKnowledgeGuide(...args, dispatcher), }[id] || null; return action && action(); }; @@ -350,3 +363,173 @@ const playSwitchProjectLanguageGuide = ( ]); $driver.drive(); }; + +const playQuestionSQLPairsGuide = ( + $driver: DriverObj, + _router: NextRouter, + _payload: StoryPayload, + dispatcher: Dispatcher, +) => { + if ($driver === null) { + console.error('Driver object is not initialized.'); + return; + } + + if ($driver.isActive()) $driver.destroy(); + + $driver.setConfig({ ...defaultConfigs, showProgress: true }); + $driver.setSteps([ + { + popover: { + title: renderToString( +
+
+ question-sql-pairs-guide +
+ Build Your Knowledge Base +
, + ), + description: renderToString( + <> + Create and manage Question-SQL Pairs to refine Wren AI’s SQL + generation. You can manually add pairs here or go to Home, ask a + question, and save the correct answer to Knowledge. The more you + save, the smarter Wren AI becomes! + , + ), + onPopoverRender: (popoverDom: DriverPopoverDOM) => { + resetPopoverStyle(popoverDom, 640); + }, + doneBtnText: 'Get Started', + onNextClick: () => { + $driver.destroy(); + dispatcher?.onDone && dispatcher.onDone(); + }, + }, + }, + ]); + $driver.drive(); +}; + +const playSaveToKnowledgeGuide = async ( + $driver: DriverObj, + _router: NextRouter, + _payload: StoryPayload, + dispatcher: Dispatcher, +) => { + if ($driver === null) { + console.error('Driver object is not initialized.'); + return; + } + if ($driver.isActive()) $driver.destroy(); + + $driver.setConfig({ ...defaultConfigs, showProgress: false }); + + const selectors = { + saveToKnowledge: + '[data-guideid="last-answer-result"] [data-guideid="save-to-knowledge"]', + previewData: + '[data-guideid="last-answer-result"] [data-guideid="text-answer-preview-data"]', + }; + + $driver.setSteps([ + { + element: selectors.saveToKnowledge, + popover: { + side: 'top', + align: 'start', + title: renderToString( + <> +
+ +
+ Save to Knowledge + , + ), + description: renderToString( + <> + If the AI-generated answer is correct, save it as a{' '} + Question-SQL Pairs to improve AI learning. If it's incorrect, + refine it with follow-ups before saving to ensure accuracy. + , + ), + onPopoverRender: (popoverDom: DriverPopoverDOM) => { + resetPopoverStyle(popoverDom, 360); + }, + doneBtnText: 'Got it', + onNextClick: () => { + $driver.destroy(); + dispatcher?.onDone && dispatcher.onDone(); + }, + }, + }, + ]); + + let mutationObserver: MutationObserver | null = null; + let intersectionObserver: IntersectionObserver | null = null; + + const cleanMutationObserverup = () => { + // if MutationObserver is listening to the element, disable it + if (mutationObserver) { + mutationObserver.disconnect(); + mutationObserver = null; + } + }; + + const cleanIntersectionObserverup = () => { + if (intersectionObserver) { + intersectionObserver.disconnect(); + intersectionObserver = null; + } + }; + + const startDriver = () => { + const target = document.querySelector( + selectors.previewData, + ) as HTMLElement | null; + + if (!target) return false; + + cleanMutationObserverup(); + + // use IntersectionObserver to ensure the element is in viewport before driving + intersectionObserver = new IntersectionObserver( + async (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + cleanIntersectionObserverup(); + + await nextTick(700); + $driver.drive(); + return; + } + } + }, + { threshold: 0.5 }, // 50% of the element is visible + ); + + intersectionObserver.observe(target); + return true; + }; + + // try to start Driver.js + if (startDriver()) return; + + // if the target element not appear, use MutationObserver to listen DOM changes + mutationObserver = new MutationObserver(() => { + if (startDriver()) { + cleanMutationObserverup(); + } + }); + + mutationObserver.observe(document.body, { childList: true, subtree: true }); + + // 60 seconds after, observer will be cleared + await nextTick(60000); + cleanMutationObserverup(); + cleanIntersectionObserverup(); +}; diff --git a/wren-ui/src/components/learning/guide/utils.ts b/wren-ui/src/components/learning/guide/utils.ts index e23fd3f816..4f68bec47d 100644 --- a/wren-ui/src/components/learning/guide/utils.ts +++ b/wren-ui/src/components/learning/guide/utils.ts @@ -22,4 +22,8 @@ export enum LEARNING { SWITCH_PROJECT_LANGUAGE = 'SWITCH_PROJECT_LANGUAGE', SHARE_RESULTS = 'SHARE_RESULTS', VIEW_FULL_SQL = 'VIEW_FULL_SQL', + + // knowledge + QUESTION_SQL_PAIRS_GUIDE = 'QUESTION_SQL_PAIRS_GUIDE', + SAVE_TO_KNOWLEDGE = 'SAVE_TO_KNOWLEDGE', } diff --git a/wren-ui/src/components/learning/index.tsx b/wren-ui/src/components/learning/index.tsx index 74dbb1e30b..5441bf7010 100644 --- a/wren-ui/src/components/learning/index.tsx +++ b/wren-ui/src/components/learning/index.tsx @@ -262,39 +262,63 @@ export default function SidebarSection(_props: Props) { stories.some((item) => !learningRecord.paths.includes(item.id)), ); - // play the data modeling guide if it's not finished - if ( - router.pathname === Path.Modeling && - !learningRecord.paths.includes(LEARNING.DATA_MODELING_GUIDE) - ) { - nextTick(1000).then(() => { + const routerAction = { + [Path.Modeling]: async () => { + const isGuideDone = learningRecord.paths.includes( + LEARNING.DATA_MODELING_GUIDE, + ); const isSkipBefore = !!window.sessionStorage.getItem( 'skipDataModelingGuide', ); - if (isSkipBefore) return; - $guide.current?.play(LEARNING.DATA_MODELING_GUIDE, { - onDone: () => saveRecord(LEARNING.DATA_MODELING_GUIDE), - }); - }); - } - - if ( - router.pathname === Path.Home && - !learningRecord.paths.includes(LEARNING.SWITCH_PROJECT_LANGUAGE) - ) { - nextTick(1000).then(() => { + if (!(isGuideDone || isSkipBefore)) { + await nextTick(1000); + $guide.current?.play(LEARNING.DATA_MODELING_GUIDE, { + onDone: () => saveRecord(LEARNING.DATA_MODELING_GUIDE), + }); + } + }, + [Path.Home]: async () => { + const isGuideDone = learningRecord.paths.includes( + LEARNING.SWITCH_PROJECT_LANGUAGE, + ); const isSkipBefore = !!window.sessionStorage.getItem( 'skipSwitchProjectLanguageGuide', ); - if (isSkipBefore) return; - $guide.current?.play(LEARNING.SWITCH_PROJECT_LANGUAGE, { - onDone: () => saveRecord(LEARNING.SWITCH_PROJECT_LANGUAGE), - onSaveLanguage: saveLanguage, - }); - }); - } + if (!(isGuideDone || isSkipBefore)) { + await nextTick(1000); + $guide.current?.play(LEARNING.SWITCH_PROJECT_LANGUAGE, { + onDone: () => saveRecord(LEARNING.SWITCH_PROJECT_LANGUAGE), + onSaveLanguage: saveLanguage, + }); + } + }, + [Path.Thread]: async () => { + const isGuideDone = learningRecord.paths.includes( + LEARNING.SAVE_TO_KNOWLEDGE, + ); + if (!isGuideDone) { + await nextTick(1500); + $guide.current?.play(LEARNING.SAVE_TO_KNOWLEDGE, { + onDone: () => saveRecord(LEARNING.SAVE_TO_KNOWLEDGE), + }); + } + }, + [Path.KnowledgeQuestionSQLPairs]: async () => { + const isGuideDone = learningRecord.paths.includes( + LEARNING.QUESTION_SQL_PAIRS_GUIDE, + ); + if (!isGuideDone) { + await nextTick(1000); + $guide.current?.play(LEARNING.QUESTION_SQL_PAIRS_GUIDE, { + onDone: () => saveRecord(LEARNING.QUESTION_SQL_PAIRS_GUIDE), + }); + } + }, + }; + + routerAction[router.pathname] && routerAction[router.pathname](); } - }, [learningRecordResult?.learningRecord]); + }, [learningRecordResult?.learningRecord, router.pathname]); useEffect(() => { collapseBlock(active); @@ -305,35 +329,35 @@ export default function SidebarSection(_props: Props) { }; // Hide learning section if the page not in whitelist - if (!isLearningAccessible(router.pathname)) return null; - return ( <> -
-
-
- - Learning + {isLearningAccessible(router.pathname) && ( +
+
+
+ + Learning +
+
- + + +
+ + + {current}/{total} Finished + +
+
- - -
- - - {current}/{total} Finished - -
-
-
+ )} ); } diff --git a/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx b/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx index 02e830334e..96f4194e16 100644 --- a/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx +++ b/wren-ui/src/components/pages/home/promptThread/AnswerResult.tsx @@ -273,6 +273,7 @@ export default function AnswerResult(props: Props) { { isCreateMode: true }, ) } + data-guideid="save-to-knowledge" >
diff --git a/wren-ui/src/components/pages/home/promptThread/TextBasedAnswer.tsx b/wren-ui/src/components/pages/home/promptThread/TextBasedAnswer.tsx index 1fb6dcbf9c..b7468ca135 100644 --- a/wren-ui/src/components/pages/home/promptThread/TextBasedAnswer.tsx +++ b/wren-ui/src/components/pages/home/promptThread/TextBasedAnswer.tsx @@ -177,7 +177,10 @@ export default function TextBasedAnswer( {previewDataResult?.data?.previewData && ( -
+
Considering the limit of the context window, we retrieve up to 500 rows of results to generate the answer. diff --git a/wren-ui/src/components/pages/home/promptThread/index.tsx b/wren-ui/src/components/pages/home/promptThread/index.tsx index aded37ac5b..4976b68fd1 100644 --- a/wren-ui/src/components/pages/home/promptThread/index.tsx +++ b/wren-ui/src/components/pages/home/promptThread/index.tsx @@ -98,7 +98,10 @@ const AnswerResultTemplate: React.FC< const isLastThreadResponse = id === lastResponseId; return ( -
+
{index > 0 && }