mn-docs-mcp 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,11 +24,30 @@ pnpm preview # 预览构建结果
24
24
 
25
25
  ## 本地MCP搜索
26
26
 
27
- 本项目内置一个本地MCPServer,支持stdio与HTTPStream两种方式,返回纯文本片段,适合AI直接调用。
27
+ 本项目内置一个本地MCPServer,支持stdio与HTTPStream两种方式,面向AI开发问答提供“两步检索”工作流:先发现相关文档,再按需读取全文。
28
28
 
29
29
  embedding模型使用本地BGE-small-zh-v1.5(ONNX),首次启动会自动下载到transformers.js默认缓存目录。模型文件约95.8MB,向量维度为512。
30
30
  模型下载使用镜像https://hf-mirror.com
31
31
 
32
+ ### 工具设计
33
+
34
+ - `discover_docs`
35
+ - 用于第一步检索。
36
+ - 支持`hybrid`、`keyword`、`semantic`三种模式。
37
+ - 返回按文档聚合的结果:`doc_id`、`title`、`url`、`summary`、`matched_by`、`snippets[]`。
38
+ - 适合回答“先找到该看哪篇文档”。
39
+
40
+ - `read_doc`
41
+ - 用于第二步读取全文。
42
+ - 支持通过`doc_id`、`slug`或`url`读取指定文档。
43
+ - 返回完整文档内容与章节标题,适合继续回答“完整字段有哪些”“完整API是什么”“示例代码在哪里”。
44
+
45
+ ### 推荐调用顺序
46
+
47
+ 1. 先调用`discover_docs`定位最相关文档。
48
+ 2. 若结果里已出现明确目标文档,再调用`read_doc`读取整篇文档。
49
+ 3. 当问题涉及字段、方法、返回值、完整API或完整示例时,不要只依赖片段,应该继续读取全文。
50
+
32
51
  ### 快速开始(npx)
33
52
 
34
53
  ### MCP配置示例(npx)
package/mcp/lib.mjs CHANGED
@@ -11,6 +11,38 @@ const __dirname = path.dirname(__filename);
11
11
 
12
12
  const DEFAULT_ROOT = path.resolve(__dirname, '..');
13
13
 
14
+ const MODEL_ID = 'Xenova/bge-small-zh-v1.5';
15
+ const MODEL_DIM = 512;
16
+ const INDEX_VERSION = 2;
17
+ const MAX_EXTRACTOR_RETRIES = 3;
18
+
19
+ const QUERY_SYNONYMS = {
20
+ mn: ['marginnote'],
21
+ marginnote: ['mn'],
22
+ 卡片: ['笔记', '脑图节点'],
23
+ 笔记: ['卡片'],
24
+ 字段: ['属性'],
25
+ 属性: ['字段'],
26
+ 方法: ['函数'],
27
+ comment: ['comments', '评论'],
28
+ comments: ['comment', '评论'],
29
+ markdown: ['md'],
30
+ };
31
+
32
+ const DOC_ALIAS_HINTS = {
33
+ MbBookNote: ['笔记', '卡片', '脑图节点', 'mn卡片', '笔记对象'],
34
+ Note: ['创建笔记', '新建笔记', '笔记工厂'],
35
+ MbTopic: ['笔记本', '脑图', '卡片组'],
36
+ MbBook: ['文档', '书本', '书籍'],
37
+ };
38
+
39
+ let extractorPromise;
40
+ let proxyInitialized = false;
41
+ const IS_STDIO = process.env.MCP_STDIO === '1';
42
+ const IS_SILENT = process.env.MCP_SILENT === '1';
43
+ const NO_COLOR = process.env.MCP_NO_COLOR === '1';
44
+ let lastDownloadProgress = -1;
45
+
14
46
  function resolveRootDir() {
15
47
  const envRoot = (process.env.MN_DOCS_ROOT || '').trim();
16
48
  if (envRoot && fsSyncExists(path.join(envRoot, 'src', 'content', 'docs'))) return envRoot;
@@ -35,16 +67,6 @@ const DOCS_DIR = path.join(ROOT_DIR, 'src', 'content', 'docs');
35
67
  const MCP_DIR = path.join(ROOT_DIR, '.mcp');
36
68
  const INDEX_PATH = path.join(MCP_DIR, 'index.json');
37
69
 
38
- const MODEL_ID = 'Xenova/bge-small-zh-v1.5';
39
- const MODEL_DIM = 512;
40
- let extractorPromise;
41
- let proxyInitialized = false;
42
- const MAX_EXTRACTOR_RETRIES = 3;
43
- const IS_STDIO = process.env.MCP_STDIO === '1';
44
- const IS_SILENT = process.env.MCP_SILENT === '1';
45
- const NO_COLOR = process.env.MCP_NO_COLOR === '1';
46
- let lastDownloadProgress = -1;
47
-
48
70
  function logInfo(message) {
49
71
  if (IS_SILENT) return;
50
72
  if (IS_STDIO) {
@@ -74,12 +96,11 @@ function formatBytes(bytes) {
74
96
  function logDownloadProgress(info) {
75
97
  if (IS_SILENT) return;
76
98
  if (info?.status === 'download') {
77
- // 清除当前行(如果之前有内容)
78
99
  if (IS_STDIO) {
79
- process.stderr.write('\r\x1b[K'); // 清除整行
100
+ process.stderr.write('\r\x1b[K');
80
101
  process.stderr.write(color('开始下载模型...', '38;5;45') + '\n');
81
102
  } else {
82
- process.stdout.write('\r\x1b[K'); // 清除整行
103
+ process.stdout.write('\r\x1b[K');
83
104
  console.log(color('开始下载模型...', '38;5;45'));
84
105
  }
85
106
  lastDownloadProgress = -1;
@@ -94,7 +115,7 @@ function logDownloadProgress(info) {
94
115
  const suffix = loaded && total ? ` ${loaded}/${total}` : '';
95
116
  const line = `${color('模型下载进度', '38;5;45')}: ${pct}%${suffix}`;
96
117
  if (IS_STDIO) {
97
- process.stderr.write(`\r\x1b[K${line}`); // \x1b[K 清除从光标到行尾的内容
118
+ process.stderr.write(`\r\x1b[K${line}`);
98
119
  if (pct === 100) process.stderr.write('\n');
99
120
  } else {
100
121
  process.stdout.write(`\r\x1b[K${line}`);
@@ -109,8 +130,7 @@ function setupProxy() {
109
130
  const proxyUrl = (process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY || '').trim();
110
131
  if (!proxyUrl) return;
111
132
  try {
112
- const dispatcher = new ProxyAgent(proxyUrl);
113
- setGlobalDispatcher(dispatcher);
133
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
114
134
  } catch {
115
135
  setGlobalDispatcher(new Agent());
116
136
  }
@@ -119,21 +139,14 @@ function setupProxy() {
119
139
  async function getExtractor() {
120
140
  if (extractorPromise) return extractorPromise;
121
141
  setupProxy();
122
-
123
- // 抑制 Hugging Face Transformers 的警告输出
142
+
124
143
  env.allowRemoteModels = true;
125
- env.disableProgressBars = true; // 禁用库自带的进度条
126
- env.disableSymlinksWarning = true; // 禁用符号链接警告
144
+ env.disableProgressBars = true;
145
+ env.disableSymlinksWarning = true;
127
146
  env.remoteHost = 'https://hf-mirror.com';
128
-
129
- // 设置日志级别为 error,避免 info/warning 级别日志干扰
130
- if (!process.env.LOG_LEVEL) {
131
- process.env.LOG_LEVEL = 'error';
132
- }
133
-
134
- const modelDir = env.cacheDir
135
- ? path.join(env.cacheDir, 'Xenova', 'bge-small-zh-v1.5')
136
- : null;
147
+ if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'error';
148
+
149
+ const modelDir = env.cacheDir ? path.join(env.cacheDir, 'Xenova', 'bge-small-zh-v1.5') : null;
137
150
  const create = async () =>
138
151
  pipeline('feature-extraction', MODEL_ID, {
139
152
  progress_callback: logDownloadProgress,
@@ -151,16 +164,11 @@ async function getExtractor() {
151
164
  message.includes('fetch failed') ||
152
165
  message.includes('ConnectTimeoutError');
153
166
 
154
- if (!shouldRetry || attempt === MAX_EXTRACTOR_RETRIES) {
155
- throw error;
156
- }
167
+ if (!shouldRetry || attempt === MAX_EXTRACTOR_RETRIES) throw error;
157
168
 
158
- // 清除上次的进度状态,为重试做准备
159
169
  lastDownloadProgress = -1;
160
170
  logInfo(`模型下载失败,准备重试(${attempt}/${MAX_EXTRACTOR_RETRIES})...`);
161
- if (modelDir) {
162
- await fs.rm(modelDir, { recursive: true, force: true });
163
- }
171
+ if (modelDir) await fs.rm(modelDir, { recursive: true, force: true });
164
172
  }
165
173
  }
166
174
  throw new Error('模型加载失败');
@@ -239,8 +247,69 @@ async function walkFiles(dir) {
239
247
  return results;
240
248
  }
241
249
 
242
- function makeId(slug, index) {
243
- return `${slug}::${index}`;
250
+ function makeDocId(slug) {
251
+ return slug;
252
+ }
253
+
254
+ function makeChunkId(docId, index) {
255
+ return `${docId}::${index}`;
256
+ }
257
+
258
+ function uniqueList(values) {
259
+ const set = new Set();
260
+ for (const value of values) {
261
+ const normalized = normalizeWhitespace(String(value || ''));
262
+ if (!normalized) continue;
263
+ set.add(normalized);
264
+ }
265
+ return [...set];
266
+ }
267
+
268
+ function normalizeForMatch(text) {
269
+ return normalizeWhitespace(String(text || '').toLowerCase())
270
+ .replace(/[`"'“”‘’()[\]{}:;,.!?/\\|<>+=_*&#%-]+/g, ' ')
271
+ .trim();
272
+ }
273
+
274
+ function splitIdentifierWords(text) {
275
+ const value = String(text || '')
276
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
277
+ .replace(/[_/-]+/g, ' ');
278
+ return uniqueList(value.split(/\s+/));
279
+ }
280
+
281
+ function tokenize(text) {
282
+ const normalized = normalizeForMatch(text);
283
+ if (!normalized) return [];
284
+ const matches = normalized.match(/[a-z0-9]+|[\p{Script=Han}]+/gu);
285
+ if (!matches) return [];
286
+ const tokens = [];
287
+ for (const match of matches) {
288
+ tokens.push(match);
289
+ if (/^[\p{Script=Han}]+$/u.test(match) && match.length >= 2) {
290
+ for (let i = 0; i < match.length - 1; i += 1) {
291
+ tokens.push(match.slice(i, i + 2));
292
+ }
293
+ }
294
+ }
295
+ return uniqueList(tokens);
296
+ }
297
+
298
+ function buildAliasCandidates({ title, slug, description, headings, plainText }) {
299
+ const slugTail = slug.split('/').pop() || slug;
300
+ const firstSentence = plainText.split(/[。!?.!?]/)[0] || '';
301
+ const aliases = [
302
+ title,
303
+ description,
304
+ slugTail,
305
+ slugTail.replace(/-/g, ' '),
306
+ ...splitIdentifierWords(title),
307
+ ...splitIdentifierWords(slugTail),
308
+ ...headings.slice(0, 6),
309
+ firstSentence,
310
+ ...(DOC_ALIAS_HINTS[title] || []),
311
+ ];
312
+ return uniqueList(aliases);
244
313
  }
245
314
 
246
315
  async function embedText(text) {
@@ -257,9 +326,9 @@ export async function buildIndex() {
257
326
  await fs.mkdir(MCP_DIR, { recursive: true });
258
327
 
259
328
  const files = await walkFiles(DOCS_DIR);
260
- const docs = [];
329
+ const documents = [];
330
+ const chunks = [];
261
331
  const tasks = [];
262
- let counter = 0;
263
332
 
264
333
  for (const file of files) {
265
334
  const rel = path.relative(DOCS_DIR, file).replace(/\\/g, '/');
@@ -268,20 +337,44 @@ export async function buildIndex() {
268
337
  const parsed = matter(raw);
269
338
  const frontmatterTitle = typeof parsed.data?.title === 'string' ? parsed.data.title.trim() : '';
270
339
  const frontmatterSlug = typeof parsed.data?.slug === 'string' ? parsed.data.slug.trim() : '';
271
- const content = stripMarkdown(parsed.content);
272
- const chunks = splitByHeadingAndParagraph(content);
273
- const pageTitle = frontmatterTitle || (chunks[0]?.heading || slug.split('/').pop() || slug);
274
- const url = slugToUrl(frontmatterSlug || slug);
340
+ const frontmatterDescription =
341
+ typeof parsed.data?.description === 'string' ? parsed.data.description.trim() : '';
342
+ const rawMarkdown = parsed.content.trim();
343
+ const plainText = stripMarkdown(parsed.content);
344
+ const chunkEntries = splitByHeadingAndParagraph(rawMarkdown);
345
+ const pageTitle = frontmatterTitle || (chunkEntries[0]?.heading || slug.split('/').pop() || slug);
346
+ const finalSlug = frontmatterSlug || slug;
347
+ const url = slugToUrl(finalSlug);
348
+ const headings = uniqueList(chunkEntries.map((chunk) => chunk.heading).filter(Boolean));
349
+ const docId = makeDocId(finalSlug);
350
+ const aliases = buildAliasCandidates({
351
+ title: pageTitle,
352
+ slug: finalSlug,
353
+ description: frontmatterDescription,
354
+ headings,
355
+ plainText,
356
+ });
357
+
358
+ documents.push({
359
+ doc_id: docId,
360
+ title: pageTitle,
361
+ slug: finalSlug,
362
+ url,
363
+ description: frontmatterDescription,
364
+ aliases,
365
+ headings,
366
+ raw_markdown: rawMarkdown,
367
+ plain_text: plainText,
368
+ });
275
369
 
276
- for (const chunk of chunks) {
370
+ chunkEntries.forEach((chunk, index) => {
277
371
  tasks.push({
278
- id: makeId(slug, counter++),
279
- url,
280
- title: pageTitle,
372
+ chunk_id: makeChunkId(docId, index),
373
+ doc_id: docId,
281
374
  section: chunk.heading,
282
375
  text: chunk.text,
283
376
  });
284
- }
377
+ });
285
378
  }
286
379
 
287
380
  const total = tasks.length;
@@ -300,14 +393,12 @@ export async function buildIndex() {
300
393
 
301
394
  for (const task of tasks) {
302
395
  const embedding = await embedText(task.text);
303
- docs.push({ ...task, embedding });
396
+ chunks.push({ ...task, embedding });
304
397
  done += 1;
305
398
  renderProgress(false);
306
- // 让出事件循环,避免长时间阻塞MCP握手/请求处理
307
- if (done % 10 === 0) {
308
- await new Promise((resolve) => setImmediate(resolve));
309
- }
399
+ if (done % 10 === 0) await new Promise((resolve) => setImmediate(resolve));
310
400
  }
401
+
311
402
  if (IS_STDIO ? process.stderr.isTTY : process.stdout.isTTY) {
312
403
  const stream = IS_STDIO ? process.stderr : process.stdout;
313
404
  stream.write(`\r索引构建完成:${done}/${total}\n`);
@@ -316,26 +407,34 @@ export async function buildIndex() {
316
407
  }
317
408
 
318
409
  const payload = {
319
- version: 1,
410
+ version: INDEX_VERSION,
320
411
  generatedAt: new Date().toISOString(),
321
412
  source: {
322
413
  root: 'src/content/docs',
323
- split: 'heading+paragraph',
414
+ split: 'document+heading+paragraph',
324
415
  model: MODEL_ID,
325
416
  dim: MODEL_DIM,
326
417
  },
327
- docs,
418
+ documents,
419
+ chunks,
328
420
  };
329
421
  await fs.writeFile(INDEX_PATH, JSON.stringify(payload, null, 2));
330
- return { count: docs.length, path: INDEX_PATH };
422
+ return {
423
+ documentCount: documents.length,
424
+ chunkCount: chunks.length,
425
+ path: INDEX_PATH,
426
+ };
331
427
  }
332
428
 
333
429
  export async function loadIndex() {
334
430
  const { INDEX_PATH } = getPaths();
335
431
  const raw = await fs.readFile(INDEX_PATH, 'utf-8');
336
432
  const data = JSON.parse(raw);
337
- if (!Array.isArray(data?.docs)) {
338
- throw new Error('索引文件格式错误,未找到docs数组');
433
+ if (data?.version !== INDEX_VERSION) {
434
+ throw new Error('索引版本过旧,需要重建');
435
+ }
436
+ if (!Array.isArray(data?.documents) || !Array.isArray(data?.chunks)) {
437
+ throw new Error('索引文件格式错误,未找到documents或chunks数组');
339
438
  }
340
439
  return data;
341
440
  }
@@ -363,7 +462,7 @@ function cosineSimilarity(a, b) {
363
462
  let dot = 0;
364
463
  let normA = 0;
365
464
  let normB = 0;
366
- for (let i = 0; i < a.length; i++) {
465
+ for (let i = 0; i < a.length; i += 1) {
367
466
  dot += a[i] * b[i];
368
467
  normA += a[i] * a[i];
369
468
  normB += b[i] * b[i];
@@ -371,15 +470,221 @@ function cosineSimilarity(a, b) {
371
470
  return dot / (Math.sqrt(normA) * Math.sqrt(normB) || 1);
372
471
  }
373
472
 
374
- export async function searchDocs(query, topK = 5) {
473
+ function expandQueryTerms(query) {
474
+ const normalizedQuery = normalizeForMatch(query);
475
+ const baseTerms = tokenize(query);
476
+ const expanded = new Set(baseTerms);
477
+ for (const key of Object.keys(QUERY_SYNONYMS)) {
478
+ if (normalizedQuery.includes(normalizeForMatch(key))) {
479
+ expanded.add(key);
480
+ }
481
+ }
482
+ for (const term of baseTerms) {
483
+ for (const synonym of QUERY_SYNONYMS[term] || []) {
484
+ expanded.add(synonym);
485
+ }
486
+ }
487
+ return [...expanded];
488
+ }
489
+
490
+ function countContains(text, terms) {
491
+ const normalized = normalizeForMatch(text);
492
+ if (!normalized) return 0;
493
+ let count = 0;
494
+ for (const term of terms) {
495
+ if (normalized.includes(normalizeForMatch(term))) count += 1;
496
+ }
497
+ return count;
498
+ }
499
+
500
+ function makeSnippetSummary(text, maxLength = 180) {
501
+ const compact = normalizeWhitespace(text);
502
+ if (compact.length <= maxLength) return compact;
503
+ return `${compact.slice(0, maxLength - 1)}...`;
504
+ }
505
+
506
+ function scoreDocument(doc, query, terms) {
507
+ const title = normalizeForMatch(doc.title);
508
+ const slug = normalizeForMatch(doc.slug);
509
+ const url = normalizeForMatch(doc.url);
510
+ const aliasText = normalizeForMatch(doc.aliases.join(' '));
511
+ const headingText = normalizeForMatch(doc.headings.join(' '));
512
+ const bodyText = normalizeForMatch(doc.plain_text);
513
+ const exactQuery = normalizeForMatch(query);
514
+ let score = 0;
515
+ const matchedBy = new Set();
516
+
517
+ if (exactQuery && (title === exactQuery || slug === exactQuery || url === exactQuery)) {
518
+ score += 12;
519
+ matchedBy.add('title_exact');
520
+ }
521
+
522
+ for (const alias of doc.aliases) {
523
+ if (normalizeForMatch(alias) === exactQuery && exactQuery) {
524
+ score += 10;
525
+ matchedBy.add('alias_match');
526
+ break;
527
+ }
528
+ }
529
+
530
+ if (exactQuery && slug.includes(exactQuery)) {
531
+ score += 6;
532
+ matchedBy.add('slug_match');
533
+ }
534
+ if (exactQuery && title.includes(exactQuery) && title !== exactQuery) {
535
+ score += 5;
536
+ matchedBy.add('title_match');
537
+ }
538
+ if (exactQuery && aliasText.includes(exactQuery)) {
539
+ score += 4;
540
+ matchedBy.add('alias_match');
541
+ }
542
+
543
+ const titleHits = countContains(doc.title, terms);
544
+ const slugHits = countContains(doc.slug, terms);
545
+ const aliasHits = countContains(doc.aliases.join(' '), terms);
546
+ const headingHits = countContains(doc.headings.join(' '), terms);
547
+ const bodyHits = countContains(doc.plain_text, terms);
548
+
549
+ if (titleHits > 0) matchedBy.add('title_match');
550
+ if (slugHits > 0) matchedBy.add('slug_match');
551
+ if (aliasHits > 0) matchedBy.add('alias_match');
552
+ if (bodyHits > 0) matchedBy.add('keyword_body');
553
+
554
+ score += titleHits * 2.8;
555
+ score += slugHits * 2.4;
556
+ score += aliasHits * 2.2;
557
+ score += headingHits * 1.4;
558
+ score += Math.min(bodyHits, 6) * 0.8;
559
+
560
+ if (/^[a-z][a-z0-9]+(?:[A-Z][a-z0-9]+)+$/.test(query.trim()) && doc.title === query.trim()) {
561
+ score += 8;
562
+ matchedBy.add('title_exact');
563
+ }
564
+
565
+ return { score, matchedBy: [...matchedBy] };
566
+ }
567
+
568
+ function scoreChunk(chunk, terms, queryEmbedding) {
569
+ const keywordHits = countContains(chunk.text, terms) + countContains(chunk.section, terms) * 0.8;
570
+ let score = keywordHits * 1.1;
571
+ let semanticScore = null;
572
+ if (queryEmbedding) {
573
+ semanticScore = cosineSimilarity(queryEmbedding, chunk.embedding);
574
+ score += Math.max(semanticScore, 0) * 4;
575
+ }
576
+ return {
577
+ score,
578
+ semanticScore,
579
+ };
580
+ }
581
+
582
+ function buildDocSummary(snippets) {
583
+ if (!snippets.length) return '';
584
+ const joined = snippets
585
+ .slice(0, 2)
586
+ .map((snippet) => snippet.text)
587
+ .join(' ');
588
+ return makeSnippetSummary(joined, 220);
589
+ }
590
+
591
+ export async function discoverDocs(query, options = {}) {
592
+ const trimmedQuery = normalizeWhitespace(query || '');
593
+ if (!trimmedQuery) throw new Error('query不能为空');
594
+
595
+ const topK = Number(options.topK || 5);
596
+ const mode = ['hybrid', 'keyword', 'semantic'].includes(options.mode) ? options.mode : 'hybrid';
375
597
  const index = await loadIndex();
376
- const queryEmbedding = await embedText(query);
598
+ const terms = expandQueryTerms(trimmedQuery);
599
+ const queryEmbedding = mode === 'keyword' ? null : await embedText(trimmedQuery);
600
+ const chunkMap = new Map();
601
+
602
+ for (const chunk of index.chunks) {
603
+ const result = scoreChunk(chunk, terms, mode === 'semantic' || mode === 'hybrid' ? queryEmbedding : null);
604
+ const list = chunkMap.get(chunk.doc_id) || [];
605
+ list.push({
606
+ section: chunk.section || '',
607
+ text: chunk.text,
608
+ score: result.score,
609
+ semanticScore: result.semanticScore,
610
+ });
611
+ chunkMap.set(chunk.doc_id, list);
612
+ }
613
+
614
+ const results = index.documents
615
+ .map((doc) => {
616
+ const docScore = scoreDocument(doc, trimmedQuery, terms);
617
+ const scoredChunks = (chunkMap.get(doc.doc_id) || [])
618
+ .filter((item) => item.score > 0 || item.semanticScore === null || item.semanticScore > 0.18)
619
+ .sort((a, b) => b.score - a.score);
620
+
621
+ const bestChunk = scoredChunks[0];
622
+ let score = docScore.score;
623
+ if (bestChunk) {
624
+ score += bestChunk.score;
625
+ if (bestChunk.semanticScore && bestChunk.semanticScore > 0.25) {
626
+ docScore.matchedBy.push('semantic');
627
+ }
628
+ }
629
+ if (mode === 'semantic' && bestChunk?.semanticScore != null) {
630
+ score += Math.max(bestChunk.semanticScore, 0) * 3;
631
+ }
632
+
633
+ const snippets = scoredChunks.slice(0, 3).map((item) => ({
634
+ section: item.section,
635
+ text: makeSnippetSummary(item.text, 260),
636
+ score: Number(item.score.toFixed(4)),
637
+ }));
638
+
639
+ return {
640
+ doc_id: doc.doc_id,
641
+ title: doc.title,
642
+ url: doc.url,
643
+ score,
644
+ summary: buildDocSummary(snippets),
645
+ matched_by: uniqueList(docScore.matchedBy),
646
+ snippets,
647
+ };
648
+ })
649
+ .filter((doc) => doc.score > 0)
650
+ .sort((a, b) => b.score - a.score)
651
+ .slice(0, topK)
652
+ .map((doc) => ({
653
+ ...doc,
654
+ score: Number(doc.score.toFixed(4)),
655
+ }));
656
+
657
+ return {
658
+ query: trimmedQuery,
659
+ mode,
660
+ results,
661
+ };
662
+ }
377
663
 
378
- const scored = index.docs.map((doc) => ({
379
- text: doc.text,
380
- score: cosineSimilarity(queryEmbedding, doc.embedding),
381
- }));
664
+ function findDocument(index, identifier) {
665
+ const docId = normalizeWhitespace(identifier.doc_id || '');
666
+ const slug = normalizeWhitespace(identifier.slug || '');
667
+ const url = normalizeWhitespace(identifier.url || '');
382
668
 
383
- scored.sort((a, b) => b.score - a.score);
384
- return scored.slice(0, topK).map((item) => item.text);
669
+ return index.documents.find((doc) => {
670
+ if (docId && doc.doc_id === docId) return true;
671
+ if (slug && doc.slug === slug) return true;
672
+ if (url && doc.url === url) return true;
673
+ return false;
674
+ });
675
+ }
676
+
677
+ export async function readDoc(identifier = {}) {
678
+ const index = await loadIndex();
679
+ const doc = findDocument(index, identifier);
680
+ if (!doc) {
681
+ throw new Error('未找到匹配的文档,请提供有效的doc_id、slug或url');
682
+ }
683
+ return {
684
+ doc_id: doc.doc_id,
685
+ title: doc.title,
686
+ url: doc.url,
687
+ headings: doc.headings,
688
+ content: doc.raw_markdown,
689
+ };
385
690
  }
@@ -1,8 +1,9 @@
1
1
  import { FastMCP } from 'fastmcp';
2
2
  import { z } from 'zod';
3
- import { buildIndex, getPaths, isIndexStale, loadIndex, searchDocs } from './lib.mjs';
3
+ import { buildIndex, discoverDocs, getPaths, isIndexStale, loadIndex, readDoc } from './lib.mjs';
4
4
 
5
- const TOOL_NAME = 'search_docs';
5
+ const DISCOVER_TOOL_NAME = 'discover_docs';
6
+ const READ_TOOL_NAME = 'read_doc';
6
7
  const PORT = Number(process.env.MCP_HTTP_PORT || 8788);
7
8
  const IS_SILENT = process.env.MCP_SILENT === '1';
8
9
  const NO_COLOR = process.env.MCP_NO_COLOR === '1';
@@ -72,7 +73,7 @@ async function ensureIndex() {
72
73
  await buildIndex();
73
74
  }
74
75
  } catch {
75
- console.error(`未找到索引,开始重建:${INDEX_PATH}`);
76
+ console.error(`未找到可用索引,开始重建:${INDEX_PATH}`);
76
77
  await buildIndex();
77
78
  }
78
79
  }
@@ -87,32 +88,101 @@ function initIndexInBackground() {
87
88
  return initPromise;
88
89
  }
89
90
 
91
+ async function ensureReady() {
92
+ if (initPromise) {
93
+ await initPromise;
94
+ } else {
95
+ await initIndexInBackground();
96
+ }
97
+ }
98
+
99
+ function renderJsonPayload(payload) {
100
+ return {
101
+ structuredContent: payload,
102
+ content: [
103
+ {
104
+ type: 'text',
105
+ text: JSON.stringify(payload, null, 2),
106
+ },
107
+ ],
108
+ };
109
+ }
110
+
111
+ function renderError(message) {
112
+ return {
113
+ content: [{ type: 'text', text: message }],
114
+ isError: true,
115
+ };
116
+ }
117
+
90
118
  const server = new FastMCP({
91
119
  name: 'marginnote-docs-mcp',
92
120
  version: '0.1.0',
93
121
  });
94
122
 
95
123
  server.addTool({
96
- name: TOOL_NAME,
97
- description: '在本地文档索引中检索相关文本片段',
124
+ name: DISCOVER_TOOL_NAME,
125
+ description:
126
+ [
127
+ '发现与当前问题最相关的MarginNote文档。这个工具适合做第一步检索:先找对文档,再决定是否读取全文。',
128
+ '推荐用法:当用户问某个类、对象、字段、方法、返回值、示例、完整API时,先调用discover_docs。',
129
+ '如果结果已经出现明确目标文档,再调用read_doc读取整篇文档,不要只依赖片段回答“字段有哪些”“完整API是什么”。',
130
+ '当query中包含类名、方法名、属性名时,优先使用mode=hybrid或mode=keyword。',
131
+ '返回结果按文档聚合,每项包含doc_id、title、url、summary、matched_by和snippets,便于继续跳转。',
132
+ ].join('\n'),
98
133
  parameters: z.object({
99
- query: z.string().describe('检索关键词或问题'),
100
- top_k: z.number().optional().describe('返回片段数量'),
134
+ query: z.string().describe('用户的问题、关键词或API名,例如“mn卡片字段”“MbBookNote comments”“创建新笔记的方法”'),
135
+ top_k: z
136
+ .number()
137
+ .int()
138
+ .min(1)
139
+ .max(20)
140
+ .optional()
141
+ .describe('返回文档数量,默认5。通常3到8足够。'),
142
+ mode: z
143
+ .enum(['hybrid', 'keyword', 'semantic'])
144
+ .optional()
145
+ .describe('检索模式。默认hybrid;keyword适合精确API名;semantic适合自然语言描述。'),
101
146
  }),
102
- execute: async ({ query, top_k }) => {
103
- const topK = Number(top_k || 5);
104
- if (!query.trim()) {
105
- return { content: [{ type: 'text', text: 'query不能为空' }] };
147
+ execute: async ({ query, top_k, mode }) => {
148
+ try {
149
+ await ensureReady();
150
+ const payload = await discoverDocs(query, {
151
+ topK: top_k,
152
+ mode,
153
+ });
154
+ return renderJsonPayload(payload);
155
+ } catch (error) {
156
+ return renderError(error?.message || 'discover_docs执行失败');
106
157
  }
107
- if (initPromise) {
108
- await initPromise;
109
- } else {
110
- await initIndexInBackground();
158
+ },
159
+ });
160
+
161
+ server.addTool({
162
+ name: READ_TOOL_NAME,
163
+ description:
164
+ [
165
+ '读取某篇MarginNote文档的全文。这个工具适合做第二步检索:在discover_docs确认目标文档后,拉取完整字段、方法、返回值和示例。',
166
+ '推荐优先使用discover_docs返回的doc_id调用read_doc,避免slug或url歧义。',
167
+ '当用户追问“还有哪些字段”“完整API”“相关示例”“完整方法签名”时,应继续调用read_doc,而不是只根据片段猜测。',
168
+ ].join('\n'),
169
+ parameters: z
170
+ .object({
171
+ doc_id: z.string().optional().describe('discover_docs返回的doc_id,最推荐使用'),
172
+ slug: z.string().optional().describe('文档slug,例如reference/marginnote/mb-book-note'),
173
+ url: z.string().optional().describe('文档URL,例如/reference/marginnote/mb-book-note/'),
174
+ })
175
+ .refine((value) => Boolean(value.doc_id || value.slug || value.url), {
176
+ message: 'doc_id、slug、url至少需要提供一个',
177
+ }),
178
+ execute: async ({ doc_id, slug, url }) => {
179
+ try {
180
+ await ensureReady();
181
+ const payload = await readDoc({ doc_id, slug, url });
182
+ return renderJsonPayload(payload);
183
+ } catch (error) {
184
+ return renderError(error?.message || 'read_doc执行失败');
111
185
  }
112
- const results = await searchDocs(query, topK);
113
- return {
114
- content: results.map((text) => ({ type: 'text', text })),
115
- };
116
186
  },
117
187
  });
118
188
 
@@ -126,5 +196,4 @@ await server.start({
126
196
 
127
197
  renderSplash();
128
198
 
129
- // 默认自动构建,异步启动避免阻塞握手
130
199
  setTimeout(() => initIndexInBackground(), 0);
package/mcp/server.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { FastMCP } from 'fastmcp';
2
2
  import { z } from 'zod';
3
- import { buildIndex, getPaths, isIndexStale, loadIndex, searchDocs } from './lib.mjs';
3
+ import { buildIndex, discoverDocs, getPaths, isIndexStale, loadIndex, readDoc } from './lib.mjs';
4
4
 
5
- const TOOL_NAME = 'search_docs';
5
+ const DISCOVER_TOOL_NAME = 'discover_docs';
6
+ const READ_TOOL_NAME = 'read_doc';
6
7
  const IS_SILENT = process.env.MCP_SILENT === '1';
7
8
  const NO_COLOR = process.env.MCP_NO_COLOR === '1';
8
9
 
@@ -26,7 +27,6 @@ function stringWidth(text) {
26
27
  for (const char of plain) {
27
28
  const code = char.codePointAt(0);
28
29
  if (!code) continue;
29
- // CJK / Fullwidth / Wide characters
30
30
  const isWide =
31
31
  (code >= 0x1100 && code <= 0x115f) ||
32
32
  (code === 0x2329 || code === 0x232a) ||
@@ -76,7 +76,7 @@ async function ensureIndex() {
76
76
  await buildIndex();
77
77
  }
78
78
  } catch {
79
- logError(`未找到索引,开始重建:${INDEX_PATH}`);
79
+ logError(`未找到可用索引,开始重建:${INDEX_PATH}`);
80
80
  await buildIndex();
81
81
  }
82
82
  }
@@ -91,6 +91,33 @@ function initIndexInBackground() {
91
91
  return initPromise;
92
92
  }
93
93
 
94
+ async function ensureReady() {
95
+ if (initPromise) {
96
+ await initPromise;
97
+ } else {
98
+ await initIndexInBackground();
99
+ }
100
+ }
101
+
102
+ function renderJsonPayload(payload) {
103
+ return {
104
+ structuredContent: payload,
105
+ content: [
106
+ {
107
+ type: 'text',
108
+ text: JSON.stringify(payload, null, 2),
109
+ },
110
+ ],
111
+ };
112
+ }
113
+
114
+ function renderError(message) {
115
+ return {
116
+ content: [{ type: 'text', text: message }],
117
+ isError: true,
118
+ };
119
+ }
120
+
94
121
  const logger = IS_SILENT
95
122
  ? {
96
123
  debug() {},
@@ -114,26 +141,68 @@ const server = new FastMCP({
114
141
  });
115
142
 
116
143
  server.addTool({
117
- name: TOOL_NAME,
118
- description: '在本地文档索引中检索相关文本片段',
144
+ name: DISCOVER_TOOL_NAME,
145
+ description:
146
+ [
147
+ '发现与当前问题最相关的MarginNote文档。这个工具适合做第一步检索:先找对文档,再决定是否读取全文。',
148
+ '推荐用法:当用户问某个类、对象、字段、方法、返回值、示例、完整API时,先调用discover_docs。',
149
+ '如果结果已经出现明确目标文档,再调用read_doc读取整篇文档,不要只依赖片段回答“字段有哪些”“完整API是什么”。',
150
+ '当query中包含类名、方法名、属性名时,优先使用mode=hybrid或mode=keyword。',
151
+ '返回结果按文档聚合,每项包含doc_id、title、url、summary、matched_by和snippets,便于继续跳转。',
152
+ ].join('\n'),
119
153
  parameters: z.object({
120
- query: z.string().describe('检索关键词或问题'),
121
- top_k: z.number().optional().describe('返回片段数量'),
154
+ query: z.string().describe('用户的问题、关键词或API名,例如“mn卡片字段”“MbBookNote comments”“创建新笔记的方法”'),
155
+ top_k: z
156
+ .number()
157
+ .int()
158
+ .min(1)
159
+ .max(20)
160
+ .optional()
161
+ .describe('返回文档数量,默认5。通常3到8足够。'),
162
+ mode: z
163
+ .enum(['hybrid', 'keyword', 'semantic'])
164
+ .optional()
165
+ .describe('检索模式。默认hybrid;keyword适合精确API名;semantic适合自然语言描述。'),
122
166
  }),
123
- execute: async ({ query, top_k }) => {
124
- const topK = Number(top_k || 5);
125
- if (!query.trim()) {
126
- return { content: [{ type: 'text', text: 'query不能为空' }] };
167
+ execute: async ({ query, top_k, mode }) => {
168
+ try {
169
+ await ensureReady();
170
+ const payload = await discoverDocs(query, {
171
+ topK: top_k,
172
+ mode,
173
+ });
174
+ return renderJsonPayload(payload);
175
+ } catch (error) {
176
+ return renderError(error?.message || 'discover_docs执行失败');
127
177
  }
128
- if (initPromise) {
129
- await initPromise;
130
- } else {
131
- await initIndexInBackground();
178
+ },
179
+ });
180
+
181
+ server.addTool({
182
+ name: READ_TOOL_NAME,
183
+ description:
184
+ [
185
+ '读取某篇MarginNote文档的全文。这个工具适合做第二步检索:在discover_docs确认目标文档后,拉取完整字段、方法、返回值和示例。',
186
+ '推荐优先使用discover_docs返回的doc_id调用read_doc,避免slug或url歧义。',
187
+ '当用户追问“还有哪些字段”“完整API”“相关示例”“完整方法签名”时,应继续调用read_doc,而不是只根据片段猜测。',
188
+ ].join('\n'),
189
+ parameters: z
190
+ .object({
191
+ doc_id: z.string().optional().describe('discover_docs返回的doc_id,最推荐使用'),
192
+ slug: z.string().optional().describe('文档slug,例如reference/marginnote/mb-book-note'),
193
+ url: z.string().optional().describe('文档URL,例如/reference/marginnote/mb-book-note/'),
194
+ })
195
+ .refine((value) => Boolean(value.doc_id || value.slug || value.url), {
196
+ message: 'doc_id、slug、url至少需要提供一个',
197
+ }),
198
+ execute: async ({ doc_id, slug, url }) => {
199
+ try {
200
+ await ensureReady();
201
+ const payload = await readDoc({ doc_id, slug, url });
202
+ return renderJsonPayload(payload);
203
+ } catch (error) {
204
+ return renderError(error?.message || 'read_doc执行失败');
132
205
  }
133
- const results = await searchDocs(query, topK);
134
- return {
135
- content: results.map((text) => ({ type: 'text', text })),
136
- };
137
206
  },
138
207
  });
139
208
 
@@ -143,5 +212,4 @@ await server.start({
143
212
 
144
213
  renderSplash();
145
214
 
146
- // 默认自动构建,异步启动避免阻塞握手
147
215
  setTimeout(() => initIndexInBackground(), 0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mn-docs-mcp",
3
3
  "type": "module",
4
- "version": "0.5.1",
4
+ "version": "0.6.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/Temsys-Shen/marginnote-addon-docs.git"
@@ -1,32 +1,238 @@
1
1
  ---
2
2
  title: 全局入口对象(Global Variables)
3
- description: MarginNote 插件运行时直接注入到 JS 环境的全局变量清单与用法入口。
3
+ description: MarginNote插件运行时直接注入到JS环境的全局变量清单与用法入口。
4
4
  ---
5
+ 本页列出MarginNote插件运行时可直接使用的全局变量名。这些全局名对应的对象通常是单例、工厂对象、或系统级入口。
5
6
 
6
- 本页列出 **MarginNote 插件运行时可直接使用的全局变量名**(你在 JS 里直接输入即可访问)。这些全局名对应的对象通常是 **单例**、**工厂对象**、或 **系统级入口**。
7
-
8
- > 说明:本页仅做“可发现性索引”。每个对象的完整属性/方法请进入对应参考页。
9
-
10
- ## 全局变量清单
11
-
12
- | 全局变量名 | 对应原生类/说明 | 核心用途 | 参考 |
13
- | :--- | :--- | :--- | :--- |
14
- | `JSB` | Bridge | 定义类、日志输出、require | [JSB](/reference/global/jsb/) |
15
- | `self` | `JSExtension` 实例 | 当前插件实例(仅实例方法内可用) | [self](/reference/global/self/) |
16
- | `Application` | `JSBApplication` | App 全局单例:路径、HUD、openURL、studyController | [Application](/reference/global/application/) |
17
- | `Database` | `MbModelTool` 单例 | 笔记/笔记本/文档数据库访问 | [Database](/reference/global/database/) |
18
- | `Note` | 笔记工厂(全局) | 创建新笔记:`Note.createWithTitleNotebookDocument(...)` | [Note](/reference/global/note/) |
19
- | `UndoManager` | `JSBUndoManager` 单例 | undoGrouping/撤销重做 | [UndoManager](/reference/utility/undo-manager/) |
20
- | `SpeechManager` | `JSBSpeechManager` 单例 | 语音朗读 | [SpeechManager](/reference/utility/speech-manager/) |
21
- | `ZipArchive` | `JSBZipArchive` | zip 压缩/解压 | [ZipArchive](/reference/utility/zip-archive/) |
22
- | `MenuController` | 菜单控制器(全局) | 表格化菜单视图 | [MenuController](/reference/utility/menu-controller/) |
23
- | `PopupMenu` | 弹出菜单(全局) | 轻量弹出菜单 | [PopupMenu](/reference/global/popup-menu/) |
24
- | `PopupMenuItem` | 弹出菜单项(全局) | 菜单项对象 | [PopupMenuItem](/reference/global/popup-menu-item/) |
25
- | `SQLiteDatabase` | `JSBSQLiteDatabase` | SQLite 连接器 | [SQLiteDatabase](/reference/utility/sqlite-database/) |
26
- | `NSFileManager` | `NSFileManager` 单例 | 文件管理 | [NSFileManager](/reference/foundation/ns-file-manager/)(待补齐:全量接口) |
27
- | `NSUserDefaults` | `NSUserDefaults` 单例 | 偏好设置存储 | [NSUserDefaults](/reference/foundation/ns-user-defaults/) |
28
- | `NSNotificationCenter` | `NSNotificationCenter` 单例 | 通知中心 | [NSNotificationCenter](/reference/foundation/ns-notification-center/) |
29
- | `UIApplication` | `UIApplication` 单例 | iOS 应用级入口(openURL 等) | [UIApplication](/reference/uikit/uiapplication/) |
30
- | `UIPasteboard` | `UIPasteboard` 单例 | 系统剪贴板 | [UIPasteboard](/reference/uikit/uipasteboard/) |
31
- | `UIDevice` | `UIDevice` 单例 | 设备信息 | [UIDevice](/reference/uikit/uidevice/) |
32
- | `UIScreen` | `UIScreen` 单例 | 屏幕信息 | [UIScreen](/reference/uikit/uiscreen/) |
7
+ > 说明:本页仅做索引。每个对象的完整属性/方法请进入对应参考页。
8
+
9
+ ## 全局与入口
10
+
11
+ <table>
12
+ <colgroup>
13
+ <col style="width:28%">
14
+ <col style="width:44%">
15
+ <col style="width:28%">
16
+ </colgroup>
17
+ <thead>
18
+ <tr>
19
+ <th>名称</th>
20
+ <th>用途</th>
21
+ <th>参考</th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ <tr>
26
+ <td><code>JSB</code></td>
27
+ <td>Bridge入口</td>
28
+ <td><a href="/reference/global/jsb/">JSB</a></td>
29
+ </tr>
30
+ <tr>
31
+ <td><code>self</code></td>
32
+ <td>当前实例上下文</td>
33
+ <td><a href="/reference/global/self/">self</a></td>
34
+ </tr>
35
+ <tr>
36
+ <td><code>Application</code></td>
37
+ <td>App入口</td>
38
+ <td><a href="/reference/global/application/">Application</a></td>
39
+ </tr>
40
+ <tr>
41
+ <td><code>Database</code></td>
42
+ <td>数据库访问</td>
43
+ <td><a href="/reference/global/database/">Database</a></td>
44
+ </tr>
45
+ <tr>
46
+ <td><code>Note</code></td>
47
+ <td>创建笔记</td>
48
+ <td><a href="/reference/global/note/">Note</a></td>
49
+ </tr>
50
+ <tr>
51
+ <td><code>PopupMenu</code></td>
52
+ <td>弹出菜单</td>
53
+ <td><a href="/reference/global/popup-menu/">PopupMenu</a></td>
54
+ </tr>
55
+ <tr>
56
+ <td><code>PopupMenuItem</code></td>
57
+ <td>菜单项</td>
58
+ <td><a href="/reference/global/popup-menu-item/">PopupMenuItem</a></td>
59
+ </tr>
60
+ <tr>
61
+ <td><code>SearchManager</code></td>
62
+ <td>搜索与索引(<code>Application.sharedInstance().searchManager</code>)</td>
63
+ <td><a href="/reference/global/search-manager/">SearchManager</a></td>
64
+ </tr>
65
+ </tbody>
66
+ </table>
67
+
68
+ ## MarginNote核心
69
+
70
+ 下表覆盖 `/reference/marginnote/`目录下的全部参考页,用于快速定位对象定义与用法入口。
71
+
72
+ <table>
73
+ <colgroup>
74
+ <col style="width:28%">
75
+ <col style="width:44%">
76
+ <col style="width:28%">
77
+ </colgroup>
78
+ <thead>
79
+ <tr>
80
+ <th>名称</th>
81
+ <th>用途</th>
82
+ <th>参考</th>
83
+ </tr>
84
+ </thead>
85
+ <tbody>
86
+ <tr>
87
+ <td><code>StudyController</code></td>
88
+ <td>学习入口(从<code>Application</code>获取)</td>
89
+ <td><a href="/reference/marginnote/study-controller/">StudyController</a></td>
90
+ </tr>
91
+ <tr>
92
+ <td><code>NotebookController</code></td>
93
+ <td>脑图与大纲(从<code>StudyController</code>获取)</td>
94
+ <td><a href="/reference/marginnote/notebook-controller/">NotebookController</a></td>
95
+ </tr>
96
+ <tr>
97
+ <td><code>ReaderController</code></td>
98
+ <td>阅读区控制(从<code>StudyController</code>获取)</td>
99
+ <td><a href="/reference/marginnote/reader-controller/">ReaderController</a></td>
100
+ </tr>
101
+ <tr>
102
+ <td><code>DocumentController</code></td>
103
+ <td>单文档控制(从<code>ReaderController</code>获取)</td>
104
+ <td><a href="/reference/marginnote/document-controller/">DocumentController</a></td>
105
+ </tr>
106
+ <tr>
107
+ <td><code>MindMapView</code></td>
108
+ <td>脑图视图(从<code>NotebookController</code>获取)</td>
109
+ <td><a href="/reference/marginnote/mindmap-view/">MindMapView</a></td>
110
+ </tr>
111
+ <tr>
112
+ <td><code>OutlineView</code></td>
113
+ <td>大纲视图</td>
114
+ <td><a href="/reference/marginnote/outline-view/">OutlineView</a></td>
115
+ </tr>
116
+ <tr>
117
+ <td><code>MindMapNode</code></td>
118
+ <td>脑图节点(来自<code>MindMapView</code>/选中列表)</td>
119
+ <td><a href="/reference/marginnote/mindmap-node/">MindMapNode</a></td>
120
+ </tr>
121
+ <tr>
122
+ <td><code>MbBookNote</code></td>
123
+ <td>笔记对象</td>
124
+ <td><a href="/reference/marginnote/mb-book-note/">MbBookNote</a></td>
125
+ </tr>
126
+ <tr>
127
+ <td><code>MbTopic</code></td>
128
+ <td>笔记本对象</td>
129
+ <td><a href="/reference/marginnote/mb-topic/">MbTopic</a></td>
130
+ </tr>
131
+ <tr>
132
+ <td><code>MbBook</code></td>
133
+ <td>文档对象</td>
134
+ <td><a href="/reference/marginnote/mb-book/">MbBook</a></td>
135
+ </tr>
136
+ <tr>
137
+ <td><code>NoteComment</code></td>
138
+ <td>评论结构</td>
139
+ <td><a href="/reference/marginnote/note-comment/">NoteComment</a></td>
140
+ </tr>
141
+ <tr>
142
+ <td><code>JSExtension</code></td>
143
+ <td>插件主类</td>
144
+ <td><a href="/reference/marginnote/jsextension/">JSExtension</a></td>
145
+ </tr>
146
+ <tr>
147
+ <td><code>MbModelTool</code></td>
148
+ <td>数据库协议</td>
149
+ <td><a href="/reference/marginnote/mb-model-tool/">MbModelTool</a></td>
150
+ </tr>
151
+ </tbody>
152
+ </table>
153
+
154
+ ## Utility
155
+
156
+ <table>
157
+ <colgroup>
158
+ <col style="width:28%">
159
+ <col style="width:44%">
160
+ <col style="width:28%">
161
+ </colgroup>
162
+ <thead>
163
+ <tr>
164
+ <th>名称</th>
165
+ <th>用途</th>
166
+ <th>参考</th>
167
+ </tr>
168
+ </thead>
169
+ <tbody>
170
+ <tr>
171
+ <td><code>UndoManager</code></td>
172
+ <td>撤销与刷新</td>
173
+ <td><a href="/reference/utility/undo-manager/">UndoManager</a></td>
174
+ </tr>
175
+ <tr>
176
+ <td><code>SpeechManager</code></td>
177
+ <td>语音朗读</td>
178
+ <td><a href="/reference/utility/speech-manager/">SpeechManager</a></td>
179
+ </tr>
180
+ <tr>
181
+ <td><code>ZipArchive</code></td>
182
+ <td>ZIP压缩解压</td>
183
+ <td><a href="/reference/utility/zip-archive/">ZipArchive</a></td>
184
+ </tr>
185
+ <tr>
186
+ <td><code>MenuController</code></td>
187
+ <td>菜单视图</td>
188
+ <td><a href="/reference/utility/menu-controller/">MenuController</a></td>
189
+ </tr>
190
+ <tr>
191
+ <td><code>SQLiteDatabase</code></td>
192
+ <td>执行SQL</td>
193
+ <td><a href="/reference/utility/sqlite-database/">SQLiteDatabase</a></td>
194
+ </tr>
195
+ <tr>
196
+ <td><code>SQLiteResultSet</code></td>
197
+ <td>读取结果</td>
198
+ <td><a href="/reference/utility/sqlite-result-set/">SQLiteResultSet</a></td>
199
+ </tr>
200
+ <tr>
201
+ <td><code>SQLiteStatement</code></td>
202
+ <td>缓存语句</td>
203
+ <td><a href="/reference/utility/sqlite-statement/">SQLiteStatement</a></td>
204
+ </tr>
205
+ </tbody>
206
+ </table>
207
+
208
+ ## 其它对象
209
+
210
+ 本页不再列出Foundation/UIKit/QuartzCore与JavaScript原生环境的全量对象清单;它们的参考条目请在侧边栏对应分组中查阅。下面仅给出常见入口与例子。
211
+
212
+ ### Foundation
213
+
214
+ 多数Foundation类可直接使用(通常以类/单例形式导出)。例:
215
+
216
+ - [NSFileManager](/reference/foundation/ns-file-manager/)
217
+ - [NSData](/reference/foundation/ns-data/)
218
+ - [NSTimer](/reference/foundation/ns-timer/)
219
+
220
+ ### UIKit
221
+
222
+ 可用UIKit搭原生UI(视图/控制器/控件等)。例:
223
+
224
+ - [UIApplication](/reference/uikit/uiapplication/)
225
+ - [UIViewController](/reference/uikit/uiview-controller/)
226
+ - [UIButton](/reference/uikit/uibutton/)
227
+
228
+ ### QuartzCore
229
+
230
+ 图层/动画相关能力主要来自QuartzCore。例:
231
+
232
+ - [CALayer](/reference/quartzcore/calayer/)
233
+ - [CAShapeLayer](/reference/quartzcore/cashape-layer/)
234
+ - [CATransaction](/reference/quartzcore/catransaction/)
235
+
236
+ ### JavaScript原生环境
237
+
238
+ 插件运行在JavaScriptCore中,标准JavaScript内置对象可用但非浏览器环境;详见:[JavaScript原生环境](/reference/js-runtime/)