Skip to content

Commit 484de67

Browse files
author
iammm0
committed
feat(cli): 改善流式推理块渲染并精简安全报告输出
- 后端 SSE 为推理/工具事件增加 step_key,并行子任务不再共用 iteration 导致时间线串台 - 摘要阶段输出结构化精炼「安全报告」,最终 response 改为短收尾避免与报告重复刷屏 - SummaryAgent 截断过长观察/思路传入 LLM,并约束报告篇幅与禁止粘贴大段原始数据 - TUI:thoughtChunks 按 step 索引;跳过空占位推理块;展示 report 块;report_generator 工具短时隐藏 - 推理正文改用默认前景色,避免 Windows 控制台 dim 不可读 Made-with: Cursor
1 parent 3c2f781 commit 484de67

6 files changed

Lines changed: 149 additions & 41 deletions

File tree

server/src/modules/agents/core/summary-agent.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,17 @@ const SUMMARY_SYSTEM_PROMPT =
1717
'1. 语言简洁专业,使用中文。\n' +
1818
'2. 包含任务概述、关键发现、风险评估、修复建议和总结。\n' +
1919
'3. 关键发现需要按严重程度排序(高危 > 中危 > 低危 > 信息)。\n' +
20-
'4. 修复建议要具体可操作,而非泛泛而谈。';
20+
'4. 修复建议要具体可操作,而非泛泛而谈。\n' +
21+
'5. 全文控制在约 600–1200 汉字;用要点归纳,禁止大段粘贴原始 JSON、进程列表或完整配置文件。';
22+
23+
const MAX_THOUGHT_CHARS = 1_200;
24+
const MAX_OBS_CHARS = 2_500;
25+
26+
function clipForSummary(text: string, maxChars: number): string {
27+
const t = text.trim();
28+
if (t.length <= maxChars) return t;
29+
return `${t.slice(0, maxChars)}\n…(已截断,原文约 ${t.length} 字符)`;
30+
}
2131

2232
export class SummaryAgent extends BaseAgent {
2333
private readonly llm: LLMProvider;
@@ -103,16 +113,23 @@ export class SummaryAgent extends BaseAgent {
103113
}
104114

105115
if (options?.thoughts?.length) {
106-
parts.push(`## 分析思路\n${options.thoughts.map((t, i) => `${i + 1}. ${t}`).join('\n')}`);
116+
const lines = options.thoughts.map(
117+
(t, i) => `${i + 1}. ${clipForSummary(t, MAX_THOUGHT_CHARS)}`,
118+
);
119+
parts.push(`## 分析思路\n${lines.join('\n')}`);
107120
}
108121

109122
if (options?.observations?.length) {
110-
parts.push(`## 观察结果\n${options.observations.map((o, i) => `${i + 1}. ${o}`).join('\n')}`);
123+
const lines = options.observations.map(
124+
(o, i) => `${i + 1}. ${clipForSummary(o, MAX_OBS_CHARS)}`,
125+
);
126+
parts.push(`## 观察结果\n${lines.join('\n')}`);
111127
}
112128

113129
if (options?.toolResults?.length) {
114130
const toolLines = options.toolResults.map(
115-
(r) => `- **${r.tool}**: ${r.success ? '成功' : '失败'}${r.result}`,
131+
(r) =>
132+
`- **${r.tool}**: ${r.success ? '成功' : '失败'}${clipForSummary(String(r.result ?? ''), MAX_OBS_CHARS)}`,
116133
);
117134
parts.push(`## 工具执行结果\n${toolLines.join('\n')}`);
118135
}

server/src/modules/chat/chat.service.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,49 @@ import { TaskExecutor } from '../agents/core/task-executor';
2828
import { ChatRequestDto, ChatResponseDto } from './dto/chat.dto';
2929
import { ToolsService } from '../tools/tools.service';
3030

31+
/** 与 SecurityReActAgent 中 THINK/EXEC 事件的 step 关联键一致,避免并行子任务共用 iteration 导致前端时间线串台 */
32+
function sseStepKey(data: Record<string, unknown>, iteration: number): string {
33+
const todoId = data['todoId'];
34+
if (todoId !== undefined && todoId !== null && String(todoId).length > 0) {
35+
return `todo-${todoId}`;
36+
}
37+
return `iter-${iteration}`;
38+
}
39+
40+
/** CLI 流式输出用:结构化精炼报告 + 较短最终回复,避免刷屏 */
41+
function buildStreamSummaryPayload(summary: InteractionSummary): {
42+
report: string;
43+
response: string;
44+
} {
45+
const findings = summary.keyFindings
46+
.slice(0, 6)
47+
.map((l) => `- ${l}`)
48+
.join('\n');
49+
const recs = summary.recommendations
50+
.slice(0, 6)
51+
.map((l) => `- ${l}`)
52+
.join('\n');
53+
const parts: string[] = [];
54+
const head = summary.taskSummary?.trim();
55+
if (head) parts.push(`## 摘要\n${head}`);
56+
if (findings) parts.push(`## 关键发现\n${findings}`);
57+
if (recs) parts.push(`## 修复建议\n${recs}`);
58+
const tail = summary.overallConclusion?.trim();
59+
if (tail) parts.push(`## 总结\n${tail}`);
60+
let report = parts.join('\n\n');
61+
if (!report.trim()) {
62+
report = summary.rawReport?.trim() ? summary.rawReport.slice(0, 12_000) : '(未能生成摘要,请查看服务端日志。)';
63+
}
64+
/** 短收尾:详细章节已在「安全报告」块中展示,避免 TUI 再打一屏重复摘要 */
65+
const response =
66+
tail ||
67+
(summary.keyFindings[0]?.trim()
68+
? `首要发现:${summary.keyFindings[0].trim()}`
69+
: '') ||
70+
'详情见上方「安全报告」。';
71+
return { report, response };
72+
}
73+
3174
@Injectable()
3275
export class ChatService {
3376
private eventBus = new EventBus();
@@ -120,34 +163,44 @@ export class ChatService {
120163
const t = event.type;
121164
const d = event.data;
122165
if (t === EventType.THINK_START) {
123-
emit('thought_start', { iteration: d['iteration'] ?? 1 });
166+
const iteration = Number(d['iteration'] ?? 1);
167+
emit('thought_start', {
168+
iteration,
169+
step_key: sseStepKey(d, iteration),
170+
task: d['task'] as string | undefined,
171+
});
124172
} else if (t === EventType.THINK_END) {
173+
const iteration = Number(d['iteration'] ?? 1);
125174
emit('thought', {
126175
content: d['thought'] ?? '',
127-
iteration: d['iteration'] ?? 1,
176+
iteration,
177+
step_key: sseStepKey(d, iteration),
128178
});
129179
} else if (t === EventType.EXEC_START) {
180+
const iteration = Number(d['iteration'] ?? 1);
130181
emit('action_start', {
131182
tool: d['tool'] ?? '',
132183
params: d['params'] ?? {},
133-
iteration: d['iteration'] ?? 1,
184+
iteration,
185+
step_key: sseStepKey(d, iteration),
134186
});
135187
} else if (t === EventType.EXEC_RESULT) {
188+
const iteration = Number(d['iteration'] ?? 1);
136189
emit('action_result', {
137190
tool: d['tool'] ?? '',
138191
success: d['success'] ?? true,
139192
result: d['observation'] ?? '',
140-
iteration: d['iteration'] ?? 1,
193+
iteration,
194+
step_key: sseStepKey(d, iteration),
141195
});
142196
}
143197
};
144198

145-
let response: string;
146199
if (planResult.todos.length > 1) {
147200
const executor = new TaskExecutor(planResult, selectedAgent, this.eventBus);
148-
response = await executor.run(message, onAgentEvent);
201+
await executor.run(message, onAgentEvent);
149202
} else {
150-
response = await selectedAgent.process(message, { onEvent: onAgentEvent });
203+
await selectedAgent.process(message, { onEvent: onAgentEvent });
151204
}
152205

153206
emit('phase', { phase: 'summarizing', detail: '正在生成报告...' });
@@ -162,13 +215,12 @@ export class ChatService {
162215
mode: planResult.todos.length <= 1 ? 'brief' : 'full',
163216
});
164217

165-
if (summary.rawReport) {
166-
emit('report', { content: summary.rawReport });
167-
}
218+
const streamPayload = buildStreamSummaryPayload(summary);
219+
emit('report', { content: streamPayload.report });
168220

169-
emit('response', { content: response, agent: agentType });
221+
emit('response', { content: streamPayload.response, agent: agentType });
170222
emit('done', {});
171-
return response;
223+
return streamPayload.response;
172224
}
173225

174226
async chatSync(body: ChatRequestDto): Promise<ChatResponseDto> {

terminal-ui/src/components/blocks/ThoughtBlock.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,13 @@ export function ThoughtBlock({ title, body, noMargin }: ThoughtBlockProps) {
3333
</Text>
3434
) : null}
3535

36-
{/* 正文:每行前缀 ┊,dim gray */}
36+
{/* 正文:前缀弱化;正文用默认前景色,避免 Windows 控制台 dim+gray 几乎不可读 */}
3737
{lines.map((line, i) => (
3838
<Box key={i} flexDirection="row">
3939
<Text dimColor color={theme.textMuted}>
4040
{"┊ "}
4141
</Text>
42-
<Text dimColor color={theme.textMuted}>
43-
{line || " "}
44-
</Text>
42+
<Text color={theme.text}>{line || " "}</Text>
4543
</Box>
4644
))}
4745
</Box>

terminal-ui/src/contentBlocks.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ import type { ContentBlock } from "./types.js";
1515
const MAX_RESULT_LINES = 24;
1616

1717
/** 完成后仅标识"完成"并随后消失、且不渲染其输出内容的工具 */
18-
const TRANSIENT_TOOLS = new Set<string>(["system_info", "network_analyze"]);
18+
const TRANSIENT_TOOLS = new Set<string>([
19+
"system_info",
20+
"network_analyze",
21+
"report_generator",
22+
]);
23+
24+
/** 安全报告块最大行数(略宽于工具结果,仍防止刷屏) */
25+
const MAX_REPORT_LINES = 48;
1926

2027
// ─── 工具函数 ──────────────────────────────────────────────────────────────────
2128

@@ -119,6 +126,7 @@ export function streamStateToBlocks(
119126
actions,
120127
error,
121128
timeline,
129+
report,
122130
} = streamState;
123131

124132
const dismissed = dismissedTransientTools ?? new Set<string>();
@@ -212,11 +220,16 @@ export function streamStateToBlocks(
212220
lineStart = lineEnd;
213221
}
214222

215-
// ── 时间线渲染(按事件发生顺序)───────────────────────────────────────────────
223+
// ── 时间线渲染(按事件发生顺序);final 排在精炼「安全报告」之后,顺序与 SSE 一致 ──
216224
if (timeline.length > 0) {
217-
for (const item of timeline) {
225+
const nonFinal = timeline.filter((x) => x.type !== "final");
226+
const finals = timeline.filter((x) => x.type === "final");
227+
228+
for (const item of nonFinal) {
218229
if (item.type === "thought") {
219-
const extracted = extractThoughtOnly(item.body || "…");
230+
const extracted = extractThoughtOnly(item.body || "");
231+
const trimmed = extracted.trim();
232+
if (!trimmed || trimmed === "…") continue;
220233
addBlock(
221234
item.id,
222235
"thought",
@@ -259,7 +272,18 @@ export function streamStateToBlocks(
259272
);
260273
continue;
261274
}
275+
}
276+
277+
if (report?.trim()) {
278+
addBlock(
279+
"stream-report",
280+
"report",
281+
"安全报告",
282+
truncateBody(report.trim(), MAX_REPORT_LINES),
283+
);
284+
}
262285

286+
for (const item of finals) {
263287
if (item.type === "final") {
264288
addBlock(
265289
item.id,
@@ -276,6 +300,14 @@ export function streamStateToBlocks(
276300
}
277301
} else {
278302
// 兼容旧结构:无 timeline 时退回到 content。
303+
if (report?.trim()) {
304+
addBlock(
305+
"stream-report",
306+
"report",
307+
"安全报告",
308+
truncateBody(report.trim(), MAX_REPORT_LINES),
309+
);
310+
}
279311
if (actions.length > 0) {
280312
const filtered = actions.filter(
281313
(a) => !TRANSIENT_TOOLS.has(a.tool) || !dismissed.has(a.tool),

terminal-ui/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export interface StreamState {
4141
todos: Array<{ content: string; status?: string }>;
4242
} | null;
4343
thought: { iteration: number; content: string } | null;
44-
thoughtChunks: Map<number, string>;
44+
/** 推理流分片正文,键为后端 step_key(或 iter-N),避免并行子任务 iteration 重复时覆盖 */
45+
thoughtChunks: Map<string, string>;
4546
actions: Array<{
4647
tool: string;
4748
params: Record<string, unknown>;

terminal-ui/src/useChat.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export function useChat() {
9999
/** Typewriter 定时器,新消息到来时需要清理上一个 */
100100
const typewriterRef = useRef<ReturnType<typeof setInterval> | null>(null);
101101
const thoughtSeqRef = useRef<number>(0);
102-
const activeThoughtIdByIterationRef = useRef<Map<number, string>>(new Map());
102+
/** 按 step_key(子任务 todo / ReAct 轮次)关联推理时间线项,避免并行子任务共用 iteration 时串台 */
103+
const activeThoughtIdByStepRef = useRef<Map<string, string>>(new Map());
103104

104105
useEffect(() => {
105106
streamStateRef.current = streamState;
@@ -213,7 +214,7 @@ export function useChat() {
213214
currentSentAtRef.current = now;
214215
completedAtRef.current = 0;
215216
thoughtSeqRef.current = 0;
216-
activeThoughtIdByIterationRef.current = new Map();
217+
activeThoughtIdByStepRef.current = new Map();
217218

218219
setCurrentUserMessage(message);
219220
setCurrentSentAt(now);
@@ -254,13 +255,15 @@ export function useChat() {
254255
case "thought_start":
255256
setStreamState((s) => ({
256257
...(function () {
257-
const it = (data.iteration as number) ?? 1;
258+
const it = Number(data.iteration ?? 1);
259+
const stepKey =
260+
(data.step_key as string) || `iter-${it}`;
258261
thoughtSeqRef.current += 1;
259-
const thoughtId = `thought-${it}-${thoughtSeqRef.current}`;
260-
activeThoughtIdByIterationRef.current.set(it, thoughtId);
262+
const thoughtId = `thought-${stepKey}-${thoughtSeqRef.current}`;
263+
activeThoughtIdByStepRef.current.set(stepKey, thoughtId);
261264
const nextThoughtChunks = new Map(s.thoughtChunks);
262-
// 新一轮相同 iteration 的推理开始时,重置该 iteration 的分片缓存,避免串台
263-
nextThoughtChunks.set(it, "");
265+
// 新一轮相同 step 的推理开始时,重置该 step 的分片缓存,避免串台
266+
nextThoughtChunks.set(stepKey, "");
264267
return {
265268
...s,
266269
thought: {
@@ -282,14 +285,17 @@ export function useChat() {
282285
break;
283286

284287
case "thought_chunk": {
285-
const it = (data.iteration as number) ?? 1;
288+
const it = Number(data.iteration ?? 1);
289+
const stepKey =
290+
(data.step_key as string) || `iter-${it}`;
286291
const chunk = (data.chunk as string) ?? "";
287292
const thoughtId =
288-
activeThoughtIdByIterationRef.current.get(it) ?? `thought-${it}`;
293+
activeThoughtIdByStepRef.current.get(stepKey) ??
294+
`thought-${stepKey}`;
289295
setStreamState((s) => {
290296
const next = new Map(s.thoughtChunks);
291-
next.set(it, (next.get(it) ?? "") + chunk);
292-
const thoughtBody = next.get(it) ?? "";
297+
next.set(stepKey, (next.get(stepKey) ?? "") + chunk);
298+
const thoughtBody = next.get(stepKey) ?? "";
293299
return {
294300
...s,
295301
thoughtChunks: next,
@@ -311,10 +317,12 @@ export function useChat() {
311317
}
312318

313319
case "thought": {
314-
const tIt = (data.iteration as number) ?? 1;
320+
const tIt = Number(data.iteration ?? 1);
321+
const stepKey =
322+
(data.step_key as string) || `iter-${tIt}`;
315323
const tId =
316-
activeThoughtIdByIterationRef.current.get(tIt) ??
317-
`thought-${tIt}`;
324+
activeThoughtIdByStepRef.current.get(stepKey) ??
325+
`thought-${stepKey}`;
318326
setStreamState((s) => {
319327
const existing = s.timeline.find((t) => t.id === tId);
320328
if (existing?.status === "done") return s;
@@ -334,15 +342,15 @@ export function useChat() {
334342
body:
335343
(data.content as string) ??
336344
prev?.body ??
337-
s.thoughtChunks.get(tIt) ??
345+
s.thoughtChunks.get(stepKey) ??
338346
"",
339347
iteration: tIt,
340348
status: "done",
341349
}),
342350
),
343351
};
344352
});
345-
activeThoughtIdByIterationRef.current.delete(tIt);
353+
activeThoughtIdByStepRef.current.delete(stepKey);
346354
break;
347355
}
348356

0 commit comments

Comments
 (0)