1+ /**
2+ * AI 学习伴侣——多模式对话面板
3+ * AI learning companion — multi-mode dialogue panel
4+ *
5+ * @ai -context: 支持 4 种对话模式:苏格拉底/教师/辩论/类比。每种模式有
6+ * 不同的 AI prompt 和追问策略。对话历史与当前笔记关联,可一键插入。
7+ * @ai -context: Supports 4 dialogue modes: Socratic/Teacher/Debate/Analogy.
8+ * Each mode has different AI prompts and questioning strategies.
9+ */
10+ import { useState , useCallback , useRef , useEffect } from 'react' ;
11+ import { motion , AnimatePresence } from 'framer-motion' ;
12+ import { X , Send , MessageSquare , HelpCircle , BookOpen , Shield , GitBranch , Plus } from 'lucide-react' ;
13+ import { aiPluginLoader } from '@/lib/ai/AIPluginLoader' ;
14+ import { useToast } from '@/components/ui' ;
15+ import { cn } from '@/lib/utils' ;
16+
17+ type CompanionMode = 'socratic' | 'teacher' | 'debate' | 'analogy' ;
18+
19+ interface ChatMessage {
20+ role : 'user' | 'assistant' ;
21+ content : string ;
22+ }
23+
24+ const MODE_CONFIG : Record < CompanionMode , { label : string ; icon : React . FC < React . SVGProps < SVGSVGElement > & { strokeWidth ?: number | string } > ; color : string ; desc : string } > = {
25+ socratic : { label : '苏格拉底' , icon : HelpCircle , color : 'text-brand-500' , desc : '通过追问引导你自主发现答案' } ,
26+ teacher : { label : '教师模式' , icon : BookOpen , color : 'text-blue-500' , desc : '直接讲解概念,提供例子和类比' } ,
27+ debate : { label : '辩论模式' , icon : Shield , color : 'text-red-500' , desc : '提出反方论点,帮你多角度审视' } ,
28+ analogy : { label : '类比模式' , icon : GitBranch , color : 'text-emerald-500' , desc : '用熟悉的事物类比复杂概念' } ,
29+ } ;
30+
31+ const MODE_PROMPTS : Record < CompanionMode , ( context : string , question : string ) => string > = {
32+ socratic : ( ctx , q ) => `你是一个苏格拉底式学习助手。不要直接给答案,通过追问引导用户思考。\n上下文:${ ctx } \n用户:${ q } ` ,
33+ teacher : ( ctx , q ) => `你是一个耐心的教师。清晰解释概念,提供具体例子。\n上下文:${ ctx } \n用户:${ q } ` ,
34+ debate : ( ctx , q ) => `你是一个辩论对手。对用户观点提出反方论证,要求用证据支持论点。\n上下文:${ ctx } \n用户:${ q } ` ,
35+ analogy : ( ctx , q ) => `你是一个类比大师。用用户熟悉的事物做类比来解释复杂概念。\n上下文:${ ctx } \n用户:${ q } ` ,
36+ } ;
37+
38+ interface LearningCompanionProps {
39+ noteContext : string ;
40+ selectedText : string ;
41+ isOpen : boolean ;
42+ onClose : ( ) => void ;
43+ onInsertText : ( text : string ) => void ;
44+ }
45+
46+ export function LearningCompanion ( {
47+ noteContext,
48+ selectedText,
49+ isOpen,
50+ onClose,
51+ onInsertText,
52+ } : LearningCompanionProps ) {
53+ const [ mode , setMode ] = useState < CompanionMode > ( 'socratic' ) ;
54+ const [ messages , setMessages ] = useState < ChatMessage [ ] > ( [ ] ) ;
55+ const [ input , setInput ] = useState ( '' ) ;
56+ const [ loading , setLoading ] = useState ( false ) ;
57+ const { toast } = useToast ( ) ;
58+ const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
59+
60+ useEffect ( ( ) => {
61+ if ( isOpen && messages . length === 0 ) {
62+ const initialText = selectedText
63+ ? `我注意到你选中了「${ selectedText . slice ( 0 , 60 ) } 」。想用什么方式讨论?`
64+ : '有什么想讨论的内容吗?' ;
65+ setMessages ( [ { role : 'assistant' , content : initialText } ] ) ;
66+ }
67+ } , [ isOpen , selectedText ] ) ;
68+
69+ useEffect ( ( ) => {
70+ messagesEndRef . current ?. scrollIntoView ( { behavior : 'smooth' } ) ;
71+ } , [ messages ] ) ;
72+
73+ const handleSend = useCallback ( async ( ) => {
74+ if ( ! input . trim ( ) || loading ) return ;
75+ const userMsg : ChatMessage = { role : 'user' , content : input . trim ( ) } ;
76+ setMessages ( ( prev ) => [ ...prev , userMsg ] ) ;
77+ setInput ( '' ) ;
78+ setLoading ( true ) ;
79+
80+ try {
81+ const context = `笔记上下文:${ noteContext . slice ( 0 , 300 ) } ${ selectedText ? `\n选中内容:${ selectedText . slice ( 0 , 150 ) } ` : '' } ` ;
82+ const prompt = MODE_PROMPTS [ mode ] ( context , userMsg . content ) ;
83+ const result = await aiPluginLoader . summarizeNote ( prompt , { style : 'paragraph' } ) ;
84+ setMessages ( ( prev ) => [ ...prev , { role : 'assistant' , content : result ?. summary || '思考中...' } ] ) ;
85+ } catch {
86+ toast ( { type : 'error' , message : 'AI 响应失败' } ) ;
87+ } finally {
88+ setLoading ( false ) ;
89+ }
90+ } , [ input , loading , mode , noteContext , selectedText , toast ] ) ;
91+
92+ return (
93+ < AnimatePresence >
94+ { isOpen && (
95+ < motion . div
96+ className = "fixed bottom-0 right-0 w-96 h-[480px] z-50 backdrop-blur-xl bg-bg-elevated/95 border-l border-t border-border/40 shadow-kb-lg rounded-tl-xl flex flex-col"
97+ initial = { { y : '100%' , opacity : 0 } }
98+ animate = { { y : 0 , opacity : 1 } }
99+ exit = { { y : '100%' , opacity : 0 } }
100+ transition = { { type : 'spring' , damping : 30 , stiffness : 300 } }
101+ >
102+ < div className = "flex items-center gap-2 px-4 py-3 border-b border-border/30 flex-shrink-0" >
103+ < MessageSquare className = "w-4 h-4 text-brand-500" strokeWidth = { 1.5 } />
104+ < span className = "text-b3 font-medium text-text-primary flex-1" > AI 学习伴侣</ span >
105+ < button onClick = { ( ) => { const text = messages . map ( ( m ) => `**${ m . role === 'user' ? '我' : 'AI' } **: ${ m . content } ` ) . join ( '\n\n' ) ; onInsertText ( `\n\n--- AI 讨论 ---\n${ text } \n---\n` ) ; toast ( { type : 'success' , message : '已插入' , silent : true } ) ; onClose ( ) ; } }
106+ className = "p-1 text-text-tertiary hover:text-brand-600 transition-colors" title = "插入对话" >
107+ < Plus className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
108+ </ button >
109+ < button onClick = { onClose } className = "p-1 text-text-tertiary hover:text-text-primary transition-colors" >
110+ < X className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
111+ </ button >
112+ </ div >
113+
114+ < div className = "flex gap-1 px-3 py-2 border-b border-border/20 bg-bg-elevated/50" >
115+ { ( Object . entries ( MODE_CONFIG ) as [ CompanionMode , typeof MODE_CONFIG [ 'socratic' ] ] [ ] ) . map ( ( [ key , config ] ) => {
116+ const Icon = config . icon ;
117+ return (
118+ < button key = { key } onClick = { ( ) => { setMode ( key ) ; setMessages ( [ ] ) ; } }
119+ className = { cn ( 'flex items-center gap-1 px-2 py-1 rounded-kb-full text-c1 font-medium transition-colors' , mode === key ? `${ config . color } bg-brand-500/10` : 'text-text-tertiary hover:text-text-secondary' ) } >
120+ < Icon className = "w-3 h-3" strokeWidth = { 1.5 } /> { config . label }
121+ </ button >
122+ ) ;
123+ } ) }
124+ </ div >
125+
126+ < div className = "flex-1 overflow-y-auto p-3 space-y-3" >
127+ { messages . map ( ( msg , i ) => (
128+ < div key = { i } className = { cn ( 'flex gap-2' , msg . role === 'user' ? 'justify-end' : 'justify-start' ) } >
129+ < div className = { cn ( 'max-w-[85%] p-2.5 rounded-kb-md text-b2 leading-relaxed' , msg . role === 'user' ? 'bg-brand-500 text-white rounded-br-kb-sm' : 'bg-bg-secondary text-text-secondary rounded-bl-kb-sm' ) } >
130+ { msg . content }
131+ </ div >
132+ </ div >
133+ ) ) }
134+ { loading && < div className = "flex items-center gap-2 text-text-tertiary" > < div className = "w-3 h-3 border-2 border-brand-400/30 border-t-brand-400 rounded-full animate-spin" /> < span className = "text-c1" > 思考中...</ span > </ div > }
135+ < div ref = { messagesEndRef } />
136+ </ div >
137+
138+ < div className = "p-3 border-t border-border/30 flex gap-2" >
139+ < input value = { input } onChange = { ( e ) => setInput ( e . target . value ) } onKeyDown = { ( e ) => { if ( e . key === 'Enter' && ! e . shiftKey ) { e . preventDefault ( ) ; handleSend ( ) ; } } }
140+ placeholder = "输入你的想法..." className = "flex-1 px-3 py-2 rounded-kb-md border border-border/30 bg-bg-primary text-b2 text-text-primary placeholder:text-text-tertiary/50 focus:outline-none focus:border-brand-400 transition-colors" disabled = { loading } />
141+ < button onClick = { handleSend } disabled = { loading || ! input . trim ( ) } className = "p-2 rounded-kb-md bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50 transition-colors" >
142+ < Send className = "w-4 h-4" strokeWidth = { 1.5 } />
143+ </ button >
144+ </ div >
145+ </ motion . div >
146+ ) }
147+ </ AnimatePresence >
148+ ) ;
149+ }
150+
151+ export default LearningCompanion ;
0 commit comments