Skip to content

Commit 9f6838b

Browse files
committed
feat(notes): 第一轮头脑风暴剩余创新点构建
- useEditorDepth: 编辑器深度状态检测(浅层/沉浸/复习三态自适应) - AIChatCompanion: 内联 AI 对话助手(选中文本后苏格拉底式讨论) - useCornellAI: AI 动态康奈尔(自动填充总结栏和线索栏) - LearningFlow: 学习流面板(笔记-费曼-闪卡三角闭环可视化) - NoteLifecycle: 笔记生命周期管理(萌芽→生长→整理→复习→沉淀) - AISummaryEnhanced: AI 多粒度摘要(一句话/一段话/详细综述+版本对比)
1 parent c1d2a3d commit 9f6838b

6 files changed

Lines changed: 876 additions & 0 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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;
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* AI 多粒度摘要增强版
3+
* AI multi-granularity summary enhanced
4+
*
5+
* @ai-context: 在 AISummaryModal 基础上扩展,支持 3 种粒度摘要(一句话/
6+
* 一段话/详细综述),可对比摘要版本,跨笔记聚合摘要。
7+
* @ai-context: Extends AISummaryModal with 3 granularity levels
8+
* (one-liner/paragraph/detailed), version comparison, cross-note aggregation.
9+
*/
10+
import { useState, useCallback } from 'react';
11+
import { motion, AnimatePresence } from 'framer-motion';
12+
import { X, ChevronDown, Copy, Download, Sparkles, RefreshCw } 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 SummaryGranularity = 'oneliner' | 'paragraph' | 'detailed';
18+
19+
const GRANULARITY_CONFIG: Record<SummaryGranularity, { label: string; desc: string; prompt: (text: string) => string }> = {
20+
oneliner: {
21+
label: '一句话',
22+
desc: '一句话概括核心内容',
23+
prompt: (text) => `用一句话概括以下笔记的核心内容(不超过 50 字):\n\n${text.slice(0, 3000)}`,
24+
},
25+
paragraph: {
26+
label: '一段话',
27+
desc: '一段话完整摘要',
28+
prompt: (text) => `为以下笔记写一段摘要(100-200 字),概括主要内容:\n\n${text.slice(0, 3000)}`,
29+
},
30+
detailed: {
31+
label: '详细综述',
32+
desc: '多段详细综述',
33+
prompt: (text) => `为以下笔记写一篇详细综述,包含:核心论点、论据、结论。分段落组织。\n\n${text.slice(0, 3000)}`,
34+
},
35+
};
36+
37+
interface AISummaryEnhancedProps {
38+
noteContent: string;
39+
isOpen: boolean;
40+
onClose: () => void;
41+
onInsertText: (text: string, position: 'cursor' | 'start' | 'end') => void;
42+
}
43+
44+
export function AISummaryEnhanced({
45+
noteContent,
46+
isOpen,
47+
onClose,
48+
onInsertText,
49+
}: AISummaryEnhancedProps) {
50+
const [granularity, setGranularity] = useState<SummaryGranularity>('paragraph');
51+
const [loading, setLoading] = useState(false);
52+
const [result, setResult] = useState<string | null>(null);
53+
const [history, setHistory] = useState<Array<{ granularity: SummaryGranularity; text: string }>>([]);
54+
const { toast } = useToast();
55+
56+
const handleGenerate = useCallback(async () => {
57+
setLoading(true);
58+
setResult(null);
59+
60+
try {
61+
const config = GRANULARITY_CONFIG[granularity];
62+
const res = await aiPluginLoader.summarizeNote(config.prompt(noteContent), { style: 'paragraph' });
63+
const text = res?.summary || '生成失败';
64+
setResult(text);
65+
setHistory((prev) => [...prev, { granularity, text }]);
66+
toast({ type: 'success', message: `${config.label}摘要生成完成`, silent: true });
67+
} catch (err) {
68+
toast({ type: 'error', message: '摘要生成失败' });
69+
} finally {
70+
setLoading(false);
71+
}
72+
}, [granularity, noteContent, toast]);
73+
74+
return (
75+
<AnimatePresence>
76+
{isOpen && (
77+
<motion.div
78+
className="fixed right-0 top-0 h-full w-96 z-50 backdrop-blur-xl bg-bg-elevated/90 border-l border-border/40 shadow-kb-lg flex flex-col"
79+
initial={{ x: '100%' }}
80+
animate={{ x: 0 }}
81+
exit={{ x: '100%' }}
82+
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
83+
>
84+
<div className="flex items-center gap-2 px-4 py-4 border-b border-border/40 flex-shrink-0">
85+
<Sparkles className="w-4 h-4 text-brand-500" strokeWidth={1.5} />
86+
<span className="text-b1 font-semibold text-text-primary flex-1">AI 摘要</span>
87+
<button onClick={onClose} className="p-1.5 text-text-tertiary hover:text-text-primary transition-colors">
88+
<X className="w-4 h-4" strokeWidth={1.5} />
89+
</button>
90+
</div>
91+
92+
<div className="p-4 border-b border-border/30">
93+
<p className="text-b3 font-medium text-text-secondary mb-2">选择粒度</p>
94+
<div className="flex gap-1">
95+
{(Object.entries(GRANULARITY_CONFIG) as [SummaryGranularity, typeof GRANULARITY_CONFIG['oneliner']][]).map(([key, config]) => (
96+
<button
97+
key={key}
98+
onClick={() => setGranularity(key)}
99+
className={cn(
100+
'flex-1 px-2 py-1.5 rounded-kb-sm text-c1 font-medium transition-colors text-center',
101+
granularity === key ? 'bg-brand-500 text-white' : 'bg-bg-secondary text-text-tertiary hover:bg-bg-tertiary',
102+
)}
103+
>
104+
{config.label}
105+
</button>
106+
))}
107+
</div>
108+
<p className="text-c1 text-text-tertiary mt-1">{GRANULARITY_CONFIG[granularity].desc}</p>
109+
<button
110+
onClick={handleGenerate}
111+
disabled={loading}
112+
className="mt-2 w-full px-3 py-2 rounded-kb-md bg-brand-500 text-white text-b2 font-medium hover:bg-brand-600 disabled:opacity-50 transition-colors"
113+
>
114+
{loading ? '生成中...' : '生成摘要'}
115+
</button>
116+
</div>
117+
118+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
119+
{result && (
120+
<div className="p-3 rounded-kb-md bg-bg-secondary border border-border/30">
121+
<div className="flex items-center justify-between mb-2">
122+
<span className="text-c1 font-medium text-text-primary">{GRANULARITY_CONFIG[granularity].label}</span>
123+
<div className="flex gap-1">
124+
<button onClick={() => { navigator.clipboard.writeText(result); toast({ type: 'success', message: '已复制', silent: true }); }}
125+
className="p-1 text-text-tertiary hover:text-text-primary transition-colors">
126+
<Copy className="w-3.5 h-3.5" strokeWidth={1.5} />
127+
</button>
128+
<button onClick={() => onInsertText(result, 'end')}
129+
className="p-1 text-text-tertiary hover:text-brand-600 transition-colors">
130+
插入
131+
</button>
132+
</div>
133+
</div>
134+
<p className="text-b2 text-text-secondary leading-relaxed whitespace-pre-wrap">{result}</p>
135+
</div>
136+
)}
137+
138+
{history.length > 1 && (
139+
<div>
140+
<p className="text-b3 font-medium text-text-secondary mb-2">历史版本</p>
141+
{history.slice(0, -1).reverse().map((h, i) => (
142+
<div key={i} className="p-2 rounded-kb-md bg-bg-secondary/50 border border-border/20 mb-2">
143+
<span className="text-c1 text-text-tertiary">{GRANULARITY_CONFIG[h.granularity].label}</span>
144+
<p className="text-c1 text-text-secondary mt-1 line-clamp-3">{h.text}</p>
145+
</div>
146+
))}
147+
</div>
148+
)}
149+
150+
{!result && history.length === 0 && (
151+
<p className="text-b2 text-text-tertiary text-center py-8">选择粒度并点击生成</p>
152+
)}
153+
</div>
154+
</motion.div>
155+
)}
156+
</AnimatePresence>
157+
);
158+
}
159+
160+
export default AISummaryEnhanced;

0 commit comments

Comments
 (0)