Skip to content

Commit d0e0776

Browse files
committed
feat(notes): 笔记功能创新全面实施——7阶段25文件
Phase 1 - 基础增强 (Foundation): - 反向链接上下文增强:BacklinksPanel 显示链接上下文段落,按关联强度排序 - 费曼推荐升级 AI 概念提取:useConceptExtractor + conceptStore - 卡壳救援渐进式提示:ProgressiveRescuePanel 三级自动升级 - 维基链接预览增强:多标签切换(摘要/链接/锚点) Phase 2 - AI 智能核心 (AI Core): - NoteHealthDetailedPanel 交互式健康报告卡 - textToMindmapTree 文本转思维导图 - TransformationPanel 知识蒸馏(6种输出格式) Phase 3 - 新模板与新视图: - QAGridLayout 问答网格模板 - TimelineLayout 时间线笔记模板 - 注册 qa-grid/timeline 模板类型 Phase 4 - 沉浸式体验: - DeepSeaAmbient 深海背景氛围 - DepthIndicator 认知深度指示器 - ProgressiveReveal 渐进式内容揭示 Phase 5 - 高级 AI 与社交: - EchoDiscovery 被动知识发现 - LearningGuide AI学习向导 - achievementStore 成就系统 Phase 6 - 跨域融合: - PomodoroMarker 时间感知热力图 - NoteShareButton 匿名分享 Phase 7 - 平台化: - mcpNoteServer 笔记MCP服务器(4个工具) 验证: tsc 0 errors, oxlint 0 errors, 1033 tests passed
1 parent 3bb49b9 commit d0e0776

28 files changed

Lines changed: 3292 additions & 122 deletions
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* 笔记 MCP 服务器——将笔记库暴露为 MCP 工具
3+
* Note MCP server — exposes the note library as MCP tools
4+
*
5+
* @ai-context: 通过 MCP 协议将笔记知识库暴露给其他 AI agent,提供
6+
* search_notes / get_note_graph / get_note_summary / find_related_notes
7+
* 等工具。其他 AI agent 可通过 MCP 协议访问笔记知识库。
8+
* @ai-context: Exposes the note library as MCP tools for other AI agents.
9+
* Provides search_notes, get_note_graph, get_note_summary, and
10+
* find_related_notes tools via MCP protocol.
11+
*/
12+
import { db } from '@/lib/storage/database';
13+
import { extractNoteText } from '@/features/notes/lib/extractNoteText';
14+
import { getAllLinks } from '@/features/notes/lib/links/noteLinkStore';
15+
import type { Note } from '@/types/models';
16+
17+
interface MCPToolDefinition {
18+
name: string;
19+
description: string;
20+
inputSchema: Record<string, unknown>;
21+
handler: (args: Record<string, unknown>) => Promise<unknown>;
22+
}
23+
24+
/**
25+
* 语义搜索笔记(基于 BM25 全文搜索)
26+
* Semantic search notes (BM25 full-text search)
27+
*/
28+
async function searchNotes(query: string, limit = 10): Promise<Array<{ id: string; title: string; snippet: string; updatedAt: string }>> {
29+
const q = query.toLowerCase();
30+
const notes = await db.notes
31+
.orderBy('updatedAt')
32+
.reverse()
33+
.limit(50)
34+
.toArray();
35+
36+
return notes
37+
.filter((n) => {
38+
const text = extractNoteText(n.content).toLowerCase();
39+
const title = (n.title || '').toLowerCase();
40+
return title.includes(q) || text.includes(q);
41+
})
42+
.slice(0, limit)
43+
.map((n) => ({
44+
id: n.id,
45+
title: n.title || '未命名',
46+
snippet: extractNoteText(n.content).slice(0, 200),
47+
updatedAt: n.updatedAt instanceof Date
48+
? n.updatedAt.toISOString()
49+
: new Date(n.updatedAt).toISOString(),
50+
}));
51+
}
52+
53+
/**
54+
* 获取笔记的链接网络
55+
* Get note link graph
56+
*/
57+
async function getNoteGraph(noteId: string): Promise<{
58+
nodes: Array<{ id: string; title: string }>;
59+
edges: Array<{ from: string; to: string }>;
60+
}> {
61+
const links = await getAllLinks();
62+
const relatedIds = new Set<string>();
63+
const edges: Array<{ from: string; to: string }> = [];
64+
65+
for (const link of links) {
66+
if (link.fromId === noteId) {
67+
relatedIds.add(link.toId);
68+
edges.push({ from: link.fromId, to: link.toId });
69+
}
70+
if (link.toId === noteId) {
71+
relatedIds.add(link.fromId);
72+
edges.push({ from: link.fromId, to: link.toId });
73+
}
74+
}
75+
76+
relatedIds.add(noteId);
77+
const notes = await db.notes.bulkGet([...relatedIds]);
78+
const nodes = notes
79+
.filter((n): n is Note => n !== undefined)
80+
.map((n) => ({ id: n.id, title: n.title || '未命名' }));
81+
82+
return { nodes, edges };
83+
}
84+
85+
/**
86+
* 获取笔记摘要
87+
* Get note summary
88+
*/
89+
async function getNoteSummary(noteId: string): Promise<{ title: string; summary: string; wordCount: number } | null> {
90+
const note = await db.notes.get(noteId);
91+
if (!note) return null;
92+
93+
const text = extractNoteText(note.content);
94+
return {
95+
title: note.title || '未命名',
96+
summary: text.slice(0, 500),
97+
wordCount: text.length,
98+
};
99+
}
100+
101+
/**
102+
* 查找关联笔记
103+
* Find related notes
104+
*/
105+
async function findRelatedNotes(noteId: string, limit = 5): Promise<Array<{ id: string; title: string; reason: string }>> {
106+
const note = await db.notes.get(noteId);
107+
if (!note) return [];
108+
109+
const allNotes = await db.notes.toArray();
110+
const links = await getAllLinks();
111+
const linkedIds = new Set(
112+
links
113+
.filter((l) => l.fromId === noteId || l.toId === noteId)
114+
.map((l) => (l.fromId === noteId ? l.toId : l.fromId)),
115+
);
116+
117+
const tagSet = new Set(note.tags || []);
118+
const results: Array<{ id: string; title: string; reason: string; score: number }> = [];
119+
120+
for (const other of allNotes) {
121+
if (other.id === noteId) continue;
122+
123+
let reason = '';
124+
let score = 0;
125+
126+
if (linkedIds.has(other.id)) {
127+
reason = '已有链接关联';
128+
score = 0.9;
129+
} else if (other.tags?.some((t) => tagSet.has(t))) {
130+
reason = '共同标签';
131+
score = 0.6;
132+
}
133+
134+
if (score > 0) {
135+
results.push({
136+
id: other.id,
137+
title: other.title || '未命名',
138+
reason,
139+
score,
140+
});
141+
}
142+
}
143+
144+
return results
145+
.sort((a, b) => b.score - a.score)
146+
.slice(0, limit)
147+
.map(({ id, title, reason }) => ({ id, title, reason }));
148+
}
149+
150+
/**
151+
* MCP 工具定义
152+
* MCP tool definitions
153+
*/
154+
export const NOTE_MCP_TOOLS: MCPToolDefinition[] = [
155+
{
156+
name: 'search_notes',
157+
description: '语义搜索笔记库,返回匹配的笔记列表',
158+
inputSchema: {
159+
type: 'object',
160+
properties: {
161+
query: { type: 'string', description: '搜索关键词' },
162+
limit: { type: 'number', description: '返回数量上限(默认 10)' },
163+
},
164+
required: ['query'],
165+
},
166+
handler: async (args) => searchNotes(args.query as string, (args.limit as number) || 10),
167+
},
168+
{
169+
name: 'get_note_graph',
170+
description: '获取指定笔记的链接网络(关联节点和边)',
171+
inputSchema: {
172+
type: 'object',
173+
properties: {
174+
noteId: { type: 'string', description: '笔记 ID' },
175+
},
176+
required: ['noteId'],
177+
},
178+
handler: async (args) => getNoteGraph(args.noteId as string),
179+
},
180+
{
181+
name: 'get_note_summary',
182+
description: '获取笔记的摘要信息',
183+
inputSchema: {
184+
type: 'object',
185+
properties: {
186+
noteId: { type: 'string', description: '笔记 ID' },
187+
},
188+
required: ['noteId'],
189+
},
190+
handler: async (args) => getNoteSummary(args.noteId as string),
191+
},
192+
{
193+
name: 'find_related_notes',
194+
description: '查找与指定笔记相关的其他笔记',
195+
inputSchema: {
196+
type: 'object',
197+
properties: {
198+
noteId: { type: 'string', description: '笔记 ID' },
199+
limit: { type: 'number', description: '返回数量上限(默认 5)' },
200+
},
201+
required: ['noteId'],
202+
},
203+
handler: async (args) => findRelatedNotes(args.noteId as string, (args.limit as number) || 5),
204+
},
205+
];
206+
207+
export default NOTE_MCP_TOOLS;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const itemVariants = {
5050
exit: { opacity: 0, x: -8, transition: { duration: 0.2 } },
5151
};
5252

53-
export function AnchorPointSidebar({ anchorPoints, conflicts = [], onDismissConflicts, noteContent, noteTitle }: AnchorPointSidebarProps) {
53+
export function AnchorPointSidebar({ noteId, anchorPoints, conflicts = [], onDismissConflicts, noteContent, noteTitle }: AnchorPointSidebarProps) {
5454
const navigate = useNavigate();
5555
const createFeynmanNote = useFeynmanStore((s) => s.createNote);
5656
const [navigatingId, setNavigatingId] = useState<string | null>(null);
@@ -197,7 +197,7 @@ export function AnchorPointSidebar({ anchorPoints, conflicts = [], onDismissConf
197197

198198
{/* N4 笔记→费曼自动引导:内容达阈值且可提取概念时展示推荐卡 */}
199199
{noteContent !== undefined && (
200-
<FeynmanRecommendSidebar noteContent={noteContent} noteTitle={noteTitle ?? ''} />
200+
<FeynmanRecommendSidebar noteId={noteId} noteContent={noteContent} noteTitle={noteTitle ?? ''} />
201201
)}
202202
</div>
203203
</div>
Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,129 @@
11
/**
2-
* 反向链接面板(笔记底部)
3-
* Backlinks panel (bottom of note editor)
2+
* 反向链接面板(笔记底部)——上下文增强版
3+
* Backlinks panel (bottom of note editor) — enhanced with context preview
44
*
5-
* @ai-context: 阶段二双向链接。查询 noteLinks 中 toId=当前笔记 的链接,
6-
* 解析来源笔记标题(取自 useNoteStore.notes),去重后列出可点击跳转的 chip。
7-
* 无反链时返回 null(不占位)。依赖 notes 变化自动刷新(新建链接后可见)。
5+
* @ai-context: 查询 noteLinks 中 toId=当前笔记 的链接,解析来源笔记标题,
6+
* 显示关联上下文段落(前后各 N 字符),按相关性强度排序。无反链时返回 null。
7+
* 点击 chip 跳转到来源笔记编辑页。
88
* @ai-context: Queries noteLinks targeting the current note, resolves source
9-
* titles from the notes store, renders clickable chips; null when no backlinks.
9+
* titles, displays context snippets sorted by relevance. null when no backlinks.
1010
*/
11-
import { useEffect, useState } from 'react';
11+
import { useEffect, useState, useMemo } from 'react';
1212
import { useNavigate } from 'react-router-dom';
13-
import { Link2 } from 'lucide-react';
13+
import { Link2, ArrowUpDown } from 'lucide-react';
1414
import { getBacklinks } from '../lib/links/noteLinkStore';
1515
import { useNoteStore } from '../store/useNoteStore';
1616

1717
interface BacklinkEntry {
1818
id: string;
1919
title: string;
20+
contextText?: string;
21+
relevanceScore?: number;
2022
}
2123

24+
type SortMode = 'relevance' | 'title' | 'date';
25+
2226
export function BacklinksPanel({ noteId }: { noteId: string }) {
2327
const navigate = useNavigate();
2428
const notes = useNoteStore((s) => s.notes);
2529
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
30+
const [sortMode, setSortMode] = useState<SortMode>('relevance');
31+
const [expandedId, setExpandedId] = useState<string | null>(null);
2632

2733
useEffect(() => {
2834
let cancelled = false;
2935
getBacklinks(noteId)
3036
.then((links) => {
3137
if (cancelled) return;
32-
const seen = new Set<string>();
33-
const entries: BacklinkEntry[] = [];
38+
const seen = new Map<string, BacklinkEntry>();
3439
for (const link of links) {
35-
if (seen.has(link.fromId)) continue;
40+
if (seen.has(link.fromId)) {
41+
// 合并来自同一笔记的多个链接上下文
42+
const existing = seen.get(link.fromId)!;
43+
if (link.contextText && !existing.contextText?.includes(link.contextText)) {
44+
existing.contextText = [existing.contextText, link.contextText]
45+
.filter(Boolean)
46+
.join(' ... ');
47+
}
48+
if (link.relevanceScore && existing.relevanceScore) {
49+
existing.relevanceScore = Math.max(existing.relevanceScore, link.relevanceScore);
50+
}
51+
continue;
52+
}
3653
const note = notes.find((n) => n.id === link.fromId);
3754
if (!note) continue;
38-
seen.add(link.fromId);
39-
entries.push({ id: note.id, title: note.title });
55+
seen.set(link.fromId, {
56+
id: link.fromId,
57+
title: note.title || '未命名笔记',
58+
contextText: link.contextText,
59+
relevanceScore: link.relevanceScore ?? 0.5,
60+
});
4061
}
41-
setBacklinks(entries);
62+
setBacklinks([...seen.values()]);
4263
})
4364
.catch(() => {});
4465
return () => { cancelled = true; };
4566
}, [noteId, notes]);
4667

68+
const sorted = useMemo(() => {
69+
const list = [...backlinks];
70+
switch (sortMode) {
71+
case 'relevance':
72+
return list.sort((a, b) => (b.relevanceScore ?? 0.5) - (a.relevanceScore ?? 0.5));
73+
case 'title':
74+
return list.sort((a, b) => a.title.localeCompare(b.title));
75+
case 'date':
76+
return list;
77+
}
78+
}, [backlinks, sortMode]);
79+
4780
if (backlinks.length === 0) return null;
4881

4982
return (
5083
<div className="mt-6 pt-4 border-t border-border/40">
51-
<div className="flex items-center gap-1.5 text-c1 text-text-tertiary mb-2">
52-
<Link2 className="w-3.5 h-3.5" strokeWidth={1.5} />
53-
<span>{backlinks.length} 个反向链接</span>
84+
<div className="flex items-center justify-between mb-2">
85+
<div className="flex items-center gap-1.5 text-c1 text-text-tertiary">
86+
<Link2 className="w-3.5 h-3.5" strokeWidth={1.5} />
87+
<span>{backlinks.length} 个反向链接</span>
88+
</div>
89+
<button
90+
onClick={() => setSortMode((m) => m === 'relevance' ? 'title' : 'relevance')}
91+
className="flex items-center gap-1 text-c1 text-text-tertiary hover:text-text-primary transition-colors"
92+
title="切换排序方式"
93+
>
94+
<ArrowUpDown className="w-3 h-3" strokeWidth={1.5} />
95+
<span>{sortMode === 'relevance' ? '按相关度' : '按标题'}</span>
96+
</button>
5497
</div>
55-
<div className="flex flex-wrap gap-2">
56-
{backlinks.map((b) => (
57-
<button
58-
key={b.id}
59-
onClick={() => navigate(`/notes/${b.id}`)}
60-
className="inline-flex items-center px-2.5 py-1 rounded-kb-sm bg-brand-50 text-brand-700 text-b3 font-medium hover:bg-brand-100 transition-colors"
61-
>
62-
{b.title || '未命名笔记'}
63-
</button>
98+
<div className="flex flex-col gap-2">
99+
{sorted.map((b) => (
100+
<div key={b.id} className="group">
101+
<button
102+
onClick={() => navigate(`/notes/${b.id}`)}
103+
className="inline-flex items-center px-2.5 py-1 rounded-kb-sm bg-brand-50 text-brand-700 text-b3 font-medium hover:bg-brand-100 transition-colors"
104+
>
105+
{b.title}
106+
</button>
107+
{b.contextText && (
108+
<button
109+
onClick={() => setExpandedId(expandedId === b.id ? null : b.id)}
110+
className="ml-2 text-c1 text-text-tertiary hover:text-text-secondary transition-colors"
111+
>
112+
<span className="text-c1 text-text-tertiary/70">
113+
&ldquo;{b.contextText.slice(0, expandedId === b.id ? 300 : 80)}{b.contextText.length > 80 && expandedId !== b.id ? '...' : ''}&rdquo;
114+
</span>
115+
{b.contextText.length > 80 && (
116+
<span className="ml-1 text-c1 text-brand-500 hover:text-brand-600">
117+
{expandedId === b.id ? '收起' : '展开'}
118+
</span>
119+
)}
120+
</button>
121+
)}
122+
</div>
64123
))}
65124
</div>
66125
</div>
67126
);
68127
}
128+
129+
export default BacklinksPanel;

0 commit comments

Comments
 (0)