autosnippet 2.19.0 → 2.19.2

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.
@@ -8,6 +8,7 @@ import { asyncHandler } from '../middleware/errorHandler.js';
8
8
  import { getServiceContainer } from '../../injection/ServiceContainer.js';
9
9
  import { ValidationError } from '../../shared/errors/index.js';
10
10
  import Logger from '../../infrastructure/logging/Logger.js';
11
+ import { createStreamSession, getStreamSession } from '../utils/sse-sessions.js';
11
12
 
12
13
  const router = express.Router();
13
14
  const logger = Logger.getInstance();
@@ -177,6 +178,148 @@ router.post('/scan', asyncHandler(async (req, res) => {
177
178
  });
178
179
  }));
179
180
 
181
+ // ── 流式 Target 扫描(SSE Session + EventSource 架构) ─────────
182
+
183
+ /**
184
+ * POST /api/v1/spm/scan/stream
185
+ * 创建流式扫描会话,后台异步执行 AI 扫描
186
+ *
187
+ * 协议事件(通过 SSE session 缓冲 + EventSource 交付):
188
+ * scan:started — 扫描启动
189
+ * scan:files-loaded — 文件列表就绪,含 files[] + count
190
+ * scan:reading — 读取文件内容中
191
+ * scan:ai-extracting — AI 提取开始(耗时阶段)
192
+ * scan:enriching — 后处理阶段
193
+ * scan:completed — 最终结果 {recipes, scannedFiles, recipeCount, fileCount}
194
+ * scan:error — 发生错误
195
+ * stream:done — 会话结束标记
196
+ */
197
+ router.post('/scan/stream', asyncHandler(async (req, res) => {
198
+ const { target, targetName, options = {} } = req.body;
199
+
200
+ if (!target && !targetName) {
201
+ throw new ValidationError('target object or targetName is required');
202
+ }
203
+
204
+ const container = getServiceContainer();
205
+ const spmService = container.get('spmService');
206
+
207
+ let resolvedTarget = target;
208
+ if (!resolvedTarget && targetName) {
209
+ const targets = await spmService.listTargets();
210
+ resolvedTarget = targets.find(t => t.name === targetName);
211
+ if (!resolvedTarget) {
212
+ return res.status(404).json({
213
+ success: false,
214
+ error: { code: 'NOT_FOUND', message: `Target not found: ${targetName}` },
215
+ });
216
+ }
217
+ }
218
+
219
+ // 创建 SSE session
220
+ const session = createStreamSession('scan');
221
+ const tName = resolvedTarget.name || targetName;
222
+
223
+ // 立即返回 sessionId
224
+ res.json({ sessionId: session.sessionId });
225
+
226
+ // 异步执行扫描,通过 session 推送进度事件
227
+ setImmediate(async () => {
228
+ try {
229
+ logger.info('SPM stream scan started', { target: tName, sessionId: session.sessionId });
230
+ const result = await spmService.scanTarget(resolvedTarget, {
231
+ ...options,
232
+ onProgress(event) {
233
+ session.send(event);
234
+ },
235
+ });
236
+
237
+ // 发送最终结果
238
+ session.send({
239
+ type: 'scan:result',
240
+ recipes: result.recipes || [],
241
+ scannedFiles: result.scannedFiles || [],
242
+ message: result.message || '',
243
+ recipeCount: (result.recipes || []).length,
244
+ fileCount: (result.scannedFiles || []).length,
245
+ });
246
+ session.end();
247
+ } catch (err) {
248
+ logger.error('SPM stream scan failed', { target: tName, error: err.message });
249
+ session.error(err.message, 'SCAN_ERROR');
250
+ }
251
+ });
252
+ }));
253
+
254
+ /**
255
+ * GET /api/v1/spm/scan/events/:sessionId
256
+ * EventSource SSE 端点 — 消费扫描进度事件
257
+ *
258
+ * 复用 chat/events 相同的 SSE 交付模式:回放缓冲 → 订阅实时 → 心跳保活
259
+ */
260
+ router.get('/scan/events/:sessionId', (req, res) => {
261
+ const session = getStreamSession(req.params.sessionId);
262
+ if (!session) {
263
+ return res.status(404).json({ success: false, error: 'Session not found or expired' });
264
+ }
265
+
266
+ // ─── SSE Headers ───
267
+ res.setHeader('Content-Type', 'text/event-stream');
268
+ res.setHeader('Cache-Control', 'no-cache');
269
+ res.setHeader('Connection', 'keep-alive');
270
+ res.setHeader('X-Accel-Buffering', 'no');
271
+ res.flushHeaders();
272
+
273
+ if (res.socket) {
274
+ res.socket.setNoDelay(true);
275
+ res.socket.setTimeout(0);
276
+ }
277
+
278
+ function writeEvent(event) {
279
+ if (res.writableEnded) return;
280
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
281
+ }
282
+
283
+ // 1) 回放缓冲区
284
+ let isDone = false;
285
+ for (const event of session.buffer) {
286
+ writeEvent(event);
287
+ if (event.type === 'stream:done' || event.type === 'stream:error') {
288
+ isDone = true;
289
+ }
290
+ }
291
+
292
+ if (isDone || session.completed) {
293
+ res.end();
294
+ return;
295
+ }
296
+
297
+ // 2) 订阅实时事件
298
+ const unsubscribe = session.on((event) => {
299
+ writeEvent(event);
300
+ if (event.type === 'stream:done' || event.type === 'stream:error') {
301
+ unsubscribe();
302
+ clearInterval(heartbeat);
303
+ res.end();
304
+ }
305
+ });
306
+
307
+ // 心跳保活 (每 15 秒)
308
+ const heartbeat = setInterval(() => {
309
+ if (res.writableEnded) {
310
+ clearInterval(heartbeat);
311
+ return;
312
+ }
313
+ res.write(`: ping ${Date.now()}\n\n`);
314
+ }, 15_000);
315
+
316
+ // 客户端断开连接时清理
317
+ res.on('close', () => {
318
+ unsubscribe();
319
+ clearInterval(heartbeat);
320
+ });
321
+ });
322
+
180
323
  /**
181
324
  * POST /api/v1/spm/scan-project
182
325
  * 全项目扫描:AI 提取候选 + Guard 审计
@@ -0,0 +1,114 @@
1
+ /**
2
+ * SSE Session Manager — 基于 EventSource 的流式会话管理
3
+ *
4
+ * 架构:
5
+ * POST /chat/stream → 创建 session + 后台执行 ChatAgent → 返回 { sessionId }
6
+ * GET /chat/events/:sessionId → EventSource 端点, 回放缓冲事件 + 实时推送
7
+ *
8
+ * 为什么不用 fetch + ReadableStream:
9
+ * Chrome/Safari 的 fetch() streaming 会缓冲初始响应体(~1-4KB),导致小体积
10
+ * SSE 事件滞留在缓冲区中不被交付给 ReadableStream reader。
11
+ * 原生 EventSource API 是浏览器专门为 SSE 优化的消费者,不受此限制。
12
+ *
13
+ * @module lib/http/utils/sse-sessions
14
+ */
15
+
16
+ import { EventEmitter } from 'events';
17
+
18
+ /** @type {Map<string, StreamSession>} */
19
+ const _sessions = new Map();
20
+
21
+ /** Session 自动清理 TTL (5 分钟) */
22
+ const SESSION_TTL = 5 * 60 * 1000;
23
+
24
+ /** 完成后保留时间 (60 秒, 供客户端重连回放) */
25
+ const COMPLETED_KEEP = 60 * 1000;
26
+
27
+ /**
28
+ * 创建一个 stream session
29
+ *
30
+ * @param {'chat'|'refine'} scene 场景标识
31
+ * @returns {StreamSession}
32
+ */
33
+ export function createStreamSession(scene) {
34
+ const sessionId = `ss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
35
+ const emitter = new EventEmitter();
36
+ emitter.setMaxListeners(20);
37
+
38
+ const session = {
39
+ sessionId,
40
+ scene,
41
+ /** @type {Array<object>} 事件缓冲区(供 EventSource 连接后回放) */
42
+ buffer: [],
43
+ /** 会话是否已结束 */
44
+ completed: false,
45
+ createdAt: Date.now(),
46
+
47
+ /**
48
+ * 缓冲 + 广播一个事件
49
+ * @param {object} event — 必须包含 type 字段
50
+ */
51
+ send(event) {
52
+ const payload = { ...event, ts: event.ts || Date.now() };
53
+ session.buffer.push(payload);
54
+ emitter.emit('event', payload);
55
+ },
56
+
57
+ /**
58
+ * 标记会话完成,发送 stream:done
59
+ * @param {object} [donePayload={}]
60
+ */
61
+ end(donePayload = {}) {
62
+ if (session.completed) return;
63
+ const payload = { type: 'stream:done', ts: Date.now(), ...donePayload };
64
+ session.buffer.push(payload);
65
+ emitter.emit('event', payload);
66
+ session.completed = true;
67
+ // 完成后保留一段时间供客户端重连
68
+ const keepTimer = setTimeout(() => _sessions.delete(sessionId), COMPLETED_KEEP);
69
+ if (keepTimer.unref) keepTimer.unref();
70
+ },
71
+
72
+ /**
73
+ * 标记会话错误,发送 stream:error
74
+ * @param {string} message
75
+ * @param {string} [code]
76
+ */
77
+ error(message, code) {
78
+ if (session.completed) return;
79
+ const payload = { type: 'stream:error', ts: Date.now(), message, code };
80
+ session.buffer.push(payload);
81
+ emitter.emit('event', payload);
82
+ session.completed = true;
83
+ const keepTimer = setTimeout(() => _sessions.delete(sessionId), COMPLETED_KEEP);
84
+ if (keepTimer.unref) keepTimer.unref();
85
+ },
86
+
87
+ /**
88
+ * 订阅实时事件
89
+ * @param {(event: object) => void} handler
90
+ * @returns {() => void} unsubscribe 函数
91
+ */
92
+ on(handler) {
93
+ emitter.on('event', handler);
94
+ return () => emitter.removeListener('event', handler);
95
+ },
96
+ };
97
+
98
+ _sessions.set(sessionId, session);
99
+
100
+ // 硬性 TTL: 无论是否完成,5 分钟后强制清理
101
+ const ttlTimer = setTimeout(() => _sessions.delete(sessionId), SESSION_TTL);
102
+ if (ttlTimer.unref) ttlTimer.unref();
103
+
104
+ return session;
105
+ }
106
+
107
+ /**
108
+ * 获取已有的 session
109
+ * @param {string} sessionId
110
+ * @returns {StreamSession|undefined}
111
+ */
112
+ export function getStreamSession(sessionId) {
113
+ return _sessions.get(sessionId);
114
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SSE (Server-Sent Events) 会话工具模块
3
+ *
4
+ * 提供统一的 SSE 连接管理:headers 设置、心跳保活、安全写入、生命周期事件。
5
+ * 所有 SSE 端点共用此模块,确保协议一致性。
6
+ *
7
+ * @module lib/http/utils/sse
8
+ */
9
+
10
+ /**
11
+ * 创建 SSE 会话 — 统一设置 headers、心跳、安全写入
12
+ *
13
+ * @param {import('express').Request} req
14
+ * @param {import('express').Response} res
15
+ * @param {'chat'|'refine'} scene — 场景标识
16
+ * @returns {{ send, end, error, isDisconnected, sessionId }}
17
+ */
18
+ export function createSSESession(req, res, scene) {
19
+ // ─── SSE Headers ───
20
+ res.setHeader('Content-Type', 'text/event-stream');
21
+ res.setHeader('Cache-Control', 'no-cache');
22
+ res.setHeader('Connection', 'keep-alive');
23
+ res.setHeader('X-Accel-Buffering', 'no');
24
+ res.flushHeaders();
25
+
26
+ // ─── 禁用 Nagle 算法,确保 SSE 小包即时发送 ───
27
+ if (res.socket) {
28
+ res.socket.setNoDelay(true);
29
+ res.socket.setTimeout(0);
30
+ }
31
+
32
+ let disconnected = false;
33
+
34
+ // 注意:必须监听 res.on('close') 而非 req.on('close')。
35
+ // 在 Node.js 20 中,IncomingMessage (req) 的 'close' 事件在请求体被消费后即触发,
36
+ // 而 ServerResponse (res) 的 'close' 事件仅在底层 socket 关闭时触发(即客户端真正断开连接)。
37
+ res.on('close', () => {
38
+ disconnected = true;
39
+ });
40
+
41
+ const sessionId = Math.random().toString(36).slice(2, 10);
42
+
43
+ /** 安全写入一段 SSE 数据 */
44
+ function _write(data) {
45
+ if (disconnected || res.writableEnded) return false;
46
+ try {
47
+ return res.write(data);
48
+ } catch {
49
+ disconnected = true;
50
+ return false;
51
+ }
52
+ }
53
+
54
+ // ─── 心跳 (每 15 秒发送 SSE 注释保活) ───
55
+ const heartbeat = setInterval(() => {
56
+ _write(`: ping ${Date.now()}\n\n`);
57
+ }, 15_000);
58
+
59
+ // ─── 发送 stream:start ───
60
+ const startPayload = JSON.stringify({ type: 'stream:start', ts: Date.now(), sessionId, scene });
61
+ _write(`data: ${startPayload}\n\n`);
62
+
63
+ // ─── 性能跟踪 ───
64
+ const metrics = {
65
+ startTime: Date.now(),
66
+ eventCount: 0,
67
+ totalBytes: 0,
68
+ firstTextDeltaTime: 0,
69
+ };
70
+
71
+ return {
72
+ /**
73
+ * 发送一个 SSE 事件
74
+ * @param {object} event — 必须包含 type 字段
75
+ */
76
+ send(event) {
77
+ if (disconnected || res.writableEnded) return;
78
+ const payload = JSON.stringify({ ...event, ts: event.ts || Date.now() });
79
+ _write(`data: ${payload}\n\n`);
80
+ metrics.eventCount++;
81
+ metrics.totalBytes += payload.length;
82
+ if (event.type === 'text:delta' && !metrics.firstTextDeltaTime) {
83
+ metrics.firstTextDeltaTime = Date.now();
84
+ }
85
+ },
86
+
87
+ /**
88
+ * 正常结束流 — 发送 stream:done 并关闭连接
89
+ * @param {object} [donePayload={}] — done 事件携带的额外数据
90
+ */
91
+ end(donePayload = {}) {
92
+ clearInterval(heartbeat);
93
+ if (disconnected || res.writableEnded) return;
94
+ const payload = JSON.stringify({ type: 'stream:done', ts: Date.now(), ...donePayload });
95
+ _write(`data: ${payload}\n\n`);
96
+ res.end();
97
+ },
98
+
99
+ /**
100
+ * 发送错误并结束流
101
+ * @param {string} message
102
+ * @param {string} [code]
103
+ */
104
+ error(message, code) {
105
+ clearInterval(heartbeat);
106
+ if (disconnected || res.writableEnded) return;
107
+ const payload = JSON.stringify({ type: 'stream:error', ts: Date.now(), message, code });
108
+ _write(`data: ${payload}\n\n`);
109
+ res.end();
110
+ },
111
+
112
+ /** 是否已断开 */
113
+ get isDisconnected() { return disconnected; },
114
+
115
+ /** 会话 ID */
116
+ sessionId,
117
+
118
+ /** 获取性能指标 */
119
+ get metrics() {
120
+ return {
121
+ ...metrics,
122
+ endTime: Date.now(),
123
+ duration: Date.now() - metrics.startTime,
124
+ ttft: metrics.firstTextDeltaTime ? metrics.firstTextDeltaTime - metrics.startTime : null,
125
+ };
126
+ },
127
+ };
128
+ }
@@ -174,6 +174,8 @@ export class ChatAgent {
174
174
  workingMemory, // WorkingMemory 实例 (由 orchestrator 注入)
175
175
  episodicMemory, // EpisodicMemory 实例 (跨维度情景记忆)
176
176
  toolResultCache, // ToolResultCache 实例 (跨维度工具结果缓存)
177
+ // v5.1: SSE 流式进度回调
178
+ onProgress, // (event: {type, ...}) => void — 实时推送思考/工具/回答事件
177
179
  } = {}) {
178
180
  this.#currentSource = source;
179
181
  this.#currentTokenUsage = { input: 0, output: 0 };
@@ -208,8 +210,22 @@ export class ChatAgent {
208
210
  systemPromptOverride, allowedTools, disablePhaseRouter, temperatureOverride,
209
211
  projectLanguage,
210
212
  workingMemory, episodicMemory, toolResultCache,
213
+ onProgress,
211
214
  });
212
215
 
216
+ // SSE: 推送最终回答(分块模拟流式)
217
+ if (onProgress && result.reply) {
218
+ const textId = `ans_${Date.now()}`;
219
+ onProgress({ type: 'text:start', id: textId, role: 'answer' });
220
+ // 分块推送:每 ~20 字符一块,模拟逐 token 打字效果
221
+ const CHUNK = 20;
222
+ const text = result.reply;
223
+ for (let i = 0; i < text.length; i += CHUNK) {
224
+ onProgress({ type: 'text:delta', id: textId, delta: text.slice(i, i + CHUNK) });
225
+ }
226
+ onProgress({ type: 'text:end', id: textId });
227
+ }
228
+
213
229
  // 持久化 assistant 回复
214
230
  if (conversationId && this.#conversations) {
215
231
  this.#conversations.append(conversationId, { role: 'assistant', content: result.reply });
@@ -271,6 +287,8 @@ export class ChatAgent {
271
287
  projectLanguage,
272
288
  // v4.0: Agent Memory 集成
273
289
  workingMemory, episodicMemory, toolResultCache,
290
+ // v5.1: SSE 流式进度回调
291
+ onProgress,
274
292
  }) {
275
293
  const isSystem = source === 'system';
276
294
  const isSkillOnly = dimensionMeta?.outputType === 'skill';
@@ -489,7 +507,11 @@ export class ChatAgent {
489
507
  let aiResult;
490
508
  try {
491
509
  const messages = ctx.toMessages();
492
- this.#logger.info(`[ChatAgent] 🔄 iteration ${currentIter}/${maxIter} — phase=${phaseRouter?.phase || 'user'}, ${messages.length} msgs, toolChoice=${currentChoice}, tokens~${ctx.estimateTokens()}`);
510
+ const currentPhase = phaseRouter?.phase || 'user';
511
+ this.#logger.info(`[ChatAgent] 🔄 iteration ${currentIter}/${maxIter} — phase=${currentPhase}, ${messages.length} msgs, toolChoice=${currentChoice}, tokens~${ctx.estimateTokens()}`);
512
+
513
+ // SSE: 推送步骤开始
514
+ onProgress?.({ type: 'step:start', step: currentIter, maxSteps: maxIter, phase: currentPhase });
493
515
 
494
516
  aiResult = await this.#aiProvider.chatWithTools(prompt, {
495
517
  messages,
@@ -545,6 +567,7 @@ export class ChatAgent {
545
567
  break;
546
568
  }
547
569
  reasoning.afterRound();
570
+ onProgress?.({ type: 'step:end', step: currentIter });
548
571
  return {
549
572
  reply: `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`,
550
573
  toolCalls,
@@ -592,6 +615,9 @@ export class ChatAgent {
592
615
  const toolStartTime = Date.now();
593
616
  this.#logger.info(`[ChatAgent] 🔧 ${fc.name}(${JSON.stringify(fc.args).substring(0, 100)})`);
594
617
 
618
+ // SSE: 推送工具调用开始
619
+ onProgress?.({ type: 'tool:start', id: `tc_${fc.name}_${Date.now()}`, tool: fc.name, args: fc.args });
620
+
595
621
  let toolResult;
596
622
  let cacheHit = false;
597
623
 
@@ -624,9 +650,15 @@ export class ChatAgent {
624
650
  const toolDuration = Date.now() - toolStartTime;
625
651
  const resultSize = typeof toolResult === 'string' ? toolResult.length : JSON.stringify(toolResult).length;
626
652
  this.#logger.info(`[ChatAgent] 🔧 done: ${fc.name} → ${resultSize} chars in ${toolDuration}ms`);
653
+
654
+ // SSE: 推送工具调用完成
655
+ onProgress?.({ type: 'tool:end', tool: fc.name, status: 'ok', resultSize, duration: toolDuration });
627
656
  } catch (toolErr) {
628
657
  this.#logger.warn(`[ChatAgent] 🔧 FAILED: ${fc.name} — ${toolErr.message}`);
629
658
  toolResult = { error: `tool "${fc.name}" failed: ${toolErr.message}` };
659
+
660
+ // SSE: 推送工具调用失败
661
+ onProgress?.({ type: 'tool:end', tool: fc.name, status: 'error', error: toolErr.message, duration: Date.now() - toolStartTime });
630
662
  }
631
663
  }
632
664
 
@@ -812,10 +844,14 @@ export class ChatAgent {
812
844
  }
813
845
  }
814
846
 
847
+ // SSE: 步骤结束
848
+ onProgress?.({ type: 'step:end', step: currentIter });
815
849
  continue;
816
850
  }
817
851
 
818
852
  // ── 文字回答 ──
853
+ // SSE: 文字回答意味着步骤结束
854
+ onProgress?.({ type: 'step:end', step: currentIter });
819
855
  // 空响应重试(Gemini 偶发)
820
856
  if (!aiResult.text && isSystem && consecutiveEmptyResponses < 2) {
821
857
  consecutiveEmptyResponses++;
@@ -1081,7 +1081,7 @@ const extractRecipes = {
1081
1081
  type: 'object',
1082
1082
  properties: {
1083
1083
  targetName: { type: 'string', description: 'SPM Target / 模块名称' },
1084
- files: { type: 'array', description: '文件数组 [{name, content}]' },
1084
+ files: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, content: { type: 'string' } } }, description: '文件数组 [{name, content}]' },
1085
1085
  },
1086
1086
  required: ['targetName', 'files'],
1087
1087
  },
@@ -1214,7 +1214,7 @@ const enrichCandidate = {
1214
1214
  parameters: {
1215
1215
  type: 'object',
1216
1216
  properties: {
1217
- candidateIds: { type: 'array', description: '候选 ID 列表 (最多 20 个)' },
1217
+ candidateIds: { type: 'array', items: { type: 'string' }, description: '候选 ID 列表 (最多 20 个)' },
1218
1218
  },
1219
1219
  required: ['candidateIds'],
1220
1220
  },
@@ -1236,7 +1236,7 @@ const refineBootstrapCandidates = {
1236
1236
  parameters: {
1237
1237
  type: 'object',
1238
1238
  properties: {
1239
- candidateIds: { type: 'array', description: '指定候选 ID 列表(可选,默认全部 bootstrap 候选)' },
1239
+ candidateIds: { type: 'array', items: { type: 'string' }, description: '指定候选 ID 列表(可选,默认全部 bootstrap 候选)' },
1240
1240
  userPrompt: { type: 'string', description: '用户自定义润色提示词,指导 AI 润色方向(如“侧重描述线程安全注意事项”)' },
1241
1241
  dryRun: { type: 'boolean', description: '仅预览 AI 润色结果,不写入数据库' },
1242
1242
  },
@@ -1324,6 +1324,13 @@ const discoverRelations = {
1324
1324
  properties: {
1325
1325
  recipePairs: {
1326
1326
  type: 'array',
1327
+ items: {
1328
+ type: 'object',
1329
+ properties: {
1330
+ a: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, category: { type: 'string' }, code: { type: 'string' } } },
1331
+ b: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, category: { type: 'string' }, code: { type: 'string' } } },
1332
+ },
1333
+ },
1327
1334
  description: 'Recipe 对数组 [{ a: {id, title, category, code}, b: {id, title, category, code} }]',
1328
1335
  },
1329
1336
  dryRun: { type: 'boolean', description: '仅分析不写入,默认 false' },
@@ -8,7 +8,7 @@ import { PackageSwiftParser } from './PackageSwiftParser.js';
8
8
  import { DependencyGraph } from './DependencyGraph.js';
9
9
  import { PolicyEngine } from './PolicyEngine.js';
10
10
  import { GraphCache } from '../../infrastructure/cache/GraphCache.js';
11
- import { dirname, relative, sep, resolve as pathResolve } from 'node:path';
11
+ import { basename as _pathBasename, dirname, relative, sep, resolve as pathResolve } from 'node:path';
12
12
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
13
13
 
14
14
  export class SpmService {
@@ -809,14 +809,24 @@ export class SpmService {
809
809
  */
810
810
  async scanTarget(target, options = {}) {
811
811
  const targetName = typeof target === 'string' ? target : target?.name;
812
+ /** @type {((event: {type: string, [key:string]: any}) => void) | undefined} */
813
+ const onProgress = options.onProgress;
812
814
 
813
815
  // 1. 获取源文件列表
816
+ onProgress?.({ type: 'scan:started', targetName });
814
817
  const fileList = await this.getTargetFiles(target);
815
818
  if (!fileList || fileList.length === 0) {
816
819
  return { recipes: [], scannedFiles: [], message: `No source files found for target: ${targetName}` };
817
820
  }
818
821
 
822
+ const scannedFilesMeta = fileList.map(f => {
823
+ const filePath = typeof f === 'string' ? f : f.path;
824
+ return { name: _pathBasename(filePath), path: f.relativePath || _pathBasename(filePath) };
825
+ });
826
+ onProgress?.({ type: 'scan:files-loaded', files: scannedFilesMeta, count: fileList.length });
827
+
819
828
  // 2. 读取文件内容
829
+ onProgress?.({ type: 'scan:reading', count: fileList.length });
820
830
  const { readFileSync } = await import('fs');
821
831
  const { basename, resolve } = await import('path');
822
832
  const files = fileList.map(f => {
@@ -841,6 +851,7 @@ export class SpmService {
841
851
  return { recipes: [], scannedFiles, message: 'AI provider not configured. Please set ASD_AI_PROVIDER.' };
842
852
  }
843
853
 
854
+ onProgress?.({ type: 'scan:ai-extracting', fileCount: files.length, targetName });
844
855
  const AI_EXTRACT_TIMEOUT = 120_000; // 2 分钟 AI 提取超时
845
856
  let recipes;
846
857
  try {
@@ -922,12 +933,14 @@ export class SpmService {
922
933
  }
923
934
 
924
935
  // 4. 工具增强:语义标准化 + 标签 + 评分
936
+ onProgress?.({ type: 'scan:enriching', recipeCount: recipes.length });
925
937
  this._enrichRecipes(recipes);
926
938
 
927
939
  const result = { recipes, scannedFiles };
928
940
  if (recipes.length === 0) {
929
941
  result.message = `AI extraction returned 0 recipes for ${targetName} (${files.length} files analyzed)`;
930
942
  }
943
+ onProgress?.({ type: 'scan:completed', recipeCount: recipes.length, fileCount: scannedFiles.length });
931
944
  return result;
932
945
  }
933
946
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autosnippet",
3
- "version": "2.19.0",
3
+ "version": "2.19.2",
4
4
  "description": "AutoSnippet - 连接开发者、AI 与项目知识库的工具",
5
5
  "type": "module",
6
6
  "main": "lib/bootstrap.js",