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
|
@@ -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();
|
|
@@ -67,24 +68,45 @@ router.post('/enrich', asyncHandler(async (req, res) => {
|
|
|
67
68
|
const results = [];
|
|
68
69
|
|
|
69
70
|
if (aiProvider) {
|
|
71
|
+
let enriched = [];
|
|
70
72
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
enriched = await aiProvider.enrichCandidates(candidates);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.warn('AI enrichCandidates failed', { error: err.message });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const item of enriched) {
|
|
79
|
+
// 安全的 index 映射:AI 未返回 index 时根据数组位置推断
|
|
80
|
+
const idx = typeof item.index === 'number' ? item.index : enriched.indexOf(item);
|
|
81
|
+
const cand = candidates[idx];
|
|
82
|
+
if (!cand) continue;
|
|
76
83
|
|
|
84
|
+
try {
|
|
77
85
|
const updateData = {};
|
|
78
86
|
let changed = false;
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
// content 嵌套字段(rationale / steps)共用一次 DB 读取
|
|
89
|
+
const needsContentMerge = (item.rationale && !cand.rationale) ||
|
|
90
|
+
(item.steps && (!cand.steps || cand.steps.length === 0));
|
|
91
|
+
let contentBase = null;
|
|
92
|
+
if (needsContentMerge) {
|
|
83
93
|
const entry = await knowledgeService.get(cand.id);
|
|
84
94
|
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
85
|
-
|
|
95
|
+
contentBase = { ...(json.content || {}) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (item.rationale && !cand.rationale) {
|
|
99
|
+
contentBase.rationale = item.rationale;
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
if (item.steps && (!cand.steps || cand.steps.length === 0)) {
|
|
103
|
+
contentBase.steps = item.steps;
|
|
86
104
|
changed = true;
|
|
87
105
|
}
|
|
106
|
+
if (contentBase && changed) {
|
|
107
|
+
updateData.content = contentBase;
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
if (item.knowledgeType && !cand.knowledgeType) {
|
|
89
111
|
updateData.knowledgeType = item.knowledgeType;
|
|
90
112
|
changed = true;
|
|
@@ -97,12 +119,6 @@ router.post('/enrich', asyncHandler(async (req, res) => {
|
|
|
97
119
|
updateData.scope = item.scope;
|
|
98
120
|
changed = true;
|
|
99
121
|
}
|
|
100
|
-
if (item.steps && (!cand.steps || cand.steps.length === 0)) {
|
|
101
|
-
const entry = await knowledgeService.get(cand.id);
|
|
102
|
-
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
103
|
-
updateData.content = { ...(json.content || {}), ...(updateData.content || {}), steps: item.steps };
|
|
104
|
-
changed = true;
|
|
105
|
-
}
|
|
106
122
|
if (item.constraints && !cand.constraints?.preconditions?.length) {
|
|
107
123
|
updateData.constraints = item.constraints;
|
|
108
124
|
changed = true;
|
|
@@ -113,9 +129,10 @@ router.post('/enrich', asyncHandler(async (req, res) => {
|
|
|
113
129
|
enrichedCount++;
|
|
114
130
|
}
|
|
115
131
|
results.push({ id: cand.id, enriched: changed, filledFields: Object.keys(item).filter(k => k !== 'index') });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger.warn(`enrich: failed to update candidate ${cand.id}`, { error: err.message });
|
|
134
|
+
results.push({ id: cand.id, enriched: false, filledFields: [], error: err.message });
|
|
116
135
|
}
|
|
117
|
-
} catch (err) {
|
|
118
|
-
logger.warn('AI enrichCandidates failed', { error: err.message });
|
|
119
136
|
}
|
|
120
137
|
}
|
|
121
138
|
|
|
@@ -142,9 +159,8 @@ router.post('/bootstrap-refine', asyncHandler(async (req, res) => {
|
|
|
142
159
|
const ctx = { container, logger };
|
|
143
160
|
const result = await bootstrapRefine(ctx, { candidateIds, userPrompt, dryRun });
|
|
144
161
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
: { refined: 0, total: 0, errors: [], results: [] };
|
|
162
|
+
// envelope 返回 { success, data, meta, ... },直接取 data
|
|
163
|
+
const data = result?.data ?? { refined: 0, total: 0, errors: [], results: [] };
|
|
148
164
|
|
|
149
165
|
res.json({ success: true, data });
|
|
150
166
|
}));
|
|
@@ -160,6 +176,8 @@ function extractBeforeFields(json) {
|
|
|
160
176
|
title: json.title || '',
|
|
161
177
|
description: json.description || '',
|
|
162
178
|
pattern: json.content?.pattern || '',
|
|
179
|
+
markdown: json.content?.markdown || '',
|
|
180
|
+
rationale: json.content?.rationale || '',
|
|
163
181
|
tags: json.tags || [],
|
|
164
182
|
confidence: json.reasoning?.confidence ?? 0.6,
|
|
165
183
|
relations: json.relations || {},
|
|
@@ -177,41 +195,63 @@ function extractBeforeFields(json) {
|
|
|
177
195
|
function buildRefinePrompt(before, userPrompt) {
|
|
178
196
|
return `你是一位知识库条目润色助手。你必须**严格按照用户指令**修改知识条目。
|
|
179
197
|
|
|
180
|
-
##
|
|
198
|
+
## ⭐ JSON key 规范(最高优先级)
|
|
181
199
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
返回的 JSON 必须且只能使用以下 9 个 key,大小写必须完全一致:
|
|
201
|
+
|
|
202
|
+
description → 摘要(string)
|
|
203
|
+
pattern → 代码/标准用法(string)
|
|
204
|
+
markdown → Markdown 文档(string)
|
|
205
|
+
rationale → 设计原理(string)
|
|
206
|
+
tags → 标签(string[])
|
|
207
|
+
confidence → 置信度(number 0.0–1.0)
|
|
208
|
+
aiInsight → AI 洞察(string | null)
|
|
209
|
+
agentNotes → Agent 笔记(string[] | null)
|
|
210
|
+
relations → 关联关系(object)
|
|
211
|
+
|
|
212
|
+
禁止使用其他 key。不允许用 content/summary/insight/notes/title 等替代名。
|
|
213
|
+
|
|
214
|
+
## 字段与 UI 子标题的对应关系
|
|
215
|
+
|
|
216
|
+
用户输入的指令可能使用 UI 上显示的子标题名称,对应规则如下:
|
|
217
|
+
- “摘要”“描述” → description
|
|
218
|
+
- “代码”“标准用法”“代码/标准用法” → pattern
|
|
219
|
+
- “Markdown 文档”“markdown” → markdown
|
|
220
|
+
- “设计原理”“原理” → rationale
|
|
221
|
+
- “标签” → tags
|
|
222
|
+
- “AI 洞察” → aiInsight
|
|
223
|
+
- “Agent 笔记” → agentNotes
|
|
224
|
+
- “关联关系” → relations
|
|
191
225
|
|
|
192
226
|
## 当前条目信息
|
|
193
227
|
|
|
194
228
|
标题: ${before.title}
|
|
195
229
|
|
|
196
|
-
|
|
230
|
+
【description】摘要
|
|
197
231
|
${before.description || '(空)'}
|
|
198
232
|
|
|
199
|
-
|
|
233
|
+
【pattern】代码/标准用法
|
|
200
234
|
${(before.pattern || '(空)').substring(0, 3000)}
|
|
201
235
|
|
|
202
|
-
|
|
236
|
+
【markdown】Markdown 文档
|
|
237
|
+
${(before.markdown || '(空)').substring(0, 3000)}
|
|
238
|
+
|
|
239
|
+
【rationale】设计原理
|
|
240
|
+
${before.rationale || '(空)'}
|
|
241
|
+
|
|
242
|
+
【tags】标签
|
|
203
243
|
${JSON.stringify(before.tags)}
|
|
204
244
|
|
|
205
|
-
|
|
245
|
+
【confidence】置信度
|
|
206
246
|
${before.confidence}
|
|
207
247
|
|
|
208
|
-
|
|
248
|
+
【relations】关联关系
|
|
209
249
|
${JSON.stringify(before.relations)}
|
|
210
250
|
|
|
211
|
-
【AI 洞察
|
|
251
|
+
【aiInsight】AI 洞察
|
|
212
252
|
${before.aiInsight || '(空)'}
|
|
213
253
|
|
|
214
|
-
【Agent 笔记
|
|
254
|
+
【agentNotes】Agent 笔记
|
|
215
255
|
${JSON.stringify(before.agentNotes || [])}
|
|
216
256
|
|
|
217
257
|
## 用户指令
|
|
@@ -220,74 +260,121 @@ ${userPrompt}
|
|
|
220
260
|
|
|
221
261
|
## 严格约束
|
|
222
262
|
|
|
223
|
-
1.
|
|
224
|
-
2.
|
|
225
|
-
3.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"pattern": "原样或修改后的内容文档",
|
|
231
|
-
"tags": ["原样或修改后的标签数组"],
|
|
232
|
-
"confidence": 原样或修改后的数字,
|
|
233
|
-
"aiInsight": "原样或修改后的AI洞察 或 null",
|
|
234
|
-
"agentNotes": ["原样或修改后的笔记数组"] 或 null,
|
|
235
|
-
"relations": {原样或修改后的关联关系}
|
|
236
|
-
}
|
|
263
|
+
1. **只修改用户指令涉及的字段**。参考上方“字段与 UI 子标题的对应关系”识别用户指的是哪个字段。
|
|
264
|
+
2. **未涉及的字段必须原样返回**,不得做任何改写、改善、优化或翻译。
|
|
265
|
+
3. 如果不确定用户指的是哪个字段,优先修改 description(摘要)、pattern(代码)、markdown(文档)、rationale(设计原理)。
|
|
266
|
+
4. **翻译/语言转换类指令**(如“翻译为中文”): 翻译 description、pattern、markdown、rationale、aiInsight、agentNotes 等文本字段,但 tags/relations/confidence 保持原样。
|
|
267
|
+
5. **tags 和 relations** 只在用户明确提及“标签”或“关联”时才修改,其他情况一律原样返回。
|
|
268
|
+
|
|
269
|
+
## 输出格式
|
|
237
270
|
|
|
238
|
-
|
|
271
|
+
返回严格符合以下结构的 JSON,不要添加任何其他文字或代码块标记:
|
|
272
|
+
{"description": "...", "pattern": "...", "markdown": "...", "rationale": "...", "tags": [...], "confidence": 0.6, "aiInsight": "...or null", "agentNotes": ["..."] or null, "relations": {...}}
|
|
273
|
+
|
|
274
|
+
每个 key 都必须存在,key 名称必须与上述完全一致。`;
|
|
239
275
|
}
|
|
240
276
|
|
|
241
277
|
/**
|
|
242
278
|
* 将 AI 返回的润色结果合并到 before 上生成 after,并构造 knowledgeService.update() 所需的 updateData
|
|
243
279
|
*/
|
|
244
280
|
function buildUpdateFromRefineResult(before, parsed) {
|
|
281
|
+
// ─── key 别名归一化:AI 可能返回不精确的 key,统一映射到标准 key ───
|
|
282
|
+
const KEY_ALIASES = {
|
|
283
|
+
// description 别名
|
|
284
|
+
summary: 'description', desc: 'description', 摘要: 'description', 描述: 'description',
|
|
285
|
+
// pattern 别名
|
|
286
|
+
content: 'pattern', designPattern: 'pattern',
|
|
287
|
+
内容: 'pattern', 代码: 'pattern', 标准用法: 'pattern',
|
|
288
|
+
// markdown 别名
|
|
289
|
+
markdownDoc: 'markdown', Markdown文档: 'markdown', 文档: 'markdown', doc: 'markdown',
|
|
290
|
+
// rationale 别名
|
|
291
|
+
design: 'rationale', 设计原理: 'rationale', 原理: 'rationale', design_rationale: 'rationale', designRationale: 'rationale',
|
|
292
|
+
// tags 别名
|
|
293
|
+
tag: 'tags', label: 'tags', labels: 'tags', 标签: 'tags',
|
|
294
|
+
// confidence 别名
|
|
295
|
+
score: 'confidence', 置信度: 'confidence', 评分: 'confidence',
|
|
296
|
+
// aiInsight 别名
|
|
297
|
+
ai_insight: 'aiInsight', insight: 'aiInsight', aiinsight: 'aiInsight', 洞察: 'aiInsight',
|
|
298
|
+
// agentNotes 别名
|
|
299
|
+
agent_notes: 'agentNotes', notes: 'agentNotes', agentnotes: 'agentNotes', 笔记: 'agentNotes',
|
|
300
|
+
// relations 别名
|
|
301
|
+
relation: 'relations', 关联: 'relations', 关联关系: 'relations',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const VALID_KEYS = new Set(['description', 'pattern', 'markdown', 'rationale', 'tags', 'confidence', 'aiInsight', 'agentNotes', 'relations']);
|
|
305
|
+
const normalized = {};
|
|
306
|
+
|
|
307
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
308
|
+
if (VALID_KEYS.has(key)) {
|
|
309
|
+
normalized[key] = value;
|
|
310
|
+
} else {
|
|
311
|
+
const mapped = KEY_ALIASES[key] || KEY_ALIASES[key.toLowerCase()];
|
|
312
|
+
if (mapped && !(mapped in normalized)) {
|
|
313
|
+
normalized[mapped] = value;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 确保未返回的字段保留 before 值
|
|
319
|
+
for (const k of VALID_KEYS) {
|
|
320
|
+
if (!(k in normalized)) normalized[k] = before[k];
|
|
321
|
+
}
|
|
322
|
+
|
|
245
323
|
const after = { ...before };
|
|
246
324
|
const updateData = {};
|
|
247
325
|
let changed = false;
|
|
248
326
|
|
|
249
|
-
if (
|
|
250
|
-
after.description =
|
|
251
|
-
updateData.description =
|
|
327
|
+
if (normalized.description != null && normalized.description !== before.description) {
|
|
328
|
+
after.description = normalized.description;
|
|
329
|
+
updateData.description = normalized.description;
|
|
252
330
|
changed = true;
|
|
253
331
|
}
|
|
254
|
-
if (
|
|
255
|
-
after.pattern =
|
|
256
|
-
|
|
257
|
-
updateData._patternChanged = parsed.pattern;
|
|
332
|
+
if (normalized.pattern != null && normalized.pattern !== before.pattern) {
|
|
333
|
+
after.pattern = normalized.pattern;
|
|
334
|
+
updateData._patternChanged = normalized.pattern;
|
|
258
335
|
changed = true;
|
|
259
336
|
}
|
|
260
|
-
if (
|
|
261
|
-
|
|
337
|
+
if (normalized.markdown != null && normalized.markdown !== before.markdown) {
|
|
338
|
+
after.markdown = normalized.markdown;
|
|
339
|
+
updateData._markdownChanged = normalized.markdown;
|
|
340
|
+
changed = true;
|
|
341
|
+
}
|
|
342
|
+
if (normalized.rationale != null && normalized.rationale !== before.rationale) {
|
|
343
|
+
after.rationale = normalized.rationale;
|
|
344
|
+
updateData._rationaleChanged = normalized.rationale;
|
|
345
|
+
changed = true;
|
|
346
|
+
}
|
|
347
|
+
if (normalized.tags != null && Array.isArray(normalized.tags)) {
|
|
348
|
+
const newTags = JSON.stringify(normalized.tags);
|
|
262
349
|
if (newTags !== JSON.stringify(before.tags)) {
|
|
263
|
-
after.tags =
|
|
264
|
-
updateData.tags =
|
|
350
|
+
after.tags = normalized.tags;
|
|
351
|
+
updateData.tags = normalized.tags;
|
|
265
352
|
changed = true;
|
|
266
353
|
}
|
|
267
354
|
}
|
|
268
|
-
if (typeof
|
|
269
|
-
after.confidence =
|
|
270
|
-
updateData._confidenceChanged =
|
|
355
|
+
if (typeof normalized.confidence === 'number' && normalized.confidence !== before.confidence) {
|
|
356
|
+
after.confidence = normalized.confidence;
|
|
357
|
+
updateData._confidenceChanged = normalized.confidence;
|
|
271
358
|
changed = true;
|
|
272
359
|
}
|
|
273
|
-
if (
|
|
274
|
-
after.aiInsight =
|
|
275
|
-
updateData.aiInsight =
|
|
360
|
+
if (normalized.aiInsight !== undefined && normalized.aiInsight !== before.aiInsight) {
|
|
361
|
+
after.aiInsight = normalized.aiInsight;
|
|
362
|
+
updateData.aiInsight = normalized.aiInsight;
|
|
276
363
|
changed = true;
|
|
277
364
|
}
|
|
278
|
-
if (
|
|
279
|
-
const newNotes = JSON.stringify(
|
|
365
|
+
if (normalized.agentNotes !== undefined) {
|
|
366
|
+
const newNotes = JSON.stringify(normalized.agentNotes);
|
|
280
367
|
if (newNotes !== JSON.stringify(before.agentNotes)) {
|
|
281
|
-
after.agentNotes =
|
|
282
|
-
updateData.agentNotes =
|
|
368
|
+
after.agentNotes = normalized.agentNotes;
|
|
369
|
+
updateData.agentNotes = normalized.agentNotes;
|
|
283
370
|
changed = true;
|
|
284
371
|
}
|
|
285
372
|
}
|
|
286
|
-
if (
|
|
287
|
-
const newRels = JSON.stringify(
|
|
373
|
+
if (normalized.relations !== undefined) {
|
|
374
|
+
const newRels = JSON.stringify(normalized.relations);
|
|
288
375
|
if (newRels !== JSON.stringify(before.relations)) {
|
|
289
|
-
after.relations =
|
|
290
|
-
updateData.relations =
|
|
376
|
+
after.relations = normalized.relations;
|
|
377
|
+
updateData.relations = normalized.relations;
|
|
291
378
|
changed = true;
|
|
292
379
|
}
|
|
293
380
|
}
|
|
@@ -335,6 +422,194 @@ router.post('/refine-preview', asyncHandler(async (req, res) => {
|
|
|
335
422
|
});
|
|
336
423
|
}));
|
|
337
424
|
|
|
425
|
+
/* ═══ 对话式润色 — 流式预览 (SSE) ═══════════════════════ */
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* POST /api/v1/candidates/refine-preview-stream
|
|
429
|
+
* 润色预览 — 统一 SSE 协议,使用 chatWithStructuredOutput 获取可靠结构化结果
|
|
430
|
+
*
|
|
431
|
+
* 不再流式推送 JSON 碎片。改为:
|
|
432
|
+
* stream:start — 会话开始
|
|
433
|
+
* data:progress — AI 润色进度(前端展示进度条/加载动画)
|
|
434
|
+
* stream:done — 完成,携带 before/after/preview
|
|
435
|
+
* stream:error — 错误
|
|
436
|
+
*
|
|
437
|
+
* Body: { candidateId: string, userPrompt: string }
|
|
438
|
+
*/
|
|
439
|
+
router.post('/refine-preview-stream', asyncHandler(async (req, res) => {
|
|
440
|
+
const { candidateId, userPrompt } = req.body;
|
|
441
|
+
if (!candidateId) throw new ValidationError('candidateId is required');
|
|
442
|
+
if (!userPrompt || !userPrompt.trim()) throw new ValidationError('userPrompt is required');
|
|
443
|
+
|
|
444
|
+
const container = getServiceContainer();
|
|
445
|
+
const knowledgeService = container.get('knowledgeService');
|
|
446
|
+
const aiProvider = container.get('aiProvider');
|
|
447
|
+
if (!aiProvider) throw new ValidationError('AI provider not configured');
|
|
448
|
+
|
|
449
|
+
const entry = await knowledgeService.get(candidateId);
|
|
450
|
+
if (!entry) throw new ValidationError('Candidate not found');
|
|
451
|
+
const json = typeof entry.toJSON === 'function' ? entry.toJSON() : entry;
|
|
452
|
+
const before = extractBeforeFields(json);
|
|
453
|
+
|
|
454
|
+
// ─── Session + EventSource 架构 ───
|
|
455
|
+
const session = createStreamSession('refine');
|
|
456
|
+
const prompt = buildRefinePrompt(before, userPrompt.trim());
|
|
457
|
+
|
|
458
|
+
// 立即返回 sessionId
|
|
459
|
+
res.json({ sessionId: session.sessionId });
|
|
460
|
+
|
|
461
|
+
// 异步执行 AI 润色,通过 session 推送进度事件
|
|
462
|
+
setImmediate(async () => {
|
|
463
|
+
try {
|
|
464
|
+
// 进度事件: AI 调用开始
|
|
465
|
+
session.send({ type: 'data:progress', stage: 'ai_calling', message: 'AI 润色中...' });
|
|
466
|
+
|
|
467
|
+
// 定时进度心跳 — AI 调用是阻塞的,前端需要看到动态变化
|
|
468
|
+
const progressMsgs = [
|
|
469
|
+
{ delay: 3000, stage: 'analyzing', message: '正在分析候选内容...' },
|
|
470
|
+
{ delay: 8000, stage: 'generating', message: '正在生成润色建议...' },
|
|
471
|
+
{ delay: 16000, stage: 'thinking', message: 'AI 深度分析中...' },
|
|
472
|
+
{ delay: 28000, stage: 'almost_done', message: '即将完成,请稍候...' },
|
|
473
|
+
];
|
|
474
|
+
const progressTimers = [];
|
|
475
|
+
let aiDone = false;
|
|
476
|
+
for (const pm of progressMsgs) {
|
|
477
|
+
const t = setTimeout(() => {
|
|
478
|
+
if (!aiDone) session.send({ type: 'data:progress', stage: pm.stage, message: pm.message });
|
|
479
|
+
}, pm.delay);
|
|
480
|
+
progressTimers.push(t);
|
|
481
|
+
}
|
|
482
|
+
// 超过 35 秒后每 15 秒报一次耗时
|
|
483
|
+
const longTimer = setInterval(() => {
|
|
484
|
+
if (aiDone) return;
|
|
485
|
+
const elapsed = Math.round((Date.now() - session.createdAt) / 1000);
|
|
486
|
+
session.send({ type: 'data:progress', stage: 'waiting', message: `AI 仍在处理中 (${elapsed}s)...` });
|
|
487
|
+
}, 15_000);
|
|
488
|
+
const longTimerStart = setTimeout(() => {}, 35_000); // placeholder
|
|
489
|
+
progressTimers.push(longTimerStart);
|
|
490
|
+
|
|
491
|
+
function clearProgressTimers() {
|
|
492
|
+
aiDone = true;
|
|
493
|
+
for (const t of progressTimers) clearTimeout(t);
|
|
494
|
+
clearInterval(longTimer);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 使用 chatWithStructuredOutput 获取可靠的 JSON 结果(非流式),120 秒超时
|
|
498
|
+
let parsed;
|
|
499
|
+
try {
|
|
500
|
+
parsed = await Promise.race([
|
|
501
|
+
aiProvider.chatWithStructuredOutput(prompt, { temperature: 0.3 }),
|
|
502
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('AI refine timeout (120s)')), 120_000)),
|
|
503
|
+
]);
|
|
504
|
+
} finally {
|
|
505
|
+
clearProgressTimers();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (parsed) {
|
|
509
|
+
// 进度事件: 构建 diff
|
|
510
|
+
session.send({ type: 'data:progress', stage: 'building_diff', message: '生成修改对比...' });
|
|
511
|
+
|
|
512
|
+
const { after } = buildUpdateFromRefineResult(before, parsed);
|
|
513
|
+
session.end({ candidateId, before, after, preview: parsed });
|
|
514
|
+
} else {
|
|
515
|
+
// 结构化输出失败,回退到 chat() 重试
|
|
516
|
+
session.send({ type: 'data:progress', stage: 'fallback', message: 'AI 正在重新生成...' });
|
|
517
|
+
const fullText = await aiProvider.chat(prompt, { temperature: 0.3 });
|
|
518
|
+
|
|
519
|
+
let fallbackParsed = null;
|
|
520
|
+
try {
|
|
521
|
+
const jsonStr = fullText.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
522
|
+
fallbackParsed = JSON.parse(jsonStr);
|
|
523
|
+
} catch {
|
|
524
|
+
const match = fullText.match(/\{[\s\S]*\}/);
|
|
525
|
+
if (match) {
|
|
526
|
+
try { fallbackParsed = JSON.parse(match[0]); } catch { /* ignore */ }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (fallbackParsed) {
|
|
531
|
+
const { after } = buildUpdateFromRefineResult(before, fallbackParsed);
|
|
532
|
+
session.end({ candidateId, before, after, preview: fallbackParsed });
|
|
533
|
+
} else {
|
|
534
|
+
session.end({ candidateId, before, after: before, preview: null, rawText: fullText });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
logger.warn('SSE refine-preview stream error', { error: err.message });
|
|
539
|
+
session.error(err.message, 'REFINE_ERROR');
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}));
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* GET /api/v1/candidates/refine-preview/events/:sessionId
|
|
546
|
+
* EventSource SSE 端点 — 消费润色预览进度事件
|
|
547
|
+
*
|
|
548
|
+
* 复用 scan/events 相同的 SSE 交付模式:回放缓冲 → 订阅实时 → 心跳保活
|
|
549
|
+
*/
|
|
550
|
+
router.get('/refine-preview/events/:sessionId', (req, res) => {
|
|
551
|
+
const session = getStreamSession(req.params.sessionId);
|
|
552
|
+
if (!session) {
|
|
553
|
+
return res.status(404).json({ success: false, error: 'Session not found or expired' });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ─── SSE Headers ───
|
|
557
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
558
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
559
|
+
res.setHeader('Connection', 'keep-alive');
|
|
560
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
561
|
+
res.flushHeaders();
|
|
562
|
+
|
|
563
|
+
if (res.socket) {
|
|
564
|
+
res.socket.setNoDelay(true);
|
|
565
|
+
res.socket.setTimeout(0);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function writeEvent(event) {
|
|
569
|
+
if (res.writableEnded) return;
|
|
570
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 1) 回放缓冲区
|
|
574
|
+
let isDone = false;
|
|
575
|
+
for (const event of session.buffer) {
|
|
576
|
+
writeEvent(event);
|
|
577
|
+
if (event.type === 'stream:done' || event.type === 'stream:error') {
|
|
578
|
+
isDone = true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (isDone || session.completed) {
|
|
583
|
+
res.end();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 2) 订阅实时事件
|
|
588
|
+
const unsubscribe = session.on((event) => {
|
|
589
|
+
writeEvent(event);
|
|
590
|
+
if (event.type === 'stream:done' || event.type === 'stream:error') {
|
|
591
|
+
unsubscribe();
|
|
592
|
+
clearInterval(heartbeat);
|
|
593
|
+
res.end();
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// 心跳保活 (每 15 秒)
|
|
598
|
+
const heartbeat = setInterval(() => {
|
|
599
|
+
if (res.writableEnded) {
|
|
600
|
+
clearInterval(heartbeat);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
res.write(`: ping ${Date.now()}\n\n`);
|
|
604
|
+
}, 15_000);
|
|
605
|
+
|
|
606
|
+
// 客户端断开连接时清理
|
|
607
|
+
res.on('close', () => {
|
|
608
|
+
unsubscribe();
|
|
609
|
+
clearInterval(heartbeat);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
338
613
|
/* ═══ 对话式润色 — 应用 ══════════════════════════════════ */
|
|
339
614
|
|
|
340
615
|
/**
|
|
@@ -381,9 +656,25 @@ router.post('/refine-apply', asyncHandler(async (req, res) => {
|
|
|
381
656
|
const finalUpdate = { ...updateData };
|
|
382
657
|
delete finalUpdate._patternChanged;
|
|
383
658
|
delete finalUpdate._confidenceChanged;
|
|
659
|
+
delete finalUpdate._markdownChanged;
|
|
660
|
+
delete finalUpdate._rationaleChanged;
|
|
384
661
|
|
|
662
|
+
const contentPatch = { ...(json.content || {}) };
|
|
663
|
+
let contentChanged = false;
|
|
385
664
|
if (updateData._patternChanged != null) {
|
|
386
|
-
|
|
665
|
+
contentPatch.pattern = updateData._patternChanged;
|
|
666
|
+
contentChanged = true;
|
|
667
|
+
}
|
|
668
|
+
if (updateData._markdownChanged != null) {
|
|
669
|
+
contentPatch.markdown = updateData._markdownChanged;
|
|
670
|
+
contentChanged = true;
|
|
671
|
+
}
|
|
672
|
+
if (updateData._rationaleChanged != null) {
|
|
673
|
+
contentPatch.rationale = updateData._rationaleChanged;
|
|
674
|
+
contentChanged = true;
|
|
675
|
+
}
|
|
676
|
+
if (contentChanged) {
|
|
677
|
+
finalUpdate.content = contentPatch;
|
|
387
678
|
}
|
|
388
679
|
if (updateData._confidenceChanged != null) {
|
|
389
680
|
finalUpdate.reasoning = { ...(json.reasoning || {}), confidence: updateData._confidenceChanged };
|