flzxsqc-demo 1.4.21 → 1.4.23

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.
@@ -26,6 +26,7 @@ import org.springframework.stereotype.Component;
26
26
  import org.springframework.web.reactive.function.client.WebClient;
27
27
  import org.springframework.web.reactive.function.client.WebClientRequestException;
28
28
  import org.springframework.web.reactive.function.client.WebClientResponseException;
29
+ import reactor.core.publisher.Flux;
29
30
  import reactor.netty.http.client.HttpClient;
30
31
 
31
32
  import java.util.ArrayList;
@@ -152,8 +153,18 @@ public class DummyLlmClient implements LlmClient {
152
153
  .accept(MediaType.TEXT_EVENT_STREAM)
153
154
  .headers(headers -> headers.setBearerAuth(resolvedModel.apiKey))
154
155
  .bodyValue(body)
155
- .retrieve()
156
- .bodyToFlux(STRING_SSE_TYPE)
156
+ .exchangeToFlux(response -> {
157
+ int status = response.rawStatusCode();
158
+ if (status < 200 || status >= 300) {
159
+ return response.bodyToMono(String.class)
160
+ .defaultIfEmpty("")
161
+ .flatMapMany(errorBody -> Flux.error(new LlmApiException(
162
+ resolvedModel.provider,
163
+ status,
164
+ responsePreview(errorBody))));
165
+ }
166
+ return response.bodyToFlux(STRING_SSE_TYPE);
167
+ })
157
168
  .collectList()
158
169
  .block();
159
170
  return assembleStreamingResponse(events == null ? Collections.<ServerSentEvent<String>>emptyList() : events);
@@ -288,6 +299,11 @@ public class DummyLlmClient implements LlmClient {
288
299
  if (!properties.getRetry().isEnabled() || attempt >= maxAttempts) {
289
300
  return false;
290
301
  }
302
+ if (ex instanceof LlmApiException) {
303
+ LlmApiException apiException = (LlmApiException) ex;
304
+ int status = apiException.statusCode;
305
+ return status == 408 || status == 409 || status == 429 || status >= 500 || isTokenRateLimit(status, apiException.responseBody);
306
+ }
291
307
  if (ex instanceof WebClientRequestException) {
292
308
  return true;
293
309
  }
@@ -305,7 +321,7 @@ public class DummyLlmClient implements LlmClient {
305
321
  long jitterMs = Math.max(0L, properties.getRetry().getJitterMs());
306
322
  long exponentialFactor = 1L << Math.max(0, attempt - 1);
307
323
  long backoffMs = Math.min(maxBackoffMs, initialBackoffMs * exponentialFactor);
308
- if (ex instanceof WebClientResponseException && isTokenRateLimit((WebClientResponseException) ex)) {
324
+ if (isTokenRateLimitException(ex)) {
309
325
  backoffMs = Math.max(backoffMs, Math.max(0L, properties.getRetry().getTokenLimitBackoffMs()));
310
326
  }
311
327
  if (jitterMs <= 0L) {
@@ -318,10 +334,21 @@ public class DummyLlmClient implements LlmClient {
318
334
  if (ex == null) {
319
335
  return false;
320
336
  }
321
- if (ex.getRawStatusCode() == 432) {
337
+ return isTokenRateLimit(ex.getRawStatusCode(), ex.getResponseBodyAsString());
338
+ }
339
+
340
+ private boolean isTokenRateLimitException(Exception ex) {
341
+ if (ex instanceof LlmApiException) {
342
+ LlmApiException apiException = (LlmApiException) ex;
343
+ return isTokenRateLimit(apiException.statusCode, apiException.responseBody);
344
+ }
345
+ return ex instanceof WebClientResponseException && isTokenRateLimit((WebClientResponseException) ex);
346
+ }
347
+
348
+ private boolean isTokenRateLimit(int statusCode, String body) {
349
+ if (statusCode == 432) {
322
350
  return true;
323
351
  }
324
- String body = ex.getResponseBodyAsString();
325
352
  return body != null && (body.contains("\"returnCode\":\"0432\"")
326
353
  || body.contains("\"returnCode\": \"0432\"")
327
354
  || body.contains("输入Token数已达到每分钟上限"));
@@ -340,6 +367,10 @@ public class DummyLlmClient implements LlmClient {
340
367
  }
341
368
 
342
369
  private String summarizeException(Exception ex) {
370
+ if (ex instanceof LlmApiException) {
371
+ LlmApiException apiException = (LlmApiException) ex;
372
+ return apiException.statusCode + " " + apiException.responseBody;
373
+ }
343
374
  if (ex instanceof WebClientResponseException) {
344
375
  WebClientResponseException responseException = (WebClientResponseException) ex;
345
376
  return responseException.getRawStatusCode() + " " + responseException.getStatusText();
@@ -349,14 +380,18 @@ public class DummyLlmClient implements LlmClient {
349
380
 
350
381
  private List<Map<String, Object>> toApiMessages(List<ChatMessage> messages) {
351
382
  List<Map<String, Object>> list = new ArrayList<>();
352
- boolean seenNonSystemMessage = false;
383
+ boolean emittedInitialSystemMessage = false;
353
384
  for (ChatMessage msg : messages) {
354
385
  ChatRole originalRole = msg.getRole();
355
386
  ChatRole apiRole = originalRole;
356
387
  String content = msg.getContent();
357
- if (originalRole == ChatRole.SYSTEM && seenNonSystemMessage) {
358
- apiRole = ChatRole.USER;
359
- content = lateSystemContentAsUser(content);
388
+ if (originalRole == ChatRole.SYSTEM) {
389
+ if (list.isEmpty() && !emittedInitialSystemMessage) {
390
+ emittedInitialSystemMessage = true;
391
+ } else {
392
+ apiRole = ChatRole.USER;
393
+ content = lateSystemContentAsUser(content);
394
+ }
360
395
  }
361
396
  Map<String, Object> m = new HashMap<>();
362
397
  m.put("role", apiRole.getCode());
@@ -382,9 +417,6 @@ public class DummyLlmClient implements LlmClient {
382
417
  m.put("tool_calls", toolCalls);
383
418
  }
384
419
  list.add(m);
385
- if (originalRole != ChatRole.SYSTEM) {
386
- seenNonSystemMessage = true;
387
- }
388
420
  }
389
421
  return list;
390
422
  }
@@ -527,4 +559,15 @@ public class DummyLlmClient implements LlmClient {
527
559
  StringBuilder name = new StringBuilder();
528
560
  StringBuilder arguments = new StringBuilder();
529
561
  }
562
+
563
+ private static class LlmApiException extends RuntimeException {
564
+ final int statusCode;
565
+ final String responseBody;
566
+
567
+ LlmApiException(String provider, int statusCode, String responseBody) {
568
+ super(provider + " API error: HTTP " + statusCode + " response=" + responseBody);
569
+ this.statusCode = statusCode;
570
+ this.responseBody = responseBody;
571
+ }
572
+ }
530
573
  }
@@ -114,7 +114,7 @@ public class DummyLlmClientTest {
114
114
  }
115
115
 
116
116
  @Test
117
- public void convertsSystemMessagesAfterConversationStartToUserMessages() throws Exception {
117
+ public void convertsAdditionalSystemMessagesToUserMessages() throws Exception {
118
118
  try (TestLlmServer server = new TestLlmServer()) {
119
119
  server.enqueue(200, "text/event-stream", streamContent("ok"));
120
120
 
@@ -129,8 +129,9 @@ public class DummyLlmClientTest {
129
129
  JsonNode messages = OBJECT_MAPPER.readTree(server.requestBody(0)).path("messages");
130
130
  assertEquals("system", messages.get(0).path("role").asText());
131
131
  assertEquals("initial system", messages.get(0).path("content").asText());
132
- assertEquals("system", messages.get(1).path("role").asText());
133
- assertEquals("second system", messages.get(1).path("content").asText());
132
+ assertEquals("user", messages.get(1).path("role").asText());
133
+ assertTrue(messages.get(1).path("content").asText().contains("Runtime instruction from system channel"));
134
+ assertTrue(messages.get(1).path("content").asText().contains("second system"));
134
135
  assertEquals("user", messages.get(2).path("role").asText());
135
136
  assertEquals("hello", messages.get(2).path("content").asText());
136
137
  assertEquals("user", messages.get(3).path("role").asText());
@@ -0,0 +1,394 @@
1
+ # 分析对话临时助手气泡:公司版本迁移操作说明
2
+
3
+ ## 1. 文档目的
4
+
5
+ 本文说明如何把当前前端已经实现的“运行中临时助手气泡”迁移到公司版本。
6
+
7
+ 该能力用于解决分析任务执行时间较长时,用户无法判断页面是卡住还是任务仍在运行的问题。前端根据 Session 状态和 SSE 运行事件,展示与当前步骤对应的提示文案,并在同一阶段内每 8 秒轮播一次。
8
+
9
+ 当前工程的参考实现位于:
10
+
11
+ - `src/pages/analysis-task/useAnalysisProgress.ts`:状态推导和文案轮播。
12
+ - `src/pages/AnalysisTaskPage.tsx`:接收事件并生成进度状态。
13
+ - `src/pages/analysis-task/TaskConversationTab.tsx`:渲染临时助手气泡。
14
+ - `src/pages/analysis-task/analysisTaskUtils.tsx`:真实聊天消息整理,已移除旧的固定占位消息。
15
+ - `src/styles.css`:气泡、动态圆点和无障碍降级样式。
16
+
17
+ ## 2. 迁移边界
18
+
19
+ 本次迁移应保持为前端能力:
20
+
21
+ - 不修改后端接口、数据库或任务执行逻辑。
22
+ - 不向 `session.transcript` 添加提示文案。
23
+ - 不把提示文案保存到历史记录、报告或 DSL。
24
+ - 不修改真实的用户消息和助手消息结构。
25
+ - 不显示百分比、预计完成时间或“马上完成”等无法由后端保证的信息。
26
+
27
+ 只有在公司版本没有提供运行事件时,才需要后端补充等价事件;如果公司版本已有类似 SSE/WebSocket 事件,只需要在前端建立字段适配。
28
+
29
+ ## 3. 运行机制
30
+
31
+ 数据流如下:
32
+
33
+ ```text
34
+ Session 状态 + 当前 Session 的运行事件
35
+
36
+
37
+ useAnalysisProgress 状态推导
38
+
39
+ 阶段 + 提示文案 + 标签
40
+
41
+
42
+ TaskConversationTab 临时气泡
43
+
44
+ 真实答案或终态出现后卸载
45
+ ```
46
+
47
+ 临时气泡是独立的 UI 状态,不是 `ChatMessage`。因此即使它显示在对话区域中,也不会被保存或回传给后端。
48
+
49
+ ## 4. 公司版本迁移前提
50
+
51
+ 迁移前先在公司版本中找到以下等价对象:
52
+
53
+ | 当前工程对象 | 公司版本需要找到的等价对象 | 必需字段 |
54
+ |---|---|---|
55
+ | `AgentSession` | 当前分析任务或会话 | `sessionId`、`status` |
56
+ | `RuntimeEvent` | SSE/WebSocket 运行事件 | `id`、`sessionId`、`turnId`、`eventType`、`payload` |
57
+ | `activeSession` | 当前打开的任务 | 当前状态必须能实时刷新 |
58
+ | `events` | 当前任务的事件列表 | 能按事件 ID 或时间排序 |
59
+ | `TaskConversationTab` | 对话内容组件 | 可以在消息列表和输入框之间插入状态 UI |
60
+
61
+ 建议公司版本提供或适配以下状态:
62
+
63
+ ```ts
64
+ type SessionStatus =
65
+ | "RUNNING"
66
+ | "WAITING_ASYNC_CALLBACK"
67
+ | "WAITING_USER_INPUT"
68
+ | "SUCCEEDED"
69
+ | "FAILED";
70
+ ```
71
+
72
+ 如果公司版本的名称不同,在页面层转换为以上语义即可,不要为了迁移 UI 修改后端枚举。
73
+
74
+ ## 5. 阶段映射规则
75
+
76
+ 当前实现包含以下前端展示阶段:
77
+
78
+ ```ts
79
+ type AnalysisProgressPhase =
80
+ | "PREPARING"
81
+ | "PLANNING"
82
+ | "RETRIEVING"
83
+ | "ANALYZING"
84
+ | "AGGREGATING"
85
+ | "REPORTING"
86
+ | "WAITING_ASYNC"
87
+ | "PROCESSING_RESULTS"
88
+ | "RECOVERING"
89
+ | "PROCESSING";
90
+ ```
91
+
92
+ 事件映射规则:
93
+
94
+ | 运行事件或工具名称 | 展示阶段 |
95
+ |---|---|
96
+ | `SESSION_STARTED`、`TURN_STARTED` | `PREPARING` |
97
+ | `MODEL_DECISION` | `PLANNING` |
98
+ | `query_*`、`sample_*`、`search_*` | `RETRIEVING` |
99
+ | `analyze_*` | `ANALYZING` |
100
+ | `aggregate_*`、`cluster_*` | `AGGREGATING` |
101
+ | 工具名包含 `report`,或 `final_response` | `REPORTING` |
102
+ | `WAITING_ASYNC_CALLBACK` | `WAITING_ASYNC` |
103
+ | `TOOL_CALLBACK_RECEIVED`、`TOOL_EXECUTION_FINISHED` | `PROCESSING_RESULTS` |
104
+ | `TOOL_EXECUTION_FAILED`,但 Session 仍为运行态 | `RECOVERING` |
105
+ | 未识别的运行步骤 | `PROCESSING` |
106
+
107
+ Harness 阶段映射:
108
+
109
+ | `payload.phase` | 展示阶段 |
110
+ |---|---|
111
+ | `INTENT_RECOGNITION`、`SKILL_ROUTING`、`QUESTION_REQUIREMENT_VALIDATION` | `PREPARING` |
112
+ | `PLAN_NEXT_ACTION`、`TOOL_CALL_VALIDATION`、`COMPLETION_VALIDATION` | `PLANNING` |
113
+ | `RESULT_VALIDATION` | `PROCESSING_RESULTS` |
114
+ | `RESULT_ASSEMBLY`、`FINALIZE` | `REPORTING` |
115
+ | `WAITING_ASYNC_CALLBACK` | `WAITING_ASYNC` |
116
+ | `TOOL_EXECUTION` | `PROCESSING` |
117
+
118
+ 公司版本如果没有 Harness 事件,可以只保留 Session、模型、工具和异步事件映射,不影响基本能力。
119
+
120
+ ## 6. 具体代码修改步骤
121
+
122
+ ### 6.1 新增状态推导 Hook
123
+
124
+ 在公司版本的分析任务模块中新增 `useAnalysisProgress.ts`。可以直接复制当前工程的:
125
+
126
+ ```text
127
+ src/pages/analysis-task/useAnalysisProgress.ts
128
+ ```
129
+
130
+ 复制后只需要调整 `AgentSession`、`RuntimeEvent` 的 import 路径和字段名称。
131
+
132
+ Hook 的核心处理顺序必须保留:
133
+
134
+ 1. 仅在 `RUNNING` 或 `WAITING_ASYNC_CALLBACK` 时生成进度状态。
135
+ 2. 过滤出当前 Session 的事件,并按事件 ID 升序排列。
136
+ 3. 找到最后一个 `TURN_STARTED`,只处理这个 Turn 之后的事件。
137
+ 4. 当前 Turn 已出现真实助手答案、等待用户输入或终态时,返回 `null`。
138
+ 5. 按最新有效事件推导展示阶段。
139
+ 6. 使用 `sessionId + turnId + phase` 作为轮播键;切换任务、Turn 或阶段时从第一条文案重新开始。
140
+ 7. 每 8 秒切换同阶段的下一条文案,并在组件卸载或阶段变化时清理定时器。
141
+
142
+ 关键逻辑参考:
143
+
144
+ ```ts
145
+ const sessionEvents = events
146
+ .filter((event) => event.sessionId === session.sessionId)
147
+ .sort((left, right) => left.id - right.id);
148
+
149
+ const lastTurnStartIndex = findLastIndex(
150
+ sessionEvents,
151
+ (event) => event.eventType === "TURN_STARTED",
152
+ );
153
+
154
+ const turnEvents = lastTurnStartIndex >= 0
155
+ ? sessionEvents.slice(lastTurnStartIndex)
156
+ : sessionEvents;
157
+ ```
158
+
159
+ 异步状态需要特别处理:Session 刷新可能晚于回调事件。如果 `TOOL_CALLBACK_RECEIVED` 已经出现在最近一次 `WAITING_ASYNC_CALLBACK` 之后,应显示“继续处理结果”,不能继续显示“等待后台结果”。
160
+
161
+ 真实结果和终态的隐藏规则参考:
162
+
163
+ ```ts
164
+ if (
165
+ event.eventType === "ASSISTANT_MESSAGE"
166
+ || event.eventType === "WAITING_USER_INPUT"
167
+ ) {
168
+ return null;
169
+ }
170
+
171
+ // TURN_FINISHED 的 status 为以下值时也隐藏:
172
+ // SUCCEEDED、FAILED、WAITING_USER_INPUT
173
+ ```
174
+
175
+ ### 6.2 保留步骤感知文案池
176
+
177
+ 每个阶段维护三条文案,当前实现位于 `PHASE_MESSAGES`。公司版本可以替换措辞,但需要遵循以下规则:
178
+
179
+ - 描述正在执行的动作,不承诺完成时间。
180
+ - 不暴露内部工具名称、参数、异常堆栈或敏感字段。
181
+ - 异步阶段明确告诉用户任务仍在后台执行。
182
+ - 恢复阶段只说明正在调整处理路径,不直接向用户展示内部失败。
183
+ - 同阶段顺序轮播,不使用完全随机选择,避免连续重复和测试不稳定。
184
+
185
+ 轮播间隔保持为:
186
+
187
+ ```ts
188
+ const MESSAGE_ROTATION_INTERVAL_MS = 8_000;
189
+ ```
190
+
191
+ ### 6.3 在分析任务页面接入 Hook
192
+
193
+ 在公司版本的分析任务页面引入 Hook:
194
+
195
+ ```ts
196
+ import { useAnalysisProgress } from "./analysis-task/useAnalysisProgress";
197
+ ```
198
+
199
+ 在事件排序之后生成状态:
200
+
201
+ ```ts
202
+ const sortedEvents = useMemo(
203
+ () => [...events].sort((a, b) => (a.id ?? 0) - (b.id ?? 0)),
204
+ [events],
205
+ );
206
+
207
+ const analysisProgress = useAnalysisProgress(activeSession, sortedEvents);
208
+ ```
209
+
210
+ 传给对话组件:
211
+
212
+ ```tsx
213
+ <TaskConversationTab
214
+ session={activeSession}
215
+ messages={chatMessages}
216
+ progress={analysisProgress}
217
+ // 其他原有参数保持不变
218
+ />
219
+ ```
220
+
221
+ 公司版本必须继续把所有 SSE 事件加入本地 `events`。不要求每一种事件都触发 Session 接口刷新,因为阶段提示可以直接由本地事件更新。
222
+
223
+ ### 6.4 保证真实消息数组不包含临时文案
224
+
225
+ 聊天消息继续只从真实 transcript 和待提交用户消息生成:
226
+
227
+ ```ts
228
+ const chatMessages = useMemo(
229
+ () => withPendingUserInputMessage(
230
+ activeSession,
231
+ visibleChatMessages(activeSession?.transcript),
232
+ pendingUserInputMessages,
233
+ ),
234
+ [activeSession, pendingUserInputMessages],
235
+ );
236
+ ```
237
+
238
+ 如果公司版本已有类似以下逻辑,需要删除:
239
+
240
+ ```ts
241
+ withStartupAssistantMessage(activeSession, chatMessages)
242
+ ```
243
+
244
+ 创建 Session 请求尚未返回前的固定“已收到,正在启动分析”提示可以保留。需要删除的是 Session 已创建以后、混入聊天消息数组的运行态占位消息。
245
+
246
+ ### 6.5 修改对话组件 Props
247
+
248
+ 为公司版本的对话组件增加进度参数:
249
+
250
+ ```ts
251
+ interface TaskConversationTabProps {
252
+ session: AgentSession | null;
253
+ messages: ChatMessage[];
254
+ progress: AnalysisProgress | null;
255
+ // 其他原有字段
256
+ }
257
+ ```
258
+
259
+ 在真实聊天列表和输入框之间渲染:
260
+
261
+ ```tsx
262
+ {progress ? (
263
+ <div
264
+ className={`chat-message assistant analysis-progress-bubble${
265
+ progress.waitingAsync ? " waiting" : ""
266
+ }`}
267
+ role="status"
268
+ aria-live="polite"
269
+ aria-atomic="true"
270
+ >
271
+ <div className="chat-message-role analysis-progress-label">
272
+ <span>{progress.label}</span>
273
+ <span className="analysis-progress-dots" aria-hidden="true">
274
+ <span />
275
+ <span />
276
+ <span />
277
+ </span>
278
+ </div>
279
+ <div className="chat-message-content">{progress.message}</div>
280
+ </div>
281
+ ) : null}
282
+ ```
283
+
284
+ 建议把等待用户输入卡片、消息列表和临时气泡放进统一的 `conversation-content` 容器,输入框保持在容器外,避免新增气泡后破坏原 Grid 行布局。
285
+
286
+ ### 6.6 迁移样式
287
+
288
+ 从当前工程 `src/styles.css` 迁移以下样式块:
289
+
290
+ - `.conversation-content`
291
+ - `.chat-message.analysis-progress-bubble`
292
+ - `.chat-message.analysis-progress-bubble.waiting`
293
+ - `.analysis-progress-label`
294
+ - `.analysis-progress-dots`
295
+ - `@keyframes analysis-progress-enter`
296
+ - `@keyframes analysis-progress-dot`
297
+ - `@media (prefers-reduced-motion: reduce)` 中对应规则
298
+
299
+ 如果公司版本没有 `.chat-message`、`.assistant`、`.chat-message-role` 和 `.chat-message-content`,需要换成公司现有对话气泡类名。不要复制整套聊天样式,只迁移进度气泡新增部分。
300
+
301
+ 对话区采用 Grid 时,建议保持:
302
+
303
+ ```css
304
+ .conversation-workspace {
305
+ display: grid;
306
+ grid-template-rows: minmax(0, 1fr) auto;
307
+ gap: 16px;
308
+ }
309
+
310
+ .conversation-content {
311
+ display: grid;
312
+ align-content: start;
313
+ gap: 16px;
314
+ min-height: 0;
315
+ }
316
+ ```
317
+
318
+ `prefers-reduced-motion` 必须保留,使关闭动画的用户看到静态圆点和文案,而不是持续动画。
319
+
320
+ ## 7. 公司版本字段适配建议
321
+
322
+ 如果公司事件结构不同,优先在 Hook 入口做一次适配,不要在每个判断分支中兼容多套字段:
323
+
324
+ ```ts
325
+ interface NormalizedRuntimeEvent {
326
+ id: number;
327
+ sessionId: string;
328
+ turnId?: string | null;
329
+ eventType: string;
330
+ payload: Record<string, unknown>;
331
+ }
332
+
333
+ function normalizeCompanyEvent(input: CompanyRuntimeEvent): NormalizedRuntimeEvent {
334
+ return {
335
+ id: input.sequence,
336
+ sessionId: input.taskId,
337
+ turnId: input.roundId,
338
+ eventType: input.type,
339
+ payload: input.data ?? {},
340
+ };
341
+ }
342
+ ```
343
+
344
+ 适配时需要确认:
345
+
346
+ - `id` 或替代字段能够表达事件先后顺序。
347
+ - 工具事件能够从 `payload.tool` 或等价字段读取工具名。
348
+ - Harness 事件能够从 `payload.phase` 或等价字段读取阶段。
349
+ - `TURN_FINISHED` 能够读取最终状态。
350
+ - SSE 重连如果会回放历史,前端去重逻辑需要以事件 ID 为准。
351
+
352
+ ## 8. 验收清单
353
+
354
+ ### 构建检查
355
+
356
+ ```bash
357
+ cd agent-front-react
358
+ npm run build
359
+ ```
360
+
361
+ ### 浏览器验收
362
+
363
+ - 新建分析任务后,Session 进入运行态时立即出现临时助手气泡。
364
+ - 同一阶段持续超过 8 秒时,文案切换且不连续重复。
365
+ - 查询、逐条分析、聚合、报告生成分别显示符合语义的文案。
366
+ - 进入异步等待后显示“等待后台结果”,气泡保持轮播。
367
+ - 收到工具回调后,即使 Session 接口尚未刷新,也切换为继续处理结果。
368
+ - 收到真实 `ASSISTANT_MESSAGE` 后,临时气泡立即消失。
369
+ - `WAITING_USER_INPUT` 时只显示原有的补充信息卡片。
370
+ - `SUCCEEDED` 或 `FAILED` 时不显示临时气泡。
371
+ - 切换任务、切换 Turn 或刷新页面后,没有旧文案和旧计时器残留。
372
+ - SSE 断线重连并回放历史后,只使用最新 Turn 的事件推导状态。
373
+ - Session 接口返回的 transcript、任务历史和 DSL 中都不存在临时文案。
374
+ - 系统启用“减少动态效果”后,圆点不再动画,但状态和文案仍正常显示。
375
+
376
+ ## 9. 常见迁移错误
377
+
378
+ - **把提示文案追加到 `messages` 或 transcript**:会污染历史记录,并可能被后续 LLM 当作真实上下文。
379
+ - **只判断 Session 状态**:无法区分查询、分析、聚合等阶段,也可能在回调后继续显示异步等待。
380
+ - **读取全部历史事件但不截取最新 Turn**:旧任务步骤会覆盖当前执行状态。
381
+ - **每次事件到达都重启轮播计时器**:高频事件下文案永远不会切换;Effect 依赖应使用稳定的 `sessionId + turnId + phase`。
382
+ - **显示内部工具名或异常信息**:会降低用户体验,并可能泄露内部实现。
383
+ - **增加假进度百分比**:当前事件没有提供真实进度,不能从步骤数量推算完成比例。
384
+ - **新增气泡后不调整 Grid 层级**:可能导致气泡占据弹性行,使消息列表或输入框位置异常。
385
+
386
+ ## 10. 回滚方式
387
+
388
+ 如果公司版本上线后需要临时回滚,只需:
389
+
390
+ 1. 从任务页面移除 `useAnalysisProgress()` 调用和 `progress` 参数。
391
+ 2. 从对话组件移除临时气泡 JSX。
392
+ 3. 删除新增进度样式和 Hook 文件。
393
+
394
+ 回滚不涉及后端、数据库、Session 数据或历史消息清理。
@@ -51,7 +51,6 @@ import {
51
51
  normalizeManualSessionIds,
52
52
  visibleChatMessages,
53
53
  withPendingUserInputMessage,
54
- withStartupAssistantMessage,
55
54
  } from "./analysis-task/analysisTaskUtils";
56
55
  import { AnalysisSampleModal } from "./analysis-task/AnalysisSampleModal";
57
56
  import { AnalysisSampleResultModal } from "./analysis-task/AnalysisSampleResultModal";
@@ -70,6 +69,7 @@ import { TaskConversationTab } from "./analysis-task/TaskConversationTab";
70
69
  import { TaskHeader } from "./analysis-task/TaskHeader";
71
70
  import { TaskReportTab } from "./analysis-task/TaskReportTab";
72
71
  import { TaskSidebar } from "./analysis-task/TaskSidebar";
72
+ import { useAnalysisProgress } from "./analysis-task/useAnalysisProgress";
73
73
 
74
74
  const FILTER_ONLY_SOURCE_TYPE = "FILTER_ONLY";
75
75
 
@@ -253,15 +253,13 @@ export function AnalysisTaskPage() {
253
253
  () => [...events].sort((a, b) => (a.id ?? 0) - (b.id ?? 0)),
254
254
  [events],
255
255
  );
256
+ const analysisProgress = useAnalysisProgress(activeSession, sortedEvents);
256
257
  const chatMessages = useMemo(
257
258
  () =>
258
- withStartupAssistantMessage(
259
+ withPendingUserInputMessage(
259
260
  activeSession,
260
- withPendingUserInputMessage(
261
- activeSession,
262
- visibleChatMessages(activeSession?.transcript),
263
- pendingUserInputMessages,
264
- ),
261
+ visibleChatMessages(activeSession?.transcript),
262
+ pendingUserInputMessages,
265
263
  ),
266
264
  [activeSession, pendingUserInputMessages],
267
265
  );
@@ -814,6 +812,7 @@ export function AnalysisTaskPage() {
814
812
  <TaskConversationTab
815
813
  session={activeSession}
816
814
  messages={chatMessages}
815
+ progress={analysisProgress}
817
816
  userInputText={userInputText}
818
817
  canSubmitUserInput={canSubmitUserInput}
819
818
  submittingUserInput={submittingUserInput}
@@ -3,10 +3,12 @@ import type { AgentSession, ChatMessage } from "../../types";
3
3
  import { prettyJson } from "../../utils/json";
4
4
  import { ChatComposer } from "./ChatComposer";
5
5
  import { ChatMessageList } from "./ChatMessageList";
6
+ import type { AnalysisProgress } from "./useAnalysisProgress";
6
7
 
7
8
  interface TaskConversationTabProps {
8
9
  session: AgentSession | null;
9
10
  messages: ChatMessage[];
11
+ progress: AnalysisProgress | null;
10
12
  userInputText: string;
11
13
  canSubmitUserInput: boolean;
12
14
  submittingUserInput: boolean;
@@ -21,6 +23,7 @@ interface TaskConversationTabProps {
21
23
  export function TaskConversationTab({
22
24
  session,
23
25
  messages,
26
+ progress,
24
27
  userInputText,
25
28
  canSubmitUserInput,
26
29
  submittingUserInput,
@@ -37,27 +40,48 @@ export function TaskConversationTab({
37
40
 
38
41
  return (
39
42
  <div className="conversation-workspace">
40
- {session.status === "WAITING_USER_INPUT" ? (
41
- <Card className="waiting-question-card" size="small">
42
- <Typography.Text strong>需要你补充:</Typography.Text>
43
- <div className="waiting-question-text">{session.waitingUserQuestion}</div>
44
- {session.waitingUserInputContext && Object.keys(session.waitingUserInputContext).length > 0 ? (
45
- <Collapse
46
- ghost
47
- size="small"
48
- items={[
49
- {
50
- key: "context",
51
- label: "查看模型提供的上下文",
52
- children: <div className="json-block">{prettyJson(session.waitingUserInputContext)}</div>,
53
- },
54
- ]}
55
- />
56
- ) : null}
57
- </Card>
58
- ) : null}
43
+ <div className="conversation-content">
44
+ {session.status === "WAITING_USER_INPUT" ? (
45
+ <Card className="waiting-question-card" size="small">
46
+ <Typography.Text strong>需要你补充:</Typography.Text>
47
+ <div className="waiting-question-text">{session.waitingUserQuestion}</div>
48
+ {session.waitingUserInputContext && Object.keys(session.waitingUserInputContext).length > 0 ? (
49
+ <Collapse
50
+ ghost
51
+ size="small"
52
+ items={[
53
+ {
54
+ key: "context",
55
+ label: "查看模型提供的上下文",
56
+ children: <div className="json-block">{prettyJson(session.waitingUserInputContext)}</div>,
57
+ },
58
+ ]}
59
+ />
60
+ ) : null}
61
+ </Card>
62
+ ) : null}
59
63
 
60
- <ChatMessageList messages={messages} className="workbench-chat-list" emptyDescription="暂无对话内容" />
64
+ <ChatMessageList messages={messages} className="workbench-chat-list" emptyDescription="暂无对话内容" />
65
+
66
+ {progress ? (
67
+ <div
68
+ className={`chat-message assistant analysis-progress-bubble${progress.waitingAsync ? " waiting" : ""}`}
69
+ role="status"
70
+ aria-live="polite"
71
+ aria-atomic="true"
72
+ >
73
+ <div className="chat-message-role analysis-progress-label">
74
+ <span>{progress.label}</span>
75
+ <span className="analysis-progress-dots" aria-hidden="true">
76
+ <span />
77
+ <span />
78
+ <span />
79
+ </span>
80
+ </div>
81
+ <div className="chat-message-content">{progress.message}</div>
82
+ </div>
83
+ ) : null}
84
+ </div>
61
85
 
62
86
  <ChatComposer
63
87
  value={userInputText}
@@ -8,7 +8,6 @@ import type {
8
8
  ChatMessage,
9
9
  RuntimeEvent,
10
10
  } from "../../types";
11
- import { ANALYSIS_STARTUP_ASSISTANT_MESSAGE } from "./analysisTaskConstants";
12
11
  import type { AnalysisFormValues } from "./analysisTaskTypes";
13
12
 
14
13
  export function mergeBatchContext(
@@ -54,27 +53,6 @@ export function visibleChatMessages(transcript?: ChatMessage[] | null): ChatMess
54
53
  );
55
54
  }
56
55
 
57
- export function withStartupAssistantMessage(
58
- session: AgentSession | null,
59
- messages: ChatMessage[],
60
- ): ChatMessage[] {
61
- if (!session || (session.status !== "RUNNING" && session.status !== "WAITING_ASYNC_CALLBACK")) {
62
- return messages;
63
- }
64
- const hasUserMessage = messages.some((item) => item.role === "user");
65
- const hasAssistantMessage = messages.some((item) => item.role === "assistant" && Boolean(item.content?.trim()));
66
- if (!hasUserMessage || hasAssistantMessage) {
67
- return messages;
68
- }
69
- return [
70
- ...messages,
71
- {
72
- role: "assistant",
73
- content: ANALYSIS_STARTUP_ASSISTANT_MESSAGE,
74
- },
75
- ];
76
- }
77
-
78
56
  export function withPendingUserInputMessage(
79
57
  session: AgentSession | null,
80
58
  messages: ChatMessage[],
@@ -0,0 +1,271 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import type { AgentSession, RuntimeEvent } from "../../types";
3
+
4
+ const MESSAGE_ROTATION_INTERVAL_MS = 8_000;
5
+
6
+ export type AnalysisProgressPhase =
7
+ | "PREPARING"
8
+ | "PLANNING"
9
+ | "RETRIEVING"
10
+ | "ANALYZING"
11
+ | "AGGREGATING"
12
+ | "REPORTING"
13
+ | "WAITING_ASYNC"
14
+ | "PROCESSING_RESULTS"
15
+ | "RECOVERING"
16
+ | "PROCESSING";
17
+
18
+ export interface AnalysisProgress {
19
+ phase: AnalysisProgressPhase;
20
+ label: string;
21
+ message: string;
22
+ waitingAsync: boolean;
23
+ }
24
+
25
+ interface AnalysisProgressContext {
26
+ phase: AnalysisProgressPhase;
27
+ turnId: string | null;
28
+ }
29
+
30
+ const PHASE_MESSAGES: Record<AnalysisProgressPhase, string[]> = {
31
+ PREPARING: [
32
+ "正在梳理你的分析目标…",
33
+ "正在确认数据范围和执行路径…",
34
+ "正在准备本次分析所需的信息…",
35
+ ],
36
+ PLANNING: [
37
+ "正在规划下一步分析步骤…",
38
+ "正在核对当前结果并安排后续处理…",
39
+ "正在选择合适的分析方式…",
40
+ ],
41
+ RETRIEVING: [
42
+ "正在筛选并核对相关会话…",
43
+ "正在读取命中的会话数据…",
44
+ "正在整理可用于分析的样本…",
45
+ ],
46
+ ANALYZING: [
47
+ "正在逐条分析会话中的关键信息…",
48
+ "正在提取意图、原因和相关依据…",
49
+ "正在深入核对会话内容…",
50
+ ],
51
+ AGGREGATING: [
52
+ "正在归纳相似问题和共同特征…",
53
+ "正在汇总分类结果与代表样本…",
54
+ "正在核对各类结果的数量和依据…",
55
+ ],
56
+ REPORTING: [
57
+ "正在整理分析结论和报告结构…",
58
+ "正在核对数据依据并组织最终结果…",
59
+ "正在生成便于阅读的分析报告…",
60
+ ],
61
+ WAITING_ASYNC: [
62
+ "分析任务仍在后台执行,结果返回后会自动继续…",
63
+ "后台分析仍在处理中,返回后将继续完成后续步骤…",
64
+ "正在等待后台结果,本页会在结果返回后自动更新…",
65
+ ],
66
+ PROCESSING_RESULTS: [
67
+ "后台结果已返回,正在继续处理…",
68
+ "正在校验返回结果并衔接后续分析…",
69
+ "正在把最新结果纳入本轮分析…",
70
+ ],
71
+ RECOVERING: [
72
+ "当前步骤未完成,正在调整分析路径…",
73
+ "正在根据执行反馈重新检查下一步…",
74
+ "正在尝试用更合适的方式继续处理…",
75
+ ],
76
+ PROCESSING: [
77
+ "当前分析步骤正在执行…",
78
+ "正在处理本轮分析所需的信息…",
79
+ "任务仍在继续,请稍候…",
80
+ ],
81
+ };
82
+
83
+ export function useAnalysisProgress(
84
+ session: AgentSession | null,
85
+ events: RuntimeEvent[],
86
+ ): AnalysisProgress | null {
87
+ const context = useMemo(() => deriveAnalysisProgressContext(session, events), [events, session]);
88
+ const rotationKey = context
89
+ ? `${session?.sessionId ?? ""}:${context.turnId ?? ""}:${context.phase}`
90
+ : "";
91
+ const activePhase = context?.phase;
92
+ const [rotation, setRotation] = useState({ key: "", index: 0 });
93
+
94
+ useEffect(() => {
95
+ if (!activePhase || !rotationKey) {
96
+ return;
97
+ }
98
+ const messageCount = PHASE_MESSAGES[activePhase].length;
99
+ setRotation({ key: rotationKey, index: 0 });
100
+ const timer = window.setInterval(() => {
101
+ setRotation((current) => ({
102
+ key: rotationKey,
103
+ index: current.key === rotationKey ? (current.index + 1) % messageCount : 0,
104
+ }));
105
+ }, MESSAGE_ROTATION_INTERVAL_MS);
106
+ return () => window.clearInterval(timer);
107
+ }, [activePhase, rotationKey]);
108
+
109
+ if (!context) {
110
+ return null;
111
+ }
112
+ const messages = PHASE_MESSAGES[context.phase];
113
+ const messageIndex = rotation.key === rotationKey ? rotation.index : 0;
114
+ const waitingAsync = context.phase === "WAITING_ASYNC";
115
+ return {
116
+ phase: context.phase,
117
+ label: waitingAsync ? "助手 · 等待后台结果" : "助手 · 分析中",
118
+ message: messages[messageIndex],
119
+ waitingAsync,
120
+ };
121
+ }
122
+
123
+ export function deriveAnalysisProgressContext(
124
+ session: AgentSession | null,
125
+ events: RuntimeEvent[],
126
+ ): AnalysisProgressContext | null {
127
+ if (!session || (session.status !== "RUNNING" && session.status !== "WAITING_ASYNC_CALLBACK")) {
128
+ return null;
129
+ }
130
+
131
+ const sessionEvents = events
132
+ .filter((event) => event.sessionId === session.sessionId)
133
+ .sort((left, right) => left.id - right.id);
134
+ const lastTurnStartIndex = findLastIndex(sessionEvents, (event) => event.eventType === "TURN_STARTED");
135
+ const turnEvents = lastTurnStartIndex >= 0 ? sessionEvents.slice(lastTurnStartIndex) : sessionEvents;
136
+ const turnId = turnEvents.find((event) => event.eventType === "TURN_STARTED")?.turnId
137
+ ?? turnEvents[turnEvents.length - 1]?.turnId
138
+ ?? null;
139
+
140
+ if (hasCompletedOrPausedTurn(turnEvents)) {
141
+ return null;
142
+ }
143
+
144
+ const lastWaitingIndex = findLastIndex(
145
+ turnEvents,
146
+ (event) => event.eventType === "WAITING_ASYNC_CALLBACK",
147
+ );
148
+ const lastCallbackIndex = findLastIndex(
149
+ turnEvents,
150
+ (event) => event.eventType === "TOOL_CALLBACK_RECEIVED",
151
+ );
152
+ if (
153
+ session.status === "WAITING_ASYNC_CALLBACK"
154
+ && (lastTurnStartIndex < 0 || (lastWaitingIndex >= 0 && lastCallbackIndex <= lastWaitingIndex))
155
+ ) {
156
+ return { phase: "WAITING_ASYNC", turnId };
157
+ }
158
+
159
+ for (let index = turnEvents.length - 1; index >= 0; index -= 1) {
160
+ const phase = phaseForEvent(turnEvents[index]);
161
+ if (phase) {
162
+ return { phase, turnId };
163
+ }
164
+ }
165
+ return { phase: "PREPARING", turnId };
166
+ }
167
+
168
+ function hasCompletedOrPausedTurn(events: RuntimeEvent[]): boolean {
169
+ if (events.some((event) => event.eventType === "ASSISTANT_MESSAGE" || event.eventType === "WAITING_USER_INPUT")) {
170
+ return true;
171
+ }
172
+ const lastFinished = [...events].reverse().find((event) => event.eventType === "TURN_FINISHED");
173
+ if (!lastFinished) {
174
+ return false;
175
+ }
176
+ const status = payloadString(lastFinished, "status");
177
+ return status === "SUCCEEDED" || status === "FAILED" || status === "WAITING_USER_INPUT";
178
+ }
179
+
180
+ function phaseForEvent(event: RuntimeEvent): AnalysisProgressPhase | null {
181
+ if (event.eventType === "SESSION_STARTED" || event.eventType === "TURN_STARTED") {
182
+ return "PREPARING";
183
+ }
184
+ if (event.eventType === "MODEL_DECISION") {
185
+ return "PLANNING";
186
+ }
187
+ if (event.eventType === "TOOL_CALL_REQUESTED" || event.eventType === "TOOL_EXECUTION_STARTED") {
188
+ return phaseForTool(payloadString(event, "tool"));
189
+ }
190
+ if (event.eventType === "TOOL_CALLBACK_RECEIVED" || event.eventType === "TOOL_EXECUTION_FINISHED") {
191
+ return "PROCESSING_RESULTS";
192
+ }
193
+ if (event.eventType === "TOOL_EXECUTION_FAILED") {
194
+ return "RECOVERING";
195
+ }
196
+ if (event.eventType === "WAITING_ASYNC_CALLBACK") {
197
+ return "WAITING_ASYNC";
198
+ }
199
+ if (event.eventType === "HARNESS_PHASE_STARTED" || event.eventType === "HARNESS_PHASE_FINISHED") {
200
+ if (event.eventType === "HARNESS_PHASE_FINISHED" && payloadString(event, "phase") === "TOOL_EXECUTION") {
201
+ return "PROCESSING_RESULTS";
202
+ }
203
+ return phaseForHarness(payloadString(event, "phase"));
204
+ }
205
+ return null;
206
+ }
207
+
208
+ function phaseForTool(toolName: string | null): AnalysisProgressPhase {
209
+ const normalized = toolName?.toLowerCase() ?? "";
210
+ if (
211
+ normalized.startsWith("query_")
212
+ || normalized.startsWith("sample_")
213
+ || normalized.startsWith("search_")
214
+ ) {
215
+ return "RETRIEVING";
216
+ }
217
+ if (normalized.startsWith("analyze_")) {
218
+ return "ANALYZING";
219
+ }
220
+ if (normalized.startsWith("aggregate_") || normalized.startsWith("cluster_")) {
221
+ return "AGGREGATING";
222
+ }
223
+ if (normalized.includes("report") || normalized === "final_response") {
224
+ return "REPORTING";
225
+ }
226
+ return "PROCESSING";
227
+ }
228
+
229
+ function phaseForHarness(phase: string | null): AnalysisProgressPhase | null {
230
+ if (
231
+ phase === "INTENT_RECOGNITION"
232
+ || phase === "SKILL_ROUTING"
233
+ || phase === "QUESTION_REQUIREMENT_VALIDATION"
234
+ ) {
235
+ return "PREPARING";
236
+ }
237
+ if (
238
+ phase === "PLAN_NEXT_ACTION"
239
+ || phase === "TOOL_CALL_VALIDATION"
240
+ || phase === "COMPLETION_VALIDATION"
241
+ ) {
242
+ return "PLANNING";
243
+ }
244
+ if (phase === "RESULT_VALIDATION") {
245
+ return "PROCESSING_RESULTS";
246
+ }
247
+ if (phase === "RESULT_ASSEMBLY" || phase === "FINALIZE") {
248
+ return "REPORTING";
249
+ }
250
+ if (phase === "WAITING_ASYNC_CALLBACK") {
251
+ return "WAITING_ASYNC";
252
+ }
253
+ if (phase === "TOOL_EXECUTION") {
254
+ return "PROCESSING";
255
+ }
256
+ return null;
257
+ }
258
+
259
+ function payloadString(event: RuntimeEvent, key: string): string | null {
260
+ const value = event.payload?.[key];
261
+ return typeof value === "string" ? value : null;
262
+ }
263
+
264
+ function findLastIndex<T>(items: T[], predicate: (item: T) => boolean): number {
265
+ for (let index = items.length - 1; index >= 0; index -= 1) {
266
+ if (predicate(items[index])) {
267
+ return index;
268
+ }
269
+ }
270
+ return -1;
271
+ }
@@ -581,11 +581,18 @@ body {
581
581
 
582
582
  .conversation-workspace {
583
583
  display: grid;
584
- grid-template-rows: auto 1fr auto;
584
+ grid-template-rows: minmax(0, 1fr) auto;
585
585
  gap: 16px;
586
586
  min-height: 560px;
587
587
  }
588
588
 
589
+ .conversation-content {
590
+ display: grid;
591
+ align-content: start;
592
+ gap: 16px;
593
+ min-height: 0;
594
+ }
595
+
589
596
  .draft-workspace-card {
590
597
  display: grid;
591
598
  }
@@ -627,6 +634,78 @@ body {
627
634
  padding: 4px 8px 4px 0;
628
635
  }
629
636
 
637
+ .chat-message.analysis-progress-bubble {
638
+ border-color: #cbdcf7;
639
+ background: linear-gradient(135deg, #f5f9ff, #f8fbff);
640
+ box-shadow: 0 8px 24px rgba(55, 93, 154, 0.08);
641
+ animation: analysis-progress-enter 0.2s ease-out;
642
+ }
643
+
644
+ .chat-message.analysis-progress-bubble.waiting {
645
+ border-color: #f0d28c;
646
+ background: linear-gradient(135deg, #fff9e9, #fffdf6);
647
+ }
648
+
649
+ .analysis-progress-label {
650
+ display: flex;
651
+ align-items: center;
652
+ gap: 7px;
653
+ }
654
+
655
+ .analysis-progress-dots {
656
+ display: inline-flex;
657
+ align-items: center;
658
+ gap: 3px;
659
+ }
660
+
661
+ .analysis-progress-dots > span {
662
+ width: 4px;
663
+ height: 4px;
664
+ border-radius: 50%;
665
+ background: currentColor;
666
+ opacity: 0.28;
667
+ animation: analysis-progress-dot 1.2s ease-in-out infinite;
668
+ }
669
+
670
+ .analysis-progress-dots > span:nth-child(2) {
671
+ animation-delay: 0.16s;
672
+ }
673
+
674
+ .analysis-progress-dots > span:nth-child(3) {
675
+ animation-delay: 0.32s;
676
+ }
677
+
678
+ @keyframes analysis-progress-enter {
679
+ from {
680
+ opacity: 0;
681
+ transform: translateY(4px);
682
+ }
683
+ to {
684
+ opacity: 1;
685
+ transform: translateY(0);
686
+ }
687
+ }
688
+
689
+ @keyframes analysis-progress-dot {
690
+ 0%,
691
+ 60%,
692
+ 100% {
693
+ opacity: 0.28;
694
+ transform: translateY(0);
695
+ }
696
+ 30% {
697
+ opacity: 0.9;
698
+ transform: translateY(-2px);
699
+ }
700
+ }
701
+
702
+ @media (prefers-reduced-motion: reduce) {
703
+ .analysis-progress-bubble,
704
+ .analysis-progress-dots > span {
705
+ animation: none;
706
+ }
707
+ }
708
+
630
709
  .workbench-composer {
631
710
  padding-top: 12px;
632
711
  border-top: 1px solid #e9eff7;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flzxsqc-demo",
3
- "version": "1.4.21",
3
+ "version": "1.4.23",
4
4
  "description": "flzxsqc-demo",
5
5
  "main": "index.js",
6
6
  "scripts": {