zen-gitsync 2.11.12 → 2.11.14

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.
@@ -10,10 +10,10 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-BWl1xzuJ.js"></script>
14
- <link rel="modulepreload" crossorigin href="/assets/vendor-BtihdyTS.js">
15
- <link rel="stylesheet" crossorigin href="/assets/vendor-CeElb63i.css">
16
- <link rel="stylesheet" crossorigin href="/assets/index-BVWHnypR.css">
13
+ <script type="module" crossorigin src="/assets/index-DUYNPYDG.js"></script>
14
+ <link rel="modulepreload" crossorigin href="/assets/vendor-CqblImIq.js">
15
+ <link rel="stylesheet" crossorigin href="/assets/vendor-5oPHSuwX.css">
16
+ <link rel="stylesheet" crossorigin href="/assets/index-BIm6dSSS.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="app"></div>
@@ -26,6 +26,7 @@ import { registerNpmRoutes } from './routes/npm.js';
26
26
  import { registerFileOpenRoutes } from './routes/fileOpen.js';
27
27
  import { registerGitOpsRoutes } from './routes/gitOps.js';
28
28
  import { registerCodeRoutes } from './routes/code.js';
29
+ import { registerCodeAnalysisRoutes } from './routes/codeAnalysis.js';
29
30
  import { createSavePortToFile } from './utils/createSavePortToFile.js';
30
31
  import { startServerOnAvailablePort } from './utils/startServerOnAvailablePort.js';
31
32
 
@@ -223,6 +224,8 @@ async function startUIServer(noOpen = false, savePort = false) {
223
224
  app
224
225
  });
225
226
 
227
+ registerCodeAnalysisRoutes({ app, configManager });
228
+
226
229
  registerGitOpsRoutes({
227
230
  app,
228
231
  execGitCommand,
@@ -0,0 +1,585 @@
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
+ * 调用 OpenAI 兼容 API,返回 JSON
74
+ */
75
+ async function callLlmJson(model, prompt) {
76
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
77
+ const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
78
+ const headers = { 'Content-Type': 'application/json' };
79
+ if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
80
+
81
+ const body = JSON.stringify({
82
+ model: model.model,
83
+ messages: [{ role: 'user', content: prompt }],
84
+ max_tokens: 4096,
85
+ temperature: 0.3,
86
+ response_format: { type: 'json_object' },
87
+ stream: false,
88
+ });
89
+
90
+ const controller = new AbortController();
91
+ const timer = setTimeout(() => controller.abort(), 60000);
92
+ try {
93
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
94
+ const data = await resp.json().catch(() => ({}));
95
+ if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
96
+ const content = data?.choices?.[0]?.message?.content || '{}';
97
+ try {
98
+ const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) || content.match(/({[\s\S]*})/);
99
+ return JSON.parse(jsonMatch ? jsonMatch[1] : content);
100
+ } catch {
101
+ return {};
102
+ }
103
+ } finally {
104
+ clearTimeout(timer);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * SSE 辅助函数
110
+ */
111
+ function sendSse(res, event, data) {
112
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
113
+ }
114
+
115
+ export function registerCodeAnalysisRoutes({ app, configManager }) {
116
+ /**
117
+ * GET /api/code-analysis/files?path=xxx
118
+ * 扫描指定目录下的代码文件列表
119
+ */
120
+ app.get('/api/code-analysis/files', async (req, res) => {
121
+ try {
122
+ const reqPath = req.query.path;
123
+ if (!reqPath || typeof reqPath !== 'string') {
124
+ return res.status(400).json({ error: '缺少 path 参数' });
125
+ }
126
+ const resolved = path.resolve(reqPath);
127
+ // 安全验证:确保路径存在且是目录
128
+ try {
129
+ const stat = await fs.stat(resolved);
130
+ if (!stat.isDirectory()) {
131
+ return res.status(400).json({ error: '路径不是目录' });
132
+ }
133
+ } catch {
134
+ return res.status(400).json({ error: '路径不存在' });
135
+ }
136
+ const files = await scanDirectory(resolved, resolved);
137
+ res.json({ files, basePath: resolved });
138
+ } catch (err) {
139
+ res.status(500).json({ error: err.message });
140
+ }
141
+ });
142
+
143
+ /**
144
+ * GET /api/code-analysis/file-content?path=xxx&file=yyy
145
+ * 读取单个文件内容
146
+ */
147
+ app.get('/api/code-analysis/file-content', async (req, res) => {
148
+ try {
149
+ const basePath = req.query.path;
150
+ const file = req.query.file;
151
+ if (!basePath || !file) {
152
+ return res.status(400).json({ error: '缺少 path 或 file 参数' });
153
+ }
154
+ const resolvedBase = path.resolve(String(basePath));
155
+ const resolvedFile = path.resolve(resolvedBase, String(file));
156
+ // 安全验证:文件必须在 basePath 内
157
+ if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
158
+ return res.status(403).json({ error: '禁止访问此路径' });
159
+ }
160
+ const content = await safeReadFile(resolvedFile);
161
+ res.json({ content });
162
+ } catch (err) {
163
+ res.status(500).json({ error: err.message });
164
+ }
165
+ });
166
+
167
+ /**
168
+ * POST /api/code-analysis/analyze (SSE)
169
+ * 对指定目录进行 AI 代码分析,流式推送进度和结果
170
+ *
171
+ * Body: { path: string, modelId?: string }
172
+ */
173
+ app.post('/api/code-analysis/analyze', express.json(), async (req, res) => {
174
+ const { path: projectPath, modelId } = req.body || {};
175
+
176
+ if (!projectPath || typeof projectPath !== 'string') {
177
+ return res.status(400).json({ error: '缺少 path 参数' });
178
+ }
179
+
180
+ const resolved = path.resolve(projectPath);
181
+
182
+ // 安全验证目录存在
183
+ try {
184
+ const stat = await fs.stat(resolved);
185
+ if (!stat.isDirectory()) return res.status(400).json({ error: '路径不是目录' });
186
+ } catch {
187
+ return res.status(400).json({ error: '路径不存在' });
188
+ }
189
+
190
+ // 获取 AI 模型配置
191
+ let model;
192
+ try {
193
+ const rawConfig = await configManager.readRawConfigFile();
194
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
195
+ model = (modelId ? models.find(m => m.id === modelId) : null)
196
+ || models.find(m => m.isDefault)
197
+ || models[0];
198
+ } catch (err) {
199
+ return res.status(500).json({ error: '读取配置失败: ' + err.message });
200
+ }
201
+
202
+ if (!model) {
203
+ return res.status(400).json({ error: '未配置 AI 模型,请先在通用设置中添加模型' });
204
+ }
205
+
206
+ // 设置 SSE 响应头
207
+ res.setHeader('Content-Type', 'text/event-stream');
208
+ res.setHeader('Cache-Control', 'no-cache');
209
+ res.setHeader('Connection', 'keep-alive');
210
+ res.setHeader('X-Accel-Buffering', 'no');
211
+ res.flushHeaders();
212
+
213
+ let stopped = false;
214
+ req.on('close', () => { stopped = true; });
215
+
216
+ function log(message, type = 'info') {
217
+ if (!stopped) sendSse(res, 'log', { message, type, timestamp: Date.now() });
218
+ }
219
+
220
+ try {
221
+ // Step 1: 扫描文件
222
+ log('正在扫描项目文件...', 'info');
223
+ const allFiles = await scanDirectory(resolved, resolved);
224
+ const codeFiles = allFiles.filter(f => {
225
+ const ext = path.extname(f).toLowerCase();
226
+ return ['.js', '.mjs', '.ts', '.tsx', '.jsx', '.vue', '.py', '.java', '.go',
227
+ '.rs', '.cpp', '.c', '.h', '.cs', '.rb', '.php', '.swift', '.kt'].includes(ext);
228
+ });
229
+ log(`扫描完成,共 ${allFiles.length} 个文件,${codeFiles.length} 个代码文件`, 'success');
230
+
231
+ if (stopped) return res.end();
232
+
233
+ sendSse(res, 'files', { files: allFiles, codeFiles });
234
+
235
+ if (codeFiles.length === 0) {
236
+ log('未找到代码文件,分析终止', 'error');
237
+ sendSse(res, 'done', { error: '未找到代码文件' });
238
+ return res.end();
239
+ }
240
+
241
+ // Step 2: 程序化识别子系统(扫描 package.json 位置和路径模式)
242
+ log('正在分析项目结构...', 'info');
243
+
244
+ /**
245
+ * 扫描项目中所有 package.json 文件(depth <= 4,排除 node_modules)
246
+ */
247
+ async function findPackageJsonPaths(baseDir, maxDepth = 4) {
248
+ const found = [];
249
+ async function walk(dir, depth) {
250
+ if (depth > maxDepth) return;
251
+ let entries;
252
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
253
+ for (const e of entries) {
254
+ if (e.isDirectory()) {
255
+ if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) {
256
+ await walk(path.join(dir, e.name), depth + 1);
257
+ }
258
+ } else if (e.isFile() && e.name === 'package.json' && depth > 0) {
259
+ const rel = path.relative(baseDir, path.join(dir, e.name)).replace(/\\/g, '/');
260
+ found.push(rel);
261
+ }
262
+ }
263
+ }
264
+ await walk(baseDir, 0);
265
+ return found;
266
+ }
267
+
268
+ const packageJsonPaths = await findPackageJsonPaths(resolved);
269
+
270
+ /**
271
+ * 已知路径模式 → 子系统定义(优先匹配)
272
+ */
273
+ const KNOWN_PATTERNS = [
274
+ { pathPattern: 'src/ui/client/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: 'Vue 3 前端应用' },
275
+ { pathPattern: 'src/ui/server', name: 'backend', displayName: '后端', language: 'JavaScript', description: 'Node.js/Express 后端' },
276
+ { pathPattern: 'client/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: '前端应用' },
277
+ { pathPattern: 'server', name: 'backend', displayName: '后端', language: 'JavaScript', description: '后端服务' },
278
+ { pathPattern: 'frontend/src', name: 'frontend', displayName: '前端', language: 'TypeScript', description: '前端应用' },
279
+ { pathPattern: 'backend', name: 'backend', displayName: '后端', language: 'JavaScript', description: '后端服务' },
280
+ ];
281
+
282
+ // 从 package.json 路径派生子系统 rootPath
283
+ const pkgBaseDirs = packageJsonPaths
284
+ .map(p => path.dirname(p).replace(/\\/g, '/'))
285
+ .filter(d => d !== '.' && d !== '');
286
+
287
+ let programmaticSubsystems = [];
288
+
289
+ if (pkgBaseDirs.length >= 2) {
290
+ // 多 package.json → 每个目录是一个子系统
291
+ programmaticSubsystems = pkgBaseDirs.slice(0, 4).map(dir => {
292
+ const matched = KNOWN_PATTERNS.find(p => dir.startsWith(p.pathPattern) || dir === p.pathPattern.split('/')[0]);
293
+ return matched
294
+ ? { ...matched, rootPath: dir }
295
+ : { name: path.basename(dir), displayName: path.basename(dir), rootPath: dir, language: 'Unknown', description: '' };
296
+ });
297
+ log(`程序化检测到 ${programmaticSubsystems.length} 个子系统(基于 package.json): ${programmaticSubsystems.map(s => s.displayName).join(' / ')}`, 'success');
298
+ } else {
299
+ // 无多个 package.json,尝试已知路径模式
300
+ for (const pattern of KNOWN_PATTERNS) {
301
+ const hasMatch = codeFiles.some(f => f.startsWith(pattern.pathPattern + '/') || f.startsWith(pattern.pathPattern));
302
+ if (hasMatch) {
303
+ // 避免重复添加同名模式
304
+ if (!programmaticSubsystems.find(s => s.rootPath === pattern.pathPattern)) {
305
+ programmaticSubsystems.push({ ...pattern, rootPath: pattern.pathPattern });
306
+ }
307
+ }
308
+ }
309
+ if (programmaticSubsystems.length >= 2) {
310
+ log(`程序化检测到 ${programmaticSubsystems.length} 个子系统(基于路径模式): ${programmaticSubsystems.map(s => s.displayName).join(' / ')}`, 'success');
311
+ } else {
312
+ programmaticSubsystems = []; // 回退到 AI
313
+ }
314
+ }
315
+
316
+ const SUBSYSTEM_COLORS = ['#f59e0b', '#3b82f6', '#10b981', '#8b5cf6'];
317
+ let rawSubsystems;
318
+
319
+ if (programmaticSubsystems.length >= 2) {
320
+ rawSubsystems = programmaticSubsystems;
321
+ } else {
322
+ // Step 2b: AI 识别子系统(程序化未能区分)
323
+ log('AI 正在识别子系统结构...', 'thinking');
324
+ const subsystemPrompt = `你是项目架构分析师。根据以下文件路径列表,识别项目的子系统/子模块。
325
+
326
+ 文件列表:
327
+ ${JSON.stringify(codeFiles.slice(0, 300))}
328
+
329
+ 判断依据:独立目录结构、package.json、入口文件、client/server、frontend/backend、ui/api 等模式。
330
+
331
+ 返回 JSON(必须严格是 JSON):
332
+ {
333
+ "isMonorepo": true,
334
+ "subsystems": [
335
+ {
336
+ "name": "frontend",
337
+ "displayName": "前端",
338
+ "rootPath": "src/ui/client/src",
339
+ "language": "TypeScript",
340
+ "description": "Vue 3 前端应用"
341
+ }
342
+ ]
343
+ }
344
+
345
+ 注意:
346
+ - 单一系统时 isMonorepo 为 false,subsystems 一个,rootPath 为空字符串
347
+ - 最多识别 4 个子系统
348
+ - rootPath 使用正斜杠,必须与文件列表路径前缀严格匹配`;
349
+
350
+ const subsystemData = await callLlmJson(model, subsystemPrompt);
351
+ if (stopped) return res.end();
352
+
353
+ rawSubsystems = Array.isArray(subsystemData.subsystems) && subsystemData.subsystems.length > 0
354
+ ? subsystemData.subsystems.slice(0, 4)
355
+ : [{ name: 'main', displayName: '主系统', rootPath: '', language: 'Unknown', description: '' }];
356
+
357
+ log(`识别到 ${rawSubsystems.length} 个子系统: ${rawSubsystems.map(s => s.displayName || s.name).join(' / ')}`, 'success');
358
+ }
359
+
360
+
361
+ // Step 3-4: 对每个子系统分别分析调用链
362
+ const allNodes = [];
363
+ const allEdges = [];
364
+ const allTechStack = new Set();
365
+ let overallSummary = '';
366
+ let primaryLanguage = 'Unknown';
367
+ let primaryEntryFile = '';
368
+
369
+ for (let si = 0; si < rawSubsystems.length; si++) {
370
+ if (stopped) break;
371
+ const sub = rawSubsystems[si];
372
+ const subsystemName = sub.displayName || sub.name;
373
+ const subsystemColor = SUBSYSTEM_COLORS[si % SUBSYSTEM_COLORS.length];
374
+
375
+ // 过滤该子系统的代码文件
376
+ const subFiles = sub.rootPath
377
+ ? codeFiles.filter(f => f.startsWith(sub.rootPath.replace(/\\/g, '/')))
378
+ : codeFiles;
379
+
380
+ if (subFiles.length === 0) {
381
+ log(`[${subsystemName}] 未找到代码文件,跳过`, 'info');
382
+ continue;
383
+ }
384
+
385
+ log(`[${subsystemName}] 正在识别入口文件...`, 'thinking');
386
+
387
+ // Step 3: 识别子系统入口
388
+ const subEntryPrompt = `你是软件架构师。以下是子系统的代码文件列表,请识别语言和入口文件。
389
+
390
+ 子系统: ${subsystemName}
391
+ 文件列表:
392
+ ${JSON.stringify(subFiles.slice(0, 200))}
393
+
394
+ 返回 JSON:
395
+ {
396
+ "language": "TypeScript",
397
+ "potentialEntryFiles": ["src/main.ts"],
398
+ "potentialEntryFunctions": ["main"],
399
+ "projectSummary": "简短中文描述"
400
+ }`;
401
+
402
+ const subEntryData = await callLlmJson(model, subEntryPrompt);
403
+ if (stopped) break;
404
+
405
+ const subLang = String(subEntryData.language || sub.language || 'Unknown');
406
+ const subEntryFiles = Array.isArray(subEntryData.potentialEntryFiles) ? subEntryData.potentialEntryFiles.slice(0, 3) : [];
407
+ const subEntryFunctions = Array.isArray(subEntryData.potentialEntryFunctions) ? subEntryData.potentialEntryFunctions : ['main'];
408
+ const subSummary = String(subEntryData.projectSummary || '');
409
+
410
+ if (!primaryLanguage || primaryLanguage === 'Unknown') primaryLanguage = subLang;
411
+
412
+ // 验证并读取入口文件
413
+ let entryFile = '';
414
+ let entryContent = '';
415
+ for (const candidate of subEntryFiles) {
416
+ if (stopped) break;
417
+ const candidatePath = path.resolve(resolved, candidate);
418
+ if (!candidatePath.startsWith(resolved)) continue;
419
+ const content = await safeReadFile(candidatePath);
420
+ if (content) { entryFile = candidate; entryContent = content; break; }
421
+ }
422
+ if (!entryFile && subFiles.length > 0) {
423
+ entryFile = subFiles[0];
424
+ entryContent = await safeReadFile(path.resolve(resolved, entryFile));
425
+ }
426
+ if (!primaryEntryFile) primaryEntryFile = entryFile;
427
+ if (!overallSummary) overallSummary = subSummary;
428
+
429
+ log(`[${subsystemName}] 确认入口: ${entryFile},正在分析调用链...`, 'thinking');
430
+
431
+ const entryFunction = subEntryFunctions[0] || 'main';
432
+ const snippet = entryContent.slice(0, 5000);
433
+
434
+ // Step 4: 分析调用链
435
+ const chainPrompt = `你是代码调用链分析器。请分析以下代码的函数调用关系,输出调用图。
436
+
437
+ 语言: ${subLang}
438
+ 子系统: ${subsystemName}
439
+ 入口文件: ${entryFile}
440
+ 入口函数: ${entryFunction}
441
+
442
+ 文件内容(截取前5000字符):
443
+ ${snippet}
444
+
445
+ 返回 JSON:
446
+ {
447
+ "entryFunction": "函数名",
448
+ "nodes": [
449
+ { "id": "n1", "label": "函数名", "file": "文件路径", "line": 1, "type": "function", "importance": "high", "description": "职责中文描述" }
450
+ ],
451
+ "edges": [{ "source": "n1", "target": "n2" }],
452
+ "techStack": ["Vue3", "TypeScript"],
453
+ "summary": "子系统中文概要"
454
+ }
455
+
456
+ 要求:
457
+ 1. 最多 15 个节点,深度不超过 3 层
458
+ 2. 只包含项目自身函数,不含库函数
459
+ 3. importance: high/medium/low`;
460
+
461
+ const chainData = await callLlmJson(model, chainPrompt);
462
+ if (stopped) break;
463
+
464
+ // 合并节点/边,加子系统标识,ID 加前缀避免冲突
465
+ const rawNodes = Array.isArray(chainData.nodes) ? chainData.nodes : [];
466
+ const rawEdges = Array.isArray(chainData.edges) ? chainData.edges : [];
467
+ const idMap = {};
468
+ rawNodes.forEach((n, i) => { idMap[n.id] = `sub${si}_${n.id || i}`; });
469
+
470
+ rawNodes.forEach((n, i) => {
471
+ allNodes.push({
472
+ ...n,
473
+ id: `sub${si}_${n.id || i}`,
474
+ subsystem: sub.name,
475
+ subsystemIndex: si,
476
+ subsystemColor,
477
+ });
478
+ });
479
+ rawEdges.forEach(e => {
480
+ const src = idMap[e.source] || `sub${si}_${e.source}`;
481
+ const tgt = idMap[e.target] || `sub${si}_${e.target}`;
482
+ allEdges.push({ source: src, target: tgt });
483
+ });
484
+
485
+ (Array.isArray(chainData.techStack) ? chainData.techStack : []).forEach(t => allTechStack.add(t));
486
+ log(`[${subsystemName}] 分析完成,${rawNodes.length} 个节点,${rawEdges.length} 条连接`, 'success');
487
+ }
488
+
489
+ if (stopped) return res.end();
490
+
491
+ // Step 5: 发送最终结果
492
+ sendSse(res, 'result', {
493
+ language: primaryLanguage,
494
+ entryFile: primaryEntryFile,
495
+ entryFunction: '',
496
+ nodes: allNodes,
497
+ edges: allEdges,
498
+ techStack: [...allTechStack],
499
+ summary: overallSummary,
500
+ allFiles,
501
+ codeFiles,
502
+ subsystems: rawSubsystems.map((s, i) => ({ ...s, color: SUBSYSTEM_COLORS[i % SUBSYSTEM_COLORS.length] })),
503
+ });
504
+
505
+ log('✓ 全部分析完成!', 'success');
506
+ sendSse(res, 'done', { success: true });
507
+ } catch (err) {
508
+ if (!stopped) {
509
+ log(`分析失败: ${err.message}`, 'error');
510
+ sendSse(res, 'done', { error: err.message });
511
+ }
512
+ } finally {
513
+ res.end();
514
+ }
515
+ });
516
+
517
+ /**
518
+ * POST /api/code-analysis/drill
519
+ * 手动下钻单个函数节点
520
+ *
521
+ * Body: { basePath, file, functionName, language, parentChain? }
522
+ */
523
+ app.post('/api/code-analysis/drill', express.json(), async (req, res) => {
524
+ const { basePath, file, functionName, language, parentChain, modelId } = req.body || {};
525
+
526
+ if (!basePath || !file || !functionName) {
527
+ return res.status(400).json({ error: '缺少必要参数' });
528
+ }
529
+
530
+ const resolvedBase = path.resolve(String(basePath));
531
+ const resolvedFile = path.resolve(resolvedBase, String(file));
532
+ if (!resolvedFile.startsWith(resolvedBase)) {
533
+ return res.status(403).json({ error: '禁止访问此路径' });
534
+ }
535
+
536
+ // 获取 AI 模型配置
537
+ let model;
538
+ try {
539
+ const rawConfig = await configManager.readRawConfigFile();
540
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
541
+ model = (modelId ? models.find(m => m.id === modelId) : null)
542
+ || models.find(m => m.isDefault)
543
+ || models[0];
544
+ } catch (err) {
545
+ return res.status(500).json({ error: '读取配置失败: ' + err.message });
546
+ }
547
+
548
+ if (!model) {
549
+ return res.status(400).json({ error: '未配置 AI 模型' });
550
+ }
551
+
552
+ try {
553
+ const content = await safeReadFile(resolvedFile);
554
+ if (!content) return res.status(404).json({ error: '无法读取文件内容' });
555
+
556
+ const snippet = content.slice(0, 7000);
557
+ const prompt = `你是代码调用链下钻分析器。请分析目标函数,输出其关键子调用节点。
558
+
559
+ 语言: ${String(language || 'Unknown')}
560
+ 文件: ${String(file)}
561
+ 函数: ${String(functionName)}
562
+ ${parentChain ? `调用链上下文: ${String(parentChain).slice(0, 500)}` : ''}
563
+
564
+ 文件内容(截取):
565
+ ${snippet}
566
+
567
+ 返回 JSON:
568
+ {
569
+ "functionName": "${String(functionName)}",
570
+ "description": "函数职责",
571
+ "calls": [
572
+ { "name": "子函数名", "type": "function", "description": "中文描述", "importance": "high", "shouldDrill": 1, "possibleFile": "推测文件路径" }
573
+ ]
574
+ }
575
+
576
+ shouldDrill: -1(叶子/不重要)、0(不确定)、1(值得继续下钻)
577
+ 最多输出 10 个子调用`;
578
+
579
+ const result = await callLlmJson(model, prompt);
580
+ res.json({ success: true, data: result });
581
+ } catch (err) {
582
+ res.status(500).json({ error: err.message });
583
+ }
584
+ });
585
+ }
@@ -1,3 +1,4 @@
1
+ import express from 'express';
1
2
  import chalk from 'chalk';
2
3
  import fs from 'fs/promises';
3
4
  import path from 'path';
@@ -490,4 +491,48 @@ export function registerFsRoutes({
490
491
  res.status(500).json({ success: false, error: error.message });
491
492
  }
492
493
  });
494
+
495
+ // ── 编辑器:读取文件内容 ────────────────────────────────────────
496
+ app.get('/api/editor/file', async (req, res) => {
497
+ try {
498
+ const filePath = req.query.path;
499
+ if (!filePath) return res.status(400).json({ success: false, error: '缺少 path 参数' });
500
+ // 安全:只允许读取当前工作目录内的文件
501
+ const cwd = getCurrentProjectPath() || process.cwd();
502
+ const resolved = path.resolve(filePath);
503
+ if (!resolved.startsWith(path.resolve(cwd))) {
504
+ return res.status(403).json({ success: false, error: '禁止访问工作目录以外的文件' });
505
+ }
506
+ const stat = await fs.stat(resolved);
507
+ if (!stat.isFile()) return res.status(400).json({ success: false, error: '目标不是文件' });
508
+ // 超过 2MB 不读取
509
+ if (stat.size > 2 * 1024 * 1024) {
510
+ return res.json({ success: false, error: '文件过大(> 2 MB),暂不支持在线编辑' });
511
+ }
512
+ const content = await fs.readFile(resolved, 'utf-8');
513
+ res.json({ success: true, content });
514
+ } catch (error) {
515
+ res.status(500).json({ success: false, error: error.message });
516
+ }
517
+ });
518
+
519
+ // ── 编辑器:写入文件内容 ────────────────────────────────────────
520
+ app.put('/api/editor/file', express.json(), async (req, res) => {
521
+ try {
522
+ const { path: filePath, content } = req.body;
523
+ if (!filePath || content === undefined) {
524
+ return res.status(400).json({ success: false, error: '缺少 path 或 content 参数' });
525
+ }
526
+ const cwd = getCurrentProjectPath() || process.cwd();
527
+ const resolved = path.resolve(filePath);
528
+ if (!resolved.startsWith(path.resolve(cwd))) {
529
+ return res.status(403).json({ success: false, error: '禁止写入工作目录以外的文件' });
530
+ }
531
+ await fs.writeFile(resolved, content, 'utf-8');
532
+ res.json({ success: true });
533
+ } catch (error) {
534
+ res.status(500).json({ success: false, error: error.message });
535
+ }
536
+ });
493
537
  }
538
+