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.
- package/dashboard/dist/assets/index-B5dbY-cS.js +143 -0
- package/dashboard/dist/assets/{index-BDmJqEkA.css → index-Bun3ld_J.css} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/lib/external/ai/providers/GoogleGeminiProvider.js +7 -2
- package/lib/external/mcp/handlers/bootstrap.js +144 -27
- package/lib/http/HttpServer.js +3 -2
- package/lib/http/routes/ai.js +132 -0
- package/lib/http/routes/candidates.js +369 -78
- package/lib/http/routes/spm.js +143 -0
- package/lib/http/utils/sse-sessions.js +114 -0
- package/lib/http/utils/sse.js +128 -0
- package/lib/service/chat/ChatAgent.js +37 -1
- package/lib/service/chat/tools.js +10 -3
- package/lib/service/spm/SpmService.js +14 -1
- package/package.json +1 -1
- package/dashboard/dist/assets/index-D8dCXLzr.js +0 -129
package/lib/http/routes/spm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|