1+ /**
2+ * 内联 AI 对话助手——编辑器内嵌 AI 讨论面板
3+ * Inline AI chat companion — embedded AI discussion panel in editor
4+ *
5+ * @ai -context: 选中文本后展开内联对话面板,AI 可引用选中文本进行苏格拉底
6+ * 式追问。对话结果可一键插入为笔记的一部分。对话历史与当前笔记关联。
7+ * @ai -context: Expands inline chat panel on text selection, AI conducts
8+ * Socratic questioning. Results can be inserted into the note.
9+ */
10+ import { useState , useCallback , useRef , useEffect } from 'react' ;
11+ import { motion , AnimatePresence } from 'framer-motion' ;
12+ import { X , Send , MessageSquare , Sparkles , 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+ interface ChatMessage {
18+ role : 'user' | 'assistant' ;
19+ content : string ;
20+ }
21+
22+ interface AIChatCompanionProps {
23+ /** 选中文本 */
24+ selectedText : string ;
25+ /** 笔记上下文 */
26+ noteContext : string ;
27+ isOpen : boolean ;
28+ onClose : ( ) => void ;
29+ /** 插入对话结果到编辑器 */
30+ onInsertText : ( text : string ) => void ;
31+ }
32+
33+ export function AIChatCompanion ( {
34+ selectedText,
35+ noteContext,
36+ isOpen,
37+ onClose,
38+ onInsertText,
39+ } : AIChatCompanionProps ) {
40+ const [ messages , setMessages ] = useState < ChatMessage [ ] > ( [ ] ) ;
41+ const [ input , setInput ] = useState ( '' ) ;
42+ const [ loading , setLoading ] = useState ( false ) ;
43+ const { toast } = useToast ( ) ;
44+ const messagesEndRef = useRef < HTMLDivElement > ( null ) ;
45+
46+ useEffect ( ( ) => {
47+ if ( isOpen && selectedText && messages . length === 0 ) {
48+ const initial : ChatMessage = {
49+ role : 'assistant' ,
50+ content : `我注意到你选中了「${ selectedText . slice ( 0 , 80 ) } 」这个内容。想聊聊哪些方面?我可以帮你:\n\n- 用苏格拉底式提问引导你深入思考\n- 解释这个概念\n- 提供相关例子或类比\n- 帮你整理思路` ,
51+ } ;
52+ setMessages ( [ initial ] ) ;
53+ }
54+ } , [ isOpen , selectedText ] ) ;
55+
56+ useEffect ( ( ) => {
57+ messagesEndRef . current ?. scrollIntoView ( { behavior : 'smooth' } ) ;
58+ } , [ messages ] ) ;
59+
60+ const handleSend = useCallback ( async ( ) => {
61+ if ( ! input . trim ( ) || loading ) return ;
62+ const userMsg : ChatMessage = { role : 'user' , content : input . trim ( ) } ;
63+ setMessages ( ( prev ) => [ ...prev , userMsg ] ) ;
64+ setInput ( '' ) ;
65+ setLoading ( true ) ;
66+
67+ try {
68+ const context = `笔记上下文:${ noteContext . slice ( 0 , 500 ) } \n选中内容:${ selectedText . slice ( 0 , 200 ) } ` ;
69+ const prompt = `你是一个苏格拉底式学习助手。基于以下上下文和对话历史,与用户进行深度对话。\n\n${ context } \n\n用户问题:${ userMsg . content } \n\n请用中文回复,引导用户自主发现答案。` ;
70+
71+ const result = await aiPluginLoader . summarizeNote ( prompt , { style : 'paragraph' } ) ;
72+ const reply = result ?. summary || '思考中...请稍后再试。' ;
73+ setMessages ( ( prev ) => [ ...prev , { role : 'assistant' , content : reply } ] ) ;
74+ } catch ( err ) {
75+ const msg = err instanceof Error ? err . message : '请求失败' ;
76+ setMessages ( ( prev ) => [ ...prev , { role : 'assistant' , content : `抱歉,${ msg } ` } ] ) ;
77+ toast ( { type : 'error' , message : 'AI 响应失败' } ) ;
78+ } finally {
79+ setLoading ( false ) ;
80+ }
81+ } , [ input , loading , noteContext , selectedText , toast ] ) ;
82+
83+ const handleInsertConversation = useCallback ( ( ) => {
84+ const text = messages
85+ . map ( ( m ) => `**${ m . role === 'user' ? '我' : 'AI' } **: ${ m . content } ` )
86+ . join ( '\n\n' ) ;
87+ onInsertText ( `\n\n--- AI 讨论记录 ---\n${ text } \n---\n` ) ;
88+ toast ( { type : 'success' , message : '对话已插入笔记' , silent : true } ) ;
89+ onClose ( ) ;
90+ } , [ messages , onInsertText , onClose , toast ] ) ;
91+
92+ return (
93+ < AnimatePresence >
94+ { isOpen && (
95+ < motion . div
96+ className = "fixed bottom-0 right-0 w-96 h-[400px] 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
106+ onClick = { handleInsertConversation }
107+ className = "p-1 text-text-tertiary hover:text-brand-600 transition-colors"
108+ title = "插入对话到笔记"
109+ >
110+ < Plus className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
111+ </ button >
112+ < button
113+ onClick = { onClose }
114+ className = "p-1 text-text-tertiary hover:text-text-primary transition-colors"
115+ >
116+ < X className = "w-3.5 h-3.5" strokeWidth = { 1.5 } />
117+ </ button >
118+ </ div >
119+
120+ < div className = "flex-1 overflow-y-auto p-3 space-y-3" >
121+ { messages . map ( ( msg , i ) => (
122+ < div key = { i } className = { cn ( 'flex gap-2' , msg . role === 'user' ? 'justify-end' : 'justify-start' ) } >
123+ < div className = { cn (
124+ 'max-w-[80%] p-2.5 rounded-kb-md text-b2 leading-relaxed' ,
125+ msg . role === 'user'
126+ ? 'bg-brand-500 text-white rounded-br-kb-sm'
127+ : 'bg-bg-secondary text-text-secondary rounded-bl-kb-sm' ,
128+ ) } >
129+ { msg . content }
130+ </ div >
131+ </ div >
132+ ) ) }
133+ { loading && (
134+ < div className = "flex items-center gap-2 text-text-tertiary" >
135+ < div className = "w-3 h-3 border-2 border-brand-400/30 border-t-brand-400 rounded-full animate-spin" />
136+ < span className = "text-c1" > 思考中...</ span >
137+ </ div >
138+ ) }
139+ < div ref = { messagesEndRef } />
140+ </ div >
141+
142+ < div className = "p-3 border-t border-border/30 flex gap-2" >
143+ < input
144+ value = { input }
145+ onChange = { ( e ) => setInput ( e . target . value ) }
146+ onKeyDown = { ( e ) => { if ( e . key === 'Enter' && ! e . shiftKey ) { e . preventDefault ( ) ; handleSend ( ) ; } } }
147+ placeholder = "输入你的想法..."
148+ 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"
149+ disabled = { loading }
150+ />
151+ < button
152+ onClick = { handleSend }
153+ disabled = { loading || ! input . trim ( ) }
154+ className = "p-2 rounded-kb-md bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50 transition-colors"
155+ >
156+ < Send className = "w-4 h-4" strokeWidth = { 1.5 } />
157+ </ button >
158+ </ div >
159+ </ motion . div >
160+ ) }
161+ </ AnimatePresence >
162+ ) ;
163+ }
164+
165+ export default AIChatCompanion ;
0 commit comments