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:
JOJO 2026-08-26 15:11:18 +08:00
parent 6fb5d10542
commit 4cf9c38137
10 changed files with 125 additions and 12 deletions

View File

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

View File

@ -92,7 +92,8 @@ const appOptions = {
chatAppendTextChunk: 'appendTextChunk',
chatCompleteTextAction: 'completeText',
chatAddSystemMessage: 'addSystemMessage',
chatEnsureAssistantMessage: 'ensureAssistantMessage'
chatEnsureAssistantMessage: 'ensureAssistantMessage',
chatClearStreamingResidualState: 'clearStreamingResidualState'
}),
...mapActions(useInputStore, {
inputSetFocused: 'setInputFocused',

View File

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

View File

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

View File

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

View File

@ -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');
}

View File

@ -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 间隙误清)

View File

@ -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}`;
});

View File

@ -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();
}

View File

@ -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 消息渲染,
// 避免运行时与刷新后在垂直间距上不一致。