feat(static): 进入对话统一走 bootstrap 协议(方案B)

新增 enterConversation 单一入口:一次 GET bootstrap 拿全量数据,
restoreTaskState 增加快速路径(注入 task/events 跳过三次串行请求与
4s 死等,消除运行中刷新的两段式体验);needsRebuild 仍前端重算保证
恢复行为与现状等价。PUT load 保留后端兼容、前端不再调用。
This commit is contained in:
JOJO 2026-07-20 22:31:01 +08:00
parent 21f3acdf16
commit 858ff45187
5 changed files with 260 additions and 150 deletions

View File

@ -0,0 +1,134 @@
// @ts-nocheck
import { debugLog, traceLog } from '../common';
/**
* B
*
* GET bootstrap + + +
* PUT load + GET messages + GET tasks +
* terminal safe_nav
*
* docs/conversation_load_unification_plan.md
*/
export const bootstrapMethods = {
/**
* @param conversationId ID conv_
* @param options.source 'refresh' | 'sidebar'
* @param options.workspaceId host/docker
* @param options.urlMode 'push' | 'replace' | 'none' push
* @param options.urlPrefix URL multiAgentMode
* @param options.preserveListPosition
* @param options.resetUI resetAllStatessidebar
*/
async enterConversation(conversationId, options = {}) {
const {
source = 'sidebar',
workspaceId = '',
urlMode = 'push',
urlPrefix = '',
preserveListPosition = false,
resetUI = false
} = options;
const isHostLikeMode = Boolean(this.versioningHostMode || this.dockerProjectMode);
const requestUrl = workspaceId && isHostLikeMode
? `/api/conversations/${conversationId}/bootstrap?workspace_id=${encodeURIComponent(workspaceId)}`
: `/api/conversations/${conversationId}/bootstrap`;
traceLog('enterConversation:start', { conversationId, source, urlMode });
const response = await fetch(requestUrl);
const result = await response.json();
if (!result.success) {
debugLog('enterConversation:failed', { conversationId, error: result.error || result.message });
return result;
}
const data = result.data || {};
const meta = data.meta || {};
const normalizedId = data.conversation_id || conversationId;
// 1. 应用模式/模型(与旧 PUT load 响应处理对齐)
if (typeof meta.run_mode === 'string') {
this.runMode = meta.run_mode;
this.thinkingMode =
typeof meta.thinking_mode === 'boolean' ? meta.thinking_mode : meta.run_mode !== 'fast';
} else if (typeof meta.thinking_mode === 'boolean') {
this.thinkingMode = meta.thinking_mode;
this.runMode = meta.thinking_mode ? 'thinking' : 'fast';
}
if (typeof meta.model_key === 'string' && meta.model_key) {
this.modelSet(meta.model_key);
}
if (typeof meta.multi_agent_mode === 'boolean') {
this.multiAgentMode = meta.multi_agent_mode;
}
// 2. 当前对话状态skip 标记阻止 currentConversationId watch 重复拉历史)
this.skipConversationHistoryReload = true;
this.currentConversationId = normalizedId;
this.refreshProjectGitSummary?.();
this.fetchTerminalCount();
if (!preserveListPosition) {
this.promoteConversationToTop(normalizedId);
}
if (urlMode !== 'none') {
const prefix = urlPrefix || (this.multiAgentMode ? '/multiagent/' : '/');
const stateMethod = urlMode === 'replace' ? 'replaceState' : 'pushState';
history[stateMethod](
{ conversationId: normalizedId },
'',
`${prefix}${this.stripConversationPrefix(normalizedId)}`
);
}
this.skipConversationLoadedEvent = true;
// 3. 重置 UIsidebar 切换保留「清空→填入」语义;刷新路径不传以避免闪烁)
if (resetUI) {
this.resetAllStates(`enterConversation:${normalizedId}`);
}
// 4. 渲染文件态历史(复用现有渲染器;设置防重标记避免 loadInitialData 二次拉取)
const messages = Array.isArray(data.messages) ? data.messages : [];
this.logMessageState?.('enterConversation:before-render', {
conversationId: normalizedId,
count: messages.length
});
this.messages = [];
if (messages.length > 0) {
this.renderHistoryMessages(messages);
// 与 fetchAndDisplayHistory 一致:隐藏期内等待动态高度稳定再滚到底
if (typeof this.settleHistoryRenderAndScroll === 'function') {
await this.settleHistoryRenderAndScroll();
} else {
await this.$nextTick();
this.scrollHistoryToBottomInstant();
}
}
this.lastHistoryLoadedConversationId = normalizedId;
this.refreshBlankHeroState();
// 5. 运行中任务快速恢复(任务/事件/判据已由 bootstrap 聚合,
// 免去 GET /api/tasks + 历史死等 + GET /api/tasks/{id} 三次请求)
const running = data.running || {};
if (running.is_main_running && data.task_replay) {
await this.restoreTaskState({ bootstrapReplay: data.task_replay });
}
traceLog('enterConversation:done', {
conversationId: normalizedId,
messagesCount: messages.length,
isTrulyActive: !!running.is_truly_active,
needsRebuild: data.task_replay?.needs_rebuild
});
return {
success: true,
title: meta.title || '',
run_mode: meta.run_mode,
thinking_mode: meta.thinking_mode,
model_key: meta.model_key,
multi_agent_mode: meta.multi_agent_mode,
conversation_id: normalizedId
};
}
};

View File

@ -2,9 +2,11 @@
import { stateMethods } from './state';
import { loadMethods } from './load';
import { actionMethods } from './action';
import { bootstrapMethods } from './bootstrap';
export const conversationMethods = {
...stateMethods,
...loadMethods,
...actionMethods,
...bootstrapMethods,
};

View File

@ -79,7 +79,6 @@ export const loadMethods = {
const force = Boolean(options.force);
const preserveListPosition = Boolean(options.preserveListPosition);
const workspaceId = options.workspaceId ? String(options.workspaceId) : '';
const isHostLikeMode = Boolean(this.versioningHostMode || this.dockerProjectMode);
debugLog('加载对话:', conversationId);
traceLog('loadConversation:start', {
conversationId,
@ -155,75 +154,35 @@ export const loadMethods = {
}).catch(() => {});
try {
// 1. 调用加载API多工作区模式下携带目标 workspace_id避免并发/session 漂移导致加载错误工作区)
const loadUrl = workspaceId && isHostLikeMode
? `/api/conversations/${conversationId}/load?workspace_id=${encodeURIComponent(workspaceId)}`
: `/api/conversations/${conversationId}/load`;
const response = await fetch(loadUrl, {
method: 'PUT'
// 统一加载协议:一次 bootstrap 替代「PUT load + GET messages + GET tasks」串行链。
// 模式/模型应用、历史渲染、运行中任务恢复均在 enterConversation 内完成。
const result = await this.enterConversation(conversationId, {
source: 'sidebar',
workspaceId,
urlMode: 'push',
preserveListPosition,
resetUI: true
});
const result = await response.json();
if (result.success) {
debugLog('对话加载API成功:', result);
debugLog('对话 bootstrap 成功:', result);
traceLog('loadConversation:api-success', { conversationId, title: result.title });
if (typeof result.run_mode === 'string') {
this.runMode = result.run_mode;
this.thinkingMode =
typeof result.thinking_mode === 'boolean'
? result.thinking_mode
: result.run_mode !== 'fast';
} else if (typeof result.thinking_mode === 'boolean') {
this.thinkingMode = result.thinking_mode;
this.runMode = result.thinking_mode ? 'thinking' : 'fast';
}
if (typeof result.model_key === 'string' && result.model_key) {
this.modelSet(result.model_key);
}
if (typeof result.multi_agent_mode === 'boolean') {
this.multiAgentMode = result.multi_agent_mode;
}
// 2. 更新当前对话信息
this.skipConversationHistoryReload = true;
this.currentConversationId = conversationId;
this.refreshProjectGitSummary?.();
this.fetchTerminalCount();
this.currentConversationTitle = result.title;
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false });
if (!preserveListPosition) {
this.promoteConversationToTop(conversationId);
}
const urlPrefix = this.multiAgentMode ? '/multiagent/' : '/';
history.pushState(
{ conversationId },
'',
`${urlPrefix}${this.stripConversationPrefix(conversationId)}`
);
this.skipConversationLoadedEvent = true;
// 3. 重置UI状态
this.resetAllStates(`loadConversation:${conversationId}`);
this.subAgentFetch();
this.fetchTodoList();
// 4. 立即加载历史和统计,确保列表切换后界面同步更新
await this.fetchAndDisplayHistory();
await this.refreshRunningWorkspaceTasks?.();
const visibleTask =
typeof this.getVisibleWorkspaceTaskForConversation === 'function'
? this.getVisibleWorkspaceTaskForConversation(conversationId)
: null;
const visibleTaskStatus = String(visibleTask?.status || '');
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']);
if (visibleTask && activeStatuses.has(visibleTaskStatus)) {
// 切回正在运行的对话时,必须恢复该任务的 REST 轮询和输入区忙碌态;
// 否则只切换了历史视图,后续输出不会续接到当前对话,发送按钮也不会变成停止按钮。
await this.restoreTaskState?.();
} else if (visibleTask && terminalStatuses.has(visibleTaskStatus)) {
if (visibleTask && terminalStatuses.has(visibleTaskStatus)) {
// 用户已点进完成后的“待查看”对话,清除列表里的完成标记。
this.acknowledgeCompletedWorkspaceTask?.(visibleTask.task_id);
}

View File

@ -93,7 +93,10 @@ export const compressionMethods = {
duration: 2400
});
},
async restoreTaskState() {
async restoreTaskState(options = {}) {
// 统一加载协议快速路径bootstrap 已聚合任务摘要/全量事件/判据输入,
// 跳过 GET /api/tasks、历史死等与 GET /api/tasks/{id} 三次请求,行为与原有逻辑等价。
const bootstrapReplay = options && options.bootstrapReplay ? options.bootstrapReplay : null;
// 清理已处理的事件索引
this.clearProcessedEvents();
// 启动运行状态对账循环(幂等):事件负责即时性,对账负责正确性
@ -128,8 +131,26 @@ export const compressionMethods = {
return;
}
// 查找运行中的任务
const runningTask = await taskStore.loadRunningTask(this.currentConversationId);
// 查找运行中的任务(快速路径:复用 bootstrap 任务摘要,
// 复刻 loadRunningTask 的 store 设置与续播偏移量)
let runningTask;
if (bootstrapReplay && bootstrapReplay.task && bootstrapReplay.task.task_id) {
runningTask = bootstrapReplay.task;
taskStore.currentTaskId = runningTask.task_id;
taskStore.taskStatus = runningTask.status;
taskStore.taskCreatedAt = runningTask.created_at;
taskStore.taskUpdatedAt = runningTask.updated_at;
taskStore.pollingError = null;
taskStore.pollingErrorCount = 0;
taskStore.pollingWarned = false;
taskStore.runtimeQueueSnapshotKey = '';
taskStore.lastEventIndex =
typeof bootstrapReplay.replay_from === 'number'
? bootstrapReplay.replay_from
: (Array.isArray(bootstrapReplay.events) ? bootstrapReplay.events.length : 0);
} else {
runningTask = await taskStore.loadRunningTask(this.currentConversationId);
}
if (!runningTask) {
restoreDebugLog('restore:no-running-task', {
@ -190,12 +211,12 @@ export const compressionMethods = {
currentConversationId: this.currentConversationId
});
// 检查历史是否已加载
// 检查历史是否已加载(快速路径:历史已由 enterConversation 渲染,跳过死等)
const hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
const historyLoadingSameConversation =
!!this.historyLoading && this.historyLoadingFor === this.currentConversationId;
if (!hasMessages) {
if (!hasMessages && !bootstrapReplay) {
this._restoreTaskStateWaitCount = (this._restoreTaskStateWaitCount || 0) + 1;
const waitedTooLong = this._restoreTaskStateWaitCount >= 8; // ~4s
restoreDebugLog('restore:history-empty', {
@ -233,25 +254,30 @@ export const compressionMethods = {
historyLoadingFor: this.historyLoadingFor
});
// 获取任务的所有事件
const detailResponse = await fetch(`/api/tasks/${taskStore.currentTaskId}`);
if (!detailResponse.ok) {
debugLog('[TaskPolling] 获取任务详情失败');
return;
}
// 获取任务的所有事件快速路径bootstrap 已聚合全量事件,跳过详情请求)
let allEvents;
if (bootstrapReplay) {
allEvents = Array.isArray(bootstrapReplay.events) ? bootstrapReplay.events : [];
} else {
const detailResponse = await fetch(`/api/tasks/${taskStore.currentTaskId}`);
if (!detailResponse.ok) {
debugLog('[TaskPolling] 获取任务详情失败');
return;
}
const detailResult = await detailResponse.json();
if (!detailResult.success || !detailResult.data.events) {
restoreDebugLog('restore:task-detail-invalid', {
ok: !!detailResult?.success,
hasData: !!detailResult?.data,
hasEvents: !!detailResult?.data?.events
});
debugLog('[TaskPolling] 任务详情无效');
return;
}
const detailResult = await detailResponse.json();
if (!detailResult.success || !detailResult.data.events) {
restoreDebugLog('restore:task-detail-invalid', {
ok: !!detailResult?.success,
hasData: !!detailResult?.data,
hasEvents: !!detailResult?.data?.events
});
debugLog('[TaskPolling] 任务详情无效');
return;
}
const allEvents = detailResult.data.events;
allEvents = detailResult.data.events;
}
debugLog(`[TaskPolling] 获取到 ${allEvents.length} 个事件`);
restoreDebugLog('restore:task-detail-events', {
total: allEvents.length,
@ -273,56 +299,66 @@ export const compressionMethods = {
// });
// 先分析事件状态(用于判定是否强制重建)
// 快速路径:分析输入直接取自 bootstrap 的服务端判据(语义与下方循环等价)
const injectedDecision = bootstrapReplay ? bootstrapReplay.decision_inputs || null : null;
let inThinking = false;
let inText = false;
let hasTextChunkEvent = false;
let hasAssistantResponseEvent = false;
let hasAssistantContentEvent = false;
for (let i = 0; i < allEvents.length; i++) {
const event = allEvents[i];
const eventType = String(event?.type || '');
if (
[
'ai_message_start',
'thinking_start',
'thinking_chunk',
'thinking_end',
'text_start',
'text_chunk',
'text_end',
'tool_preparing',
'tool_start',
'tool_update',
'tool_complete'
].includes(eventType)
) {
hasAssistantResponseEvent = true;
}
if (
[
'thinking_chunk',
'text_chunk',
'tool_preparing',
'tool_start',
'tool_update',
'tool_complete'
].includes(eventType)
) {
hasAssistantContentEvent = true;
}
if (event.type === 'thinking_start') {
inThinking = true;
} else if (event.type === 'thinking_end') {
inThinking = false;
}
if (event.type === 'text_start') {
inText = true;
} else if (event.type === 'text_end') {
inText = false;
}
if (event.type === 'text_chunk') {
hasTextChunkEvent = true;
if (injectedDecision) {
inThinking = !!injectedDecision.in_thinking;
inText = !!injectedDecision.in_text;
hasTextChunkEvent = !!injectedDecision.has_text_chunk_event;
hasAssistantResponseEvent = !!injectedDecision.has_assistant_response_event;
hasAssistantContentEvent = !!injectedDecision.has_assistant_content_event;
} else {
for (let i = 0; i < allEvents.length; i++) {
const event = allEvents[i];
const eventType = String(event?.type || '');
if (
[
'ai_message_start',
'thinking_start',
'thinking_chunk',
'thinking_end',
'text_start',
'text_chunk',
'text_end',
'tool_preparing',
'tool_start',
'tool_update',
'tool_complete'
].includes(eventType)
) {
hasAssistantResponseEvent = true;
}
if (
[
'thinking_chunk',
'text_chunk',
'tool_preparing',
'tool_start',
'tool_update',
'tool_complete'
].includes(eventType)
) {
hasAssistantContentEvent = true;
}
if (event.type === 'thinking_start') {
inThinking = true;
} else if (event.type === 'thinking_end') {
inThinking = false;
}
if (event.type === 'text_start') {
inText = true;
} else if (event.type === 'text_end') {
inText = false;
}
if (event.type === 'text_chunk') {
hasTextChunkEvent = true;
}
}
}

View File

@ -58,23 +58,17 @@ export const routeMethods = {
const convPart = path.slice('multiagent/'.length);
const convId = convPart.startsWith('conv_') ? convPart : `conv_${convPart}`;
try {
const resp = await fetch(`/api/conversations/${convId}/load`, { method: 'PUT' });
const result = await resp.json();
// 统一加载协议:一次 bootstrap 拿元数据+历史+运行状态(纯只读,不切换后端上下文)
const result = await this.enterConversation(convId, {
source: 'refresh',
urlMode: 'replace',
urlPrefix: '/multiagent/'
});
if (result.success) {
if (typeof result.run_mode === 'string') {
this.runMode = result.run_mode;
this.thinkingMode = typeof result.thinking_mode === 'boolean' ? result.thinking_mode : result.run_mode !== 'fast';
} else if (typeof result.thinking_mode === 'boolean') {
this.thinkingMode = result.thinking_mode;
this.runMode = result.thinking_mode ? 'thinking' : 'fast';
}
if (typeof result.model_key === 'string' && result.model_key) this.modelSet(result.model_key);
this.currentConversationId = convId;
this.currentConversationTitle = result.title || '多智能体模式';
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false });
history.replaceState({ conversationId: convId }, '', `/multiagent/${this.stripConversationPrefix(convId)}`);
this.initialRouteResolved = true;
await this.restoreComposerDraftState('bootstrap-route:multiagent-existing');
return;
@ -105,32 +99,17 @@ export const routeMethods = {
const convId = path.startsWith('conv_') ? path : `conv_${path}`;
try {
const resp = await fetch(`/api/conversations/${convId}/load`, { method: 'PUT' });
const result = await resp.json();
// 统一加载协议:一次 bootstrap 拿元数据+历史+运行状态(纯只读,不切换后端上下文)
const result = await this.enterConversation(convId, {
source: 'refresh',
urlMode: 'replace',
urlPrefix: '/'
});
if (result.success) {
if (typeof result.run_mode === 'string') {
this.runMode = result.run_mode;
this.thinkingMode =
typeof result.thinking_mode === 'boolean'
? result.thinking_mode
: result.run_mode !== 'fast';
} else if (typeof result.thinking_mode === 'boolean') {
this.thinkingMode = result.thinking_mode;
this.runMode = result.thinking_mode ? 'thinking' : 'fast';
}
if (typeof result.model_key === 'string' && result.model_key) {
this.modelSet(result.model_key);
}
this.currentConversationId = convId;
this.currentConversationTitle = result.title || '';
this.titleReady = true;
this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false });
history.replaceState(
{ conversationId: convId },
'',
`/${this.stripConversationPrefix(convId)}`
);
} else {
history.replaceState({}, '', '/new');
this.currentConversationId = null;