fix(frontend): 修复刷新后AI等待提示与终端侧边栏重复问题

- 修复模型调用工具后刷新页面,AI回复头部等待提示文字再次出现的bug
  - restoreTaskState 根据 hasAssistantContentEvent 精确设置 awaitingFirstContent
  - handleAiMessageStart 从头重建时保留 restoreTaskState 的设置
  - handleToolStart 兜底清除 awaitingFirstContent

- 修复刷新页面后终端侧边栏已有内容重复显示绿色箭头与输入内容的bug
  - 后端 get_snapshot/get_terminal_output 返回 last_event_time
  - 前端 TerminalPanel 基于 last_event_time 对 terminal_output 事件去重
This commit is contained in:
JOJO 2026-06-22 20:35:00 +08:00
parent 2eed4d5b86
commit e90610ed05
7 changed files with 60 additions and 6 deletions

View File

@ -130,9 +130,16 @@ class LifecycleMixin:
"seconds_since_last_output": self._seconds_since_last_output(), "seconds_since_last_output": self._seconds_since_last_output(),
"last_command": self.last_command, "last_command": self.last_command,
"buffer_size": self.total_output_size, "buffer_size": self.total_output_size,
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat(),
"last_event_time": self._get_last_event_time()
} }
def _get_last_event_time(self) -> Optional[float]:
"""获取 io_history 中最后一条事件的时间戳,用于前端去重"""
if self.io_history:
return self.io_history[-1][1]
return None
def get_status(self) -> Dict: def get_status(self) -> Dict:
"""获取终端状态""" """获取终端状态"""
return { return {

View File

@ -672,7 +672,8 @@ class TerminalManager:
"seconds_since_last_output": snapshot.get("seconds_since_last_output", terminal._seconds_since_last_output()), "seconds_since_last_output": snapshot.get("seconds_since_last_output", terminal._seconds_since_last_output()),
"echo_loop_detected": snapshot.get("echo_loop_detected", terminal.echo_loop_detected), "echo_loop_detected": snapshot.get("echo_loop_detected", terminal.echo_loop_detected),
"lines_returned": snapshot.get("lines_returned"), "lines_returned": snapshot.get("lines_returned"),
"truncated": snapshot.get("truncated", False) "truncated": snapshot.get("truncated", False),
"last_event_time": snapshot.get("last_event_time")
} }
def get_terminal_snapshot( def get_terminal_snapshot(

View File

@ -228,7 +228,8 @@ def handle_get_terminal_output(data):
'session': session_name, 'session': session_name,
'output': result['output'], 'output': result['output'],
'is_interactive': result.get('is_interactive', False), 'is_interactive': result.get('is_interactive', False),
'last_command': result.get('last_command', '') 'last_command': result.get('last_command', ''),
'last_event_time': result.get('last_event_time')
}) })
else: else:
emit('error', {'message': result['error']}) emit('error', {'message': result['error']})

View File

@ -101,7 +101,12 @@ export const aiStreamMethods = {
// 确保 awaitingFirstContent 被正确设置 // 确保 awaitingFirstContent 被正确设置
const hasContent = lastMessage.actions && lastMessage.actions.length > 0; const hasContent = lastMessage.actions && lastMessage.actions.length > 0;
if (!hasContent) { if (this._rebuildingFromScratch) {
// 从头重建时restoreTaskState 已根据事件流分析hasAssistantContentEvent
// 正确设置了 awaitingFirstContent此处不覆盖。
// actions 被清空不代表没有内容,事件重放会恢复。
console.log('[AiMessageStart] 从头重建,保留 awaitingFirstContent:', lastMessage.awaitingFirstContent);
} else if (!hasContent) {
lastMessage.awaitingFirstContent = true; lastMessage.awaitingFirstContent = true;
lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...'; lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...';
console.log('[AiMessageStart] 设置等待动画', { console.log('[AiMessageStart] 设置等待动画', {

View File

@ -365,9 +365,14 @@ export const compressionMethods = {
// 让 handleAiMessageStart 检测到 isRefreshRestore 并复用现有消息容器。 // 让 handleAiMessageStart 检测到 isRefreshRestore 并复用现有消息容器。
// 这样避免视觉闪回(消息不会突然消失)+ 新事件通过轮询增量追加,不会一次性同步处理几百个事件导致卡死。 // 这样避免视觉闪回(消息不会突然消失)+ 新事件通过轮询增量追加,不会一次性同步处理几百个事件导致卡死。
if (isAssistantMessage) { if (isAssistantMessage) {
debugLog('[TaskPolling] 复用现有 assistant 消息容器,清空 actions 等待事件重建'); debugLog('[TaskPolling] 复用现有 assistant 消息容器,清空 actions 等待事件重建', {
hasAssistantContentEvent
});
lastMessage.actions = []; lastMessage.actions = [];
lastMessage.awaitingFirstContent = true; // 如果事件流中已经有内容事件tool/thinking/text说明回复已经开始过
// 重放事件会恢复内容,不显示等待提示。
// 如果还没有内容事件,说明回复还没开始,应该显示等待提示。
lastMessage.awaitingFirstContent = !hasAssistantContentEvent;
lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...'; lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...';
// 清理旧的流式标记,确保新事件能正确设置 // 清理旧的流式标记,确保新事件能正确设置
if (typeof lastMessage.streaming === 'boolean') { if (typeof lastMessage.streaming === 'boolean') {

View File

@ -80,6 +80,18 @@ export const toolMethods = {
return; return;
} }
// 兜底:工具开始执行时关闭等待动画(正常流程由 tool_preparing 关闭,
// 但从头重建等场景可能跳过 tool_preparing 直接收到 tool_start
const msgForAwaiting = this.chatEnsureAssistantMessage();
if (msgForAwaiting && msgForAwaiting.awaitingFirstContent) {
console.log('[ToolStart] 关闭等待动画', {
beforeAwaitingFirstContent: msgForAwaiting.awaitingFirstContent,
toolName: data.name
});
msgForAwaiting.awaitingFirstContent = false;
msgForAwaiting.generatingLabel = '';
}
let action = null; let action = null;
if (data.preparing_id && this.preparingTools.has(data.preparing_id)) { if (data.preparing_id && this.preparingTools.has(data.preparing_id)) {
action = this.preparingTools.get(data.preparing_id); action = this.preparingTools.get(data.preparing_id);

View File

@ -69,6 +69,8 @@ let term: Terminal | null = null;
let themeObserver: MutationObserver | null = null; let themeObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
const _historyReady = ref<Record<string, boolean>>({}); const _historyReady = ref<Record<string, boolean>>({});
// terminal_output
const _historyLastEventTime = ref<Record<string, number>>({});
const sessionKeys = computed(() => Object.keys(sessions.value)); const sessionKeys = computed(() => Object.keys(sessions.value));
@ -211,6 +213,9 @@ function switchToSession(name: string) {
activeSession.value = name; activeSession.value = name;
if (!sessionHydrated.value[name]) { if (!sessionHydrated.value[name]) {
_historyReady.value = { ..._historyReady.value, [name]: false }; _historyReady.value = { ..._historyReady.value, [name]: false };
const updatedTimes = { ..._historyLastEventTime.value };
delete updatedTimes[name];
_historyLastEventTime.value = updatedTimes;
if (socket?.connected) { if (socket?.connected) {
console.log('[TerminalPanel] requesting history for:', name); console.log('[TerminalPanel] requesting history for:', name);
socket.emit('get_terminal_output', { session: name, lines: 0 }); socket.emit('get_terminal_output', { session: name, lines: 0 });
@ -246,6 +251,7 @@ watch(() => props.workspaceId, (newId, oldId) => {
sessionLogs.value = {}; sessionLogs.value = {};
sessionHydrated.value = {}; sessionHydrated.value = {};
_historyReady.value = {}; _historyReady.value = {};
_historyLastEventTime.value = {};
if (term) term.clear(); if (term) term.clear();
initSocket(); initSocket();
} }
@ -333,6 +339,17 @@ async function initSocket() {
const s = data.session; const s = data.session;
if (!s) return; if (!s) return;
const delta = (data.data || '') as string; const delta = (data.data || '') as string;
// terminal_output_history
// terminal_output
// last_event_time 线
const historyTime = _historyLastEventTime.value[s] || 0;
const eventTime = typeof data.timestamp === 'number' ? data.timestamp : 0;
if (historyTime > 0 && eventTime > 0 && eventTime <= historyTime) {
console.log('[TerminalPanel] terminal_output DEDUP skip:', s, 'eventTime:', eventTime, '<= historyTime:', historyTime);
return;
}
// sessionLogs // sessionLogs
// _historyReady false // _historyReady false
sessionLogs.value[s] = (sessionLogs.value[s] || '') + delta; sessionLogs.value[s] = (sessionLogs.value[s] || '') + delta;
@ -369,6 +386,9 @@ async function initSocket() {
if (target) { if (target) {
sessionLogs.value = { ...sessionLogs.value, [target]: '' }; sessionLogs.value = { ...sessionLogs.value, [target]: '' };
sessionHydrated.value = { ...sessionHydrated.value, [target]: true }; sessionHydrated.value = { ...sessionHydrated.value, [target]: true };
const updatedTimes = { ..._historyLastEventTime.value };
delete updatedTimes[target];
_historyLastEventTime.value = updatedTimes;
if (target === activeSession.value) renderSessionLog(); if (target === activeSession.value) renderSessionLog();
} }
}); });
@ -397,6 +417,9 @@ async function initSocket() {
// _historyReady false seemingly // _historyReady false seemingly
sessionHydrated.value = { ...sessionHydrated.value, [s]: true }; sessionHydrated.value = { ...sessionHydrated.value, [s]: true };
_historyReady.value = { ..._historyReady.value, [s]: true }; _historyReady.value = { ..._historyReady.value, [s]: true };
// terminal_output
const lastEventTime = typeof data.last_event_time === 'number' ? data.last_event_time : 0;
_historyLastEventTime.value = { ..._historyLastEventTime.value, [s]: lastEventTime };
console.log('[TerminalPanel] handleHistory SAVED:', s, 'finalLen:', payload.length, 'calling render:', s === activeSession.value); console.log('[TerminalPanel] handleHistory SAVED:', s, 'finalLen:', payload.length, 'calling render:', s === activeSession.value);
if (s === activeSession.value) { if (s === activeSession.value) {
renderSessionLog(); renderSessionLog();