feat(avatar): 状态头像新增「等待 API 响应」并修复异常中断状态卡住
- 后端每次发起 API 请求前(含每次重试)发 api_request_start 事件 - 前端新增 apiRequestPending 状态,轮询/socket 双链路维护, 响应开始(thinking_start/text_start/tool_preparing)或任务终结时清除 - InputComposer 头像旁显示固定文案「等待 API 响应…」(work+apiWaiting 标记) - chat store 新增 clearStreamingResidualState 统一清理流式残留字段, 接入全部终结路径(handleTaskError/Stopped/Complete、运行状态对账、 resetAllStates、socket error),修复断网/停止后形象卡「思考中」 - handleTaskError 补 clearPendingTools 与空占位消息清理 - avatarStatus isThinking 增加运行态防御,双保险防状态卡死
This commit is contained in:
parent
6fb5d10542
commit
4cf9c38137
@ -41,6 +41,14 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
|
||||
last_finish_reason = None
|
||||
tool_call_stream_active = False
|
||||
|
||||
# 通知前端:API 请求已发出、尚未收到首个响应(每次重试前都会重新触发)。
|
||||
# 前端据此驱动状态形象的「等待 API 响应…」文案;响应开始(thinking_start/
|
||||
# text_start/tool_preparing)或 error/任务终结时由前端清除。
|
||||
sender('api_request_start', {
|
||||
'attempt': api_attempt + 1,
|
||||
'max_attempts': max_api_retries + 1,
|
||||
})
|
||||
|
||||
# 收集流式响应
|
||||
async for chunk in web_terminal.api_client.chat(messages, tools, stream=True):
|
||||
chunk_count += 1
|
||||
|
||||
@ -92,7 +92,8 @@ const appOptions = {
|
||||
chatAppendTextChunk: 'appendTextChunk',
|
||||
chatCompleteTextAction: 'completeText',
|
||||
chatAddSystemMessage: 'addSystemMessage',
|
||||
chatEnsureAssistantMessage: 'ensureAssistantMessage'
|
||||
chatEnsureAssistantMessage: 'ensureAssistantMessage',
|
||||
chatClearStreamingResidualState: 'clearStreamingResidualState'
|
||||
}),
|
||||
...mapActions(useInputStore, {
|
||||
inputSetFocused: 'setInputFocused',
|
||||
|
||||
@ -367,9 +367,11 @@ export const computed = {
|
||||
|
||||
const intentEnabled = !!usePersonalizationStore().form?.tool_intent_enabled;
|
||||
|
||||
// 1) 思考中
|
||||
// 1) 思考中(防御:仅在任务仍运行时采信流式字段——异常中断后字段可能残留,
|
||||
// 虽有各终结路径的统一清理兑底,这里再加一层保险避免永久卡「思考中」)
|
||||
const isThinking =
|
||||
!!lastAssistant &&
|
||||
(this.taskInProgress || this.streamingMessage) &&
|
||||
(lastAssistant.currentStreamingType === 'thinking' || lastAssistant.activeThinkingId != null);
|
||||
|
||||
// 2) 工具执行中(取最后一段并行工具,过滤未完成的)
|
||||
@ -438,6 +440,11 @@ export const computed = {
|
||||
if (running) {
|
||||
// 工作态:等 API / 后台运行 / 流式输出正文
|
||||
let text = bgText;
|
||||
// 「等待 API 响应…」优先于随机等待文案:后端已发出请求、尚未开始回复
|
||||
// (api_request_start 事件驱动,覆盖首轮与工具轮次间的每一次等待)
|
||||
if (!text && this.apiRequestPending) {
|
||||
return { mode: 'work', toolKeys: [], toolTexts: [], text: '等待 API 响应…', tracking: false, apiWaiting: true };
|
||||
}
|
||||
if (!text) {
|
||||
const awaitingMsg = lastAssistant && lastAssistant.awaitingFirstContent ? lastAssistant : null;
|
||||
if (awaitingMsg) {
|
||||
|
||||
@ -25,6 +25,11 @@ export const stateMethods = {
|
||||
});
|
||||
|
||||
// 重置消息和流状态
|
||||
// 先清消息内残留的流式字段(currentStreamingType/activeThinkingId/streaming
|
||||
// action),否则异常中断后状态头像会一直卡在「思考中」(avatarStatus 的
|
||||
// isThinking 只认这两个字段,与任务标志无关)
|
||||
this.chatClearStreamingResidualState();
|
||||
this.apiRequestPending = false;
|
||||
this.streamingMessage = false;
|
||||
this.currentMessageIndex = -1;
|
||||
this.stopRequested = false;
|
||||
|
||||
@ -183,8 +183,28 @@ export const lifecycleMethods = {
|
||||
|
||||
debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData);
|
||||
|
||||
// 「等待 API 响应」状态维护:api_request_start 置位;任何「响应开始」
|
||||
// (thinking_start/text_start/tool_preparing)或任务终结信号都清除。
|
||||
// 历史事件重放时按序经过此处,最终状态自然收敛正确。
|
||||
if (eventType === 'api_request_start') {
|
||||
this.apiRequestPending = true;
|
||||
} else if (
|
||||
eventType === 'thinking_start' ||
|
||||
eventType === 'text_start' ||
|
||||
eventType === 'tool_preparing' ||
|
||||
eventType === 'task_complete' ||
|
||||
eventType === 'task_stopped' ||
|
||||
eventType === 'error'
|
||||
) {
|
||||
this.apiRequestPending = false;
|
||||
}
|
||||
|
||||
// 根据事件类型调用对应的处理方法
|
||||
switch (eventType) {
|
||||
case 'api_request_start':
|
||||
// 状态已在上方统一维护,无其他处理
|
||||
break;
|
||||
|
||||
case 'ai_message_start':
|
||||
this.handleAiMessageStart(eventData, eventIdx);
|
||||
break;
|
||||
@ -426,6 +446,9 @@ export const lifecycleMethods = {
|
||||
// 同步处理状态更新
|
||||
this.streamingMessage = false;
|
||||
this.stopRequested = false;
|
||||
// 兜底清理可能残留的流式状态(正常流程 thinking_end/text_end 已清,此处幂等)
|
||||
this.chatClearStreamingResidualState?.();
|
||||
this.apiRequestPending = false;
|
||||
if (!hasRunningSubAgents && !hasRunningMultiAgent) {
|
||||
this.markLatestUserWorkCompleted();
|
||||
}
|
||||
@ -526,6 +549,8 @@ export const lifecycleMethods = {
|
||||
});
|
||||
|
||||
this.cleanupTrailingEmptyAssistantPlaceholder('task_stopped');
|
||||
this.chatClearStreamingResidualState?.();
|
||||
this.apiRequestPending = false;
|
||||
this.streamingMessage = false;
|
||||
this.stopRequested = false;
|
||||
|
||||
@ -668,7 +693,13 @@ export const lifecycleMethods = {
|
||||
duration: 8000
|
||||
});
|
||||
|
||||
// 清理状态
|
||||
// 清理状态(顺序:先清依赖 awaitingFirstContent 判定的空占位,再清残留流式字段)
|
||||
this.cleanupTrailingEmptyAssistantPlaceholder?.('task_error');
|
||||
this.chatClearStreamingResidualState?.();
|
||||
if (typeof this.clearPendingTools === 'function') {
|
||||
this.clearPendingTools('task_error');
|
||||
}
|
||||
this.apiRequestPending = false;
|
||||
this.markLatestUserWorkCompleted();
|
||||
this.streamingMessage = false;
|
||||
this.taskInProgress = false;
|
||||
|
||||
@ -146,6 +146,9 @@ export const probeMethods = {
|
||||
this.stopRequested = false;
|
||||
this.waitingForSubAgent = false;
|
||||
this.waitingForBackgroundCommand = false;
|
||||
this.apiRequestPending = false;
|
||||
// 同步清理消息内残留的流式状态(思考/文本),避免状态头像卡在「思考中」
|
||||
this.chatClearStreamingResidualState?.();
|
||||
if (typeof this.clearPendingTools === 'function') {
|
||||
this.clearPendingTools('reconcile_auto_clear');
|
||||
}
|
||||
|
||||
@ -80,6 +80,10 @@ export function dataState() {
|
||||
toolStacks: new Map(),
|
||||
// 当前任务是否仍在进行中(用于保持输入区的"停止"状态)
|
||||
taskInProgress: false,
|
||||
// 等待 API 响应:对话运行期间后端已发出 API 请求、尚未收到首个响应事件
|
||||
// (由后端 api_request_start 事件置位,thinking_start/text_start/tool_preparing/
|
||||
// error/任务终结时清除),用于状态头像显示「等待 API 响应…」
|
||||
apiRequestPending: false,
|
||||
// 对话运行状态对账定时器(事件为主、2.5s 对账纠偏,冲突以对账为准)
|
||||
runningStateReconcileTimer: null,
|
||||
// 对账清理方向的连续空闲确认计数(防 notice/idle dispatch 间隙误清)
|
||||
|
||||
@ -818,6 +818,8 @@ const props = defineProps<{
|
||||
toolTexts?: string[];
|
||||
text: string;
|
||||
tracking?: boolean;
|
||||
/** true 时表示当前文案是「等待 API 响应…」(work 模式下仅此类文案显示在头像旁) */
|
||||
apiWaiting?: boolean;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
@ -1074,6 +1076,11 @@ const composerAvatarText = computed(() => {
|
||||
if (props.avatarStatus.mode === 'tool' || props.avatarStatus.mode === 'think') {
|
||||
return props.avatarStatus.text || '';
|
||||
}
|
||||
// work 模式仅显示「等待 API 响应…」(apiWaiting 标记);
|
||||
// 后台计数/随机等待文案不在头像旁重复显示(后者已在消息区等待动画中体现)
|
||||
if (props.avatarStatus.mode === 'work' && props.avatarStatus.apiWaiting) {
|
||||
return props.avatarStatus.text || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@ -1083,6 +1090,7 @@ const composerAvatarTextKey = computed(() => {
|
||||
const s = props.avatarStatus;
|
||||
if (!s) return 'avatar-none';
|
||||
if (s.mode === 'tool') return `avatar-tool-${s.toolKeys?.[0] || ''}`;
|
||||
if (s.mode === 'work' && s.apiWaiting) return 'avatar-work-api-waiting';
|
||||
return `avatar-${s.mode}`;
|
||||
});
|
||||
|
||||
|
||||
@ -1127,6 +1127,18 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
}
|
||||
});
|
||||
|
||||
// API 请求已发出、尚未收到首个响应:状态头像显示「等待 API 响应…」
|
||||
ctx.socket.on('api_request_start', (data) => {
|
||||
if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) {
|
||||
return;
|
||||
}
|
||||
// 轮询模式下该状态由任务事件流维护
|
||||
if (ctx.usePollingMode && !ctx.waitingForSubAgent) {
|
||||
return;
|
||||
}
|
||||
ctx.apiRequestPending = true;
|
||||
});
|
||||
|
||||
// 思考流开始
|
||||
ctx.socket.on('thinking_start', (data) => {
|
||||
// 对话定向事件:只响应当前对话,避免运行中对话的流式输出渲染到 /new 等空白页。
|
||||
@ -1140,6 +1152,8 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
return;
|
||||
}
|
||||
socketLog('思考开始');
|
||||
// 响应已开始,清除「等待 API 响应」状态(含 ignoreThinking 分支,故放最前)
|
||||
ctx.apiRequestPending = false;
|
||||
const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false;
|
||||
streamingState.ignoreThinking = ignoreThinking;
|
||||
if (ignoreThinking) {
|
||||
@ -1227,6 +1241,7 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
return;
|
||||
}
|
||||
socketLog('文本开始');
|
||||
ctx.apiRequestPending = false;
|
||||
logStreamingDebug('socket:text_start');
|
||||
finalizeStreamingText({ force: true });
|
||||
resetStreamingBuffer();
|
||||
@ -1354,6 +1369,8 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id);
|
||||
return;
|
||||
}
|
||||
// 工具流式收集即 API 响应已开始,清除「等待 API 响应」状态
|
||||
ctx.apiRequestPending = false;
|
||||
const msg = ctx.chatEnsureAssistantMessage();
|
||||
if (!msg) {
|
||||
return;
|
||||
@ -1861,7 +1878,9 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
}
|
||||
}
|
||||
if (shouldRetry) {
|
||||
// 错误后保持停止按钮态,用户可手动停止或等待自动重试
|
||||
// 错误后保持停止按钮态,用户可手动停止或等待自动重试;
|
||||
// 重试间隔不算「等待 API 响应」,重试发起时后端会重新发 api_request_start
|
||||
ctx.apiRequestPending = false;
|
||||
ctx.stopRequested = false;
|
||||
ctx.taskInProgress = true;
|
||||
ctx.streamingMessage = true;
|
||||
@ -1874,15 +1893,13 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
);
|
||||
}
|
||||
|
||||
// 最后一次报错:恢复输入状态并清理提示动画
|
||||
const msgIndex = typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : -1;
|
||||
if (msgIndex >= 0 && Array.isArray(ctx.messages)) {
|
||||
const currentMessage = ctx.messages[msgIndex];
|
||||
if (currentMessage && currentMessage.role === 'assistant') {
|
||||
currentMessage.awaitingFirstContent = false;
|
||||
currentMessage.generatingLabel = '';
|
||||
}
|
||||
// 最后一次报错:恢复输入状态并清理提示动画/流式残留
|
||||
// (统一清理 currentStreamingType/activeThinkingId 等字段,
|
||||
// 否则 avatarStatus 的 isThinking 永久为真,状态头像卡在「思考中」)
|
||||
if (typeof ctx.chatClearStreamingResidualState === 'function') {
|
||||
ctx.chatClearStreamingResidualState();
|
||||
}
|
||||
ctx.apiRequestPending = false;
|
||||
if (typeof ctx.chatClearThinkingLocks === 'function') {
|
||||
ctx.chatClearThinkingLocks();
|
||||
}
|
||||
|
||||
@ -388,6 +388,35 @@ export const useChatStore = defineStore('chat', {
|
||||
msg.currentStreamingType = null;
|
||||
delete (msg as any).__splitByShowHtml;
|
||||
},
|
||||
// 清理异常中断(断网 / API 错误 / 任务停止)残留的流式状态。
|
||||
// 正常流程由 completeThinking/completeText 收尾;异常路径不会有
|
||||
// thinking_end/text_end,残留的 currentStreamingType/activeThinkingId
|
||||
// 会让状态头像(avatarStatus)的 isThinking 永久为真,卡在「思考中」。
|
||||
// 注意:若需同时清理空占位 assistant 消息,必须先调
|
||||
// cleanupTrailingEmptyAssistantPlaceholder(它依赖 awaitingFirstContent
|
||||
// 判定占位),再调本方法。
|
||||
clearStreamingResidualState() {
|
||||
for (const msg of this.messages) {
|
||||
if (!msg || msg.role !== 'assistant') continue;
|
||||
msg.currentStreamingType = null;
|
||||
msg.activeThinkingId = null;
|
||||
msg.streamingThinking = '';
|
||||
msg.streamingText = '';
|
||||
msg.awaitingFirstContent = false;
|
||||
msg.generatingLabel = '';
|
||||
if (Array.isArray(msg.actions)) {
|
||||
for (const action of msg.actions) {
|
||||
if (
|
||||
action &&
|
||||
(action.type === 'thinking' || action.type === 'text') &&
|
||||
action.streaming === true
|
||||
) {
|
||||
action.streaming = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
addSystemMessage(content: string, meta: any = null) {
|
||||
// 与历史重建保持一致:子智能体/后台完成通知作为独立 assistant 消息渲染,
|
||||
// 避免运行时与刷新后在垂直间距上不一致。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user