Skip to content

Commit df90cf2

Browse files
committed
feat(notes): 第一轮头脑风暴剩余5项构建完成
- useCanvasAILayout + FreeCanvas AI 排版按钮(树形/时间线/聚类) - LearningCompanion 多模式学习伴侣(苏格拉底/教师/辩论/类比) - useAIClosedBook 智能出题(选择题/填空题/简答题) - useAnchorPointNetwork 锚点网络关联 - ConstellationView + NotesGraphPage 星图模式切换 验证: tsc 0 errors, oxlint 0 errors, 1037 tests (102 files) all passed
1 parent 9f6838b commit df90cf2

7 files changed

Lines changed: 641 additions & 12 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* 笔记星图视图——认知星图布局
3+
* Notes constellation view — cognitive star map layout
4+
*
5+
* @ai-context: 将笔记按 content_nature 分布在 4 个深度区(海面→海沟),
6+
* 亮度=编辑频率,颜色=模板类型,大小=字数。交互式 SVG 渲染。
7+
* @ai-context: Distributes notes across 4 depth zones (surface→trench)
8+
* based on content nature. Brightness=edit frequency, color=template type.
9+
*/
10+
import { useMemo, useState } from 'react';
11+
import { useNavigate } from 'react-router-dom';
12+
import { motion } from 'framer-motion';
13+
import { cn } from '@/lib/utils';
14+
import type { Note } from '@/types/models';
15+
16+
interface ConstellationViewProps {
17+
notes: Note[];
18+
onNoteClick?: (noteId: string) => void;
19+
}
20+
21+
type DepthZone = 'surface' | 'shallow' | 'deep' | 'trench';
22+
23+
const DEPTH_ZONES: DepthZone[] = ['surface', 'shallow', 'deep', 'trench'];
24+
const ZONE_LABELS: Record<DepthZone, string> = { surface: '海面', shallow: '浅海', deep: '深海', trench: '海沟' };
25+
const ZONE_COLORS: Record<DepthZone, string> = { surface: 'rgb(56,189,248)', shallow: 'rgb(96,165,250)', deep: 'rgb(124,58,237)', trench: 'rgb(139,92,246)' };
26+
27+
const TEMPLATE_COLORS: Record<string, string> = {
28+
outline: 'rgb(96,165,250)', cornell: 'rgb(91,138,114)', mindmap: 'rgb(251,191,36)',
29+
todo: 'rgb(16,185,129)', free: 'rgb(244,114,182)', blank: 'rgb(156,163,175)',
30+
'qa-grid': 'rgb(167,139,250)', timeline: 'rgb(251,146,60)',
31+
};
32+
33+
/** 分配笔记到深度区 */
34+
function assignDepth(note: Note): DepthZone {
35+
const age = (Date.now() - new Date(note.updatedAt).getTime()) / 86400000;
36+
if (note.title === '无标题' && !note.content) return 'surface';
37+
if (age < 1) return 'surface';
38+
if (age < 7) return 'shallow';
39+
if (note.template === 'mindmap' || note.template === 'cornell') return 'deep';
40+
return 'trench';
41+
}
42+
43+
export function ConstellationView({ notes, onNoteClick }: ConstellationViewProps) {
44+
const navigate = useNavigate();
45+
const [hoveredId, setHoveredId] = useState<string | null>(null);
46+
47+
const positioned = useMemo(() => {
48+
const zones: Record<DepthZone, Note[]> = { surface: [], shallow: [], deep: [], trench: [] };
49+
for (const note of notes) {
50+
const zone = assignDepth(note);
51+
zones[zone].push(note);
52+
}
53+
54+
const result: Array<{ note: Note; x: number; y: number; size: number; zone: DepthZone }> = [];
55+
const viewW = 800, viewH = 600;
56+
const zoneHeight = viewH / 4;
57+
58+
for (let zi = 0; zi < DEPTH_ZONES.length; zi++) {
59+
const zone = DEPTH_ZONES[zi];
60+
const zoneNotes = zones[zone];
61+
const yBase = zi * zoneHeight + zoneHeight / 2;
62+
63+
for (let i = 0; i < zoneNotes.length; i++) {
64+
const note = zoneNotes[i];
65+
const textLen = (note.content || '').length;
66+
const size = Math.max(6, Math.min(20, textLen / 50));
67+
const x = (i + 0.5) / Math.max(zoneNotes.length, 1) * viewW;
68+
const y = yBase + (Math.random() - 0.5) * zoneHeight * 0.5;
69+
70+
result.push({ note, x, y, size, zone });
71+
}
72+
}
73+
return result;
74+
}, [notes]);
75+
76+
const handleClick = (noteId: string) => {
77+
if (onNoteClick) onNoteClick(noteId);
78+
else navigate(`/notes/${noteId}`);
79+
};
80+
81+
return (
82+
<div className="relative w-full h-full min-h-[500px]">
83+
<svg viewBox="0 0 800 600" className="w-full h-full">
84+
{/* 深度区背景 */}
85+
{DEPTH_ZONES.map((zone, i) => (
86+
<g key={zone}>
87+
<rect x={0} y={i * 150} width={800} height={150} fill={ZONE_COLORS[zone]} opacity={0.03} />
88+
<text x={30} y={i * 150 + 30} fill={ZONE_COLORS[zone]} opacity={0.3} fontSize={12} fontWeight={600}>
89+
{ZONE_LABELS[zone]}
90+
</text>
91+
</g>
92+
))}
93+
94+
{/* 连线(悬停时显示) */}
95+
{hoveredId && positioned.filter((p) => p.note.id === hoveredId).map((p) => {
96+
const nearby = positioned.filter((np) => {
97+
const dx = np.x - p.x, dy = np.y - p.y;
98+
return Math.sqrt(dx * dx + dy * dy) < 150 && np.note.id !== p.note.id;
99+
});
100+
return nearby.map((np) => (
101+
<line key={`${p.note.id}-${np.note.id}`} x1={p.x} y1={p.y} x2={np.x} y2={np.y}
102+
stroke={ZONE_COLORS[p.zone]} strokeWidth={0.5} opacity={0.2} />
103+
));
104+
})}
105+
106+
{/* 节点 */}
107+
{positioned.map((p) => {
108+
const color = TEMPLATE_COLORS[p.note.template] || ZONE_COLORS[p.zone];
109+
const isHovered = hoveredId === p.note.id;
110+
return (
111+
<g key={p.note.id} onClick={() => handleClick(p.note.id)}
112+
onMouseEnter={() => setHoveredId(p.note.id)}
113+
onMouseLeave={() => setHoveredId(null)}
114+
className="cursor-pointer"
115+
>
116+
<circle cx={p.x} cy={p.y} r={p.size} fill={color} opacity={isHovered ? 0.9 : 0.5}
117+
stroke={isHovered ? color : 'none'} strokeWidth={2} />
118+
{isHovered && (
119+
<>
120+
<circle cx={p.x} cy={p.y} r={p.size + 4} fill="none" stroke={color} strokeWidth={1} opacity={0.5}>
121+
<animate attributeName="r" values={`${p.size + 4};${p.size + 10};${p.size + 4}`} dur="2s" repeatCount="indefinite" />
122+
<animate attributeName="opacity" values="0.5;0;0.5" dur="2s" repeatCount="indefinite" />
123+
</circle>
124+
<text x={p.x + p.size + 6} y={p.y + 4} fill={color} fontSize={11} fontWeight={500}>
125+
{p.note.title || '未命名'}
126+
</text>
127+
</>
128+
)}
129+
</g>
130+
);
131+
})}
132+
</svg>
133+
</div>
134+
);
135+
}
136+
137+
export default ConstellationView;

‎client/src/features/notes/components/FreeCanvas.tsx‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
* @ai-context: 自由画布组件(巨型,待拆分):手写/绘图白板,供笔记批注与费曼讲解涂鸦。
33
*/
44
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
5-
import { Plus, Trash2, Copy, Eraser, CheckSquare } from 'lucide-react';
5+
import { Plus, Trash2, Copy, Eraser, CheckSquare, LayoutGrid, Sparkles } from 'lucide-react';
66
import FreeTextBlock from './FreeTextBlock';
77
import { FreeCanvasOverlays } from './FreeCanvasOverlays';
88
import { InkToolbar } from './canvas/InkToolbar';
99
import { InkLayer } from './canvas/InkLayer';
1010
import { useInkDrawing, type InkTool } from '../lib/canvas/useInkDrawing';
11+
import { useCanvasAILayout, type LayoutMode } from '../hooks/useCanvasAILayout';
1112
import type { FreeCanvasData, FreeCanvasBlock, InkStroke, InkPoint } from '@/types/models';
1213

1314
interface FreeCanvasProps {
@@ -46,6 +47,8 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
4647

4748
const [selectedBlockIds, setSelectedBlockIds] = useState<Set<string>>(new Set());
4849
const [paletteOpen, setPaletteOpen] = useState(false);
50+
const [aiLayoutMode, setAiLayoutMode] = useState<LayoutMode | null>(null);
51+
const { loading: aiLayoutLoading, layout: aiLayout } = useCanvasAILayout();
4952

5053
// 阶段三:墨迹工具状态 / ink tool state
5154
const [inkTool, setInkTool] = useState<InkTool>('select');
@@ -613,6 +616,48 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
613616
onClosePalette={() => setPaletteOpen(false)}
614617
/>
615618

619+
{/* AI 智能排版按钮 */}
620+
{data.blocks.length > 1 && (
621+
<div className="absolute bottom-20 left-4 z-10 flex gap-1">
622+
{aiLayoutMode ? (
623+
<div className="flex items-center gap-1 px-2 py-1.5 rounded-kb-md bg-bg-elevated/90 backdrop-blur-sm border border-border/30 shadow-kb-sm">
624+
<span className="text-c1 text-text-tertiary mr-1">AI 排版</span>
625+
{(['tree', 'timeline', 'cluster'] as LayoutMode[]).map((mode) => (
626+
<button
627+
key={mode}
628+
onClick={async () => {
629+
setAiLayoutMode(mode);
630+
const newBlocks = await aiLayout(data.blocks, mode);
631+
emitChange({ ...data, blocks: newBlocks });
632+
setAiLayoutMode(null);
633+
}}
634+
disabled={aiLayoutLoading}
635+
className="px-2 py-0.5 rounded-kb-sm text-c1 font-medium bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50 transition-colors"
636+
>
637+
{mode === 'tree' ? '树形' : mode === 'timeline' ? '时间线' : '聚类'}
638+
</button>
639+
))}
640+
<button
641+
onClick={() => setAiLayoutMode(null)}
642+
className="px-1.5 py-0.5 rounded-kb-sm text-c1 text-text-tertiary hover:text-text-primary transition-colors"
643+
>
644+
取消
645+
</button>
646+
</div>
647+
) : (
648+
<button
649+
onClick={() => setAiLayoutMode('tree')}
650+
disabled={aiLayoutLoading}
651+
className="flex items-center gap-1 px-2 py-1.5 rounded-kb-md bg-bg-elevated/90 backdrop-blur-sm border border-border/30 shadow-kb-sm text-c1 text-text-tertiary hover:text-brand-600 hover:border-brand-300 transition-colors disabled:opacity-50"
652+
title="AI 智能排版"
653+
>
654+
<Sparkles className="w-3.5 h-3.5" strokeWidth={1.5} />
655+
{aiLayoutLoading ? '排版中...' : 'AI 整理'}
656+
</button>
657+
)}
658+
</div>
659+
)}
660+
616661
{/* 阶段三:墨迹工具栏 / ink toolbar */}
617662
<InkToolbar
618663
tool={inkTool}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)