Просмотр исходного кода

feat: AI 聊天流式状态增加心跳、等待与中断提示

- 后端 /chat/stream 每 10s 下发 heartbeat 帧,模型/工具长时间无输出时前端仍能判断连接存活;正文完成或出错时心跳随主流程停止。- 前端记录 streamStartedAt / lastActivityAt / lastHeartbeatAt:状态条区分「正在思考/执行工具」「连接正常但暂无新内容」「心跳已断、可能服务中断」。- 状态条移到正文之后并持续显示,长报告输出到一半等待时,用户在当前阅读位置就能看到状态;超过 5s 无新内容显示等待秒数,心跳超过 30s 或旧后端 60s 无输出标红并提示停止重试。- 未收到 done 的连接提前关闭标记为 interrupted 并追加重试提示,避免半截回答被当成正常完成。
cc 1 день назад
Родитель
Сommit
a6717bcb52

+ 3 - 0
ai-electron/frontend/src/ai/api/sse.ts

@@ -76,6 +76,9 @@ export function createParser(handlers: SseHandlers): SseFrameParser {
       case 'tool_result':
         handlers.onToolResult?.(data as any);
         break;
+      case 'heartbeat':
+        handlers.onHeartbeat?.(data as any);
+        break;
       case 'insight':
         handlers.onInsight?.(data as any);
         break;

+ 10 - 0
ai-electron/frontend/src/ai/api/types.ts

@@ -450,6 +450,13 @@ export interface SseTokenData {
   data: string;
 }
 
+/** 心跳帧(服务端在模型/工具长时间无输出时定期下发,仅用于前端判断连接是否活着) */
+export interface SseHeartbeatData {
+  type: 'heartbeat';
+  /** 服务端时间戳(毫秒) */
+  ts?: number;
+}
+
 /** 结束帧 */
 export interface SseDoneData {
   type: 'done';
@@ -475,6 +482,7 @@ export type SseEventData =
   | SseToolCallData
   | SseToolInputData
   | SseToolResultData
+  | SseHeartbeatData
   | SseInsightData
   | SseTokenData
   | SseDoneData
@@ -488,6 +496,8 @@ export interface SseHandlers {
   onToolCall?: (data: SseToolCallData) => void;
   onToolInput?: (data: SseToolInputData) => void;
   onToolResult?: (data: SseToolResultData) => void;
+  /** SSE 心跳(不产生正文/过程内容,仅用于连接存活判断) */
+  onHeartbeat?: (data: SseHeartbeatData) => void;
   /** 研判结构化产物(仅 /insight/continuous/stream 会触发) */
   onInsight?: (data: SseInsightData) => void;
   onToken?: (data: SseTokenData) => void;

+ 14 - 13
ai-electron/frontend/src/ai/components/ChatMessageItem.vue

@@ -57,16 +57,6 @@
             :message-id="message.id"
           />
 
-          <!-- 完全没有过程时才单独提示阶段,避免和时间线的汇总行重复 -->
-          <StreamStatusBar
-            v-if="message.streaming && !hasTrace"
-            :streaming="true"
-            :intent="message.intent"
-            :steps="message.toolSteps"
-            :has-content="hasContent"
-            :thinking-active="!!message.thinkingActive"
-          />
-
           <div v-if="message.messageType === 'error'" class="ai-msg__error">
             <Icon class="ai-msg__error-icon" icon="mdi:alert-circle-outline" :size="16" />
             <span>{{ message.content || '本次请求失败,请稍后重试' }}</span>
@@ -95,6 +85,20 @@
               <div class="ai-skeleton" :style="{ height: '14px', width: '88%' }"></div>
               <div class="ai-skeleton" :style="{ height: '14px', width: '46%' }"></div>
             </div>
+
+            <!-- 流式状态条放在正文之后:长回答输出到一半等待时,用户在阅读位置就能看到
+                 “正在思考 / 正在执行 / 连接正常但无新内容 / 心跳已断” -->
+            <StreamStatusBar
+              v-if="message.streaming"
+              :streaming="true"
+              :intent="message.intent"
+              :steps="message.toolSteps"
+              :has-content="hasContent"
+              :thinking-active="!!message.thinkingActive"
+              :stream-started-at="message.streamStartedAt"
+              :last-activity-at="message.lastActivityAt"
+              :last-heartbeat-at="message.lastHeartbeatAt"
+            />
           </template>
         </div>
 
@@ -176,9 +180,6 @@
     return parseGraphToolSteps(props.message.toolSteps, { includeLegacy: !hasLegacyFence });
   });
 
-  /** 是否存在过程记录(有思考或有工具调用)——决定是否显示时间线、是否还需要独立阶段提示 */
-  const hasTrace = computed(() => !!props.message.thinking?.trim() || !!props.message.toolSteps?.length);
-
   const timeText = computed(() => {
     const raw = props.message.createAt;
     if (!raw) return '';

+ 161 - 8
ai-electron/frontend/src/ai/components/StreamStatusBar.vue

@@ -1,8 +1,8 @@
 <template>
-  <div class="ai-status">
+  <div class="ai-status" :class="statusClass">
     <span class="ai-status__dot"></span>
     <span class="ai-status__phase">{{ phaseText }}</span>
-    <span class="ai-status__elapsed">{{ elapsed }}s</span>
+    <span class="ai-status__elapsed">{{ elapsedText }}</span>
 
     <template v-if="intent?.intent">
       <span class="ai-status__sep"></span>
@@ -11,6 +11,16 @@
         <span class="ai-status__hint">意图详情</span>
       </a-tooltip>
     </template>
+
+    <template v-if="waitText">
+      <span class="ai-status__sep"></span>
+      <span class="ai-status__wait" :title="waitText">{{ waitText }}</span>
+    </template>
+
+    <span v-if="!waitText" class="ai-status__sp"></span>
+    <a-tooltip v-if="detail" placement="topRight" :title="detail">
+      <span class="ai-status__hint">状态详情</span>
+    </a-tooltip>
   </div>
 </template>
 
@@ -18,8 +28,11 @@
   /**
    * 流式状态条
    *
-   * 让用户随时知道 AI 当前处于哪个阶段(识别意图 / 调用工具 / 生成回答),
-   * 并展示意图识别结果与耗时 —— 避免长任务期间界面"看起来卡住了"。
+   * 让用户随时知道 AI 当前处于哪个阶段(识别意图 / 思考 / 调用工具 / 生成回答),
+   * 并区分三种“看起来没动”的情况:
+   * 1. 模型正在思考/工具正在执行 —— 有最近活动时间,继续等待;
+   * 2. 连接正常但模型暂未输出 —— 服务端心跳还在,说明不是服务挂了;
+   * 3. 心跳也停了 —— 连接/服务可能已中断,提示用户停止后重试。
    */
   import { computed, onBeforeUnmount, ref, watch } from 'vue';
   import { Tag, Tooltip } from 'ant-design-vue';
@@ -29,6 +42,13 @@
   const ATag = Tag;
   const ATooltip = Tooltip;
 
+  /** 超过多少秒没有模型/工具输出,进入“等待”状态 */
+  const WAIT_SEC = 5;
+  /** 超过多少秒没有服务端心跳,判定连接可能已断 */
+  const HEARTBEAT_STALE_SEC = 30;
+  /** 没有心跳能力时(旧后端),超过多少秒无输出判定可能卡住 */
+  const NO_HEARTBEAT_STALL_SEC = 60;
+
   const props = defineProps<{
     streaming: boolean;
     intent?: SseIntentData;
@@ -37,15 +57,26 @@
     hasContent?: boolean;
     /** 模型是否正在输出思考过程 */
     thinkingActive?: boolean;
+    /** 流开始时间(毫秒) */
+    streamStartedAt?: number;
+    /** 最近一次模型/工具事件时间(毫秒,不含心跳) */
+    lastActivityAt?: number;
+    /** 最近一次服务端心跳时间(毫秒) */
+    lastHeartbeatAt?: number;
   }>();
 
-  const elapsed = ref(0);
+  const now = ref(Date.now());
+  const fallbackElapsed = ref(0);
   let timer: ReturnType<typeof setInterval> | null = null;
 
   function startTimer() {
-    elapsed.value = 0;
+    fallbackElapsed.value = 0;
+    now.value = Date.now();
     stopTimer();
-    timer = setInterval(() => (elapsed.value += 1), 1000);
+    timer = setInterval(() => {
+      fallbackElapsed.value += 1;
+      now.value = Date.now();
+    }, 1000);
   }
 
   function stopTimer() {
@@ -63,17 +94,96 @@
 
   onBeforeUnmount(stopTimer);
 
+  const elapsedSec = computed(() => {
+    if (props.streamStartedAt) {
+      return Math.max(0, Math.floor((now.value - props.streamStartedAt) / 1000));
+    }
+    return fallbackElapsed.value;
+  });
+
+  const elapsedText = computed(() => {
+    const s = elapsedSec.value;
+    if (s <= 0) return '';
+    return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
+  });
+
+  /** 距离最近一次模型/工具活动的秒数;心跳不计入,避免“心跳在、模型没动”被误判为有输出 */
+  const idleSec = computed(() => {
+    const from = props.lastActivityAt || props.streamStartedAt;
+    if (!from) return 0;
+    return Math.max(0, Math.floor((now.value - from) / 1000));
+  });
+
+  /** 距离最近一次服务端心跳的秒数;null = 本次流还没收到过心跳 */
+  const heartbeatAgeSec = computed<number | null>(() => {
+    if (!props.lastHeartbeatAt) return null;
+    return Math.max(0, Math.floor((now.value - props.lastHeartbeatAt) / 1000));
+  });
+
   const runningTool = computed(() => props.steps?.find((s) => s.status === 'running'));
 
+  const statusLevel = computed<'active' | 'waiting' | 'stalled'>(() => {
+    if (!props.streaming) return 'active';
+    const hb = heartbeatAgeSec.value;
+    // 心跳长时间不来:网络/服务大概率已断;有正文时尤其要提示,避免用户一直等。
+    // 用 idleSec 兜一层:如果还在持续吐 token,说明链路没断,不因单次心跳延迟误报。
+    if (hb !== null && hb >= HEARTBEAT_STALE_SEC && idleSec.value >= WAIT_SEC) return 'stalled';
+    // 旧后端没有心跳:长时间无任何事件才判定可能卡住
+    if (hb === null && idleSec.value >= NO_HEARTBEAT_STALL_SEC) return 'stalled';
+    if (idleSec.value >= WAIT_SEC) return 'waiting';
+    return 'active';
+  });
+
+  const statusClass = computed(() => ({
+    'is-waiting': statusLevel.value === 'waiting',
+    'is-stalled': statusLevel.value === 'stalled',
+  }));
+
   const phaseText = computed(() => {
     if (runningTool.value) return `正在执行:${toolLabel(runningTool.value.toolName)}`;
-    if (props.hasContent) return '正在生成回答';
     if (props.thinkingActive) return '正在深度思考';
+    if (props.hasContent && idleSec.value >= WAIT_SEC) return '等待模型继续输出';
+    if (props.hasContent) return '正在生成回答';
+    if (props.steps?.length && idleSec.value >= WAIT_SEC) return '等待工具返回';
     if (props.steps?.length) return '正在整理结果';
     if (props.intent) return '正在规划分析步骤';
+    if (idleSec.value >= WAIT_SEC) return '等待模型响应';
     return '正在理解问题';
   });
 
+  const waitText = computed(() => {
+    if (!props.streaming || idleSec.value < WAIT_SEC) return '';
+    const hb = heartbeatAgeSec.value;
+    if (statusLevel.value === 'stalled') {
+      if (hb !== null) {
+        return `已 ${hb}s 未收到服务心跳,连接或服务可能已中断,可停止后重试`;
+      }
+      return `已 ${idleSec.value}s 无新内容,服务可能无响应,可停止后重试`;
+    }
+    if (hb !== null && hb <= 15) {
+      return `连接正常,模型仍在处理(${idleSec.value}s 无新内容)`;
+    }
+    if (hb !== null) {
+      return `心跳 ${hb}s 前,等待模型响应(${idleSec.value}s 无新内容)`;
+    }
+    return `已 ${idleSec.value}s 无新内容,仍在等待模型响应`;
+  });
+
+  const detail = computed(() => {
+    const parts: string[] = [];
+    parts.push(`总耗时:${elapsedText.value || '0s'}`);
+    parts.push(`最近模型/工具活动:${idleSec.value}s 前`);
+    if (heartbeatAgeSec.value === null) {
+      parts.push('服务心跳:尚未收到');
+    } else {
+      parts.push(`服务心跳:${heartbeatAgeSec.value}s 前`);
+    }
+    if (props.lastActivityAt) {
+      parts.push(`最后活动时间:${new Date(props.lastActivityAt).toLocaleTimeString()}`);
+    }
+    return parts.join('\n');
+  });
+
   const shortIntent = computed(() => {
     const raw = props.intent?.intent || '';
     return raw.replace(/^《|》$/g, '').slice(0, 12) || '意图';
@@ -106,21 +216,25 @@
     }
 
     &__phase {
+      flex: 0 0 auto;
       color: var(--ai-text);
     }
 
     &__elapsed {
+      flex: 0 0 auto;
       font-variant-numeric: tabular-nums;
       opacity: 0.75;
     }
 
     &__sep {
+      flex: 0 0 auto;
       width: 1px;
       height: 12px;
       background: var(--ai-border);
     }
 
     &__tag {
+      flex: 0 0 auto;
       margin: 0;
       font-size: 11px;
       line-height: 18px;
@@ -128,12 +242,51 @@
       background: var(--ai-primary-soft);
     }
 
+    /* 等待/卡住提示:占用剩余宽度并省略,避免把状态条撑爆 */
+    &__wait {
+      flex: 1 1 auto;
+      min-width: 0;
+      overflow: hidden;
+      color: var(--ai-text-2);
+      font-size: 11px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__sp {
+      flex: 1 1 auto;
+    }
+
     &__hint {
+      flex: 0 0 auto;
       font-size: 11px;
       color: var(--ai-text-2);
       border-bottom: 1px dashed var(--ai-border-strong);
       cursor: help;
     }
+
+    &.is-waiting {
+      .ai-status__dot {
+        background: @warning-color;
+        animation-duration: 0.9s;
+      }
+
+      .ai-status__phase {
+        color: var(--ai-text);
+      }
+    }
+
+    &.is-stalled {
+      .ai-status__dot {
+        background: @error-color;
+        animation: none;
+      }
+
+      .ai-status__phase,
+      .ai-status__wait {
+        color: @error-color;
+      }
+    }
   }
 
   @keyframes ai-status-pulse {

+ 46 - 0
ai-electron/frontend/src/ai/store/chatStream.ts

@@ -63,6 +63,14 @@ export interface ChatMessage extends ChatMessageVO {
   suggestions?: string[];
   /** 是否正在流式输出 */
   streaming?: boolean;
+  /** 流开始时间(毫秒,仅本次流式) */
+  streamStartedAt?: number;
+  /** 最近一次模型/工具事件时间(毫秒,不含心跳;用于判断"在思考/输出"还是"在等") */
+  lastActivityAt?: number;
+  /** 最近一次服务端心跳时间(毫秒;用于区分"连接正常但模型没输出"和"连接已断") */
+  lastHeartbeatAt?: number;
+  /** 最近一次事件类型(排查/状态提示用) */
+  lastEventKind?: string;
   /** 附件徽标(用户气泡展示;落库后从 metadata.attachments 还原) */
   attachments?: import('../api/types').AttachmentBadge[];
 }
@@ -333,6 +341,9 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
         messageType: 'text',
         local: true,
         streaming: true,
+        streamStartedAt: Date.now(),
+        lastActivityAt: Date.now(),
+        lastEventKind: 'start',
         blocks: [],
         toolSteps: [],
         trace: [],
@@ -344,6 +355,18 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
       this.streaming = true;
       abortController = new AbortController();
 
+      /** 记录模型/工具侧活动时间;有心跳也不代表模型在输出,二者分开统计 */
+      const markActivity = (kind: string) => {
+        target.lastActivityAt = Date.now();
+        target.lastEventKind = kind;
+      };
+      const markHeartbeat = () => {
+        target.lastHeartbeatAt = Date.now();
+      };
+
+      /** 是否收到后端 done;用于识别“连接正常关闭但实际没跑完”的假完成 */
+      let doneReceived = false;
+
       /** 节流重扫富内容块 */
       const scheduleRescan = () => {
         if (rescanTimer) return;
@@ -373,12 +396,15 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
           },
           {
             onIntent: (data) => {
+              markActivity('intent');
               target.intent = data;
             },
             onSuggestions: (data) => {
+              markActivity('suggestions');
               target.suggestions = data.questions || [];
             },
             onThinking: (data) => {
+              markActivity('thinking');
               // start/delta/end 三段:start 表示新一段思考(段间补空行),delta 追加正文
               if (data.phase === 'start') {
                 if (target.thinking) target.thinking += '\n\n';
@@ -398,6 +424,7 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
               appendToLastThink(target, delta);
             },
             onToolCall: (data) => {
+              markActivity('tool_call');
               target.toolSteps = [
                 ...(target.toolSteps || []),
                 { id: data.toolCallId, toolName: data.toolName, status: 'running' },
@@ -406,12 +433,14 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
               target.trace = [...(target.trace || []), { kind: 'tool', toolCallId: data.toolCallId }];
             },
             onToolInput: (data) => {
+              markActivity('tool_input');
               const step = target.toolSteps?.find((s) => s.id === data.toolCallId);
               // 归一化成单层 JSON 文本:后端历史版本会多包一层字符串字面量,
               // 不归一化的话详情区 JSON.parse 得到 string,表格渲染不出来
               if (step) step.input = normalizeToolPayloadText(data.toolInput || '');
             },
             onToolResult: (data) => {
+              markActivity('tool_result');
               const step = target.toolSteps?.find((s) => s.id === data.toolCallId);
               if (step) {
                 step.result = normalizeToolPayloadText(data.toolResult || '');
@@ -420,17 +449,25 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
               }
             },
             onToken: (data) => {
+              markActivity('token');
               target.content += data.data || '';
               scheduleRescan();
             },
+            onHeartbeat: () => {
+              markHeartbeat();
+            },
             onDone: () => {
+              doneReceived = true;
+              markActivity('done');
               /* 收尾在 finally 统一处理 */
             },
             onError: (data) => {
+              markActivity('error');
               target.messageType = 'error';
               target.content = target.content || `⚠️ ${data.error || '请求失败'}`;
             },
             onTransportError: (err) => {
+              markActivity('transport_error');
               target.messageType = 'error';
               target.content = target.content || `⚠️ 网络异常:${(err as Error)?.message || err}`;
             },
@@ -439,10 +476,19 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
         );
       } catch (err: any) {
         if (err?.name !== 'AbortError') {
+          markActivity('error');
           target.messageType = 'error';
           target.content = target.content || `⚠️ ${err?.message || '请求失败'}`;
         }
       } finally {
+        // 连接被关闭但没有 done:不是正常完成,也不是后端显式 error 帧。
+        // 典型场景:服务进程挂了、网关掐断连接、接口提前 return。必须让用户看到,
+        // 不能把半截内容伪装成“已完成”。
+        if (!doneReceived && target.streaming && target.messageType !== 'error') {
+          target.messageType = 'interrupted';
+          target.lastEventKind = 'stream_incomplete';
+          target.content += `${target.content ? '\n\n' : ''}> ⚠️ 连接已结束但未收到完成信号,可能服务中断;以上为已接收内容,请重试。`;
+        }
         flushRescan();
         target.streaming = false;
         target.thinkingActive = false;

+ 15 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java

@@ -38,8 +38,10 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
 import reactor.core.scheduler.Schedulers;
 
+import java.time.Duration;
 import java.time.LocalDateTime;
 import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
@@ -427,7 +429,8 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         AtomicReference<StringBuilder> accumulatedThinking = process.thinking;
         AtomicBoolean resultPersisted = process.resultPersisted;
 
-        return events.concatMap(event -> {
+        Sinks.Empty<Void> heartbeatStop = Sinks.empty();
+        Flux<ServerSentEvent<String>> body = events.concatMap(event -> {
             List<ServerSentEvent<String>> frames = new ArrayList<>();
             if (event instanceof CustomEvent e && "intent".equals(e.getName())) {
                 // IntentMiddleware 发出的意图识别结果 → SSE intent 帧
@@ -521,7 +524,17 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
                             .subscribe();
                 }
             }
-        });
+        }).doFinally(signal -> heartbeatStop.tryEmitEmpty());
+
+        // SSE 心跳:模型/工具长时间没有输出时也持续给前端发帧,让前端能区分
+        // 「连接/服务正常、模型仍在处理」与「连接已断、服务无响应」。
+        // 心跳是独立 data 帧(event: heartbeat),不参与正文/思考/工具结果的累积。
+        Flux<ServerSentEvent<String>> heartbeats = Flux.interval(Duration.ofSeconds(10))
+                .map(i -> sse("heartbeat",
+                        Map.of("type", "heartbeat", "ts", System.currentTimeMillis())))
+                .takeUntilOther(heartbeatStop.asMono());
+
+        return Flux.merge(body, heartbeats);
     }
 
     /**