zen-gitsync 2.11.39 → 2.12.3

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.
Files changed (58) hide show
  1. package/LICENSE +190 -21
  2. package/README.md +695 -695
  3. package/index.js +25 -11
  4. package/package.json +2 -2
  5. package/scripts/convert-colors-to-vars.cjs +286 -272
  6. package/scripts/convert-fontsize-to-vars.cjs +221 -207
  7. package/scripts/convert-spacing-to-vars.cjs +256 -242
  8. package/scripts/convert-to-standard-vars.cjs +282 -268
  9. package/scripts/release.js +599 -585
  10. package/src/config.js +350 -336
  11. package/src/gitCommit.js +455 -440
  12. package/src/ui/public/assets/EditorView-CbqSI9nw.css +1 -0
  13. package/src/ui/public/assets/EditorView-GS5cmh99.js +21 -0
  14. package/src/ui/public/assets/SourceMapView-DyMK80hS.css +1 -0
  15. package/src/ui/public/assets/SourceMapView-_YRtzmZZ.js +3 -0
  16. package/src/ui/public/assets/index-ML5Y-5lO.css +1 -0
  17. package/src/ui/public/assets/index-yky0Sd13.js +73 -0
  18. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  19. package/src/ui/public/assets/{vendor-B1T2uxYO.js → vendor-DITsiaGj.js} +294 -287
  20. package/src/ui/public/assets/vendor-q83wvJns.css +1 -0
  21. package/src/ui/public/index.html +4 -4
  22. package/src/ui/server/.claude/codediff.txt +6 -0
  23. package/src/ui/server/index.js +410 -396
  24. package/src/ui/server/middleware/requestLogger.js +51 -37
  25. package/src/ui/server/routes/branchStatus.js +101 -87
  26. package/src/ui/server/routes/code.js +110 -96
  27. package/src/ui/server/routes/codeAnalysis.js +995 -981
  28. package/src/ui/server/routes/config.js +1172 -1158
  29. package/src/ui/server/routes/exec.js +272 -258
  30. package/src/ui/server/routes/fileOpen.js +279 -265
  31. package/src/ui/server/routes/fs.js +701 -699
  32. package/src/ui/server/routes/git/diff.js +352 -338
  33. package/src/ui/server/routes/git/diffUtils.js +128 -114
  34. package/src/ui/server/routes/git/stash.js +552 -538
  35. package/src/ui/server/routes/git/tags.js +172 -158
  36. package/src/ui/server/routes/git.js +190 -176
  37. package/src/ui/server/routes/gitOps.js +1179 -1165
  38. package/src/ui/server/routes/instances.js +38 -24
  39. package/src/ui/server/routes/npm.js +1023 -1009
  40. package/src/ui/server/routes/process.js +82 -68
  41. package/src/ui/server/routes/status.js +67 -53
  42. package/src/ui/server/routes/terminal.js +319 -305
  43. package/src/ui/server/socket/registerUiSocketHandlers.js +226 -212
  44. package/src/ui/server/utils/createSavePortToFile.js +46 -32
  45. package/src/ui/server/utils/instanceRegistry.js +270 -256
  46. package/src/ui/server/utils/pathGuard.js +155 -0
  47. package/src/ui/server/utils/pathGuard.test.js +138 -0
  48. package/src/ui/server/utils/randomStartPort.js +51 -37
  49. package/src/ui/server/utils/startServerOnAvailablePort.js +101 -87
  50. package/src/utils/index.js +1058 -1044
  51. package/src/ui/public/assets/devopicons-QN4QXivI.woff2 +0 -0
  52. package/src/ui/public/assets/file-icons-C0jOugUK.woff2 +0 -0
  53. package/src/ui/public/assets/fontawesome-B-jkhYfk.woff2 +0 -0
  54. package/src/ui/public/assets/index-BvVl-092.js +0 -95
  55. package/src/ui/public/assets/index-DXO3Lvqi.css +0 -1
  56. package/src/ui/public/assets/mfixx-CpAhKOZz.woff2 +0 -0
  57. package/src/ui/public/assets/octicons-CaZ_fok2.woff2 +0 -0
  58. package/src/ui/public/assets/vendor-hOO_r_AU.css +0 -1
@@ -1,981 +1,995 @@
1
- import express from 'express';
2
- import fs from 'fs/promises';
3
- import path from 'path';
4
-
5
- // 代码文件扩展名
6
- const CODE_EXTENSIONS = [
7
- '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
8
- '.py', '.java', '.go', '.rs', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp',
9
- '.cs', '.rb', '.php', '.swift', '.kt', '.scala',
10
- '.vue', '.svelte', '.html', '.css', '.scss', '.less',
11
- '.json', '.yml', '.yaml', '.md', '.sh', '.bash', '.sql',
12
- ];
13
-
14
- // 忽略的目录
15
- const IGNORE_DIRS = new Set([
16
- 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '__pycache__',
17
- '.cache', 'coverage', '.idea', '.vscode', 'vendor', 'target', 'out',
18
- '.turbo', '.svelte-kit',
19
- ]);
20
-
21
- /**
22
- * 递归扫描目录,返回文件路径数组(相对于 baseDir)
23
- */
24
- async function scanDirectory(dirPath, baseDir, maxFiles = 2000) {
25
- const results = [];
26
- async function walk(current) {
27
- if (results.length >= maxFiles) return;
28
- let entries;
29
- try {
30
- entries = await fs.readdir(current, { withFileTypes: true });
31
- } catch {
32
- return;
33
- }
34
- for (const entry of entries) {
35
- if (results.length >= maxFiles) break;
36
- if (entry.isDirectory()) {
37
- if (!IGNORE_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
38
- await walk(path.join(current, entry.name));
39
- }
40
- } else if (entry.isFile()) {
41
- const ext = path.extname(entry.name).toLowerCase();
42
- if (CODE_EXTENSIONS.includes(ext)) {
43
- const rel = path.relative(baseDir, path.join(current, entry.name)).replace(/\\/g, '/');
44
- results.push(rel);
45
- }
46
- }
47
- }
48
- }
49
- await walk(dirPath);
50
- return results;
51
- }
52
-
53
- /**
54
- * 安全读取单个文件内容(最多 200KB)
55
- */
56
- async function safeReadFile(filePath, maxBytes = 200000) {
57
- try {
58
- const stat = await fs.stat(filePath);
59
- if (stat.size > maxBytes) {
60
- const buf = Buffer.alloc(maxBytes);
61
- const fd = await fs.open(filePath, 'r');
62
- await fd.read(buf, 0, maxBytes, 0);
63
- await fd.close();
64
- return buf.toString('utf8').slice(0, maxBytes);
65
- }
66
- return await fs.readFile(filePath, 'utf8');
67
- } catch {
68
- return '';
69
- }
70
- }
71
-
72
- // ─────────────────────────────────────────────────────────────────────────────
73
- // 静态 import 解析 & 模块依赖图
74
- // ─────────────────────────────────────────────────────────────────────────────
75
-
76
- /**
77
- * 根据文件路径推断模块的语义角色(用于 AI 分析失败时的兜底描述)
78
- */
79
- function inferModuleRole(filePath, inDegree = 0) {
80
- const base = path.basename(filePath, path.extname(filePath));
81
- const ext = path.extname(filePath).toLowerCase();
82
- const fwd = filePath.replace(/\\/g, '/');
83
- const low = fwd.toLowerCase();
84
-
85
- // ── 特殊文件名 ──
86
- if (/^(main|app)$/i.test(base)) return 'Vue 应用入口';
87
- if (/^server$/i.test(base)) return 'HTTP 服务主入口';
88
- if (/^index$/i.test(base)) {
89
- const parentDir = fwd.split('/').slice(-2, -1)[0] || '';
90
- if (low.includes('/server') || low.includes('/backend')) return '服务端应用入口';
91
- if (low.includes('/stores')) return `${parentDir} 状态模块`;
92
- if (low.includes('/routes')) return `${parentDir} 路由入口`;
93
- if (low.includes('/utils')) return `${parentDir} 工具集`;
94
- return `${parentDir} 模块入口`;
95
- }
96
-
97
- // ── Pinia / Vuex Store ──
98
- if (low.includes('/stores/') || /store$/i.test(base)) {
99
- const name = base.replace(/store$/i, '').replace(/([A-Z])/g, ' $1').trim();
100
- return `${name} 状态管理`;
101
- }
102
-
103
- // ── Vue 组件 ──
104
- if (ext === '.vue' || low.includes('/components/')) return `${base} 组件`;
105
-
106
- // ── Views / Pages ──
107
- if (low.includes('/views/') || /view$/i.test(base) || /page$/i.test(base)) return `${base} 页面`;
108
-
109
- // ── Routes ──
110
- if (low.includes('/routes/') || /route[s]?$/i.test(base)) {
111
- const name = base.replace(/route[s]?$/i, '').trim();
112
- return name ? `${name} 路由模块` : '路由处理模块';
113
- }
114
-
115
- // ── Lang / i18n ──
116
- if (low.includes('/lang/') || low.includes('/i18n/') || low.includes('/locale')) return '国际化文案资源';
117
-
118
- // ── Utils / Helpers ──
119
- if (low.includes('/utils/') || /util[s]?$/i.test(base) || /helper[s]?$/i.test(base)) return `${base} 工具集`;
120
-
121
- // ── Composables / Hooks ──
122
- if (low.includes('/composables/') || low.includes('/hooks/') || /^use[A-Z]/.test(base)) return `${base} 组合式函数`;
123
-
124
- // ── API / Services ──
125
- if (low.includes('/api/') || low.includes('/services/') || /service[s]?$/i.test(base) || /api$/i.test(base)) return `${base} API 服务`;
126
-
127
- // ── Middleware ──
128
- if (low.includes('/middleware/') || /middleware$/i.test(base)) return `${base} 中间件`;
129
-
130
- // ── Config ──
131
- if (/config$/i.test(base) || low.includes('/config/')) return `${base} 配置`;
132
-
133
- // ── Types ──
134
- if (/types?$/i.test(base) || low.includes('/types/')) return `${base} 类型定义`;
135
-
136
- // ── 按引用次数降级 ──
137
- if (inDegree >= 20) return `高频共享模块(被引用 ${inDegree} 次)`;
138
- if (inDegree >= 5) return `核心共享模块(被引用 ${inDegree} 次)`;
139
- return `${base} 功能模块`;
140
- }
141
-
142
- /**
143
- * 正则提取 import / require / dynamic-import 的模块路径(TypeScript/Vue 通用)
144
- */
145
- function parseImportsRegex(src) {
146
- const results = new Set();
147
- // import ... from '...' | export ... from '...' | import type ... from '...'
148
- const importRe = /(?:^|;|\n)\s*(?:import|export)(?:\s+type)?\s+(?:[^'"`;\n]*?\bfrom\s+)?['"]([^'"` \n]+)['"]/gm;
149
- let m;
150
- while ((m = importRe.exec(src)) !== null) { if (m[1]) results.add(m[1]); }
151
- // require('...')
152
- const requireRe = /\brequire\s*\(\s*['"]([^'"` \n]+)['"]\s*\)/g;
153
- while ((m = requireRe.exec(src)) !== null) { if (m[1]) results.add(m[1]); }
154
- // dynamic import('...')
155
- const dynRe = /\bimport\s*\(\s*['"]([^'"` \n]+)['"]\s*\)/g;
156
- while ((m = dynRe.exec(src)) !== null) { if (m[1]) results.add(m[1]); }
157
- return [...results];
158
- }
159
-
160
- /**
161
- * 从源码中静态提取 import 路径
162
- * - JS/MJS/CJS/JSX:优先用 acorn AST 解析,失败则正则兜底
163
- * - TS/TSX/Vue:正则(import 语法与 JS 相同,acorn 不支持 TypeScript 类型语法)
164
- */
165
- async function parseStaticImports(filePath, content) {
166
- const ext = path.extname(filePath).toLowerCase();
167
- let src = content || '';
168
-
169
- // Vue SFC:提取 <script>
170
- if (ext === '.vue') {
171
- const m = src.match(/<script(?:[^>]*)>([\s\S]*?)<\/script>/i);
172
- src = m ? m[1] : '';
173
- }
174
-
175
- // JS / MJS / CJS / JSX → 尝试 acorn AST(最准确)
176
- if (['.js', '.mjs', '.cjs', '.jsx'].includes(ext)) {
177
- try {
178
- const acorn = await import('acorn');
179
- const ast = acorn.parse(src, {
180
- ecmaVersion: 2022,
181
- sourceType: 'module',
182
- allowImportExportEverywhere: true,
183
- allowHashBang: true,
184
- });
185
- const imports = [];
186
- for (const node of ast.body) {
187
- if (node.type === 'ImportDeclaration') imports.push(node.source.value);
188
- if (node.type === 'ExportNamedDeclaration' && node.source) imports.push(node.source.value);
189
- if (node.type === 'ExportAllDeclaration' && node.source) imports.push(node.source.value);
190
- // CommonJS require('...')
191
- if (node.type === 'ExpressionStatement') {
192
- const expr = node.expression;
193
- if (expr?.type === 'CallExpression' && expr.callee?.name === 'require' &&
194
- expr.arguments?.[0]?.type === 'Literal') {
195
- imports.push(expr.arguments[0].value);
196
- }
197
- }
198
- }
199
- return imports;
200
- } catch { /* fallthrough to regex */ }
201
- }
202
-
203
- // TS / TSX / Vue / 其他 → 正则解析
204
- return parseImportsRegex(src);
205
- }
206
-
207
- /** 可解析的扩展名(按优先级) */
208
- const RESOLVE_EXTS = ['.ts', '.tsx', '.js', '.mjs', '.jsx', '.vue'];
209
-
210
- /**
211
- * 尝试在文件集合中找到匹配的实际文件(带扩展名推断 + index 文件)
212
- */
213
- function tryResolveWithExts(base, allFilesSet) {
214
- if (allFilesSet.has(base)) return base;
215
- for (const ext of RESOLVE_EXTS) {
216
- if (allFilesSet.has(base + ext)) return base + ext;
217
- if (allFilesSet.has(base + '/index' + ext)) return base + '/index' + ext;
218
- }
219
- return null;
220
- }
221
-
222
- /**
223
- * 将 import 路径解析为项目内相对路径
224
- * 支持相对路径(./xxx)和路径别名(@/xxx、@components/xxx 等)
225
- * 别名按长度降序匹配,避免 @/ 抢占 @components/ 等更长别名
226
- */
227
- function resolveImportPath(fromFile, importPath, allFilesSet, aliasMap) {
228
- // 按别名长度降序排列,优先匹配最长别名(@components/ 优先于 @/)
229
- const sortedAliases = Object.entries(aliasMap).sort((a, b) => b[0].length - a[0].length);
230
- for (const [alias, target] of sortedAliases) {
231
- if (importPath.startsWith(alias)) {
232
- const resolved = (target + importPath.slice(alias.length)).replace(/\\/g, '/');
233
- return tryResolveWithExts(resolved, allFilesSet);
234
- }
235
- }
236
- // 非相对路径(node_modules 包)—— 不解析
237
- if (!importPath.startsWith('.')) return null;
238
-
239
- const fromDir = path.dirname(fromFile).replace(/\\/g, '/');
240
- const joined = path.posix.normalize(fromDir + '/' + importPath);
241
- return tryResolveWithExts(joined, allFilesSet);
242
- }
243
-
244
- /**
245
- * 从 vite.config 解析路径别名(支持多别名:@、@components、@views 等)
246
- * @param {string} resolvedDir 项目根目录(绝对路径)
247
- * @param {string[]} subFiles 子系统文件列表(相对路径)
248
- * @param {string} rootPath 子系统 rootPath(相对路径,如 src/ui/client/src)
249
- * @returns {Record<string, string>} { 'alias/' → 'target/dir/' }
250
- */
251
- async function detectAliasMap(resolvedDir, subFiles, rootPath = '') {
252
- const aliasMap = {};
253
-
254
- // 计算要搜索 vite.config 的候选目录
255
- // 通常 vite.config.ts 在 rootPath 的父目录(如 src/ui/client)
256
- const searchDirs = [resolvedDir];
257
- if (rootPath) {
258
- const parent = path.dirname(rootPath);
259
- if (parent && parent !== '.' && parent !== rootPath) {
260
- searchDirs.push(path.resolve(resolvedDir, parent));
261
- }
262
- // rootPath 本身也搜一下
263
- searchDirs.push(path.resolve(resolvedDir, rootPath));
264
- }
265
-
266
- for (const searchDir of searchDirs) {
267
- for (const cfgName of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) {
268
- try {
269
- const cfgPath = path.join(searchDir, cfgName);
270
- const content = await fs.readFile(cfgPath, 'utf8');
271
-
272
- // 提取 alias 对象块(支持跨行)
273
- const aliasBlockMatch = content.match(/alias\s*:\s*\{([^}]+)\}/s);
274
- if (!aliasBlockMatch) continue;
275
-
276
- const aliasBody = aliasBlockMatch[1];
277
- // 匹配 "@xxx": path.resolve(__dirname, "./relpath") 或 path.resolve(xxx, "relpath")
278
- const entryRe = /["'](@[^"']*)["']\s*:\s*path\.resolve\s*\([^,)]+,\s*["']([^"']+)["']\s*\)/g;
279
- let m;
280
- const cfgDir = path.dirname(cfgPath);
281
- while ((m = entryRe.exec(aliasBody)) !== null) {
282
- const aliasKey = m[1]; // e.g. '@', '@components'
283
- const relTarget = m[2]; // e.g. './src', './src/components'
284
- const absTarget = path.resolve(cfgDir, relTarget);
285
- const relToProject = path.relative(resolvedDir, absTarget).replace(/\\/g, '/');
286
- aliasMap[aliasKey + '/'] = relToProject + '/';
287
- }
288
-
289
- if (Object.keys(aliasMap).length > 0) return aliasMap;
290
- } catch { /* ignore */ }
291
- }
292
- if (Object.keys(aliasMap).length > 0) break;
293
- }
294
-
295
- // 兜底:启发式推断 @/ 指向文件数最多的 src 目录
296
- if (!aliasMap['@/']) {
297
- for (const c of ['src/ui/client/src', 'src/client/src', 'client/src', 'src']) {
298
- if (subFiles.some(f => f.startsWith(c + '/'))) {
299
- aliasMap['@/'] = c + '/';
300
- break;
301
- }
302
- }
303
- }
304
-
305
- return aliasMap;
306
- }
307
-
308
- /**
309
- * 构建完整模块依赖图
310
- * @param {string[]} subFiles 子系统文件列表(相对路径)
311
- * @param {string} resolvedDir 项目根目录(绝对路径)
312
- * @param {string} rootPath 子系统 rootPath(用于定位 vite.config)
313
- * @returns {{ graph, inDegree, hubFiles, entryCandidates, totalEdges, fileSizes }}
314
- */
315
- async function buildDependencyGraph(subFiles, resolvedDir, rootPath = '') {
316
- const allFilesSet = new Set(subFiles);
317
- const aliasMap = await detectAliasMap(resolvedDir, subFiles, rootPath);
318
-
319
- const graph = {}; // file → string[](它 import 的项目内文件)
320
- const fileSizes = {}; // file → 行数
321
-
322
- for (const file of subFiles) {
323
- const content = await safeReadFile(path.resolve(resolvedDir, file), 150000);
324
- fileSizes[file] = content ? content.split('\n').length : 0;
325
- const rawImports = await parseStaticImports(file, content);
326
- const resolved = [];
327
- for (const imp of rawImports) {
328
- const r = resolveImportPath(file, imp, allFilesSet, aliasMap);
329
- if (r) resolved.push(r);
330
- }
331
- graph[file] = [...new Set(resolved)];
332
- }
333
-
334
- // 计算入度(被多少文件 import)
335
- const inDegree = {};
336
- for (const file of subFiles) inDegree[file] = 0;
337
- for (const deps of Object.values(graph)) {
338
- for (const dep of deps) { if (dep in inDegree) inDegree[dep]++; }
339
- }
340
-
341
- // Hub files = 被最多模块依赖的核心文件
342
- const hubFiles = Object.entries(inDegree)
343
- .sort((a, b) => b[1] - a[1])
344
- .filter(([, d]) => d > 0)
345
- .slice(0, 8)
346
- .map(([file, inDeg]) => ({ file, inDegree: inDeg, lines: fileSizes[file] || 0 }));
347
-
348
- // 入口候选 = 出度 > 0 且入度 = 0(项目根节点,没有被其他文件 import)
349
- const entryCandidates = subFiles.filter(
350
- f => (graph[f]?.length || 0) > 0 && (inDegree[f] || 0) === 0
351
- );
352
-
353
- const totalEdges = Object.values(graph).reduce((s, d) => s + d.length, 0);
354
-
355
- return { graph, inDegree, hubFiles, entryCandidates, totalEdges, fileSizes };
356
- }
357
-
358
- // ─────────────────────────────────────────────────────────────────────────────
359
-
360
- /**
361
- * 调用 OpenAI 兼容 API,返回 JSON
362
- */
363
- async function callLlmJson(model, prompt) {
364
- const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
365
- const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
366
- const headers = { 'Content-Type': 'application/json' };
367
- if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
368
-
369
- const body = JSON.stringify({
370
- model: model.model,
371
- messages: [{ role: 'user', content: prompt }],
372
- max_tokens: 4096,
373
- temperature: 0.3,
374
- response_format: { type: 'json_object' },
375
- stream: false,
376
- });
377
-
378
- const controller = new AbortController();
379
- const timer = setTimeout(() => controller.abort(), 60000);
380
- try {
381
- const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
382
- const data = await resp.json().catch(() => ({}));
383
- if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
384
- const content = data?.choices?.[0]?.message?.content || '{}';
385
- try {
386
- const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) || content.match(/({[\s\S]*})/);
387
- return JSON.parse(jsonMatch ? jsonMatch[1] : content);
388
- } catch {
389
- return {};
390
- }
391
- } finally {
392
- clearTimeout(timer);
393
- }
394
- }
395
-
396
- /**
397
- * SSE 辅助函数
398
- */
399
- function sendSse(res, event, data) {
400
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
401
- }
402
-
403
- export function registerCodeAnalysisRoutes({ app, configManager }) {
404
- /**
405
- * GET /api/code-analysis/files?path=xxx
406
- * 扫描指定目录下的代码文件列表
407
- */
408
- app.get('/api/code-analysis/files', async (req, res) => {
409
- try {
410
- const reqPath = req.query.path;
411
- if (!reqPath || typeof reqPath !== 'string') {
412
- return res.status(400).json({ error: '缺少 path 参数' });
413
- }
414
- const resolved = path.resolve(reqPath);
415
- // 安全验证:确保路径存在且是目录
416
- try {
417
- const stat = await fs.stat(resolved);
418
- if (!stat.isDirectory()) {
419
- return res.status(400).json({ error: '路径不是目录' });
420
- }
421
- } catch {
422
- return res.status(400).json({ error: '路径不存在' });
423
- }
424
- const files = await scanDirectory(resolved, resolved);
425
- res.json({ files, basePath: resolved });
426
- } catch (err) {
427
- res.status(500).json({ error: err.message });
428
- }
429
- });
430
-
431
- /**
432
- * GET /api/code-analysis/file-content?path=xxx&file=yyy
433
- * 读取单个文件内容
434
- */
435
- app.get('/api/code-analysis/file-content', async (req, res) => {
436
- try {
437
- const basePath = req.query.path;
438
- const file = req.query.file;
439
- if (!basePath || !file) {
440
- return res.status(400).json({ error: '缺少 path 或 file 参数' });
441
- }
442
- const resolvedBase = path.resolve(String(basePath));
443
- const resolvedFile = path.resolve(resolvedBase, String(file));
444
- // 安全验证:文件必须在 basePath 内
445
- if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
446
- return res.status(403).json({ error: '禁止访问此路径' });
447
- }
448
- const content = await safeReadFile(resolvedFile);
449
- res.json({ content });
450
- } catch (err) {
451
- res.status(500).json({ error: err.message });
452
- }
453
- });
454
-
455
- /**
456
- * POST /api/code-analysis/analyze (SSE)
457
- * 对指定目录进行 AI 代码分析,流式推送进度和结果
458
- *
459
- * Body: { path: string, modelId?: string }
460
- */
461
- app.post('/api/code-analysis/analyze', express.json(), async (req, res) => {
462
- const { path: projectPath, modelId } = req.body || {};
463
-
464
- if (!projectPath || typeof projectPath !== 'string') {
465
- return res.status(400).json({ error: '缺少 path 参数' });
466
- }
467
-
468
- const resolved = path.resolve(projectPath);
469
-
470
- // 安全验证目录存在
471
- try {
472
- const stat = await fs.stat(resolved);
473
- if (!stat.isDirectory()) return res.status(400).json({ error: '路径不是目录' });
474
- } catch {
475
- return res.status(400).json({ error: '路径不存在' });
476
- }
477
-
478
- // 获取 AI 模型配置
479
- let model;
480
- try {
481
- const rawConfig = await configManager.readRawConfigFile();
482
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
483
- model = (modelId ? models.find(m => m.id === modelId) : null)
484
- || models.find(m => m.isDefault)
485
- || models[0];
486
- } catch (err) {
487
- return res.status(500).json({ error: '读取配置失败: ' + err.message });
488
- }
489
-
490
- if (!model) {
491
- return res.status(400).json({ error: '未配置 AI 模型,请先在通用设置中添加模型' });
492
- }
493
-
494
- // 设置 SSE 响应头
495
- res.setHeader('Content-Type', 'text/event-stream');
496
- res.setHeader('Cache-Control', 'no-cache');
497
- res.setHeader('Connection', 'keep-alive');
498
- res.setHeader('X-Accel-Buffering', 'no');
499
- res.flushHeaders();
500
-
501
- let stopped = false;
502
- req.on('close', () => { stopped = true; });
503
-
504
- function log(message, type = 'info') {
505
- if (!stopped) sendSse(res, 'log', { message, type, timestamp: Date.now() });
506
- }
507
-
508
- try {
509
- // Step 1: 扫描文件
510
- log('正在扫描项目文件...', 'info');
511
- const allFiles = await scanDirectory(resolved, resolved);
512
- const codeFiles = allFiles.filter(f => {
513
- const ext = path.extname(f).toLowerCase();
514
- return ['.js', '.mjs', '.ts', '.tsx', '.jsx', '.vue', '.py', '.java', '.go',
515
- '.rs', '.cpp', '.c', '.h', '.cs', '.rb', '.php', '.swift', '.kt'].includes(ext);
516
- });
517
- log(`扫描完成,共 ${allFiles.length} 个文件,${codeFiles.length} 个代码文件`, 'success');
518
-
519
- if (stopped) return res.end();
520
-
521
- sendSse(res, 'files', { files: allFiles, codeFiles });
522
-
523
- if (codeFiles.length === 0) {
524
- log('未找到代码文件,分析终止', 'error');
525
- sendSse(res, 'done', { error: '未找到代码文件' });
526
- return res.end();
527
- }
528
-
529
- // Step 2: 程序化识别子系统(扫描 package.json 位置和路径模式)
530
- log('正在分析项目结构...', 'info');
531
-
532
- /**
533
- * 扫描项目中所有 package.json 文件(depth <= 4,排除 node_modules)
534
- */
535
- async function findPackageJsonPaths(baseDir, maxDepth = 4) {
536
- const found = [];
537
- async function walk(dir, depth) {
538
- if (depth > maxDepth) return;
539
- let entries;
540
- try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
541
- for (const e of entries) {
542
- if (e.isDirectory()) {
543
- if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) {
544
- await walk(path.join(dir, e.name), depth + 1);
545
- }
546
- } else if (e.isFile() && e.name === 'package.json' && depth > 0) {
547
- const rel = path.relative(baseDir, path.join(dir, e.name)).replace(/\\/g, '/');
548
- found.push(rel);
549
- }
550
- }
551
- }
552
- await walk(baseDir, 0);
553
- return found;
554
- }
555
-
556
- const packageJsonPaths = await findPackageJsonPaths(resolved);
557
-
558
- /**
559
- * 已知路径模式 → 子系统定义(优先匹配)
560
- */
561
- const KNOWN_PATTERNS = [
562
- { pathPattern: 'src/ui/client/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: 'Vue 3 前端应用' },
563
- { pathPattern: 'src/ui/server', name: 'backend', displayName: '后端', language: 'JavaScript', description: 'Node.js/Express 后端' },
564
- { pathPattern: 'client/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: '前端应用' },
565
- { pathPattern: 'server', name: 'backend', displayName: '后端', language: 'JavaScript', description: '后端服务' },
566
- { pathPattern: 'frontend/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: '前端应用' },
567
- { pathPattern: 'backend', name: 'backend', displayName: '后端', language: 'JavaScript', description: '后端服务' },
568
- ];
569
-
570
- // package.json 路径派生子系统 rootPath
571
- const pkgBaseDirs = packageJsonPaths
572
- .map(p => path.dirname(p).replace(/\\/g, '/'))
573
- .filter(d => d !== '.' && d !== '');
574
-
575
- let programmaticSubsystems = [];
576
-
577
- if (pkgBaseDirs.length >= 2) {
578
- // package.json 每个目录是一个子系统
579
- programmaticSubsystems = pkgBaseDirs.slice(0, 4).map(dir => {
580
- const matched = KNOWN_PATTERNS.find(p => dir.startsWith(p.pathPattern) || dir === p.pathPattern.split('/')[0]);
581
- return matched
582
- ? { ...matched, rootPath: dir }
583
- : { name: path.basename(dir), displayName: path.basename(dir), rootPath: dir, language: 'Unknown', description: '' };
584
- });
585
- log(`程序化检测到 ${programmaticSubsystems.length} 个子系统(基于 package.json): ${programmaticSubsystems.map(s => s.displayName).join(' / ')}`, 'success');
586
- } else {
587
- // 无多个 package.json,尝试已知路径模式
588
- for (const pattern of KNOWN_PATTERNS) {
589
- // 必须是目录前缀匹配(加 / 后缀),避免 'server' 误匹配 'server.js'
590
- const hasMatch = codeFiles.some(f => f.startsWith(pattern.pathPattern + '/'));
591
- if (hasMatch) {
592
- // 避免重复添加同名模式
593
- if (!programmaticSubsystems.find(s => s.rootPath === pattern.pathPattern)) {
594
- programmaticSubsystems.push({ ...pattern, rootPath: pattern.pathPattern });
595
- }
596
- }
597
- }
598
- if (programmaticSubsystems.length >= 2) {
599
- log(`程序化检测到 ${programmaticSubsystems.length} 个子系统(基于路径模式): ${programmaticSubsystems.map(s => s.displayName).join(' / ')}`, 'success');
600
- } else {
601
- programmaticSubsystems = []; // 回退到 AI
602
- }
603
- }
604
-
605
- const SUBSYSTEM_COLORS = ['#f59e0b', '#3b82f6', '#10b981', '#8b5cf6'];
606
- let rawSubsystems;
607
-
608
- if (programmaticSubsystems.length >= 2) {
609
- rawSubsystems = programmaticSubsystems;
610
- } else {
611
- // Step 2b: AI 识别子系统(程序化未能区分)
612
- log('AI 正在识别子系统结构...', 'thinking');
613
- const subsystemPrompt = `你是项目架构分析师。根据以下文件路径列表,识别项目的子系统/子模块。
614
-
615
- 文件列表:
616
- ${JSON.stringify(codeFiles.slice(0, 300))}
617
-
618
- 判断依据:独立目录结构、package.json、入口文件、client/server、frontend/backend、ui/api 等模式。
619
-
620
- 返回 JSON(必须严格是 JSON):
621
- {
622
- "isMonorepo": true,
623
- "subsystems": [
624
- {
625
- "name": "frontend",
626
- "displayName": "前端",
627
- "rootPath": "src/ui/client/src",
628
- "language": "TypeScript",
629
- "description": "Vue 3 前端应用"
630
- }
631
- ]
632
- }
633
-
634
- 注意:
635
- - 单一系统时 isMonorepo 为 false,subsystems 一个,rootPath 为空字符串
636
- - 最多识别 4 个子系统
637
- - rootPath 使用正斜杠,必须与文件列表路径前缀严格匹配`;
638
-
639
- const subsystemData = await callLlmJson(model, subsystemPrompt);
640
- if (stopped) return res.end();
641
-
642
- rawSubsystems = Array.isArray(subsystemData.subsystems) && subsystemData.subsystems.length > 0
643
- ? subsystemData.subsystems.slice(0, 4)
644
- : [{ name: 'main', displayName: '主系统', rootPath: '', language: 'Unknown', description: '' }];
645
-
646
- log(`识别到 ${rawSubsystems.length} 个子系统: ${rawSubsystems.map(s => s.displayName || s.name).join(' / ')}`, 'success');
647
- }
648
-
649
-
650
- // Step 3-4: 对每个子系统分别分析调用链
651
- const allNodes = [];
652
- const allEdges = [];
653
- const allTechStack = new Set();
654
- let overallSummary = '';
655
- let primaryLanguage = 'Unknown';
656
- let primaryEntryFile = '';
657
-
658
- for (let si = 0; si < rawSubsystems.length; si++) {
659
- if (stopped) break;
660
- const sub = rawSubsystems[si];
661
- const subsystemName = sub.displayName || sub.name;
662
- const subsystemColor = SUBSYSTEM_COLORS[si % SUBSYSTEM_COLORS.length];
663
-
664
- // 过滤该子系统的代码文件
665
- const subFiles = sub.rootPath
666
- ? codeFiles.filter(f => f.startsWith(sub.rootPath.replace(/\\/g, '/')))
667
- : codeFiles;
668
-
669
- if (subFiles.length === 0) {
670
- log(`[${subsystemName}] 未找到代码文件,跳过`, 'info');
671
- continue;
672
- }
673
-
674
- // ── Step 2.5: 静态 import 解析 → 构建真实模块依赖图 ───────────────
675
- log(`[${subsystemName}] 正在静态解析 import 依赖(${subFiles.length} 个文件)...`, 'info');
676
- const { graph: depGraph, inDegree, hubFiles, entryCandidates, totalEdges, fileSizes } =
677
- await buildDependencyGraph(subFiles, resolved, sub.rootPath || '');
678
- log(`[${subsystemName}] 依赖图完成:${totalEdges} 条 import 关系,${hubFiles.length} 个核心模块`, 'success');
679
- if (stopped) break;
680
-
681
- // 向前端推送静态依赖图数据
682
- sendSse(res, 'depgraph', {
683
- subsystem: sub.name,
684
- graph: depGraph,
685
- hubFiles,
686
- entryCandidates,
687
- inDegree,
688
- fileSizes,
689
- });
690
-
691
- // ── Step 3: 识别入口文件(优先静态分析结论,AI 作为兜底) ──────────
692
- log(`[${subsystemName}] 正在识别入口文件...`, 'thinking');
693
-
694
- // 静态入口候选(入度=0 且有出度的根节点)按常见命名排序
695
- const ENTRY_NAME_PRIORITY = ['main', 'index', 'app', 'server', 'start', 'entry'];
696
- const staticEntryCandidates = [...entryCandidates].sort((a, b) => {
697
- const aScore = ENTRY_NAME_PRIORITY.findIndex(n => path.basename(a, path.extname(a)).toLowerCase().includes(n));
698
- const bScore = ENTRY_NAME_PRIORITY.findIndex(n => path.basename(b, path.extname(b)).toLowerCase().includes(n));
699
- return (aScore === -1 ? 99 : aScore) - (bScore === -1 ? 99 : bScore);
700
- });
701
-
702
- let subLang = sub.language || 'Unknown';
703
- let subSummary = '';
704
- let subEntryFiles = staticEntryCandidates.slice(0, 3);
705
- let subEntryFunctions = ['main'];
706
-
707
- // 仅当静态分析无法确定入口时,才调用 AI
708
- if (subEntryFiles.length === 0) {
709
- const subEntryPrompt = `你是软件架构师。以下是子系统的代码文件列表,请识别语言和入口文件。
710
-
711
- 子系统: ${subsystemName}
712
- 文件列表:
713
- ${JSON.stringify(subFiles.slice(0, 150))}
714
-
715
- 返回 JSON:
716
- {
717
- "language": "TypeScript",
718
- "potentialEntryFiles": ["src/main.ts"],
719
- "potentialEntryFunctions": ["main"],
720
- "projectSummary": "简短中文描述"
721
- }`;
722
- const subEntryData = await callLlmJson(model, subEntryPrompt);
723
- if (stopped) break;
724
- subLang = String(subEntryData.language || sub.language || 'Unknown');
725
- subEntryFiles = Array.isArray(subEntryData.potentialEntryFiles) ? subEntryData.potentialEntryFiles.slice(0, 3) : [];
726
- subEntryFunctions = Array.isArray(subEntryData.potentialEntryFunctions) ? subEntryData.potentialEntryFunctions : ['main'];
727
- subSummary = String(subEntryData.projectSummary || '');
728
- } else {
729
- // 从文件扩展名推断语言
730
- const extLangMap = { '.ts': 'TypeScript', '.tsx': 'TypeScript', '.vue': 'TypeScript', '.js': 'JavaScript', '.mjs': 'JavaScript', '.py': 'Python', '.go': 'Go', '.rs': 'Rust', '.java': 'Java' };
731
- const firstExt = path.extname(subEntryFiles[0]).toLowerCase();
732
- subLang = extLangMap[firstExt] || sub.language || 'Unknown';
733
- }
734
-
735
- if (!primaryLanguage || primaryLanguage === 'Unknown') primaryLanguage = subLang;
736
-
737
- // 验证并读取入口文件
738
- let entryFile = '';
739
- let entryContent = '';
740
- for (const candidate of subEntryFiles) {
741
- if (stopped) break;
742
- const candidatePath = path.resolve(resolved, candidate);
743
- if (!candidatePath.startsWith(resolved + path.sep) && candidatePath !== resolved) continue;
744
- const content = await safeReadFile(candidatePath);
745
- if (content) { entryFile = candidate; entryContent = content; break; }
746
- }
747
- if (!entryFile && subFiles.length > 0) {
748
- entryFile = subFiles[0];
749
- entryContent = await safeReadFile(path.resolve(resolved, entryFile));
750
- }
751
- if (!primaryEntryFile) primaryEntryFile = entryFile;
752
- if (!overallSummary) overallSummary = subSummary;
753
-
754
- // ── Step 4: AI 语义分析(以静态依赖图为骨架) ──────────────────────
755
- log(`[${subsystemName}] AI 正在基于依赖图做语义分析...`, 'thinking');
756
-
757
- // 读取 Hub 文件内容(被引用最多的核心模块,每个最多 2500 字符)
758
- const hubContents = [];
759
- for (const { file: hubFile } of hubFiles.slice(0, 4)) {
760
- if (stopped) break;
761
- const hubPath = path.resolve(resolved, hubFile);
762
- if (!hubPath.startsWith(resolved)) continue;
763
- const content = await safeReadFile(hubPath, 80000);
764
- if (content) hubContents.push({ file: hubFile, content: content.slice(0, 2500) });
765
- }
766
-
767
- // 构建依赖图文本摘要(展示文件间 import 关系,仅展示有边的节点)
768
- const graphLines = Object.entries(depGraph)
769
- .filter(([, deps]) => deps.length > 0)
770
- .sort((a, b) => b[1].length - a[1].length)
771
- .slice(0, 60)
772
- .map(([f, deps]) => ` ${f} (${fileSizes[f] || '?'}行) → [${deps.join(', ')}]`);
773
-
774
- const hubSummary = hubFiles
775
- .map(h => ` ${h.file} (被引用${h.inDegree}次, ${h.lines}行)`)
776
- .join('\n');
777
-
778
- const entryFunction = subEntryFunctions[0] || 'main';
779
- const entrySnippet = entryContent.slice(0, 3000);
780
-
781
- const hubContentText = hubContents.map(h =>
782
- `\n--- ${h.file} ---\n${h.content}`
783
- ).join('\n');
784
-
785
- // Step 4: 分析调用链(基于真实依赖图)
786
- const chainPrompt = `你是代码架构分析师。以下是通过静态 import 解析得到的真实模块依赖图,请基于此输出调用图节点和语义描述。
787
-
788
- 语言: ${subLang}
789
- 子系统: ${subsystemName}
790
- 入口文件: ${entryFile}(入口函数: ${entryFunction})
791
-
792
- ## 真实模块依赖图(静态 import 解析结果)
793
- ${subFiles.length} 个文件,${totalEdges} import 关系
794
- 格式:文件(行数) → [它所 import 的文件]
795
-
796
- ${graphLines.join('\n') || '(无内部 import 关系)'}
797
-
798
- ## 核心模块(按被引用次数排序)
799
- ${hubSummary || '(未检测到高频被引用模块)'}
800
-
801
- ## 入口文件内容(截取前3000字符)
802
- ${entrySnippet}
803
-
804
- ## 核心模块文件内容
805
- ${hubContentText || '(无)'}
806
-
807
- 返回 JSON(基于真实依赖图,不要凭空猜测):
808
- {
809
- "entryFunction": "函数名",
810
- "nodes": [
811
- { "id": "n1", "label": "模块/函数名", "file": "文件路径(必须来自依赖图)", "line": 1, "type": "module|function|store|component|api", "importance": "high|medium|low", "description": "职责中文描述(10-30字)" }
812
- ],
813
- "edges": [{ "source": "n1", "target": "n2" }],
814
- "techStack": ["Vue3", "TypeScript"],
815
- "summary": "子系统架构中文概要(50-100字)"
816
- }
817
-
818
- 要求:
819
- 1. 节点必须对应依赖图中真实存在的文件,不要编造节点
820
- 2. edges 必须反映真实 import 关系(A import B → A→B 有边)
821
- 3. 最多 25 个节点,优先选择高入度的核心模块
822
- 4. importance: high(核心/hub)/ medium(普通)/ low(叶子/工具)
823
- 5. type 可选: module / function / store / component / api / util / config`;
824
-
825
- const chainData = await callLlmJson(model, chainPrompt);
826
- if (stopped) break;
827
-
828
- // 合并节点/边,加子系统标识,ID 加前缀避免冲突
829
- let rawNodes = Array.isArray(chainData?.nodes) ? chainData.nodes : [];
830
- let rawEdges = Array.isArray(chainData?.edges) ? chainData.edges : [];
831
-
832
- // ── 兜底:AI 未返回节点时,直接从静态依赖图生成基础节点 ────────────
833
- if (rawNodes.length === 0) {
834
- log(`[${subsystemName}] AI 未返回节点,从静态依赖图生成基础可视化...`, 'info');
835
- const seen = new Set();
836
- const fbNodes = [];
837
- // 入口节点
838
- for (const f of staticEntryCandidates.slice(0, 3)) {
839
- if (!seen.has(f)) {
840
- seen.add(f);
841
- fbNodes.push({ id: `fe_${fbNodes.length}`, label: path.basename(f, path.extname(f)), file: f, line: 1, type: 'module', importance: 'high', description: inferModuleRole(f, 0) });
842
- }
843
- }
844
- // Hub 节点(入度最高)
845
- for (const h of hubFiles.slice(0, 10)) {
846
- if (!seen.has(h.file)) {
847
- seen.add(h.file);
848
- fbNodes.push({ id: `fh_${fbNodes.length}`, label: path.basename(h.file, path.extname(h.file)), file: h.file, line: 1, type: 'module', importance: h.inDegree >= 5 ? 'high' : h.inDegree >= 2 ? 'medium' : 'low', description: inferModuleRole(h.file, h.inDegree) });
849
- }
850
- }
851
- rawNodes = fbNodes;
852
- // 从静态依赖图生成边
853
- const nodeFileMap = {};
854
- rawNodes.forEach(n => { nodeFileMap[n.file] = n.id; });
855
- rawEdges = [];
856
- for (const [srcFile, dstFiles] of Object.entries(depGraph)) {
857
- if (!nodeFileMap[srcFile]) continue;
858
- for (const dstFile of dstFiles) {
859
- if (nodeFileMap[dstFile]) rawEdges.push({ source: nodeFileMap[srcFile], target: nodeFileMap[dstFile] });
860
- }
861
- }
862
- }
863
- const idMap = {};
864
- rawNodes.forEach((n, i) => { idMap[n.id] = `sub${si}_${n.id || i}`; });
865
-
866
- rawNodes.forEach((n, i) => {
867
- allNodes.push({
868
- ...n,
869
- id: `sub${si}_${n.id || i}`,
870
- subsystem: sub.name,
871
- subsystemIndex: si,
872
- subsystemColor,
873
- });
874
- });
875
- rawEdges.forEach(e => {
876
- const src = idMap[e.source] || `sub${si}_${e.source}`;
877
- const tgt = idMap[e.target] || `sub${si}_${e.target}`;
878
- allEdges.push({ source: src, target: tgt });
879
- });
880
-
881
- (Array.isArray(chainData.techStack) ? chainData.techStack : []).forEach(t => allTechStack.add(t));
882
- log(`[${subsystemName}] 分析完成,${rawNodes.length} 个节点,${rawEdges.length} 条连接`, 'success');
883
- }
884
-
885
- if (stopped) return res.end();
886
-
887
- // Step 5: 发送最终结果
888
- sendSse(res, 'result', {
889
- language: primaryLanguage,
890
- entryFile: primaryEntryFile,
891
- entryFunction: '',
892
- nodes: allNodes,
893
- edges: allEdges,
894
- techStack: [...allTechStack],
895
- summary: overallSummary,
896
- allFiles,
897
- codeFiles,
898
- subsystems: rawSubsystems.map((s, i) => ({ ...s, color: SUBSYSTEM_COLORS[i % SUBSYSTEM_COLORS.length] })),
899
- });
900
-
901
- log('✓ 全部分析完成!', 'success');
902
- sendSse(res, 'done', { success: true });
903
- } catch (err) {
904
- if (!stopped) {
905
- log(`分析失败: ${err.message}`, 'error');
906
- sendSse(res, 'done', { error: err.message });
907
- }
908
- } finally {
909
- res.end();
910
- }
911
- });
912
-
913
- /**
914
- * POST /api/code-analysis/drill
915
- * 手动下钻单个函数节点
916
- *
917
- * Body: { basePath, file, functionName, language, parentChain? }
918
- */
919
- app.post('/api/code-analysis/drill', express.json(), async (req, res) => {
920
- const { basePath, file, functionName, language, parentChain, modelId } = req.body || {};
921
-
922
- if (!basePath || !file || !functionName) {
923
- return res.status(400).json({ error: '缺少必要参数' });
924
- }
925
-
926
- const resolvedBase = path.resolve(String(basePath));
927
- const resolvedFile = path.resolve(resolvedBase, String(file));
928
- if (!resolvedFile.startsWith(resolvedBase)) {
929
- return res.status(403).json({ error: '禁止访问此路径' });
930
- }
931
-
932
- // 获取 AI 模型配置
933
- let model;
934
- try {
935
- const rawConfig = await configManager.readRawConfigFile();
936
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
937
- model = (modelId ? models.find(m => m.id === modelId) : null)
938
- || models.find(m => m.isDefault)
939
- || models[0];
940
- } catch (err) {
941
- return res.status(500).json({ error: '读取配置失败: ' + err.message });
942
- }
943
-
944
- if (!model) {
945
- return res.status(400).json({ error: '未配置 AI 模型' });
946
- }
947
-
948
- try {
949
- const content = await safeReadFile(resolvedFile);
950
- if (!content) return res.status(404).json({ error: '无法读取文件内容' });
951
-
952
- const snippet = content.slice(0, 7000);
953
- const prompt = `你是代码调用链下钻分析器。请分析目标函数,输出其关键子调用节点。
954
-
955
- 语言: ${String(language || 'Unknown')}
956
- 文件: ${String(file)}
957
- 函数: ${String(functionName)}
958
- ${parentChain ? `调用链上下文: ${String(parentChain).slice(0, 500)}` : ''}
959
-
960
- 文件内容(截取):
961
- ${snippet}
962
-
963
- 返回 JSON:
964
- {
965
- "functionName": "${String(functionName)}",
966
- "description": "函数职责",
967
- "calls": [
968
- { "name": "子函数名", "type": "function", "description": "中文描述", "importance": "high", "shouldDrill": 1, "possibleFile": "推测文件路径" }
969
- ]
970
- }
971
-
972
- shouldDrill: -1(叶子/不重要)、0(不确定)、1(值得继续下钻)
973
- 最多输出 10 个子调用`;
974
-
975
- const result = await callLlmJson(model, prompt);
976
- res.json({ success: true, data: result });
977
- } catch (err) {
978
- res.status(500).json({ error: err.message });
979
- }
980
- });
981
- }
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ import express from 'express';
16
+ import fs from 'fs/promises';
17
+ import path from 'path';
18
+
19
+ // 代码文件扩展名
20
+ const CODE_EXTENSIONS = [
21
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
22
+ '.py', '.java', '.go', '.rs', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp',
23
+ '.cs', '.rb', '.php', '.swift', '.kt', '.scala',
24
+ '.vue', '.svelte', '.html', '.css', '.scss', '.less',
25
+ '.json', '.yml', '.yaml', '.md', '.sh', '.bash', '.sql',
26
+ ];
27
+
28
+ // 忽略的目录
29
+ const IGNORE_DIRS = new Set([
30
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '__pycache__',
31
+ '.cache', 'coverage', '.idea', '.vscode', 'vendor', 'target', 'out',
32
+ '.turbo', '.svelte-kit',
33
+ ]);
34
+
35
+ /**
36
+ * 递归扫描目录,返回文件路径数组(相对于 baseDir)
37
+ */
38
+ async function scanDirectory(dirPath, baseDir, maxFiles = 2000) {
39
+ const results = [];
40
+ async function walk(current) {
41
+ if (results.length >= maxFiles) return;
42
+ let entries;
43
+ try {
44
+ entries = await fs.readdir(current, { withFileTypes: true });
45
+ } catch {
46
+ return;
47
+ }
48
+ for (const entry of entries) {
49
+ if (results.length >= maxFiles) break;
50
+ if (entry.isDirectory()) {
51
+ if (!IGNORE_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
52
+ await walk(path.join(current, entry.name));
53
+ }
54
+ } else if (entry.isFile()) {
55
+ const ext = path.extname(entry.name).toLowerCase();
56
+ if (CODE_EXTENSIONS.includes(ext)) {
57
+ const rel = path.relative(baseDir, path.join(current, entry.name)).replace(/\\/g, '/');
58
+ results.push(rel);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ await walk(dirPath);
64
+ return results;
65
+ }
66
+
67
+ /**
68
+ * 安全读取单个文件内容(最多 200KB)
69
+ */
70
+ async function safeReadFile(filePath, maxBytes = 200000) {
71
+ try {
72
+ const stat = await fs.stat(filePath);
73
+ if (stat.size > maxBytes) {
74
+ const buf = Buffer.alloc(maxBytes);
75
+ const fd = await fs.open(filePath, 'r');
76
+ await fd.read(buf, 0, maxBytes, 0);
77
+ await fd.close();
78
+ return buf.toString('utf8').slice(0, maxBytes);
79
+ }
80
+ return await fs.readFile(filePath, 'utf8');
81
+ } catch {
82
+ return '';
83
+ }
84
+ }
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // 静态 import 解析 & 模块依赖图
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * 根据文件路径推断模块的语义角色(用于 AI 分析失败时的兜底描述)
92
+ */
93
+ function inferModuleRole(filePath, inDegree = 0) {
94
+ const base = path.basename(filePath, path.extname(filePath));
95
+ const ext = path.extname(filePath).toLowerCase();
96
+ const fwd = filePath.replace(/\\/g, '/');
97
+ const low = fwd.toLowerCase();
98
+
99
+ // ── 特殊文件名 ──
100
+ if (/^(main|app)$/i.test(base)) return 'Vue 应用入口';
101
+ if (/^server$/i.test(base)) return 'HTTP 服务主入口';
102
+ if (/^index$/i.test(base)) {
103
+ const parentDir = fwd.split('/').slice(-2, -1)[0] || '';
104
+ if (low.includes('/server') || low.includes('/backend')) return '服务端应用入口';
105
+ if (low.includes('/stores')) return `${parentDir} 状态模块`;
106
+ if (low.includes('/routes')) return `${parentDir} 路由入口`;
107
+ if (low.includes('/utils')) return `${parentDir} 工具集`;
108
+ return `${parentDir} 模块入口`;
109
+ }
110
+
111
+ // ── Pinia / Vuex Store ──
112
+ if (low.includes('/stores/') || /store$/i.test(base)) {
113
+ const name = base.replace(/store$/i, '').replace(/([A-Z])/g, ' $1').trim();
114
+ return `${name} 状态管理`;
115
+ }
116
+
117
+ // ── Vue 组件 ──
118
+ if (ext === '.vue' || low.includes('/components/')) return `${base} 组件`;
119
+
120
+ // ── Views / Pages ──
121
+ if (low.includes('/views/') || /view$/i.test(base) || /page$/i.test(base)) return `${base} 页面`;
122
+
123
+ // ── Routes ──
124
+ if (low.includes('/routes/') || /route[s]?$/i.test(base)) {
125
+ const name = base.replace(/route[s]?$/i, '').trim();
126
+ return name ? `${name} 路由模块` : '路由处理模块';
127
+ }
128
+
129
+ // ── Lang / i18n ──
130
+ if (low.includes('/lang/') || low.includes('/i18n/') || low.includes('/locale')) return '国际化文案资源';
131
+
132
+ // ── Utils / Helpers ──
133
+ if (low.includes('/utils/') || /util[s]?$/i.test(base) || /helper[s]?$/i.test(base)) return `${base} 工具集`;
134
+
135
+ // ── Composables / Hooks ──
136
+ if (low.includes('/composables/') || low.includes('/hooks/') || /^use[A-Z]/.test(base)) return `${base} 组合式函数`;
137
+
138
+ // ── API / Services ──
139
+ if (low.includes('/api/') || low.includes('/services/') || /service[s]?$/i.test(base) || /api$/i.test(base)) return `${base} API 服务`;
140
+
141
+ // ── Middleware ──
142
+ if (low.includes('/middleware/') || /middleware$/i.test(base)) return `${base} 中间件`;
143
+
144
+ // ── Config ──
145
+ if (/config$/i.test(base) || low.includes('/config/')) return `${base} 配置`;
146
+
147
+ // ── Types ──
148
+ if (/types?$/i.test(base) || low.includes('/types/')) return `${base} 类型定义`;
149
+
150
+ // ── 按引用次数降级 ──
151
+ if (inDegree >= 20) return `高频共享模块(被引用 ${inDegree} 次)`;
152
+ if (inDegree >= 5) return `核心共享模块(被引用 ${inDegree} 次)`;
153
+ return `${base} 功能模块`;
154
+ }
155
+
156
+ /**
157
+ * 正则提取 import / require / dynamic-import 的模块路径(TypeScript/Vue 通用)
158
+ */
159
+ function parseImportsRegex(src) {
160
+ const results = new Set();
161
+ // import ... from '...' | export ... from '...' | import type ... from '...'
162
+ const importRe = /(?:^|;|\n)\s*(?:import|export)(?:\s+type)?\s+(?:[^'"`;\n]*?\bfrom\s+)?['"]([^'"` \n]+)['"]/gm;
163
+ let m;
164
+ while ((m = importRe.exec(src)) !== null) { if (m[1]) results.add(m[1]); }
165
+ // require('...')
166
+ const requireRe = /\brequire\s*\(\s*['"]([^'"` \n]+)['"]\s*\)/g;
167
+ while ((m = requireRe.exec(src)) !== null) { if (m[1]) results.add(m[1]); }
168
+ // dynamic import('...')
169
+ const dynRe = /\bimport\s*\(\s*['"]([^'"` \n]+)['"]\s*\)/g;
170
+ while ((m = dynRe.exec(src)) !== null) { if (m[1]) results.add(m[1]); }
171
+ return [...results];
172
+ }
173
+
174
+ /**
175
+ * 从源码中静态提取 import 路径
176
+ * - JS/MJS/CJS/JSX:优先用 acorn AST 解析,失败则正则兜底
177
+ * - TS/TSX/Vue:正则(import 语法与 JS 相同,acorn 不支持 TypeScript 类型语法)
178
+ */
179
+ async function parseStaticImports(filePath, content) {
180
+ const ext = path.extname(filePath).toLowerCase();
181
+ let src = content || '';
182
+
183
+ // Vue SFC:提取 <script> 块
184
+ if (ext === '.vue') {
185
+ const m = src.match(/<script(?:[^>]*)>([\s\S]*?)<\/script>/i);
186
+ src = m ? m[1] : '';
187
+ }
188
+
189
+ // JS / MJS / CJS / JSX → 尝试 acorn AST(最准确)
190
+ if (['.js', '.mjs', '.cjs', '.jsx'].includes(ext)) {
191
+ try {
192
+ const acorn = await import('acorn');
193
+ const ast = acorn.parse(src, {
194
+ ecmaVersion: 2022,
195
+ sourceType: 'module',
196
+ allowImportExportEverywhere: true,
197
+ allowHashBang: true,
198
+ });
199
+ const imports = [];
200
+ for (const node of ast.body) {
201
+ if (node.type === 'ImportDeclaration') imports.push(node.source.value);
202
+ if (node.type === 'ExportNamedDeclaration' && node.source) imports.push(node.source.value);
203
+ if (node.type === 'ExportAllDeclaration' && node.source) imports.push(node.source.value);
204
+ // CommonJS require('...')
205
+ if (node.type === 'ExpressionStatement') {
206
+ const expr = node.expression;
207
+ if (expr?.type === 'CallExpression' && expr.callee?.name === 'require' &&
208
+ expr.arguments?.[0]?.type === 'Literal') {
209
+ imports.push(expr.arguments[0].value);
210
+ }
211
+ }
212
+ }
213
+ return imports;
214
+ } catch { /* fallthrough to regex */ }
215
+ }
216
+
217
+ // TS / TSX / Vue / 其他 正则解析
218
+ return parseImportsRegex(src);
219
+ }
220
+
221
+ /** 可解析的扩展名(按优先级) */
222
+ const RESOLVE_EXTS = ['.ts', '.tsx', '.js', '.mjs', '.jsx', '.vue'];
223
+
224
+ /**
225
+ * 尝试在文件集合中找到匹配的实际文件(带扩展名推断 + index 文件)
226
+ */
227
+ function tryResolveWithExts(base, allFilesSet) {
228
+ if (allFilesSet.has(base)) return base;
229
+ for (const ext of RESOLVE_EXTS) {
230
+ if (allFilesSet.has(base + ext)) return base + ext;
231
+ if (allFilesSet.has(base + '/index' + ext)) return base + '/index' + ext;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * import 路径解析为项目内相对路径
238
+ * 支持相对路径(./xxx)和路径别名(@/xxx、@components/xxx 等)
239
+ * 别名按长度降序匹配,避免 @/ 抢占 @components/ 等更长别名
240
+ */
241
+ function resolveImportPath(fromFile, importPath, allFilesSet, aliasMap) {
242
+ // 按别名长度降序排列,优先匹配最长别名(@components/ 优先于 @/)
243
+ const sortedAliases = Object.entries(aliasMap).sort((a, b) => b[0].length - a[0].length);
244
+ for (const [alias, target] of sortedAliases) {
245
+ if (importPath.startsWith(alias)) {
246
+ const resolved = (target + importPath.slice(alias.length)).replace(/\\/g, '/');
247
+ return tryResolveWithExts(resolved, allFilesSet);
248
+ }
249
+ }
250
+ // 非相对路径(node_modules 包)—— 不解析
251
+ if (!importPath.startsWith('.')) return null;
252
+
253
+ const fromDir = path.dirname(fromFile).replace(/\\/g, '/');
254
+ const joined = path.posix.normalize(fromDir + '/' + importPath);
255
+ return tryResolveWithExts(joined, allFilesSet);
256
+ }
257
+
258
+ /**
259
+ * vite.config 解析路径别名(支持多别名:@、@components、@views 等)
260
+ * @param {string} resolvedDir 项目根目录(绝对路径)
261
+ * @param {string[]} subFiles 子系统文件列表(相对路径)
262
+ * @param {string} rootPath 子系统 rootPath(相对路径,如 src/ui/client/src)
263
+ * @returns {Record<string, string>} { 'alias/' → 'target/dir/' }
264
+ */
265
+ async function detectAliasMap(resolvedDir, subFiles, rootPath = '') {
266
+ const aliasMap = {};
267
+
268
+ // 计算要搜索 vite.config 的候选目录
269
+ // 通常 vite.config.ts 在 rootPath 的父目录(如 src/ui/client)
270
+ const searchDirs = [resolvedDir];
271
+ if (rootPath) {
272
+ const parent = path.dirname(rootPath);
273
+ if (parent && parent !== '.' && parent !== rootPath) {
274
+ searchDirs.push(path.resolve(resolvedDir, parent));
275
+ }
276
+ // rootPath 本身也搜一下
277
+ searchDirs.push(path.resolve(resolvedDir, rootPath));
278
+ }
279
+
280
+ for (const searchDir of searchDirs) {
281
+ for (const cfgName of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) {
282
+ try {
283
+ const cfgPath = path.join(searchDir, cfgName);
284
+ const content = await fs.readFile(cfgPath, 'utf8');
285
+
286
+ // 提取 alias 对象块(支持跨行)
287
+ const aliasBlockMatch = content.match(/alias\s*:\s*\{([^}]+)\}/s);
288
+ if (!aliasBlockMatch) continue;
289
+
290
+ const aliasBody = aliasBlockMatch[1];
291
+ // 匹配 "@xxx": path.resolve(__dirname, "./relpath") 或 path.resolve(xxx, "relpath")
292
+ const entryRe = /["'](@[^"']*)["']\s*:\s*path\.resolve\s*\([^,)]+,\s*["']([^"']+)["']\s*\)/g;
293
+ let m;
294
+ const cfgDir = path.dirname(cfgPath);
295
+ while ((m = entryRe.exec(aliasBody)) !== null) {
296
+ const aliasKey = m[1]; // e.g. '@', '@components'
297
+ const relTarget = m[2]; // e.g. './src', './src/components'
298
+ const absTarget = path.resolve(cfgDir, relTarget);
299
+ const relToProject = path.relative(resolvedDir, absTarget).replace(/\\/g, '/');
300
+ aliasMap[aliasKey + '/'] = relToProject + '/';
301
+ }
302
+
303
+ if (Object.keys(aliasMap).length > 0) return aliasMap;
304
+ } catch { /* ignore */ }
305
+ }
306
+ if (Object.keys(aliasMap).length > 0) break;
307
+ }
308
+
309
+ // 兜底:启发式推断 @/ 指向文件数最多的 src 目录
310
+ if (!aliasMap['@/']) {
311
+ for (const c of ['src/ui/client/src', 'src/client/src', 'client/src', 'src']) {
312
+ if (subFiles.some(f => f.startsWith(c + '/'))) {
313
+ aliasMap['@/'] = c + '/';
314
+ break;
315
+ }
316
+ }
317
+ }
318
+
319
+ return aliasMap;
320
+ }
321
+
322
+ /**
323
+ * 构建完整模块依赖图
324
+ * @param {string[]} subFiles 子系统文件列表(相对路径)
325
+ * @param {string} resolvedDir 项目根目录(绝对路径)
326
+ * @param {string} rootPath 子系统 rootPath(用于定位 vite.config)
327
+ * @returns {{ graph, inDegree, hubFiles, entryCandidates, totalEdges, fileSizes }}
328
+ */
329
+ async function buildDependencyGraph(subFiles, resolvedDir, rootPath = '') {
330
+ const allFilesSet = new Set(subFiles);
331
+ const aliasMap = await detectAliasMap(resolvedDir, subFiles, rootPath);
332
+
333
+ const graph = {}; // file → string[](它 import 的项目内文件)
334
+ const fileSizes = {}; // file → 行数
335
+
336
+ for (const file of subFiles) {
337
+ const content = await safeReadFile(path.resolve(resolvedDir, file), 150000);
338
+ fileSizes[file] = content ? content.split('\n').length : 0;
339
+ const rawImports = await parseStaticImports(file, content);
340
+ const resolved = [];
341
+ for (const imp of rawImports) {
342
+ const r = resolveImportPath(file, imp, allFilesSet, aliasMap);
343
+ if (r) resolved.push(r);
344
+ }
345
+ graph[file] = [...new Set(resolved)];
346
+ }
347
+
348
+ // 计算入度(被多少文件 import)
349
+ const inDegree = {};
350
+ for (const file of subFiles) inDegree[file] = 0;
351
+ for (const deps of Object.values(graph)) {
352
+ for (const dep of deps) { if (dep in inDegree) inDegree[dep]++; }
353
+ }
354
+
355
+ // Hub files = 被最多模块依赖的核心文件
356
+ const hubFiles = Object.entries(inDegree)
357
+ .sort((a, b) => b[1] - a[1])
358
+ .filter(([, d]) => d > 0)
359
+ .slice(0, 8)
360
+ .map(([file, inDeg]) => ({ file, inDegree: inDeg, lines: fileSizes[file] || 0 }));
361
+
362
+ // 入口候选 = 出度 > 0 且入度 = 0(项目根节点,没有被其他文件 import)
363
+ const entryCandidates = subFiles.filter(
364
+ f => (graph[f]?.length || 0) > 0 && (inDegree[f] || 0) === 0
365
+ );
366
+
367
+ const totalEdges = Object.values(graph).reduce((s, d) => s + d.length, 0);
368
+
369
+ return { graph, inDegree, hubFiles, entryCandidates, totalEdges, fileSizes };
370
+ }
371
+
372
+ // ─────────────────────────────────────────────────────────────────────────────
373
+
374
+ /**
375
+ * 调用 OpenAI 兼容 API,返回 JSON
376
+ */
377
+ async function callLlmJson(model, prompt) {
378
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
379
+ const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
380
+ const headers = { 'Content-Type': 'application/json' };
381
+ if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
382
+
383
+ const body = JSON.stringify({
384
+ model: model.model,
385
+ messages: [{ role: 'user', content: prompt }],
386
+ max_tokens: 4096,
387
+ temperature: 0.3,
388
+ response_format: { type: 'json_object' },
389
+ stream: false,
390
+ });
391
+
392
+ const controller = new AbortController();
393
+ const timer = setTimeout(() => controller.abort(), 60000);
394
+ try {
395
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
396
+ const data = await resp.json().catch(() => ({}));
397
+ if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
398
+ const content = data?.choices?.[0]?.message?.content || '{}';
399
+ try {
400
+ const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) || content.match(/({[\s\S]*})/);
401
+ return JSON.parse(jsonMatch ? jsonMatch[1] : content);
402
+ } catch {
403
+ return {};
404
+ }
405
+ } finally {
406
+ clearTimeout(timer);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * SSE 辅助函数
412
+ */
413
+ function sendSse(res, event, data) {
414
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
415
+ }
416
+
417
+ export function registerCodeAnalysisRoutes({ app, configManager }) {
418
+ /**
419
+ * GET /api/code-analysis/files?path=xxx
420
+ * 扫描指定目录下的代码文件列表
421
+ */
422
+ app.get('/api/code-analysis/files', async (req, res) => {
423
+ try {
424
+ const reqPath = req.query.path;
425
+ if (!reqPath || typeof reqPath !== 'string') {
426
+ return res.status(400).json({ error: '缺少 path 参数' });
427
+ }
428
+ const resolved = path.resolve(reqPath);
429
+ // 安全验证:确保路径存在且是目录
430
+ try {
431
+ const stat = await fs.stat(resolved);
432
+ if (!stat.isDirectory()) {
433
+ return res.status(400).json({ error: '路径不是目录' });
434
+ }
435
+ } catch {
436
+ return res.status(400).json({ error: '路径不存在' });
437
+ }
438
+ const files = await scanDirectory(resolved, resolved);
439
+ res.json({ files, basePath: resolved });
440
+ } catch (err) {
441
+ res.status(500).json({ error: err.message });
442
+ }
443
+ });
444
+
445
+ /**
446
+ * GET /api/code-analysis/file-content?path=xxx&file=yyy
447
+ * 读取单个文件内容
448
+ */
449
+ app.get('/api/code-analysis/file-content', async (req, res) => {
450
+ try {
451
+ const basePath = req.query.path;
452
+ const file = req.query.file;
453
+ if (!basePath || !file) {
454
+ return res.status(400).json({ error: '缺少 path 或 file 参数' });
455
+ }
456
+ const resolvedBase = path.resolve(String(basePath));
457
+ const resolvedFile = path.resolve(resolvedBase, String(file));
458
+ // 安全验证:文件必须在 basePath 内
459
+ if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
460
+ return res.status(403).json({ error: '禁止访问此路径' });
461
+ }
462
+ const content = await safeReadFile(resolvedFile);
463
+ res.json({ content });
464
+ } catch (err) {
465
+ res.status(500).json({ error: err.message });
466
+ }
467
+ });
468
+
469
+ /**
470
+ * POST /api/code-analysis/analyze (SSE)
471
+ * 对指定目录进行 AI 代码分析,流式推送进度和结果
472
+ *
473
+ * Body: { path: string, modelId?: string }
474
+ */
475
+ app.post('/api/code-analysis/analyze', express.json(), async (req, res) => {
476
+ const { path: projectPath, modelId } = req.body || {};
477
+
478
+ if (!projectPath || typeof projectPath !== 'string') {
479
+ return res.status(400).json({ error: '缺少 path 参数' });
480
+ }
481
+
482
+ const resolved = path.resolve(projectPath);
483
+
484
+ // 安全验证目录存在
485
+ try {
486
+ const stat = await fs.stat(resolved);
487
+ if (!stat.isDirectory()) return res.status(400).json({ error: '路径不是目录' });
488
+ } catch {
489
+ return res.status(400).json({ error: '路径不存在' });
490
+ }
491
+
492
+ // 获取 AI 模型配置
493
+ let model;
494
+ try {
495
+ const rawConfig = await configManager.readRawConfigFile();
496
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
497
+ model = (modelId ? models.find(m => m.id === modelId) : null)
498
+ || models.find(m => m.isDefault)
499
+ || models[0];
500
+ } catch (err) {
501
+ return res.status(500).json({ error: '读取配置失败: ' + err.message });
502
+ }
503
+
504
+ if (!model) {
505
+ return res.status(400).json({ error: '未配置 AI 模型,请先在通用设置中添加模型' });
506
+ }
507
+
508
+ // 设置 SSE 响应头
509
+ res.setHeader('Content-Type', 'text/event-stream');
510
+ res.setHeader('Cache-Control', 'no-cache');
511
+ res.setHeader('Connection', 'keep-alive');
512
+ res.setHeader('X-Accel-Buffering', 'no');
513
+ res.flushHeaders();
514
+
515
+ let stopped = false;
516
+ req.on('close', () => { stopped = true; });
517
+
518
+ function log(message, type = 'info') {
519
+ if (!stopped) sendSse(res, 'log', { message, type, timestamp: Date.now() });
520
+ }
521
+
522
+ try {
523
+ // Step 1: 扫描文件
524
+ log('正在扫描项目文件...', 'info');
525
+ const allFiles = await scanDirectory(resolved, resolved);
526
+ const codeFiles = allFiles.filter(f => {
527
+ const ext = path.extname(f).toLowerCase();
528
+ return ['.js', '.mjs', '.ts', '.tsx', '.jsx', '.vue', '.py', '.java', '.go',
529
+ '.rs', '.cpp', '.c', '.h', '.cs', '.rb', '.php', '.swift', '.kt'].includes(ext);
530
+ });
531
+ log(`扫描完成,共 ${allFiles.length} 个文件,${codeFiles.length} 个代码文件`, 'success');
532
+
533
+ if (stopped) return res.end();
534
+
535
+ sendSse(res, 'files', { files: allFiles, codeFiles });
536
+
537
+ if (codeFiles.length === 0) {
538
+ log('未找到代码文件,分析终止', 'error');
539
+ sendSse(res, 'done', { error: '未找到代码文件' });
540
+ return res.end();
541
+ }
542
+
543
+ // Step 2: 程序化识别子系统(扫描 package.json 位置和路径模式)
544
+ log('正在分析项目结构...', 'info');
545
+
546
+ /**
547
+ * 扫描项目中所有 package.json 文件(depth <= 4,排除 node_modules)
548
+ */
549
+ async function findPackageJsonPaths(baseDir, maxDepth = 4) {
550
+ const found = [];
551
+ async function walk(dir, depth) {
552
+ if (depth > maxDepth) return;
553
+ let entries;
554
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
555
+ for (const e of entries) {
556
+ if (e.isDirectory()) {
557
+ if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) {
558
+ await walk(path.join(dir, e.name), depth + 1);
559
+ }
560
+ } else if (e.isFile() && e.name === 'package.json' && depth > 0) {
561
+ const rel = path.relative(baseDir, path.join(dir, e.name)).replace(/\\/g, '/');
562
+ found.push(rel);
563
+ }
564
+ }
565
+ }
566
+ await walk(baseDir, 0);
567
+ return found;
568
+ }
569
+
570
+ const packageJsonPaths = await findPackageJsonPaths(resolved);
571
+
572
+ /**
573
+ * 已知路径模式 子系统定义(优先匹配)
574
+ */
575
+ const KNOWN_PATTERNS = [
576
+ { pathPattern: 'src/ui/client/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: 'Vue 3 前端应用' },
577
+ { pathPattern: 'src/ui/server', name: 'backend', displayName: '后端', language: 'JavaScript', description: 'Node.js/Express 后端' },
578
+ { pathPattern: 'client/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: '前端应用' },
579
+ { pathPattern: 'server', name: 'backend', displayName: '后端', language: 'JavaScript', description: '后端服务' },
580
+ { pathPattern: 'frontend/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: '前端应用' },
581
+ { pathPattern: 'backend', name: 'backend', displayName: '后端', language: 'JavaScript', description: '后端服务' },
582
+ ];
583
+
584
+ // 从 package.json 路径派生子系统 rootPath
585
+ const pkgBaseDirs = packageJsonPaths
586
+ .map(p => path.dirname(p).replace(/\\/g, '/'))
587
+ .filter(d => d !== '.' && d !== '');
588
+
589
+ let programmaticSubsystems = [];
590
+
591
+ if (pkgBaseDirs.length >= 2) {
592
+ // 多 package.json → 每个目录是一个子系统
593
+ programmaticSubsystems = pkgBaseDirs.slice(0, 4).map(dir => {
594
+ const matched = KNOWN_PATTERNS.find(p => dir.startsWith(p.pathPattern) || dir === p.pathPattern.split('/')[0]);
595
+ return matched
596
+ ? { ...matched, rootPath: dir }
597
+ : { name: path.basename(dir), displayName: path.basename(dir), rootPath: dir, language: 'Unknown', description: '' };
598
+ });
599
+ log(`程序化检测到 ${programmaticSubsystems.length} 个子系统(基于 package.json): ${programmaticSubsystems.map(s => s.displayName).join(' / ')}`, 'success');
600
+ } else {
601
+ // 无多个 package.json,尝试已知路径模式
602
+ for (const pattern of KNOWN_PATTERNS) {
603
+ // 必须是目录前缀匹配(加 / 后缀),避免 'server' 误匹配 'server.js'
604
+ const hasMatch = codeFiles.some(f => f.startsWith(pattern.pathPattern + '/'));
605
+ if (hasMatch) {
606
+ // 避免重复添加同名模式
607
+ if (!programmaticSubsystems.find(s => s.rootPath === pattern.pathPattern)) {
608
+ programmaticSubsystems.push({ ...pattern, rootPath: pattern.pathPattern });
609
+ }
610
+ }
611
+ }
612
+ if (programmaticSubsystems.length >= 2) {
613
+ log(`程序化检测到 ${programmaticSubsystems.length} 个子系统(基于路径模式): ${programmaticSubsystems.map(s => s.displayName).join(' / ')}`, 'success');
614
+ } else {
615
+ programmaticSubsystems = []; // 回退到 AI
616
+ }
617
+ }
618
+
619
+ const SUBSYSTEM_COLORS = ['#f59e0b', '#3b82f6', '#10b981', '#8b5cf6'];
620
+ let rawSubsystems;
621
+
622
+ if (programmaticSubsystems.length >= 2) {
623
+ rawSubsystems = programmaticSubsystems;
624
+ } else {
625
+ // Step 2b: AI 识别子系统(程序化未能区分)
626
+ log('AI 正在识别子系统结构...', 'thinking');
627
+ const subsystemPrompt = `你是项目架构分析师。根据以下文件路径列表,识别项目的子系统/子模块。
628
+
629
+ 文件列表:
630
+ ${JSON.stringify(codeFiles.slice(0, 300))}
631
+
632
+ 判断依据:独立目录结构、package.json、入口文件、client/server、frontend/backend、ui/api 等模式。
633
+
634
+ 返回 JSON(必须严格是 JSON):
635
+ {
636
+ "isMonorepo": true,
637
+ "subsystems": [
638
+ {
639
+ "name": "frontend",
640
+ "displayName": "前端",
641
+ "rootPath": "src/ui/client/src",
642
+ "language": "TypeScript",
643
+ "description": "Vue 3 前端应用"
644
+ }
645
+ ]
646
+ }
647
+
648
+ 注意:
649
+ - 单一系统时 isMonorepo 为 false,subsystems 一个,rootPath 为空字符串
650
+ - 最多识别 4 个子系统
651
+ - rootPath 使用正斜杠,必须与文件列表路径前缀严格匹配`;
652
+
653
+ const subsystemData = await callLlmJson(model, subsystemPrompt);
654
+ if (stopped) return res.end();
655
+
656
+ rawSubsystems = Array.isArray(subsystemData.subsystems) && subsystemData.subsystems.length > 0
657
+ ? subsystemData.subsystems.slice(0, 4)
658
+ : [{ name: 'main', displayName: '主系统', rootPath: '', language: 'Unknown', description: '' }];
659
+
660
+ log(`识别到 ${rawSubsystems.length} 个子系统: ${rawSubsystems.map(s => s.displayName || s.name).join(' / ')}`, 'success');
661
+ }
662
+
663
+
664
+ // Step 3-4: 对每个子系统分别分析调用链
665
+ const allNodes = [];
666
+ const allEdges = [];
667
+ const allTechStack = new Set();
668
+ let overallSummary = '';
669
+ let primaryLanguage = 'Unknown';
670
+ let primaryEntryFile = '';
671
+
672
+ for (let si = 0; si < rawSubsystems.length; si++) {
673
+ if (stopped) break;
674
+ const sub = rawSubsystems[si];
675
+ const subsystemName = sub.displayName || sub.name;
676
+ const subsystemColor = SUBSYSTEM_COLORS[si % SUBSYSTEM_COLORS.length];
677
+
678
+ // 过滤该子系统的代码文件
679
+ const subFiles = sub.rootPath
680
+ ? codeFiles.filter(f => f.startsWith(sub.rootPath.replace(/\\/g, '/')))
681
+ : codeFiles;
682
+
683
+ if (subFiles.length === 0) {
684
+ log(`[${subsystemName}] 未找到代码文件,跳过`, 'info');
685
+ continue;
686
+ }
687
+
688
+ // ── Step 2.5: 静态 import 解析 → 构建真实模块依赖图 ───────────────
689
+ log(`[${subsystemName}] 正在静态解析 import 依赖(${subFiles.length} 个文件)...`, 'info');
690
+ const { graph: depGraph, inDegree, hubFiles, entryCandidates, totalEdges, fileSizes } =
691
+ await buildDependencyGraph(subFiles, resolved, sub.rootPath || '');
692
+ log(`[${subsystemName}] 依赖图完成:${totalEdges} 条 import 关系,${hubFiles.length} 个核心模块`, 'success');
693
+ if (stopped) break;
694
+
695
+ // 向前端推送静态依赖图数据
696
+ sendSse(res, 'depgraph', {
697
+ subsystem: sub.name,
698
+ graph: depGraph,
699
+ hubFiles,
700
+ entryCandidates,
701
+ inDegree,
702
+ fileSizes,
703
+ });
704
+
705
+ // ── Step 3: 识别入口文件(优先静态分析结论,AI 作为兜底) ──────────
706
+ log(`[${subsystemName}] 正在识别入口文件...`, 'thinking');
707
+
708
+ // 静态入口候选(入度=0 且有出度的根节点)按常见命名排序
709
+ const ENTRY_NAME_PRIORITY = ['main', 'index', 'app', 'server', 'start', 'entry'];
710
+ const staticEntryCandidates = [...entryCandidates].sort((a, b) => {
711
+ const aScore = ENTRY_NAME_PRIORITY.findIndex(n => path.basename(a, path.extname(a)).toLowerCase().includes(n));
712
+ const bScore = ENTRY_NAME_PRIORITY.findIndex(n => path.basename(b, path.extname(b)).toLowerCase().includes(n));
713
+ return (aScore === -1 ? 99 : aScore) - (bScore === -1 ? 99 : bScore);
714
+ });
715
+
716
+ let subLang = sub.language || 'Unknown';
717
+ let subSummary = '';
718
+ let subEntryFiles = staticEntryCandidates.slice(0, 3);
719
+ let subEntryFunctions = ['main'];
720
+
721
+ // 仅当静态分析无法确定入口时,才调用 AI
722
+ if (subEntryFiles.length === 0) {
723
+ const subEntryPrompt = `你是软件架构师。以下是子系统的代码文件列表,请识别语言和入口文件。
724
+
725
+ 子系统: ${subsystemName}
726
+ 文件列表:
727
+ ${JSON.stringify(subFiles.slice(0, 150))}
728
+
729
+ 返回 JSON:
730
+ {
731
+ "language": "TypeScript",
732
+ "potentialEntryFiles": ["src/main.ts"],
733
+ "potentialEntryFunctions": ["main"],
734
+ "projectSummary": "简短中文描述"
735
+ }`;
736
+ const subEntryData = await callLlmJson(model, subEntryPrompt);
737
+ if (stopped) break;
738
+ subLang = String(subEntryData.language || sub.language || 'Unknown');
739
+ subEntryFiles = Array.isArray(subEntryData.potentialEntryFiles) ? subEntryData.potentialEntryFiles.slice(0, 3) : [];
740
+ subEntryFunctions = Array.isArray(subEntryData.potentialEntryFunctions) ? subEntryData.potentialEntryFunctions : ['main'];
741
+ subSummary = String(subEntryData.projectSummary || '');
742
+ } else {
743
+ // 从文件扩展名推断语言
744
+ const extLangMap = { '.ts': 'TypeScript', '.tsx': 'TypeScript', '.vue': 'TypeScript', '.js': 'JavaScript', '.mjs': 'JavaScript', '.py': 'Python', '.go': 'Go', '.rs': 'Rust', '.java': 'Java' };
745
+ const firstExt = path.extname(subEntryFiles[0]).toLowerCase();
746
+ subLang = extLangMap[firstExt] || sub.language || 'Unknown';
747
+ }
748
+
749
+ if (!primaryLanguage || primaryLanguage === 'Unknown') primaryLanguage = subLang;
750
+
751
+ // 验证并读取入口文件
752
+ let entryFile = '';
753
+ let entryContent = '';
754
+ for (const candidate of subEntryFiles) {
755
+ if (stopped) break;
756
+ const candidatePath = path.resolve(resolved, candidate);
757
+ if (!candidatePath.startsWith(resolved + path.sep) && candidatePath !== resolved) continue;
758
+ const content = await safeReadFile(candidatePath);
759
+ if (content) { entryFile = candidate; entryContent = content; break; }
760
+ }
761
+ if (!entryFile && subFiles.length > 0) {
762
+ entryFile = subFiles[0];
763
+ entryContent = await safeReadFile(path.resolve(resolved, entryFile));
764
+ }
765
+ if (!primaryEntryFile) primaryEntryFile = entryFile;
766
+ if (!overallSummary) overallSummary = subSummary;
767
+
768
+ // ── Step 4: AI 语义分析(以静态依赖图为骨架) ──────────────────────
769
+ log(`[${subsystemName}] AI 正在基于依赖图做语义分析...`, 'thinking');
770
+
771
+ // 读取 Hub 文件内容(被引用最多的核心模块,每个最多 2500 字符)
772
+ const hubContents = [];
773
+ for (const { file: hubFile } of hubFiles.slice(0, 4)) {
774
+ if (stopped) break;
775
+ const hubPath = path.resolve(resolved, hubFile);
776
+ if (!hubPath.startsWith(resolved)) continue;
777
+ const content = await safeReadFile(hubPath, 80000);
778
+ if (content) hubContents.push({ file: hubFile, content: content.slice(0, 2500) });
779
+ }
780
+
781
+ // 构建依赖图文本摘要(展示文件间 import 关系,仅展示有边的节点)
782
+ const graphLines = Object.entries(depGraph)
783
+ .filter(([, deps]) => deps.length > 0)
784
+ .sort((a, b) => b[1].length - a[1].length)
785
+ .slice(0, 60)
786
+ .map(([f, deps]) => ` ${f} (${fileSizes[f] || '?'}行) → [${deps.join(', ')}]`);
787
+
788
+ const hubSummary = hubFiles
789
+ .map(h => ` ${h.file} (被引用${h.inDegree}次, ${h.lines}行)`)
790
+ .join('\n');
791
+
792
+ const entryFunction = subEntryFunctions[0] || 'main';
793
+ const entrySnippet = entryContent.slice(0, 3000);
794
+
795
+ const hubContentText = hubContents.map(h =>
796
+ `\n--- ${h.file} ---\n${h.content}`
797
+ ).join('\n');
798
+
799
+ // Step 4: 分析调用链(基于真实依赖图)
800
+ const chainPrompt = `你是代码架构分析师。以下是通过静态 import 解析得到的真实模块依赖图,请基于此输出调用图节点和语义描述。
801
+
802
+ 语言: ${subLang}
803
+ 子系统: ${subsystemName}
804
+ 入口文件: ${entryFile}(入口函数: ${entryFunction})
805
+
806
+ ## 真实模块依赖图(静态 import 解析结果)
807
+ ${subFiles.length} 个文件,${totalEdges} 条 import 关系
808
+ 格式:文件(行数) → [它所 import 的文件]
809
+
810
+ ${graphLines.join('\n') || '(无内部 import 关系)'}
811
+
812
+ ## 核心模块(按被引用次数排序)
813
+ ${hubSummary || '(未检测到高频被引用模块)'}
814
+
815
+ ## 入口文件内容(截取前3000字符)
816
+ ${entrySnippet}
817
+
818
+ ## 核心模块文件内容
819
+ ${hubContentText || '(无)'}
820
+
821
+ 返回 JSON(基于真实依赖图,不要凭空猜测):
822
+ {
823
+ "entryFunction": "函数名",
824
+ "nodes": [
825
+ { "id": "n1", "label": "模块/函数名", "file": "文件路径(必须来自依赖图)", "line": 1, "type": "module|function|store|component|api", "importance": "high|medium|low", "description": "职责中文描述(10-30字)" }
826
+ ],
827
+ "edges": [{ "source": "n1", "target": "n2" }],
828
+ "techStack": ["Vue3", "TypeScript"],
829
+ "summary": "子系统架构中文概要(50-100字)"
830
+ }
831
+
832
+ 要求:
833
+ 1. 节点必须对应依赖图中真实存在的文件,不要编造节点
834
+ 2. edges 必须反映真实 import 关系(A import B → A→B 有边)
835
+ 3. 最多 25 个节点,优先选择高入度的核心模块
836
+ 4. importance: high(核心/hub)/ medium(普通)/ low(叶子/工具)
837
+ 5. type 可选: module / function / store / component / api / util / config`;
838
+
839
+ const chainData = await callLlmJson(model, chainPrompt);
840
+ if (stopped) break;
841
+
842
+ // 合并节点/边,加子系统标识,ID 加前缀避免冲突
843
+ let rawNodes = Array.isArray(chainData?.nodes) ? chainData.nodes : [];
844
+ let rawEdges = Array.isArray(chainData?.edges) ? chainData.edges : [];
845
+
846
+ // ── 兜底:AI 未返回节点时,直接从静态依赖图生成基础节点 ────────────
847
+ if (rawNodes.length === 0) {
848
+ log(`[${subsystemName}] AI 未返回节点,从静态依赖图生成基础可视化...`, 'info');
849
+ const seen = new Set();
850
+ const fbNodes = [];
851
+ // 入口节点
852
+ for (const f of staticEntryCandidates.slice(0, 3)) {
853
+ if (!seen.has(f)) {
854
+ seen.add(f);
855
+ fbNodes.push({ id: `fe_${fbNodes.length}`, label: path.basename(f, path.extname(f)), file: f, line: 1, type: 'module', importance: 'high', description: inferModuleRole(f, 0) });
856
+ }
857
+ }
858
+ // Hub 节点(入度最高)
859
+ for (const h of hubFiles.slice(0, 10)) {
860
+ if (!seen.has(h.file)) {
861
+ seen.add(h.file);
862
+ fbNodes.push({ id: `fh_${fbNodes.length}`, label: path.basename(h.file, path.extname(h.file)), file: h.file, line: 1, type: 'module', importance: h.inDegree >= 5 ? 'high' : h.inDegree >= 2 ? 'medium' : 'low', description: inferModuleRole(h.file, h.inDegree) });
863
+ }
864
+ }
865
+ rawNodes = fbNodes;
866
+ // 从静态依赖图生成边
867
+ const nodeFileMap = {};
868
+ rawNodes.forEach(n => { nodeFileMap[n.file] = n.id; });
869
+ rawEdges = [];
870
+ for (const [srcFile, dstFiles] of Object.entries(depGraph)) {
871
+ if (!nodeFileMap[srcFile]) continue;
872
+ for (const dstFile of dstFiles) {
873
+ if (nodeFileMap[dstFile]) rawEdges.push({ source: nodeFileMap[srcFile], target: nodeFileMap[dstFile] });
874
+ }
875
+ }
876
+ }
877
+ const idMap = {};
878
+ rawNodes.forEach((n, i) => { idMap[n.id] = `sub${si}_${n.id || i}`; });
879
+
880
+ rawNodes.forEach((n, i) => {
881
+ allNodes.push({
882
+ ...n,
883
+ id: `sub${si}_${n.id || i}`,
884
+ subsystem: sub.name,
885
+ subsystemIndex: si,
886
+ subsystemColor,
887
+ });
888
+ });
889
+ rawEdges.forEach(e => {
890
+ const src = idMap[e.source] || `sub${si}_${e.source}`;
891
+ const tgt = idMap[e.target] || `sub${si}_${e.target}`;
892
+ allEdges.push({ source: src, target: tgt });
893
+ });
894
+
895
+ (Array.isArray(chainData.techStack) ? chainData.techStack : []).forEach(t => allTechStack.add(t));
896
+ log(`[${subsystemName}] 分析完成,${rawNodes.length} 个节点,${rawEdges.length} 条连接`, 'success');
897
+ }
898
+
899
+ if (stopped) return res.end();
900
+
901
+ // Step 5: 发送最终结果
902
+ sendSse(res, 'result', {
903
+ language: primaryLanguage,
904
+ entryFile: primaryEntryFile,
905
+ entryFunction: '',
906
+ nodes: allNodes,
907
+ edges: allEdges,
908
+ techStack: [...allTechStack],
909
+ summary: overallSummary,
910
+ allFiles,
911
+ codeFiles,
912
+ subsystems: rawSubsystems.map((s, i) => ({ ...s, color: SUBSYSTEM_COLORS[i % SUBSYSTEM_COLORS.length] })),
913
+ });
914
+
915
+ log('✓ 全部分析完成!', 'success');
916
+ sendSse(res, 'done', { success: true });
917
+ } catch (err) {
918
+ if (!stopped) {
919
+ log(`分析失败: ${err.message}`, 'error');
920
+ sendSse(res, 'done', { error: err.message });
921
+ }
922
+ } finally {
923
+ res.end();
924
+ }
925
+ });
926
+
927
+ /**
928
+ * POST /api/code-analysis/drill
929
+ * 手动下钻单个函数节点
930
+ *
931
+ * Body: { basePath, file, functionName, language, parentChain? }
932
+ */
933
+ app.post('/api/code-analysis/drill', express.json(), async (req, res) => {
934
+ const { basePath, file, functionName, language, parentChain, modelId } = req.body || {};
935
+
936
+ if (!basePath || !file || !functionName) {
937
+ return res.status(400).json({ error: '缺少必要参数' });
938
+ }
939
+
940
+ const resolvedBase = path.resolve(String(basePath));
941
+ const resolvedFile = path.resolve(resolvedBase, String(file));
942
+ if (!resolvedFile.startsWith(resolvedBase)) {
943
+ return res.status(403).json({ error: '禁止访问此路径' });
944
+ }
945
+
946
+ // 获取 AI 模型配置
947
+ let model;
948
+ try {
949
+ const rawConfig = await configManager.readRawConfigFile();
950
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
951
+ model = (modelId ? models.find(m => m.id === modelId) : null)
952
+ || models.find(m => m.isDefault)
953
+ || models[0];
954
+ } catch (err) {
955
+ return res.status(500).json({ error: '读取配置失败: ' + err.message });
956
+ }
957
+
958
+ if (!model) {
959
+ return res.status(400).json({ error: '未配置 AI 模型' });
960
+ }
961
+
962
+ try {
963
+ const content = await safeReadFile(resolvedFile);
964
+ if (!content) return res.status(404).json({ error: '无法读取文件内容' });
965
+
966
+ const snippet = content.slice(0, 7000);
967
+ const prompt = `你是代码调用链下钻分析器。请分析目标函数,输出其关键子调用节点。
968
+
969
+ 语言: ${String(language || 'Unknown')}
970
+ 文件: ${String(file)}
971
+ 函数: ${String(functionName)}
972
+ ${parentChain ? `调用链上下文: ${String(parentChain).slice(0, 500)}` : ''}
973
+
974
+ 文件内容(截取):
975
+ ${snippet}
976
+
977
+ 返回 JSON:
978
+ {
979
+ "functionName": "${String(functionName)}",
980
+ "description": "函数职责",
981
+ "calls": [
982
+ { "name": "子函数名", "type": "function", "description": "中文描述", "importance": "high", "shouldDrill": 1, "possibleFile": "推测文件路径" }
983
+ ]
984
+ }
985
+
986
+ shouldDrill: -1(叶子/不重要)、0(不确定)、1(值得继续下钻)
987
+ 最多输出 10 个子调用`;
988
+
989
+ const result = await callLlmJson(model, prompt);
990
+ res.json({ success: true, data: result });
991
+ } catch (err) {
992
+ res.status(500).json({ error: err.message });
993
+ }
994
+ });
995
+ }