From 858ff4518771c1e14e6c7d46b8b3ee56b260423c Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 20 Jul 2026 22:31:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(static):=20=E8=BF=9B=E5=85=A5=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E7=BB=9F=E4=B8=80=E8=B5=B0=20bootstrap=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=EF=BC=88=E6=96=B9=E6=A1=88B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 enterConversation 单一入口:一次 GET bootstrap 拿全量数据, restoreTaskState 增加快速路径(注入 task/events 跳过三次串行请求与 4s 死等,消除运行中刷新的两段式体验);needsRebuild 仍前端重算保证 恢复行为与现状等价。PUT load 保留后端兼容、前端不再调用。 --- .../src/app/methods/conversation/bootstrap.ts | 134 ++++++++++++++ static/src/app/methods/conversation/index.ts | 2 + static/src/app/methods/conversation/load.ts | 61 ++----- .../app/methods/taskPolling/compression.ts | 168 +++++++++++------- static/src/app/methods/ui/route.ts | 45 ++--- 5 files changed, 260 insertions(+), 150 deletions(-) create mode 100644 static/src/app/methods/conversation/bootstrap.ts diff --git a/static/src/app/methods/conversation/bootstrap.ts b/static/src/app/methods/conversation/bootstrap.ts new file mode 100644 index 00000000..2b49515e --- /dev/null +++ b/static/src/app/methods/conversation/bootstrap.ts @@ -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 渲染前 resetAllStates(sidebar 切换保留「清空→填入」语义) + */ + 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. 重置 UI(sidebar 切换保留「清空→填入」语义;刷新路径不传以避免闪烁) + 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 + }; + } +}; diff --git a/static/src/app/methods/conversation/index.ts b/static/src/app/methods/conversation/index.ts index 0503f24d..9c41ef96 100644 --- a/static/src/app/methods/conversation/index.ts +++ b/static/src/app/methods/conversation/index.ts @@ -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, }; diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index e07a6dcc..307769d4 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -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); } diff --git a/static/src/app/methods/taskPolling/compression.ts b/static/src/app/methods/taskPolling/compression.ts index 1159b8e6..84a3c69c 100644 --- a/static/src/app/methods/taskPolling/compression.ts +++ b/static/src/app/methods/taskPolling/compression.ts @@ -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; + } } } diff --git a/static/src/app/methods/ui/route.ts b/static/src/app/methods/ui/route.ts index f6773a69..5848745b 100644 --- a/static/src/app/methods/ui/route.ts +++ b/static/src/app/methods/ui/route.ts @@ -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;