1+ /**
2+ * PredictionPrompt — 预测题前置组件
3+ * PredictionPrompt — Pre-learning prediction questions
4+ *
5+ * @ai -context: 打开新笔记或空笔记时,弹出预测性问题让用户先写下猜测,
6+ * 提交猜测后揭示笔记正文,利用预测编码机制增强学习效果。
7+ * 基于 useAIPredict 调用 AI 网关 /api/v1/ai/predict 路由。
8+ * @ai -context: Shows prediction questions when opening a new/empty note.
9+ * User writes guesses before seeing content, leveraging predictive coding.
10+ */
11+ import { useState , useCallback } from 'react' ;
12+ import { motion , AnimatePresence } from 'framer-motion' ;
13+ import { Lightbulb , Send } from 'lucide-react' ;
14+ import { Button } from '@/components/ui/Button' ;
15+ import { useAIPredict } from '@/lib/ai/hooks/useAIPredict' ;
16+ import { useToast } from '@/components/ui' ;
17+
18+ interface PredictionPromptProps {
19+ noteTitle : string ;
20+ noteContent : string ;
21+ noteId : string ;
22+ /** 关闭后标记为已处理,不再重复弹出 */
23+ onDismiss : ( ) => void ;
24+ }
25+
26+ export function PredictionPrompt ( { noteTitle, noteContent, noteId, onDismiss } : PredictionPromptProps ) {
27+ const { predict, loading : predictLoading } = useAIPredict ( ) ;
28+ const { toast } = useToast ( ) ;
29+ const [ predictions , setPredictions ] = useState < Array < { question : string ; guess : string } > > ( [ ] ) ;
30+ const [ revealed , setRevealed ] = useState ( false ) ;
31+
32+ // 检查是否为空笔记(首次打开)
33+ const isEmpty = ! noteContent || noteContent . length < 50 ;
34+
35+ // 生成预测题
36+ const handleGenerate = useCallback ( async ( ) => {
37+ if ( ! noteId ) return ;
38+ toast ( { type : 'info' , message : 'AI 正在生成预测题…' , duration : 1500 } ) ;
39+ const result = await predict ( noteId , noteContent || noteTitle ) ;
40+ if ( result ?. predictions ?. length ) {
41+ setPredictions ( result . predictions . map ( ( p : { question : string } ) => ( { question : p . question , guess : '' } ) ) ) ;
42+ } else {
43+ toast ( { type : 'warning' , message : '暂无法生成预测题,可跳过直接开始' , silent : true } ) ;
44+ onDismiss ( ) ;
45+ }
46+ } , [ noteId , noteContent , noteTitle , predict , toast , onDismiss ] ) ;
47+
48+ const updateGuess = ( index : number , value : string ) => {
49+ setPredictions ( ( prev ) => prev . map ( ( p , i ) => ( i === index ? { ...p , guess : value } : p ) ) ) ;
50+ } ;
51+
52+ const handleReveal = ( ) => {
53+ setRevealed ( true ) ;
54+ } ;
55+
56+ if ( revealed || ! isEmpty ) return null ;
57+
58+ return (
59+ < AnimatePresence >
60+ < motion . div
61+ initial = { { opacity : 0 , y : 20 } }
62+ animate = { { opacity : 1 , y : 0 } }
63+ exit = { { opacity : 0 , y : - 20 } }
64+ className = "mb-4 rounded-xl border border-brand-300/30 bg-brand-50/10 p-4"
65+ >
66+ < div className = "flex items-center gap-2 mb-3" >
67+ < Lightbulb className = "w-5 h-5 text-brand-500" strokeWidth = { 1.5 } />
68+ < span className = "text-[13px] font-semibold text-text-primary" > 预测驱动学习</ span >
69+ < span className = "text-c1 text-text-tertiary" > —— 先猜后学,记忆更深刻</ span >
70+ </ div >
71+
72+ { predictions . length === 0 ? (
73+ < div className = "flex flex-col gap-2" >
74+ < p className = "text-b2 text-text-secondary" >
75+ 关于「{ noteTitle || '这篇笔记' } 」的内容,你了解多少?先让 AI 出几个预测题,写下你的猜测再看笔记正文。
76+ </ p >
77+ < div className = "flex gap-2" >
78+ < Button size = "sm" onClick = { handleGenerate } disabled = { predictLoading } >
79+ { predictLoading ? 'AI 思考中…' : '生成预测题' }
80+ </ Button >
81+ < Button variant = "secondary" size = "sm" onClick = { onDismiss } >
82+ 跳过,直接开始
83+ </ Button >
84+ </ div >
85+ </ div >
86+ ) : (
87+ < div className = "flex flex-col gap-3" >
88+ { predictions . map ( ( p , i ) => (
89+ < div key = { i } className = "flex flex-col gap-1.5" >
90+ < p className = "text-b2 text-text-primary font-medium" >
91+ { i + 1 } . { p . question }
92+ </ p >
93+ < div className = "flex gap-2" >
94+ < input
95+ value = { p . guess }
96+ onChange = { ( e ) => updateGuess ( i , e . target . value ) }
97+ placeholder = "写下你的猜测…"
98+ className = "flex-1 px-3 py-1.5 rounded-kb-md border border-border/40 bg-bg-secondary text-b2 text-text-primary outline-none focus:border-focus"
99+ />
100+ { p . guess && (
101+ < span className = "flex items-center text-c1 text-text-tertiary" >
102+ < Send className = "w-3 h-3" strokeWidth = { 1.5 } />
103+ </ span >
104+ ) }
105+ </ div >
106+ </ div >
107+ ) ) }
108+ < div className = "flex gap-2 mt-1" >
109+ < Button size = "sm" onClick = { handleReveal } >
110+ 我已写下猜测,开始学习
111+ </ Button >
112+ < Button variant = "secondary" size = "sm" onClick = { onDismiss } >
113+ 跳过
114+ </ Button >
115+ </ div >
116+ </ div >
117+ ) }
118+ </ motion . div >
119+ </ AnimatePresence >
120+ ) ;
121+ }
0 commit comments