| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- <template>
- <div class="ai-status" :class="statusClass">
- <span class="ai-status__dot"></span>
- <span class="ai-status__phase">{{ phaseText }}</span>
- <span class="ai-status__elapsed">{{ elapsedText }}</span>
- <template v-if="intent?.intent">
- <span class="ai-status__sep"></span>
- <a-tag class="ai-status__tag" :bordered="false">{{ shortIntent }}</a-tag>
- <a-tooltip v-if="intentDetail" placement="topLeft" :title="intentDetail">
- <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>
- <script lang="ts" setup>
- /**
- * 流式状态条
- *
- * 让用户随时知道 AI 当前处于哪个阶段(识别意图 / 思考 / 调用工具 / 生成回答),
- * 并区分三种“看起来没动”的情况:
- * 1. 模型正在思考/工具正在执行 —— 有最近活动时间,继续等待;
- * 2. 连接正常但模型暂未输出 —— 服务端心跳还在,说明不是服务挂了;
- * 3. 心跳也停了 —— 连接/服务可能已中断,提示用户停止后重试。
- */
- import { computed, onBeforeUnmount, ref, watch } from 'vue';
- import { Tag, Tooltip } from 'ant-design-vue';
- import type { SseIntentData, ToolStep } from '../api/types';
- import { toolLabel } from '../utils/constants';
- 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;
- steps?: ToolStep[];
- /** 是否已开始输出正文 */
- hasContent?: boolean;
- /** 模型是否正在输出思考过程 */
- thinkingActive?: boolean;
- /** 流开始时间(毫秒) */
- streamStartedAt?: number;
- /** 最近一次模型/工具事件时间(毫秒,不含心跳) */
- lastActivityAt?: number;
- /** 最近一次服务端心跳时间(毫秒) */
- lastHeartbeatAt?: number;
- }>();
- const now = ref(Date.now());
- const fallbackElapsed = ref(0);
- let timer: ReturnType<typeof setInterval> | null = null;
- function startTimer() {
- fallbackElapsed.value = 0;
- now.value = Date.now();
- stopTimer();
- timer = setInterval(() => {
- fallbackElapsed.value += 1;
- now.value = Date.now();
- }, 1000);
- }
- function stopTimer() {
- if (timer) {
- clearInterval(timer);
- timer = null;
- }
- }
- watch(
- () => props.streaming,
- (val) => (val ? startTimer() : stopTimer()),
- { immediate: true },
- );
- 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.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) || '意图';
- });
- const intentDetail = computed(() => {
- const parts: string[] = [];
- if (props.intent?.enhanced) parts.push(`增强问题:${props.intent.enhanced}`);
- if (props.intent?.entities?.length) parts.push(`关键实体:${props.intent.entities.join('、')}`);
- return parts.join('\n');
- });
- </script>
- <style lang="less" scoped>
- .ai-status {
- display: flex;
- align-items: center;
- gap: var(--ai-sp-2);
- margin-bottom: var(--ai-sp-2);
- font-size: 12px;
- color: var(--ai-text-2);
- &__dot {
- flex: 0 0 auto;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: @primary-color;
- animation: ai-status-pulse 1.2s ease-in-out infinite;
- }
- &__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;
- color: @primary-color;
- 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 {
- 0%,
- 100% {
- opacity: 1;
- transform: scale(1);
- }
- 50% {
- opacity: 0.35;
- transform: scale(0.82);
- }
- }
- </style>
|