StreamStatusBar.vue 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <template>
  2. <div class="ai-status" :class="statusClass">
  3. <span class="ai-status__dot"></span>
  4. <span class="ai-status__phase">{{ phaseText }}</span>
  5. <span class="ai-status__elapsed">{{ elapsedText }}</span>
  6. <template v-if="intent?.intent">
  7. <span class="ai-status__sep"></span>
  8. <a-tag class="ai-status__tag" :bordered="false">{{ shortIntent }}</a-tag>
  9. <a-tooltip v-if="intentDetail" placement="topLeft" :title="intentDetail">
  10. <span class="ai-status__hint">意图详情</span>
  11. </a-tooltip>
  12. </template>
  13. <template v-if="waitText">
  14. <span class="ai-status__sep"></span>
  15. <span class="ai-status__wait" :title="waitText">{{ waitText }}</span>
  16. </template>
  17. <span v-if="!waitText" class="ai-status__sp"></span>
  18. <a-tooltip v-if="detail" placement="topRight" :title="detail">
  19. <span class="ai-status__hint">状态详情</span>
  20. </a-tooltip>
  21. </div>
  22. </template>
  23. <script lang="ts" setup>
  24. /**
  25. * 流式状态条
  26. *
  27. * 让用户随时知道 AI 当前处于哪个阶段(识别意图 / 思考 / 调用工具 / 生成回答),
  28. * 并区分三种“看起来没动”的情况:
  29. * 1. 模型正在思考/工具正在执行 —— 有最近活动时间,继续等待;
  30. * 2. 连接正常但模型暂未输出 —— 服务端心跳还在,说明不是服务挂了;
  31. * 3. 心跳也停了 —— 连接/服务可能已中断,提示用户停止后重试。
  32. */
  33. import { computed, onBeforeUnmount, ref, watch } from 'vue';
  34. import { Tag, Tooltip } from 'ant-design-vue';
  35. import type { SseIntentData, ToolStep } from '../api/types';
  36. import { toolLabel } from '../utils/constants';
  37. const ATag = Tag;
  38. const ATooltip = Tooltip;
  39. /** 超过多少秒没有模型/工具输出,进入“等待”状态 */
  40. const WAIT_SEC = 5;
  41. /** 超过多少秒没有服务端心跳,判定连接可能已断 */
  42. const HEARTBEAT_STALE_SEC = 30;
  43. /** 没有心跳能力时(旧后端),超过多少秒无输出判定可能卡住 */
  44. const NO_HEARTBEAT_STALL_SEC = 60;
  45. const props = defineProps<{
  46. streaming: boolean;
  47. intent?: SseIntentData;
  48. steps?: ToolStep[];
  49. /** 是否已开始输出正文 */
  50. hasContent?: boolean;
  51. /** 模型是否正在输出思考过程 */
  52. thinkingActive?: boolean;
  53. /** 流开始时间(毫秒) */
  54. streamStartedAt?: number;
  55. /** 最近一次模型/工具事件时间(毫秒,不含心跳) */
  56. lastActivityAt?: number;
  57. /** 最近一次服务端心跳时间(毫秒) */
  58. lastHeartbeatAt?: number;
  59. }>();
  60. const now = ref(Date.now());
  61. const fallbackElapsed = ref(0);
  62. let timer: ReturnType<typeof setInterval> | null = null;
  63. function startTimer() {
  64. fallbackElapsed.value = 0;
  65. now.value = Date.now();
  66. stopTimer();
  67. timer = setInterval(() => {
  68. fallbackElapsed.value += 1;
  69. now.value = Date.now();
  70. }, 1000);
  71. }
  72. function stopTimer() {
  73. if (timer) {
  74. clearInterval(timer);
  75. timer = null;
  76. }
  77. }
  78. watch(
  79. () => props.streaming,
  80. (val) => (val ? startTimer() : stopTimer()),
  81. { immediate: true },
  82. );
  83. onBeforeUnmount(stopTimer);
  84. const elapsedSec = computed(() => {
  85. if (props.streamStartedAt) {
  86. return Math.max(0, Math.floor((now.value - props.streamStartedAt) / 1000));
  87. }
  88. return fallbackElapsed.value;
  89. });
  90. const elapsedText = computed(() => {
  91. const s = elapsedSec.value;
  92. if (s <= 0) return '';
  93. return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
  94. });
  95. /** 距离最近一次模型/工具活动的秒数;心跳不计入,避免“心跳在、模型没动”被误判为有输出 */
  96. const idleSec = computed(() => {
  97. const from = props.lastActivityAt || props.streamStartedAt;
  98. if (!from) return 0;
  99. return Math.max(0, Math.floor((now.value - from) / 1000));
  100. });
  101. /** 距离最近一次服务端心跳的秒数;null = 本次流还没收到过心跳 */
  102. const heartbeatAgeSec = computed<number | null>(() => {
  103. if (!props.lastHeartbeatAt) return null;
  104. return Math.max(0, Math.floor((now.value - props.lastHeartbeatAt) / 1000));
  105. });
  106. const runningTool = computed(() => props.steps?.find((s) => s.status === 'running'));
  107. const statusLevel = computed<'active' | 'waiting' | 'stalled'>(() => {
  108. if (!props.streaming) return 'active';
  109. const hb = heartbeatAgeSec.value;
  110. // 心跳长时间不来:网络/服务大概率已断;有正文时尤其要提示,避免用户一直等。
  111. // 用 idleSec 兜一层:如果还在持续吐 token,说明链路没断,不因单次心跳延迟误报。
  112. if (hb !== null && hb >= HEARTBEAT_STALE_SEC && idleSec.value >= WAIT_SEC) return 'stalled';
  113. // 旧后端没有心跳:长时间无任何事件才判定可能卡住
  114. if (hb === null && idleSec.value >= NO_HEARTBEAT_STALL_SEC) return 'stalled';
  115. if (idleSec.value >= WAIT_SEC) return 'waiting';
  116. return 'active';
  117. });
  118. const statusClass = computed(() => ({
  119. 'is-waiting': statusLevel.value === 'waiting',
  120. 'is-stalled': statusLevel.value === 'stalled',
  121. }));
  122. const phaseText = computed(() => {
  123. if (runningTool.value) return `正在执行:${toolLabel(runningTool.value.toolName)}`;
  124. if (props.thinkingActive) return '正在深度思考';
  125. if (props.hasContent && idleSec.value >= WAIT_SEC) return '等待模型继续输出';
  126. if (props.hasContent) return '正在生成回答';
  127. if (props.steps?.length && idleSec.value >= WAIT_SEC) return '等待工具返回';
  128. if (props.steps?.length) return '正在整理结果';
  129. if (props.intent) return '正在规划分析步骤';
  130. if (idleSec.value >= WAIT_SEC) return '等待模型响应';
  131. return '正在理解问题';
  132. });
  133. const waitText = computed(() => {
  134. if (!props.streaming || idleSec.value < WAIT_SEC) return '';
  135. const hb = heartbeatAgeSec.value;
  136. if (statusLevel.value === 'stalled') {
  137. if (hb !== null) {
  138. return `已 ${hb}s 未收到服务心跳,连接或服务可能已中断,可停止后重试`;
  139. }
  140. return `已 ${idleSec.value}s 无新内容,服务可能无响应,可停止后重试`;
  141. }
  142. if (hb !== null && hb <= 15) {
  143. return `连接正常,模型仍在处理(${idleSec.value}s 无新内容)`;
  144. }
  145. if (hb !== null) {
  146. return `心跳 ${hb}s 前,等待模型响应(${idleSec.value}s 无新内容)`;
  147. }
  148. return `已 ${idleSec.value}s 无新内容,仍在等待模型响应`;
  149. });
  150. const detail = computed(() => {
  151. const parts: string[] = [];
  152. parts.push(`总耗时:${elapsedText.value || '0s'}`);
  153. parts.push(`最近模型/工具活动:${idleSec.value}s 前`);
  154. if (heartbeatAgeSec.value === null) {
  155. parts.push('服务心跳:尚未收到');
  156. } else {
  157. parts.push(`服务心跳:${heartbeatAgeSec.value}s 前`);
  158. }
  159. if (props.lastActivityAt) {
  160. parts.push(`最后活动时间:${new Date(props.lastActivityAt).toLocaleTimeString()}`);
  161. }
  162. return parts.join('\n');
  163. });
  164. const shortIntent = computed(() => {
  165. const raw = props.intent?.intent || '';
  166. return raw.replace(/^《|》$/g, '').slice(0, 12) || '意图';
  167. });
  168. const intentDetail = computed(() => {
  169. const parts: string[] = [];
  170. if (props.intent?.enhanced) parts.push(`增强问题:${props.intent.enhanced}`);
  171. if (props.intent?.entities?.length) parts.push(`关键实体:${props.intent.entities.join('、')}`);
  172. return parts.join('\n');
  173. });
  174. </script>
  175. <style lang="less" scoped>
  176. .ai-status {
  177. display: flex;
  178. align-items: center;
  179. gap: var(--ai-sp-2);
  180. margin-bottom: var(--ai-sp-2);
  181. font-size: 12px;
  182. color: var(--ai-text-2);
  183. &__dot {
  184. flex: 0 0 auto;
  185. width: 6px;
  186. height: 6px;
  187. border-radius: 50%;
  188. background: @primary-color;
  189. animation: ai-status-pulse 1.2s ease-in-out infinite;
  190. }
  191. &__phase {
  192. flex: 0 0 auto;
  193. color: var(--ai-text);
  194. }
  195. &__elapsed {
  196. flex: 0 0 auto;
  197. font-variant-numeric: tabular-nums;
  198. opacity: 0.75;
  199. }
  200. &__sep {
  201. flex: 0 0 auto;
  202. width: 1px;
  203. height: 12px;
  204. background: var(--ai-border);
  205. }
  206. &__tag {
  207. flex: 0 0 auto;
  208. margin: 0;
  209. font-size: 11px;
  210. line-height: 18px;
  211. color: @primary-color;
  212. background: var(--ai-primary-soft);
  213. }
  214. /* 等待/卡住提示:占用剩余宽度并省略,避免把状态条撑爆 */
  215. &__wait {
  216. flex: 1 1 auto;
  217. min-width: 0;
  218. overflow: hidden;
  219. color: var(--ai-text-2);
  220. font-size: 11px;
  221. white-space: nowrap;
  222. text-overflow: ellipsis;
  223. }
  224. &__sp {
  225. flex: 1 1 auto;
  226. }
  227. &__hint {
  228. flex: 0 0 auto;
  229. font-size: 11px;
  230. color: var(--ai-text-2);
  231. border-bottom: 1px dashed var(--ai-border-strong);
  232. cursor: help;
  233. }
  234. &.is-waiting {
  235. .ai-status__dot {
  236. background: @warning-color;
  237. animation-duration: 0.9s;
  238. }
  239. .ai-status__phase {
  240. color: var(--ai-text);
  241. }
  242. }
  243. &.is-stalled {
  244. .ai-status__dot {
  245. background: @error-color;
  246. animation: none;
  247. }
  248. .ai-status__phase,
  249. .ai-status__wait {
  250. color: @error-color;
  251. }
  252. }
  253. }
  254. @keyframes ai-status-pulse {
  255. 0%,
  256. 100% {
  257. opacity: 1;
  258. transform: scale(1);
  259. }
  260. 50% {
  261. opacity: 0.35;
  262. transform: scale(0.82);
  263. }
  264. }
  265. </style>