From 0381557fe9f2f8650059fe25a8a5ccf1f4a64a40 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 8 Sep 2026 08:59:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E7=AA=97=E5=8F=A3=E7=BC=BA=E5=8F=A3=E5=A4=84?= =?UTF-8?q?=E7=90=86=E2=80=94=E2=80=94Web=20=E5=BF=AB=E7=85=A7=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=20+=20CLI=20=E7=AA=97=E5=8F=A3=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web stores/task.ts:检测 offset < window_start 发合成事件 event_window_gap(复用 runtime_queue_sync 解耦模式) - lifecycle.ts 新增 case:fetchAndDisplayHistory({force:true}) 会话 JSON 权威重读对账(协议 §5.4)+ toast 提示 - i18n:stores.eventWindowGap / eventWindowGapReload(zh/en) - CLI api.ts TaskPollResult 补 window_start;App.tsx pollTask 检测后提示用户并对齐窗口续读 - 协议文档 §5.2 标注客户端兑现状态 验证:i18n 栏杆通过;CLI typecheck+build 通过;改动文件 eslint 0 errors;前端 vite build 受沙箱读边界限制未跑 (PostCSS 搜索 /Users/jojo/package.json EPERM,环境问题) --- cli/src/App.tsx | 9 +++++- cli/src/api.ts | 2 ++ .../src/app/methods/taskPolling/lifecycle.ts | 15 ++++++++++ static/src/locales/en-US/stores.ts | 2 ++ static/src/locales/zh-CN/stores.ts | 2 ++ static/src/stores/task.ts | 28 +++++++++++++++++++ 6 files changed, 57 insertions(+), 1 deletion(-) diff --git a/cli/src/App.tsx b/cli/src/App.tsx index 9ed673ae..c561efd6 100644 --- a/cli/src/App.tsx +++ b/cli/src/App.tsx @@ -493,7 +493,14 @@ export default function App() { const renderState = createEventRenderState(); for (;;) { const result = await api.pollTask(taskId, offset); - offset = result.next_offset; + // 事件窗口缺口检测(协议 §5.2):中间事件已被裁剪,提示用户并对齐窗口继续 + const windowStart = Number(result.window_start ?? 0); + if (offset > 0 && offset < windowStart) { + addItem({ kind: 'system', body: `部分实时输出已被裁剪(窗口起点 ${windowStart}),以最终结果为准` }); + offset = windowStart; + } else { + offset = result.next_offset; + } for (const event of result.events || []) { if (event.type === 'tool_approval_required') { const approval = event.data?.approval || event.data; diff --git a/cli/src/api.ts b/cli/src/api.ts index 65964d65..0038c718 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -17,6 +17,8 @@ export type TaskPollResult = { conversation_id?: string; events: Array<{ idx: number; type: string; data: any; ts?: number }>; next_offset: number; + // 事件窗口当前最小 idx(协议 §5.2):offset 落后于此值说明中间事件已被裁剪 + window_start?: number; }; export class ApiClient { diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index 79ac1640..44ba29ae 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -392,6 +392,21 @@ export const lifecycleMethods = { if (this._rebuildingFromScratch) break; this.handleRuntimeQueueSync(eventData); break; + case 'event_window_gap': + // 事件窗口缺口(协议 §5.2/§5.4):中间事件已被服务端裁剪, + // 以会话 JSON 为权威强制重读历史对账。重建期间历史本就是新加载的,跳过。 + if (this._rebuildingFromScratch) break; + debugLog('[TaskPolling] 事件窗口缺口,触发会话快照对账:', eventData); + if ((window as any).__vueApp?.uiPushToast) { + (window as any).__vueApp.uiPushToast({ + title: t('stores.eventWindowGap'), + message: t('stores.eventWindowGapReload'), + type: 'warning', + duration: 5000 + }); + } + this.fetchAndDisplayHistory({ force: true }); + break; default: debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); diff --git a/static/src/locales/en-US/stores.ts b/static/src/locales/en-US/stores.ts index 0f9bdf7e..5000adb9 100644 --- a/static/src/locales/en-US/stores.ts +++ b/static/src/locales/en-US/stores.ts @@ -60,6 +60,8 @@ export default { fetchTaskListFailed: 'Failed to fetch task list', pollingFluctuation: 'Polling fluctuation', pollingUnstableRetry: 'Message updates are temporarily unstable, retrying automatically', + eventWindowGap: 'Sync gap detected', + eventWindowGapReload: 'Some live updates were trimmed; resyncing conversation content', requestFailed: 'Request failed', fileTreeUnavailableHostMode: 'File tree unavailable in host mode', unlimited: 'Unlimited', diff --git a/static/src/locales/zh-CN/stores.ts b/static/src/locales/zh-CN/stores.ts index 4426adaf..7a078913 100644 --- a/static/src/locales/zh-CN/stores.ts +++ b/static/src/locales/zh-CN/stores.ts @@ -60,6 +60,8 @@ export default { fetchTaskListFailed: '获取任务列表失败', pollingFluctuation: '轮询波动', pollingUnstableRetry: '消息更新暂时不稳定,正在自动重试', + eventWindowGap: '消息同步缺口', + eventWindowGapReload: '部分实时更新已被裁剪,正在重新同步对话内容', requestFailed: '请求失败', fileTreeUnavailableHostMode: '宿主机模式下文件树不可用', unlimited: '未限制', diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index 3888b2f0..4ec6c534 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -258,6 +258,34 @@ export const useTaskStore = defineStore('task', { let sawTaskCompleteEvent = false; let sawTaskStoppedEvent = false; + // 事件窗口缺口检测(协议 §5.2):请求 offset 落后于窗口起点,说明中间事件 + // 已被裁剪,禁止当作“无新事件”静默吞掉——通知上层走会话快照对账(§5.4), + // 本批返回的事件(idx >= window_start)是连续段,游标照常推进。 + const windowStart = Number(data?.window_start ?? 0); + if (fromOffset > 0 && fromOffset < windowStart) { + debugLog('[Task] 事件窗口缺口:', { from: fromOffset, windowStart }); + taskPollDiag('task-poll-window-gap', { + requestId, + taskId, + from: fromOffset, + windowStart, + nextOffset: data?.next_offset + }); + try { + eventHandler({ + type: 'event_window_gap', + data: { + task_id: data?.task_id || this.currentTaskId, + conversation_id: data?.conversation_id || null, + from_offset: fromOffset, + window_start: windowStart + } + }); + } catch (err) { + console.warn('[Task] 处理 event_window_gap 失败:', err); + } + } + // 处理新事件 if (data.events && data.events.length > 0) { debugLog(`[Task] 收到 ${data.events.length} 个新事件`);