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