@ppdocs/mcp 2.6.25 → 2.6.26
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/dist/tools/index.js +146 -13
- package/dist/vector/index.d.ts +44 -0
- package/dist/vector/index.js +203 -0
- package/package.json +2 -1
- package/dist/storage/fileStorage.d.ts +0 -41
- package/dist/storage/fileStorage.js +0 -458
package/dist/tools/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import * as storage from '../storage/httpClient.js';
|
|
3
3
|
import { decodeUnicodeEscapes, decodeObjectStrings, getRules, RULE_TYPE_LABELS } from '../utils.js';
|
|
4
|
+
import * as vector from '../vector/index.js';
|
|
4
5
|
// 辅助函数: 包装返回结果
|
|
5
6
|
function wrap(_projectId, text) {
|
|
6
7
|
return { content: [{ type: 'text', text }] };
|
|
@@ -36,11 +37,27 @@ export function registerTools(server, projectId, _user) {
|
|
|
36
37
|
dependencies: decoded.dependencies || [],
|
|
37
38
|
relatedFiles: decoded.relatedFiles || []
|
|
38
39
|
});
|
|
40
|
+
// 自动更新向量索引
|
|
41
|
+
try {
|
|
42
|
+
await vector.upsertNode(projectId, node.id, node.title, node.description, node.categories);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
console.warn('[kg_create_node] Vector index update failed:', e);
|
|
46
|
+
}
|
|
39
47
|
return wrap(projectId, JSON.stringify(node, null, 2));
|
|
40
48
|
});
|
|
41
49
|
// 2. 删除节点
|
|
42
50
|
server.tool('kg_delete_node', '删除节点(锁定节点和根节点不可删除)', { nodeId: z.string().describe('节点ID') }, async (args) => {
|
|
43
51
|
const success = await storage.deleteNode(projectId, args.nodeId);
|
|
52
|
+
// 同步删除向量索引
|
|
53
|
+
if (success) {
|
|
54
|
+
try {
|
|
55
|
+
await vector.removeNode(projectId, args.nodeId);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
console.warn('[kg_delete_node] Vector index removal failed:', e);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
44
61
|
return wrap(projectId, success ? '删除成功' : '删除失败(节点不存在/已锁定/是根节点)');
|
|
45
62
|
});
|
|
46
63
|
// 3. 更新节点
|
|
@@ -95,6 +112,15 @@ export function registerTools(server, projectId, _user) {
|
|
|
95
112
|
if (lastSyncAt !== undefined)
|
|
96
113
|
updates.lastSyncAt = lastSyncAt;
|
|
97
114
|
const node = await storage.updateNode(projectId, nodeId, updates);
|
|
115
|
+
// 自动更新向量索引 (标题或描述变更时)
|
|
116
|
+
if (node && (rest.title || rest.description || tags)) {
|
|
117
|
+
try {
|
|
118
|
+
await vector.upsertNode(projectId, node.id, node.title, node.description, node.categories);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
console.warn('[kg_update_node] Vector index update failed:', e);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
98
124
|
return wrap(projectId, node ? JSON.stringify(node, null, 2) : '更新失败(节点不存在或已锁定)');
|
|
99
125
|
});
|
|
100
126
|
// 3.5 更新根节点 (项目介绍)
|
|
@@ -144,22 +170,109 @@ export function registerTools(server, projectId, _user) {
|
|
|
144
170
|
const node = await storage.lockNode(projectId, args.nodeId, true); // 强制锁定
|
|
145
171
|
return wrap(projectId, node ? JSON.stringify(node, null, 2) : '操作失败');
|
|
146
172
|
});
|
|
147
|
-
// 5. 搜索节点
|
|
148
|
-
server.tool('kg_search', '
|
|
149
|
-
keywords: z.array(z.string()).describe('
|
|
150
|
-
limit: z.number().optional().describe('返回数量(默认10)')
|
|
173
|
+
// 5. 搜索节点 (混合检索: 向量语义 + 关键词)
|
|
174
|
+
server.tool('kg_search', '智能搜索节点(语义+关键词混合)。支持中英文语义匹配,空白关键词[""]返回全部节点', {
|
|
175
|
+
keywords: z.array(z.string()).describe('搜索词列表,支持语义匹配(如"登录"可找到auth)'),
|
|
176
|
+
limit: z.number().optional().describe('返回数量(默认10)'),
|
|
177
|
+
mode: z.enum(['hybrid', 'keyword', 'semantic']).optional().describe('搜索模式: hybrid=混合(默认), keyword=仅关键词, semantic=仅语义')
|
|
151
178
|
}, async (args) => {
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
const limit = args.limit || 10;
|
|
180
|
+
const mode = args.mode || 'hybrid';
|
|
181
|
+
const query = args.keywords.filter(k => k.trim()).join(' ');
|
|
182
|
+
// 空白关键词返回全部
|
|
183
|
+
if (!query) {
|
|
184
|
+
const results = await storage.searchNodes(projectId, args.keywords, limit);
|
|
185
|
+
const output = results.map(r => ({
|
|
186
|
+
id: r.node.id,
|
|
187
|
+
title: r.node.title,
|
|
188
|
+
tags: r.node.categories || [],
|
|
189
|
+
status: r.node.status,
|
|
190
|
+
summary: r.node.summary || '',
|
|
191
|
+
hitRate: `${Math.round(r.score)}%`
|
|
192
|
+
}));
|
|
193
|
+
return wrap(projectId, `${JSON.stringify(output, null, 2)}\n\n💡 需要详细内容请使用 kg_read_node(nodeId) 获取节点详情`);
|
|
194
|
+
}
|
|
195
|
+
// 获取关键词结果
|
|
196
|
+
let keywordResults = [];
|
|
197
|
+
if (mode === 'hybrid' || mode === 'keyword') {
|
|
198
|
+
const kResults = await storage.searchNodes(projectId, args.keywords, limit * 2);
|
|
199
|
+
keywordResults = kResults.map(r => ({
|
|
200
|
+
id: r.node.id,
|
|
201
|
+
title: r.node.title,
|
|
202
|
+
tags: r.node.categories || [],
|
|
203
|
+
status: r.node.status,
|
|
204
|
+
summary: r.node.summary || '',
|
|
205
|
+
score: r.score / 100 // 归一化到 0-1
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
// 获取语义结果
|
|
209
|
+
let semanticResults = [];
|
|
210
|
+
if (mode === 'hybrid' || mode === 'semantic') {
|
|
211
|
+
try {
|
|
212
|
+
semanticResults = await vector.semanticSearch(projectId, query, limit * 2);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
console.warn('[kg_search] Semantic search failed, falling back to keyword:', e);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// 融合排序 (混合模式)
|
|
219
|
+
const scoreMap = new Map();
|
|
220
|
+
// 合并关键词结果
|
|
221
|
+
for (const r of keywordResults) {
|
|
222
|
+
scoreMap.set(r.id, {
|
|
223
|
+
id: r.id,
|
|
224
|
+
title: r.title,
|
|
225
|
+
tags: r.tags,
|
|
226
|
+
status: r.status,
|
|
227
|
+
summary: r.summary,
|
|
228
|
+
keywordScore: r.score,
|
|
229
|
+
semanticScore: 0
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// 合并语义结果
|
|
233
|
+
for (const r of semanticResults) {
|
|
234
|
+
const existing = scoreMap.get(r.id);
|
|
235
|
+
if (existing) {
|
|
236
|
+
existing.semanticScore = r.similarity;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// 需要获取节点详情
|
|
240
|
+
const node = await storage.getNode(projectId, r.id);
|
|
241
|
+
if (node) {
|
|
242
|
+
scoreMap.set(r.id, {
|
|
243
|
+
id: r.id,
|
|
244
|
+
title: r.title,
|
|
245
|
+
tags: node.categories || [],
|
|
246
|
+
status: node.status,
|
|
247
|
+
summary: node.summary || '',
|
|
248
|
+
keywordScore: 0,
|
|
249
|
+
semanticScore: r.similarity
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// 计算综合得分并排序
|
|
255
|
+
const combined = Array.from(scoreMap.values()).map(item => {
|
|
256
|
+
// 混合权重: 语义 0.6 + 关键词 0.4
|
|
257
|
+
const finalScore = mode === 'keyword' ? item.keywordScore
|
|
258
|
+
: mode === 'semantic' ? item.semanticScore
|
|
259
|
+
: item.semanticScore * 0.6 + item.keywordScore * 0.4;
|
|
260
|
+
return { ...item, finalScore };
|
|
261
|
+
});
|
|
262
|
+
combined.sort((a, b) => b.finalScore - a.finalScore);
|
|
263
|
+
const topResults = combined.slice(0, limit);
|
|
264
|
+
const output = topResults.map(r => ({
|
|
265
|
+
id: r.id,
|
|
266
|
+
title: r.title,
|
|
267
|
+
tags: r.tags,
|
|
268
|
+
status: r.status,
|
|
269
|
+
summary: r.summary,
|
|
270
|
+
score: `${Math.round(r.finalScore * 100)}%`,
|
|
271
|
+
matchType: r.keywordScore > 0 && r.semanticScore > 0 ? 'hybrid'
|
|
272
|
+
: r.semanticScore > 0 ? 'semantic' : 'keyword'
|
|
160
273
|
}));
|
|
161
274
|
const json = JSON.stringify(output, null, 2);
|
|
162
|
-
return wrap(projectId, `${json}\n\n💡 需要详细内容请使用 kg_read_node(nodeId)
|
|
275
|
+
return wrap(projectId, `${json}\n\n💡 需要详细内容请使用 kg_read_node(nodeId) 获取节点详情\n🔍 搜索模式: ${mode}`);
|
|
163
276
|
});
|
|
164
277
|
// 5.5 列出所有标签
|
|
165
278
|
server.tool('kg_list_tags', '获取所有节点使用过的标签列表(去重)', {}, async () => {
|
|
@@ -527,4 +640,24 @@ export function registerTools(server, projectId, _user) {
|
|
|
527
640
|
};
|
|
528
641
|
return wrap(projectId, JSON.stringify(result, null, 2));
|
|
529
642
|
});
|
|
643
|
+
// ===================== 向量索引管理 =====================
|
|
644
|
+
// 16. 构建向量索引
|
|
645
|
+
server.tool('kg_build_index', '构建/重建向量语义索引(首次使用或索引损坏时调用)', {}, async () => {
|
|
646
|
+
const nodes = await storage.listNodes(projectId);
|
|
647
|
+
const count = await vector.buildIndex(projectId, nodes.map(n => ({
|
|
648
|
+
id: n.id,
|
|
649
|
+
title: n.title,
|
|
650
|
+
description: n.description,
|
|
651
|
+
categories: n.categories
|
|
652
|
+
})));
|
|
653
|
+
return wrap(projectId, `✅ 向量索引构建完成,共 ${count} 个节点`);
|
|
654
|
+
});
|
|
655
|
+
// 17. 查看索引状态
|
|
656
|
+
server.tool('kg_index_stats', '查看向量索引状态', {}, async () => {
|
|
657
|
+
const stats = await vector.getIndexStats(projectId);
|
|
658
|
+
return wrap(projectId, JSON.stringify({
|
|
659
|
+
...stats,
|
|
660
|
+
tip: stats.count === 0 ? '索引为空,请运行 kg_build_index 构建索引' : '索引正常'
|
|
661
|
+
}, null, 2));
|
|
662
|
+
});
|
|
530
663
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 向量检索服务 - 基于 transformers.js 的语义搜索
|
|
3
|
+
* @version 0.1
|
|
4
|
+
*/
|
|
5
|
+
interface VectorSearchResult {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
similarity: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 添加或更新节点向量
|
|
12
|
+
*/
|
|
13
|
+
export declare function upsertNode(projectId: string, nodeId: string, title: string, description: string, categories?: string[]): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* 删除节点向量
|
|
16
|
+
*/
|
|
17
|
+
export declare function removeNode(projectId: string, nodeId: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* 向量语义搜索
|
|
20
|
+
*/
|
|
21
|
+
export declare function semanticSearch(projectId: string, query: string, limit?: number): Promise<VectorSearchResult[]>;
|
|
22
|
+
/**
|
|
23
|
+
* 批量构建索引 (用于初始化或重建)
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildIndex(projectId: string, nodes: Array<{
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
description: string;
|
|
29
|
+
categories?: string[];
|
|
30
|
+
}>): Promise<number>;
|
|
31
|
+
/**
|
|
32
|
+
* 检查索引状态
|
|
33
|
+
*/
|
|
34
|
+
export declare function getIndexStats(projectId: string): Promise<{
|
|
35
|
+
count: number;
|
|
36
|
+
model: string;
|
|
37
|
+
dimension: number;
|
|
38
|
+
loaded: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* 预加载模型 (可选, 用于启动时预热)
|
|
42
|
+
*/
|
|
43
|
+
export declare function preloadModel(): Promise<void>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 向量检索服务 - 基于 transformers.js 的语义搜索
|
|
3
|
+
* @version 0.1
|
|
4
|
+
*/
|
|
5
|
+
import { pipeline } from '@xenova/transformers';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
// 模型配置
|
|
9
|
+
const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2'; // 23MB, 384维, 中英双语
|
|
10
|
+
const VECTOR_DIM = 384;
|
|
11
|
+
// 单例模式
|
|
12
|
+
let extractor = null;
|
|
13
|
+
let isLoading = false;
|
|
14
|
+
let loadPromise = null;
|
|
15
|
+
// 内存索引 (projectId -> entries)
|
|
16
|
+
const vectorIndex = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* 加载嵌入模型 (懒加载, 首次调用时下载)
|
|
19
|
+
*/
|
|
20
|
+
async function getExtractor() {
|
|
21
|
+
if (extractor)
|
|
22
|
+
return extractor;
|
|
23
|
+
if (isLoading && loadPromise) {
|
|
24
|
+
return loadPromise;
|
|
25
|
+
}
|
|
26
|
+
isLoading = true;
|
|
27
|
+
console.log('[Vector] Loading embedding model...');
|
|
28
|
+
loadPromise = pipeline('feature-extraction', MODEL_NAME, {
|
|
29
|
+
quantized: true, // 使用量化版本,更小更快
|
|
30
|
+
});
|
|
31
|
+
extractor = await loadPromise;
|
|
32
|
+
isLoading = false;
|
|
33
|
+
console.log('[Vector] Model loaded successfully');
|
|
34
|
+
return extractor;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 文本转向量
|
|
38
|
+
*/
|
|
39
|
+
async function embed(text) {
|
|
40
|
+
const ext = await getExtractor();
|
|
41
|
+
const output = await ext(text, { pooling: 'mean', normalize: true });
|
|
42
|
+
return Array.from(output.data);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 计算余弦相似度
|
|
46
|
+
*/
|
|
47
|
+
function cosineSimilarity(a, b) {
|
|
48
|
+
if (a.length !== b.length)
|
|
49
|
+
return 0;
|
|
50
|
+
let dotProduct = 0;
|
|
51
|
+
let normA = 0;
|
|
52
|
+
let normB = 0;
|
|
53
|
+
for (let i = 0; i < a.length; i++) {
|
|
54
|
+
dotProduct += a[i] * b[i];
|
|
55
|
+
normA += a[i] * a[i];
|
|
56
|
+
normB += b[i] * b[i];
|
|
57
|
+
}
|
|
58
|
+
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
|
59
|
+
return magnitude === 0 ? 0 : dotProduct / magnitude;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 获取索引文件路径
|
|
63
|
+
*/
|
|
64
|
+
function getIndexPath(projectId) {
|
|
65
|
+
const baseDir = process.env.PPDOCS_DATA_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '', '.ppdocs');
|
|
66
|
+
return path.join(baseDir, 'projects', projectId, 'vector-index.json');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 加载持久化索引
|
|
70
|
+
*/
|
|
71
|
+
async function loadIndex(projectId) {
|
|
72
|
+
// 先检查内存
|
|
73
|
+
if (vectorIndex.has(projectId)) {
|
|
74
|
+
return vectorIndex.get(projectId);
|
|
75
|
+
}
|
|
76
|
+
// 尝试从文件加载
|
|
77
|
+
const indexPath = getIndexPath(projectId);
|
|
78
|
+
try {
|
|
79
|
+
if (fs.existsSync(indexPath)) {
|
|
80
|
+
const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
81
|
+
vectorIndex.set(projectId, data.entries || []);
|
|
82
|
+
console.log(`[Vector] Loaded ${data.entries?.length || 0} entries from disk`);
|
|
83
|
+
return data.entries || [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
console.warn('[Vector] Failed to load index:', e);
|
|
88
|
+
}
|
|
89
|
+
vectorIndex.set(projectId, []);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 保存索引到磁盘
|
|
94
|
+
*/
|
|
95
|
+
async function saveIndex(projectId) {
|
|
96
|
+
const entries = vectorIndex.get(projectId) || [];
|
|
97
|
+
const indexPath = getIndexPath(projectId);
|
|
98
|
+
try {
|
|
99
|
+
const dir = path.dirname(indexPath);
|
|
100
|
+
if (!fs.existsSync(dir)) {
|
|
101
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
102
|
+
}
|
|
103
|
+
fs.writeFileSync(indexPath, JSON.stringify({
|
|
104
|
+
version: 1,
|
|
105
|
+
model: MODEL_NAME,
|
|
106
|
+
dimension: VECTOR_DIM,
|
|
107
|
+
updatedAt: new Date().toISOString(),
|
|
108
|
+
entries
|
|
109
|
+
}, null, 2));
|
|
110
|
+
console.log(`[Vector] Saved ${entries.length} entries to disk`);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
console.warn('[Vector] Failed to save index:', e);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 添加或更新节点向量
|
|
118
|
+
*/
|
|
119
|
+
export async function upsertNode(projectId, nodeId, title, description, categories = []) {
|
|
120
|
+
const entries = await loadIndex(projectId);
|
|
121
|
+
// 合并可搜索文本
|
|
122
|
+
const searchableText = `${title} ${description} ${categories.join(' ')}`;
|
|
123
|
+
const embedding = await embed(searchableText);
|
|
124
|
+
// 查找现有条目
|
|
125
|
+
const existingIndex = entries.findIndex(e => e.id === nodeId);
|
|
126
|
+
const entry = { id: nodeId, title, embedding };
|
|
127
|
+
if (existingIndex >= 0) {
|
|
128
|
+
entries[existingIndex] = entry;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
entries.push(entry);
|
|
132
|
+
}
|
|
133
|
+
vectorIndex.set(projectId, entries);
|
|
134
|
+
await saveIndex(projectId);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 删除节点向量
|
|
138
|
+
*/
|
|
139
|
+
export async function removeNode(projectId, nodeId) {
|
|
140
|
+
const entries = await loadIndex(projectId);
|
|
141
|
+
const filtered = entries.filter(e => e.id !== nodeId);
|
|
142
|
+
vectorIndex.set(projectId, filtered);
|
|
143
|
+
await saveIndex(projectId);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 向量语义搜索
|
|
147
|
+
*/
|
|
148
|
+
export async function semanticSearch(projectId, query, limit = 10) {
|
|
149
|
+
const entries = await loadIndex(projectId);
|
|
150
|
+
if (entries.length === 0) {
|
|
151
|
+
console.log('[Vector] Index is empty, skipping semantic search');
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
// 查询向量化
|
|
155
|
+
const queryEmbedding = await embed(query);
|
|
156
|
+
// 计算相似度
|
|
157
|
+
const results = entries.map(entry => ({
|
|
158
|
+
id: entry.id,
|
|
159
|
+
title: entry.title,
|
|
160
|
+
similarity: cosineSimilarity(queryEmbedding, entry.embedding)
|
|
161
|
+
}));
|
|
162
|
+
// 排序并返回 top-k
|
|
163
|
+
results.sort((a, b) => b.similarity - a.similarity);
|
|
164
|
+
return results.slice(0, limit);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 批量构建索引 (用于初始化或重建)
|
|
168
|
+
*/
|
|
169
|
+
export async function buildIndex(projectId, nodes) {
|
|
170
|
+
console.log(`[Vector] Building index for ${nodes.length} nodes...`);
|
|
171
|
+
const entries = [];
|
|
172
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
173
|
+
const node = nodes[i];
|
|
174
|
+
const searchableText = `${node.title} ${node.description} ${(node.categories || []).join(' ')}`;
|
|
175
|
+
const embedding = await embed(searchableText);
|
|
176
|
+
entries.push({ id: node.id, title: node.title, embedding });
|
|
177
|
+
if ((i + 1) % 10 === 0) {
|
|
178
|
+
console.log(`[Vector] Processed ${i + 1}/${nodes.length}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
vectorIndex.set(projectId, entries);
|
|
182
|
+
await saveIndex(projectId);
|
|
183
|
+
console.log(`[Vector] Index built with ${entries.length} entries`);
|
|
184
|
+
return entries.length;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 检查索引状态
|
|
188
|
+
*/
|
|
189
|
+
export async function getIndexStats(projectId) {
|
|
190
|
+
const entries = await loadIndex(projectId);
|
|
191
|
+
return {
|
|
192
|
+
count: entries.length,
|
|
193
|
+
model: MODEL_NAME,
|
|
194
|
+
dimension: VECTOR_DIM,
|
|
195
|
+
loaded: extractor !== null
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 预加载模型 (可选, 用于启动时预热)
|
|
200
|
+
*/
|
|
201
|
+
export async function preloadModel() {
|
|
202
|
+
await getExtractor();
|
|
203
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ppdocs/mcp",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.26",
|
|
4
4
|
"description": "ppdocs MCP Server - Knowledge Graph for Claude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
33
|
+
"@xenova/transformers": "^2.17.2",
|
|
33
34
|
"proper-lockfile": "^4.1.2",
|
|
34
35
|
"zod": "^4.1.13"
|
|
35
36
|
},
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { NodeData, Project, SearchResult, PathResult, BugfixRecord } from './types.js';
|
|
2
|
-
export declare function listProjects(): Promise<Project[]>;
|
|
3
|
-
export declare function ensureProject(projectId: string): Promise<Project>;
|
|
4
|
-
export declare function createProject(name: string): Promise<Project>;
|
|
5
|
-
export declare function getChangeMarkerTime(projectId: string): Promise<number>;
|
|
6
|
-
export declare function listNodes(projectId: string): Promise<NodeData[]>;
|
|
7
|
-
export declare function getNode(projectId: string, nodeId: string): Promise<NodeData | null>;
|
|
8
|
-
type CreateNodeInput = Omit<NodeData, 'id' | 'x' | 'y'> & {
|
|
9
|
-
x?: number;
|
|
10
|
-
y?: number;
|
|
11
|
-
};
|
|
12
|
-
export declare function createNode(projectId: string, node: CreateNodeInput): Promise<NodeData>;
|
|
13
|
-
export declare function updateNode(projectId: string, nodeId: string, updates: Partial<NodeData>): Promise<NodeData | null>;
|
|
14
|
-
export declare function deleteNode(projectId: string, nodeId: string): Promise<boolean>;
|
|
15
|
-
export declare function lockNode(projectId: string, nodeId: string, locked: boolean): Promise<NodeData | null>;
|
|
16
|
-
export declare function searchNodes(projectId: string, keywords: string[], limit?: number): Promise<SearchResult[]>;
|
|
17
|
-
export declare function findPath(projectId: string, startId: string, endId: string): Promise<PathResult | null>;
|
|
18
|
-
export interface OrphanNode {
|
|
19
|
-
id: string;
|
|
20
|
-
title: string;
|
|
21
|
-
type: string;
|
|
22
|
-
status: string;
|
|
23
|
-
}
|
|
24
|
-
export interface RelationNode {
|
|
25
|
-
nodeId: string;
|
|
26
|
-
title: string;
|
|
27
|
-
description: string;
|
|
28
|
-
edgeType: string;
|
|
29
|
-
direction: 'outgoing' | 'incoming';
|
|
30
|
-
}
|
|
31
|
-
/** 查找孤立节点(没有任何边连接的节点) */
|
|
32
|
-
export declare function findOrphans(projectId: string): Promise<OrphanNode[]>;
|
|
33
|
-
/** 查询节点关系网(所有直接连接的节点) */
|
|
34
|
-
export declare function getRelations(projectId: string, nodeId: string): Promise<RelationNode[]>;
|
|
35
|
-
/** 添加错误修复记录到节点 */
|
|
36
|
-
export declare function addBugfix(projectId: string, nodeId: string, bugfix: {
|
|
37
|
-
issue: string;
|
|
38
|
-
solution: string;
|
|
39
|
-
impact?: string;
|
|
40
|
-
}): Promise<BugfixRecord | null>;
|
|
41
|
-
export {};
|
|
@@ -1,458 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs/promises';
|
|
2
|
-
import * as path from 'path';
|
|
3
|
-
import { homedir } from 'os';
|
|
4
|
-
import lockfile from 'proper-lockfile';
|
|
5
|
-
const DATA_DIR = path.join(homedir(), '.ppdocs');
|
|
6
|
-
// 文件锁配置
|
|
7
|
-
const LOCK_OPTIONS = {
|
|
8
|
-
stale: 10000, // 锁超时 10秒 (防止死锁)
|
|
9
|
-
retries: {
|
|
10
|
-
retries: 5, // 最多重试 5 次
|
|
11
|
-
minTimeout: 100, // 最小等待 100ms
|
|
12
|
-
maxTimeout: 500 // 最大等待 500ms
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
async function ensureDir(dir) {
|
|
16
|
-
await fs.mkdir(dir, { recursive: true });
|
|
17
|
-
}
|
|
18
|
-
async function readJSON(filePath, defaultValue) {
|
|
19
|
-
try {
|
|
20
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
21
|
-
return JSON.parse(content);
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return defaultValue;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
// 带文件锁的写入 (防止并发数据丢失)
|
|
28
|
-
async function writeJSON(filePath, data) {
|
|
29
|
-
await ensureDir(path.dirname(filePath));
|
|
30
|
-
// 确保文件存在 (lockfile 需要文件存在)
|
|
31
|
-
try {
|
|
32
|
-
await fs.access(filePath);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
await fs.writeFile(filePath, '{}');
|
|
36
|
-
}
|
|
37
|
-
let release = null;
|
|
38
|
-
try {
|
|
39
|
-
release = await lockfile.lock(filePath, LOCK_OPTIONS);
|
|
40
|
-
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
|
41
|
-
}
|
|
42
|
-
finally {
|
|
43
|
-
if (release)
|
|
44
|
-
await release();
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
// ============ 项目操作 ============
|
|
48
|
-
export async function listProjects() {
|
|
49
|
-
const filePath = path.join(DATA_DIR, 'projects.json');
|
|
50
|
-
return readJSON(filePath, []);
|
|
51
|
-
}
|
|
52
|
-
export async function ensureProject(projectId) {
|
|
53
|
-
const projects = await listProjects();
|
|
54
|
-
const existing = projects.find(p => p.id === projectId);
|
|
55
|
-
if (existing)
|
|
56
|
-
return existing;
|
|
57
|
-
// 创建新项目
|
|
58
|
-
const project = { id: projectId, name: projectId, updatedAt: new Date().toISOString() };
|
|
59
|
-
projects.push(project);
|
|
60
|
-
await writeJSON(path.join(DATA_DIR, 'projects.json'), projects);
|
|
61
|
-
// 创建根节点
|
|
62
|
-
const rootNode = {
|
|
63
|
-
id: 'root',
|
|
64
|
-
title: projectId,
|
|
65
|
-
type: 'intro',
|
|
66
|
-
status: 'complete',
|
|
67
|
-
x: 0, y: 0, // 根节点为坐标原点
|
|
68
|
-
locked: true,
|
|
69
|
-
isOrigin: true,
|
|
70
|
-
signature: projectId,
|
|
71
|
-
categories: ['logic'], // 与 Frontend/Rust 统一
|
|
72
|
-
description: `# ${projectId}\n\n项目根节点`,
|
|
73
|
-
dependencies: []
|
|
74
|
-
};
|
|
75
|
-
const meta = { projectId, projectName: projectId, updatedAt: project.updatedAt, edges: [] };
|
|
76
|
-
const projectDir = path.join(DATA_DIR, 'projects', projectId);
|
|
77
|
-
await writeJSON(path.join(projectDir, 'meta.json'), meta);
|
|
78
|
-
await writeJSON(path.join(projectDir, 'nodes', 'root.json'), rootNode);
|
|
79
|
-
return project;
|
|
80
|
-
}
|
|
81
|
-
export async function createProject(name) {
|
|
82
|
-
const id = `project-${Date.now()}`;
|
|
83
|
-
return ensureProject(id);
|
|
84
|
-
}
|
|
85
|
-
// 自动计算边 (基于 dependencies.name 匹配 signature)
|
|
86
|
-
function computeAutoEdges(nodes) {
|
|
87
|
-
const signatureList = [];
|
|
88
|
-
nodes.forEach(n => {
|
|
89
|
-
if (n.signature) {
|
|
90
|
-
signatureList.push({ sig: n.signature.toLowerCase(), id: n.id });
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
const edges = [];
|
|
94
|
-
const edgeSet = new Set();
|
|
95
|
-
nodes.forEach(node => {
|
|
96
|
-
node.dependencies?.forEach(dep => {
|
|
97
|
-
const depName = dep.name.toLowerCase();
|
|
98
|
-
const match = signatureList.find(s => s.sig.startsWith(depName));
|
|
99
|
-
if (match && match.id !== node.id) {
|
|
100
|
-
const key = `${node.id}->${match.id}`;
|
|
101
|
-
if (!edgeSet.has(key)) {
|
|
102
|
-
edgeSet.add(key);
|
|
103
|
-
edges.push({ source: node.id, target: match.id, type: 'uses' });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
return edges;
|
|
109
|
-
}
|
|
110
|
-
function calculateAutoPosition(nodes, dependencies) {
|
|
111
|
-
const root = nodes.find(n => n.isOrigin);
|
|
112
|
-
if (!root)
|
|
113
|
-
return { x: 200, y: 200 };
|
|
114
|
-
// 无依赖 → 随机环绕 root
|
|
115
|
-
if (!dependencies?.length) {
|
|
116
|
-
const angle = Math.random() * 2 * Math.PI;
|
|
117
|
-
const radius = 200 + Math.random() * 100;
|
|
118
|
-
return {
|
|
119
|
-
x: Math.round(root.x + Math.cos(angle) * radius),
|
|
120
|
-
y: Math.round(root.y + Math.sin(angle) * radius)
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
// 找距 root 最远的依赖节点作为 parent
|
|
124
|
-
let parent = null;
|
|
125
|
-
let maxDist = -1;
|
|
126
|
-
for (const dep of dependencies) {
|
|
127
|
-
const depName = dep.name.toLowerCase();
|
|
128
|
-
const match = nodes.find(n => n.signature?.toLowerCase().startsWith(depName));
|
|
129
|
-
if (match) {
|
|
130
|
-
const dist = Math.hypot(match.x - root.x, match.y - root.y);
|
|
131
|
-
if (dist > maxDist) {
|
|
132
|
-
maxDist = dist;
|
|
133
|
-
parent = match;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// 无匹配依赖 → 随机环绕 root
|
|
138
|
-
if (!parent) {
|
|
139
|
-
const angle = Math.random() * 2 * Math.PI;
|
|
140
|
-
const radius = 200 + Math.random() * 100;
|
|
141
|
-
return {
|
|
142
|
-
x: Math.round(root.x + Math.cos(angle) * radius),
|
|
143
|
-
y: Math.round(root.y + Math.sin(angle) * radius)
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
// 计算方向向量 (root → parent)
|
|
147
|
-
let dx = parent.x - root.x;
|
|
148
|
-
let dy = parent.y - root.y;
|
|
149
|
-
const len = Math.hypot(dx, dy);
|
|
150
|
-
if (len < 10) {
|
|
151
|
-
const angle = Math.random() * 2 * Math.PI;
|
|
152
|
-
dx = Math.cos(angle);
|
|
153
|
-
dy = Math.sin(angle);
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
dx /= len;
|
|
157
|
-
dy /= len;
|
|
158
|
-
}
|
|
159
|
-
// 新位置 = parent + 方向 * 150 (加小随机偏移防重叠)
|
|
160
|
-
const offset = 150 + (Math.random() - 0.5) * 30;
|
|
161
|
-
const angleOffset = (Math.random() - 0.5) * 0.3;
|
|
162
|
-
const cos = Math.cos(angleOffset), sin = Math.sin(angleOffset);
|
|
163
|
-
return {
|
|
164
|
-
x: Math.round(parent.x + (dx * cos - dy * sin) * offset),
|
|
165
|
-
y: Math.round(parent.y + (dx * sin + dy * cos) * offset)
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
// ============ 节点操作 ============
|
|
169
|
-
function getNodePath(projectId, nodeId) {
|
|
170
|
-
return path.join(DATA_DIR, 'projects', projectId, 'nodes', `${nodeId}.json`);
|
|
171
|
-
}
|
|
172
|
-
function getMetaPath(projectId) {
|
|
173
|
-
return path.join(DATA_DIR, 'projects', projectId, 'meta.json');
|
|
174
|
-
}
|
|
175
|
-
function getChangeMarkerPath(projectId) {
|
|
176
|
-
return path.join(DATA_DIR, 'projects', projectId, '.changed');
|
|
177
|
-
}
|
|
178
|
-
// 更新变更标记 (前端用于检测 MCP 变更)
|
|
179
|
-
async function touchChangeMarker(projectId) {
|
|
180
|
-
const markerPath = getChangeMarkerPath(projectId);
|
|
181
|
-
await ensureDir(path.dirname(markerPath));
|
|
182
|
-
await fs.writeFile(markerPath, Date.now().toString());
|
|
183
|
-
}
|
|
184
|
-
// 获取变更标记时间戳 (前端轮询用)
|
|
185
|
-
export async function getChangeMarkerTime(projectId) {
|
|
186
|
-
const markerPath = getChangeMarkerPath(projectId);
|
|
187
|
-
try {
|
|
188
|
-
const content = await fs.readFile(markerPath, 'utf-8');
|
|
189
|
-
return parseInt(content, 10) || 0;
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
return 0;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// 更新节点的最后访问时间 (触发前端发光效果)
|
|
196
|
-
async function touchNode(projectId, nodeId) {
|
|
197
|
-
const nodePath = getNodePath(projectId, nodeId);
|
|
198
|
-
try {
|
|
199
|
-
const node = await readJSON(nodePath, null);
|
|
200
|
-
if (node) {
|
|
201
|
-
node.lastAccessedAt = new Date().toISOString();
|
|
202
|
-
await writeJSON(nodePath, node);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
catch { /* ignore */ }
|
|
206
|
-
}
|
|
207
|
-
// 批量更新访问时间 (同时更新变更标记)
|
|
208
|
-
async function touchNodes(projectId, nodeIds) {
|
|
209
|
-
await Promise.all(nodeIds.map(id => touchNode(projectId, id)));
|
|
210
|
-
await touchChangeMarker(projectId);
|
|
211
|
-
}
|
|
212
|
-
export async function listNodes(projectId) {
|
|
213
|
-
const nodesDir = path.join(DATA_DIR, 'projects', projectId, 'nodes');
|
|
214
|
-
try {
|
|
215
|
-
const files = await fs.readdir(nodesDir);
|
|
216
|
-
const nodes = [];
|
|
217
|
-
for (const file of files) {
|
|
218
|
-
if (file.endsWith('.json')) {
|
|
219
|
-
const node = await readJSON(path.join(nodesDir, file), null);
|
|
220
|
-
if (node)
|
|
221
|
-
nodes.push(node);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return nodes;
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
return [];
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
export async function getNode(projectId, nodeId) {
|
|
231
|
-
return readJSON(getNodePath(projectId, nodeId), null);
|
|
232
|
-
}
|
|
233
|
-
export async function createNode(projectId, node) {
|
|
234
|
-
const id = `node-${Date.now()}`;
|
|
235
|
-
const now = new Date().toISOString();
|
|
236
|
-
// 自动布局: 未指定位置时计算
|
|
237
|
-
let x = node.x;
|
|
238
|
-
let y = node.y;
|
|
239
|
-
if (x === undefined || y === undefined) {
|
|
240
|
-
const nodes = await listNodes(projectId);
|
|
241
|
-
const pos = calculateAutoPosition(nodes, node.dependencies || []);
|
|
242
|
-
x = pos.x;
|
|
243
|
-
y = pos.y;
|
|
244
|
-
}
|
|
245
|
-
const fullNode = {
|
|
246
|
-
...node,
|
|
247
|
-
id,
|
|
248
|
-
x,
|
|
249
|
-
y,
|
|
250
|
-
updatedAt: now,
|
|
251
|
-
versions: [{ version: 1, date: now, changes: '初始创建' }],
|
|
252
|
-
bugfixes: []
|
|
253
|
-
};
|
|
254
|
-
await writeJSON(getNodePath(projectId, id), fullNode);
|
|
255
|
-
await touchChangeMarker(projectId);
|
|
256
|
-
return fullNode;
|
|
257
|
-
}
|
|
258
|
-
export async function updateNode(projectId, nodeId, updates) {
|
|
259
|
-
const node = await readJSON(getNodePath(projectId, nodeId), null);
|
|
260
|
-
if (!node)
|
|
261
|
-
return null;
|
|
262
|
-
if (node.locked && !('locked' in updates))
|
|
263
|
-
return null; // 锁定节点只能改locked
|
|
264
|
-
const now = new Date().toISOString();
|
|
265
|
-
// 自动检测变更字段
|
|
266
|
-
const changes = [];
|
|
267
|
-
if (updates.title !== undefined && updates.title !== node.title)
|
|
268
|
-
changes.push('标题');
|
|
269
|
-
if (updates.description !== undefined && updates.description !== node.description)
|
|
270
|
-
changes.push('描述');
|
|
271
|
-
if (updates.status !== undefined && updates.status !== node.status)
|
|
272
|
-
changes.push('状态');
|
|
273
|
-
if (updates.categories !== undefined && JSON.stringify(updates.categories) !== JSON.stringify(node.categories))
|
|
274
|
-
changes.push('分类');
|
|
275
|
-
if (updates.dependencies !== undefined && JSON.stringify(updates.dependencies) !== JSON.stringify(node.dependencies))
|
|
276
|
-
changes.push('依赖');
|
|
277
|
-
if (updates.signature !== undefined && updates.signature !== node.signature)
|
|
278
|
-
changes.push('签名');
|
|
279
|
-
// 合并更新
|
|
280
|
-
const updated = { ...node, ...updates, updatedAt: now };
|
|
281
|
-
// 有变更时自动追加版本记录
|
|
282
|
-
if (changes.length > 0) {
|
|
283
|
-
const currentVersion = node.versions?.[0]?.version || 0;
|
|
284
|
-
const newVersion = {
|
|
285
|
-
version: currentVersion + 1,
|
|
286
|
-
date: now,
|
|
287
|
-
changes: `更新: ${changes.join(', ')}`
|
|
288
|
-
};
|
|
289
|
-
updated.versions = [newVersion, ...(node.versions || [])].slice(0, 10);
|
|
290
|
-
}
|
|
291
|
-
await writeJSON(getNodePath(projectId, nodeId), updated);
|
|
292
|
-
await touchChangeMarker(projectId);
|
|
293
|
-
return updated;
|
|
294
|
-
}
|
|
295
|
-
export async function deleteNode(projectId, nodeId) {
|
|
296
|
-
const node = await getNode(projectId, nodeId);
|
|
297
|
-
if (!node || node.isOrigin || node.locked)
|
|
298
|
-
return false;
|
|
299
|
-
try {
|
|
300
|
-
await fs.unlink(getNodePath(projectId, nodeId));
|
|
301
|
-
await touchChangeMarker(projectId);
|
|
302
|
-
return true;
|
|
303
|
-
}
|
|
304
|
-
catch {
|
|
305
|
-
return false;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
export async function lockNode(projectId, nodeId, locked) {
|
|
309
|
-
const node = await getNode(projectId, nodeId);
|
|
310
|
-
if (!node || node.isOrigin)
|
|
311
|
-
return null;
|
|
312
|
-
const updated = { ...node, locked };
|
|
313
|
-
await writeJSON(getNodePath(projectId, nodeId), updated);
|
|
314
|
-
await touchChangeMarker(projectId);
|
|
315
|
-
return updated;
|
|
316
|
-
}
|
|
317
|
-
// ============ 搜索 ============
|
|
318
|
-
export async function searchNodes(projectId, keywords, limit = 20) {
|
|
319
|
-
const nodes = await listNodes(projectId);
|
|
320
|
-
const results = [];
|
|
321
|
-
for (const node of nodes) {
|
|
322
|
-
let score = 0;
|
|
323
|
-
const matches = [];
|
|
324
|
-
const lowerKeywords = keywords.map(k => k.toLowerCase());
|
|
325
|
-
for (const keyword of lowerKeywords) {
|
|
326
|
-
if (node.title?.toLowerCase().includes(keyword)) {
|
|
327
|
-
score += 3;
|
|
328
|
-
matches.push(`title: ${node.title}`);
|
|
329
|
-
}
|
|
330
|
-
if (node.signature?.toLowerCase().includes(keyword)) {
|
|
331
|
-
score += 2;
|
|
332
|
-
matches.push(`signature: ${node.signature}`);
|
|
333
|
-
}
|
|
334
|
-
if (node.categories?.some(t => t.toLowerCase().includes(keyword))) {
|
|
335
|
-
score += 2.5;
|
|
336
|
-
matches.push(`categories: ${node.categories.join(', ')}`);
|
|
337
|
-
}
|
|
338
|
-
if (node.description?.toLowerCase().includes(keyword)) {
|
|
339
|
-
score += 1;
|
|
340
|
-
matches.push('description');
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
if (score > 0)
|
|
344
|
-
results.push({ node, score, matches: [...new Set(matches)] });
|
|
345
|
-
}
|
|
346
|
-
const finalResults = results.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
347
|
-
// 触发发光效果
|
|
348
|
-
await touchNodes(projectId, finalResults.map(r => r.node.id));
|
|
349
|
-
return finalResults;
|
|
350
|
-
}
|
|
351
|
-
// ============ 路径查找 (BFS) ============
|
|
352
|
-
export async function findPath(projectId, startId, endId) {
|
|
353
|
-
const nodes = await listNodes(projectId);
|
|
354
|
-
const edges = computeAutoEdges(nodes);
|
|
355
|
-
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
356
|
-
if (!nodeMap.has(startId) || !nodeMap.has(endId))
|
|
357
|
-
return null;
|
|
358
|
-
// 建立邻接表 (双向)
|
|
359
|
-
const adj = new Map();
|
|
360
|
-
for (const edge of edges) {
|
|
361
|
-
if (!adj.has(edge.source))
|
|
362
|
-
adj.set(edge.source, []);
|
|
363
|
-
if (!adj.has(edge.target))
|
|
364
|
-
adj.set(edge.target, []);
|
|
365
|
-
adj.get(edge.source).push({ nodeId: edge.target, edge });
|
|
366
|
-
adj.get(edge.target).push({ nodeId: edge.source, edge });
|
|
367
|
-
}
|
|
368
|
-
// BFS
|
|
369
|
-
const visited = new Set([startId]);
|
|
370
|
-
const queue = [{ nodeId: startId, path: [startId], edges: [] }];
|
|
371
|
-
while (queue.length > 0) {
|
|
372
|
-
const { nodeId, path, edges: pathEdges } = queue.shift();
|
|
373
|
-
if (nodeId === endId) {
|
|
374
|
-
// 触发发光效果
|
|
375
|
-
await touchNodes(projectId, path);
|
|
376
|
-
return { path: path.map(id => nodeMap.get(id)), edges: pathEdges };
|
|
377
|
-
}
|
|
378
|
-
for (const { nodeId: nextId, edge } of adj.get(nodeId) || []) {
|
|
379
|
-
if (!visited.has(nextId)) {
|
|
380
|
-
visited.add(nextId);
|
|
381
|
-
queue.push({ nodeId: nextId, path: [...path, nextId], edges: [...pathEdges, edge] });
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
return null;
|
|
386
|
-
}
|
|
387
|
-
/** 查找孤立节点(没有任何边连接的节点) */
|
|
388
|
-
export async function findOrphans(projectId) {
|
|
389
|
-
const nodes = await listNodes(projectId);
|
|
390
|
-
const edges = computeAutoEdges(nodes);
|
|
391
|
-
// 收集所有有边连接的节点ID
|
|
392
|
-
const connected = new Set();
|
|
393
|
-
for (const edge of edges) {
|
|
394
|
-
connected.add(edge.source);
|
|
395
|
-
connected.add(edge.target);
|
|
396
|
-
}
|
|
397
|
-
// 过滤出孤立节点
|
|
398
|
-
return nodes
|
|
399
|
-
.filter(n => !connected.has(n.id))
|
|
400
|
-
.map(n => ({ id: n.id, title: n.title, type: n.type, status: n.status }));
|
|
401
|
-
}
|
|
402
|
-
/** 查询节点关系网(所有直接连接的节点) */
|
|
403
|
-
export async function getRelations(projectId, nodeId) {
|
|
404
|
-
const nodes = await listNodes(projectId);
|
|
405
|
-
const edges = computeAutoEdges(nodes);
|
|
406
|
-
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
407
|
-
const relations = [];
|
|
408
|
-
for (const edge of edges) {
|
|
409
|
-
let relatedId = null;
|
|
410
|
-
let direction = null;
|
|
411
|
-
if (edge.source === nodeId) {
|
|
412
|
-
relatedId = edge.target;
|
|
413
|
-
direction = 'outgoing';
|
|
414
|
-
}
|
|
415
|
-
else if (edge.target === nodeId) {
|
|
416
|
-
relatedId = edge.source;
|
|
417
|
-
direction = 'incoming';
|
|
418
|
-
}
|
|
419
|
-
if (relatedId && direction) {
|
|
420
|
-
const node = nodeMap.get(relatedId);
|
|
421
|
-
if (node) {
|
|
422
|
-
relations.push({
|
|
423
|
-
nodeId: relatedId,
|
|
424
|
-
title: node.title,
|
|
425
|
-
description: node.description.slice(0, 100),
|
|
426
|
-
edgeType: edge.type,
|
|
427
|
-
direction
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
// 触发发光效果 (当前节点 + 所有关联节点)
|
|
433
|
-
const touchList = [nodeId, ...relations.map(r => r.nodeId)];
|
|
434
|
-
await touchNodes(projectId, touchList);
|
|
435
|
-
return relations;
|
|
436
|
-
}
|
|
437
|
-
// ============ 错误修复记录 ============
|
|
438
|
-
/** 添加错误修复记录到节点 */
|
|
439
|
-
export async function addBugfix(projectId, nodeId, bugfix) {
|
|
440
|
-
const node = await getNode(projectId, nodeId);
|
|
441
|
-
if (!node || node.locked)
|
|
442
|
-
return null;
|
|
443
|
-
const newBugfix = {
|
|
444
|
-
id: `bug-${Date.now()}`,
|
|
445
|
-
date: new Date().toISOString(),
|
|
446
|
-
issue: bugfix.issue,
|
|
447
|
-
solution: bugfix.solution,
|
|
448
|
-
impact: bugfix.impact
|
|
449
|
-
};
|
|
450
|
-
const updated = {
|
|
451
|
-
...node,
|
|
452
|
-
bugfixes: [newBugfix, ...(node.bugfixes || [])].slice(0, 20), // 保留最近 20 条
|
|
453
|
-
updatedAt: new Date().toISOString()
|
|
454
|
-
};
|
|
455
|
-
await writeJSON(getNodePath(projectId, nodeId), updated);
|
|
456
|
-
await touchChangeMarker(projectId);
|
|
457
|
-
return newBugfix;
|
|
458
|
-
}
|