|
|
@@ -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 {
|