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();
@@ -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
- const enriched = await aiProvider.enrichCandidates(candidates);
72
- for (const item of enriched) {
73
- const idx = item.index ?? 0;
74
- const cand = candidates[idx];
75
- if (!cand) continue;
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
- if (item.rationale && !cand.rationale) {
81
- updateData['content.rationale'] = item.rationale;
82
- // 需要合并到 content 对象
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
- updateData.content = { ...(json.content || {}), rationale: item.rationale };
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
- const data = result?.data || result?.content?.[0]?.text
146
- ? JSON.parse(result.content[0].text)?.data
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
- ## 可修改字段(字段名 UI 名称)
198
+ ## JSON key 规范(最高优先级)
181
199
 
182
- | JSON key | UI 标签 | 说明 |
183
- |---------------|-----------|--------------------------|
184
- | description | 摘要 | 条目的简要概述 |
185
- | pattern | 内容文档 / 设计原理 | 条目的详细内容、代码模式、设计原理 |
186
- | tags | 标签 | 分类标签数组 |
187
- | confidence | 置信度 | 0.0-1.0 的评分 |
188
- | aiInsight | AI 洞察 | AI 生成的架构洞察 |
189
- | agentNotes | Agent 笔记 | Agent 生成的笔记 |
190
- | relations | 关联关系 | 与其他条目的关系 |
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
- 【摘要 / description
230
+ description】摘要
197
231
  ${before.description || '(空)'}
198
232
 
199
- 【内容文档 / pattern
233
+ pattern】代码/标准用法
200
234
  ${(before.pattern || '(空)').substring(0, 3000)}
201
235
 
202
- 【标签 / tags】
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
- 【置信度 / confidence
245
+ confidence】置信度
206
246
  ${before.confidence}
207
247
 
208
- 【关联关系 / relations
248
+ relations】关联关系
209
249
  ${JSON.stringify(before.relations)}
210
250
 
211
- 【AI 洞察 / aiInsight】
251
+ aiInsight】AI 洞察
212
252
  ${before.aiInsight || '(空)'}
213
253
 
214
- 【Agent 笔记 / agentNotes】
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. **只修改用户指令明确提到的字段**。用户说"设计原理"或"内容"指 pattern 字段,说"摘要"或"描述"指 description 字段。
224
- 2. **未提及的字段必须原样返回**,不得做任何改写、改善、优化或翻译。
225
- 3. 如果你不确定用户指的是哪个字段,优先修改 pattern(内容文档)。
226
-
227
- 请返回 JSON(所有字段都必须包含):
228
- {
229
- "description": "原样或修改后的摘要",
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
- 仅返回 JSON,不要添加其他文字。`;
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 (parsed.description != null && parsed.description !== before.description) {
250
- after.description = parsed.description;
251
- updateData.description = parsed.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 (parsed.pattern != null && parsed.pattern !== before.pattern) {
255
- after.pattern = parsed.pattern;
256
- // pattern 需要写入 content.pattern
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 (parsed.tags != null && Array.isArray(parsed.tags)) {
261
- const newTags = JSON.stringify(parsed.tags);
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 = parsed.tags;
264
- updateData.tags = parsed.tags;
350
+ after.tags = normalized.tags;
351
+ updateData.tags = normalized.tags;
265
352
  changed = true;
266
353
  }
267
354
  }
268
- if (typeof parsed.confidence === 'number' && parsed.confidence !== before.confidence) {
269
- after.confidence = parsed.confidence;
270
- updateData._confidenceChanged = parsed.confidence;
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 (parsed.aiInsight !== undefined && parsed.aiInsight !== before.aiInsight) {
274
- after.aiInsight = parsed.aiInsight;
275
- updateData.aiInsight = parsed.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 (parsed.agentNotes !== undefined) {
279
- const newNotes = JSON.stringify(parsed.agentNotes);
365
+ if (normalized.agentNotes !== undefined) {
366
+ const newNotes = JSON.stringify(normalized.agentNotes);
280
367
  if (newNotes !== JSON.stringify(before.agentNotes)) {
281
- after.agentNotes = parsed.agentNotes;
282
- updateData.agentNotes = parsed.agentNotes;
368
+ after.agentNotes = normalized.agentNotes;
369
+ updateData.agentNotes = normalized.agentNotes;
283
370
  changed = true;
284
371
  }
285
372
  }
286
- if (parsed.relations !== undefined) {
287
- const newRels = JSON.stringify(parsed.relations);
373
+ if (normalized.relations !== undefined) {
374
+ const newRels = JSON.stringify(normalized.relations);
288
375
  if (newRels !== JSON.stringify(before.relations)) {
289
- after.relations = parsed.relations;
290
- updateData.relations = parsed.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
- finalUpdate.content = { ...(json.content || {}), pattern: updateData._patternChanged };
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 };