/* ───────────────────────────────────────────────────────────────────────────── * 状态机(单一来源,档案 81 · R1-②)—— 本文件是「回到页面」链路**唯一**的策略实现。 * * 事件(触发面) 判定 动作 * ───────────────────────────────────────────────────────────────────────────── * ① 切回页面 / 首次进入 探针(平台 /api/dsh/status + 实例 GET /) 坏 → 恢复;好 → 撤提示条 * (visibilitychange / pageshow) 提示条「正在检查工作区连接…」 * ② 页面自己发出的请求 401 传输层已透明重放(档案 51);仍失败 → 恢复 * 或 404 not_running * ③ 心跳(**页面可见**时每 25 s) soft 模式:**连续两次**失败才动作 恢复(滞后 ≤ 50 s) * ④ EventSource / WebSocket 断开 soft 模式:同上 恢复(比心跳更早) * ⑤ 有界保护 窗口 10 min 内恢复 > 3 次 停手:明确失败态 + 手动「重试」 * ───────────────────────────────────────────────────────────────────────────── * 恢复动作:覆盖层「工作区正在恢复…」→ POST /api/dsh/enter → 拿到新 token 的直达地址 * → **原地 replace(保留 path/hash)**;拿不到 url → 原地 reload。 * 冷却/告警:平台侧 orchestrator 负责崩溃熔断与告警(档案 78);本文件只管"页面这一侧"。 * ⚠️ 改这个文件**不需要**再动 proxy.ts(档案 81 R1-① 已把脚本外置),但改完必须跑 * `npm run verify`(会做 node --check + 内联回退检查)。 * ──────────────────────────────────────────────────────────────────────────── */ (function () { if (window.__dshRecover) return; window.__dshRecover = 1; // 一层覆盖层,两种用途: // pending —— 请求长时间未返回(服务端正在拉起实例)→ 显示"正在启动…",请求结束后撤掉 // expired —— 401(实例被回收重建,launch token 已轮换)→ 显示后整页 reload // expired 是终态,不可被 pending 覆盖、也不主动撤除(随后就 reload 了)。 var state = 'none'; var box = null; var ticker = null; function ensureCss() { if (document.getElementById('__dshRecoverCss')) return; var st = document.createElement('style'); st.id = '__dshRecoverCss'; // 档案 77 视觉升级:整套样式一次性注入(含 prefers-reduced-motion 回退)。 // ⚠️ 本文件是注入脚本的模板字面量 —— 内容不得含反引号 / 美元花括号; // 故 CSS 里的字体名一律用单引号('Segoe UI'),JS 字符串用双引号。 st.textContent = [ "@keyframes __dshr-spin{to{transform:rotate(360deg)}}", "@keyframes __dshr-spin-rev{to{transform:rotate(-360deg)}}", "@keyframes __dshr-core{0%,100%{transform:scale(.82);opacity:.6}50%{transform:scale(1.12);opacity:1}}", "@keyframes __dshr-halo{0%,100%{opacity:.45;transform:scale(.96)}50%{opacity:.9;transform:scale(1.04)}}", "@keyframes __dshr-scan{0%{transform:translateX(-115%)}100%{transform:translateX(275%)}}", "@keyframes __dshr-dot{0%,100%{opacity:.25}50%{opacity:1}}", "@keyframes __dshr-sheen{0%{background-position:190% 0}100%{background-position:-90% 0}}", ".__dsh-ov{", "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;", "background:radial-gradient(900px 460px at 50% 26%, rgba(88,166,255,.13), transparent 62%),radial-gradient(760px 420px at 50% 84%, rgba(163,113,247,.13), transparent 62%),rgba(9,11,16,.9);", "}", ".__dsh-card{", "position:relative;display:flex;flex-direction:column;align-items:center;gap:13px;", "padding:26px 34px 22px;border-radius:16px;", "background:linear-gradient(180deg, rgba(26,31,43,.86), rgba(17,21,30,.86));", "box-shadow:0 1px 0 rgba(255,255,255,.06) inset,0 0 0 1px rgba(120,150,210,.16),0 18px 48px rgba(0,0,0,.5);", "-webkit-backdrop-filter:blur(9px);backdrop-filter:blur(9px);", "}", ".__dsh-orb{position:relative;width:86px;height:86px}", ".__dsh-halo{", "position:absolute;inset:-20px;border-radius:50%;", "background:radial-gradient(circle, rgba(88,166,255,.30), rgba(163,113,247,.17) 46%, transparent 70%);", "animation:__dshr-halo 2.6s ease-in-out infinite;", "}", ".__dsh-arc{", "position:absolute;inset:0;border-radius:50%;", "background:conic-gradient(from 0deg, rgba(88,166,255,0) 0deg, rgba(88,166,255,.06) 110deg, #58a6ff 296deg, #a371f7 342deg, rgba(163,113,247,0) 360deg);", "-webkit-mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));", "mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));", "animation:__dshr-spin 1.5s linear infinite;", "}", ".__dsh-arc2{position:absolute;inset:13px;border-radius:50%;border:1px dashed rgba(163,113,247,.42);animation:__dshr-spin-rev 7s linear infinite}", ".__dsh-ring{position:absolute;inset:24px;border-radius:50%;border:1px solid rgba(120,160,230,.20)}", ".__dsh-core{", "position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;", "background:radial-gradient(circle, #eaf3ff 0%, #66b0ff 46%, rgba(88,166,255,0) 74%);", "box-shadow:0 0 12px rgba(88,166,255,.85),0 0 34px rgba(163,113,247,.45);", "animation:__dshr-core 1.6s ease-in-out infinite;", "}", ".__dsh-dots{position:absolute;inset:0;animation:__dshr-spin 3.4s linear infinite}", ".__dsh-dots i{position:absolute;left:50%;top:-2px;width:5px;height:5px;margin-left:-2.5px;border-radius:50%;background:#a371f7;box-shadow:0 0 8px #a371f7;animation:__dshr-dot 1.4s ease-in-out infinite}", ".__dsh-dots i:nth-child(2){top:auto;bottom:-2px;animation-delay:.35s;background:#58a6ff;box-shadow:0 0 8px #58a6ff}", ".__dsh-dots i:nth-child(3){left:-2px;top:50%;margin:-2.5px 0 0 0;animation-delay:.7s;background:#7ee0c0;box-shadow:0 0 8px #7ee0c0}", ".__dsh-msg{", "font:14.5px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;color:#e9f1fa;letter-spacing:.2px;text-align:center;", "background:linear-gradient(90deg, rgba(233,241,250,.94) 0%, #ffffff 48%, rgba(233,241,250,.94) 96%);", "-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;", "background-size:230% 100%;animation:__dshr-sheen 3s linear infinite;", "}", ".__dsh-label{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.16em;color:rgba(150,175,215,.72);text-transform:uppercase}", ".__dsh-track{position:relative;width:208px;height:3px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden}", ".__dsh-track i{position:absolute;top:0;bottom:0;width:42%;border-radius:3px;background:linear-gradient(90deg, transparent, #58a6ff 45%, #a371f7 70%, transparent);animation:__dshr-scan 1.45s ease-in-out infinite}", "@media (prefers-reduced-motion: reduce){", ".__dsh-arc,.__dsh-arc2,.__dsh-dots,.__dsh-dots i,.__dsh-core,.__dsh-halo,.__dsh-track i,.__dsh-msg{animation:none!important}", "}" ].join(String.fromCharCode(10)); (document.head || document.documentElement).appendChild(st); } // spinner 只创建一次,之后仅改文案 —— 否则每次重设 innerHTML 都会重建元素, // 旋转动画被打断,看起来像"卡住不动"。 function ensureBox() { if (box) return; ensureCss(); box = document.createElement('div'); box.id = '__dshRecover'; box.className = '__dsh-ov'; // 「AI 核心」加载体:扫描弧(conic 渐变)+ 反向虚线环 + 脉冲核心 + 三颗轨道粒子。 // 全部 createElement 构建(不用 innerHTML —— 实例页可能启用 Trusted Types)。 var card = document.createElement('div'); card.className = '__dsh-card'; var orb = document.createElement('div'); orb.className = '__dsh-orb'; var parts = ['__dsh-halo', '__dsh-arc', '__dsh-arc2', '__dsh-ring', '__dsh-core']; for (var i = 0; i < parts.length; i++) { var el = document.createElement('div'); el.className = parts[i]; orb.appendChild(el); } var dots = document.createElement('div'); dots.className = '__dsh-dots'; for (var j = 0; j < 3; j++) dots.appendChild(document.createElement('i')); orb.appendChild(dots); var tx = document.createElement('div'); tx.id = '__dshRecoverMsg'; tx.className = '__dsh-msg'; var lb = document.createElement('div'); lb.className = '__dsh-label'; lb.textContent = 'DSH · auto recovery'; var tr = document.createElement('div'); tr.className = '__dsh-track'; tr.appendChild(document.createElement('i')); card.appendChild(orb); card.appendChild(tx); card.appendChild(lb); card.appendChild(tr); box.appendChild(card); (document.body || document.documentElement).appendChild(box); } function setMsg(msg) { var m = document.getElementById('__dshRecoverMsg'); if (m) m.textContent = msg; } function showPending(base) { if (state === 'expired') return; state = 'pending'; ensureBox(); var t0 = Date.now(); setMsg(base + '(已等待 0 秒)'); if (ticker) clearInterval(ticker); ticker = setInterval(function () { if (state !== 'pending') return; setMsg(base + '(已等待 ' + Math.floor((Date.now() - t0) / 1000) + ' 秒)'); }, 1000); } function hidePending() { if (state !== 'pending') return; state = 'none'; if (ticker) { clearInterval(ticker); ticker = null; } if (box && box.parentNode) box.parentNode.removeChild(box); box = null; } // ── 顶部轻提示条(不遮罩)──────────────────────────────────────────────── // 用户明确反馈「回到页面根本没看到过提示」——所以「看得见」本身就是要交付的东西。 // 只在"离开 ≥20 秒又回来"时出现;瞬时切换不打扰。至少显示 900ms 再撤, // 否则一闪而过等于没提示。 var bar = null; var barAt = 0; var barTimer = null; function ensureBar() { if (bar) return; ensureCss(); bar = document.createElement('div'); bar.id = '__dshConnBar'; bar.setAttribute( 'style', 'position:fixed;top:0;left:0;right:0;z-index:2147483646;padding:7px 12px;' + 'text-align:center;font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;' + 'color:#fff;background:rgba(24,95,165,.95)' ); (document.body || document.documentElement).appendChild(bar); } function showBar(msg) { if (state === 'pending' || state === 'expired') return; ensureBar(); barAt = Date.now(); bar.textContent = msg; if (barTimer) { clearTimeout(barTimer); barTimer = null; } } function hideBar() { if (!bar) return; var wait = Math.max(0, 900 - (Date.now() - barAt)); if (barTimer) clearTimeout(barTimer); barTimer = setTimeout(function () { barTimer = null; if (bar && bar.parentNode) bar.parentNode.removeChild(bar); bar = null; }, wait + 60); } function expire() { if (state === 'expired') return; hideBar(); hidePending(); state = 'expired'; ensureBox(); setMsg('正在重新连接你的工作区…'); setTimeout(function () { location.reload(); }, 900); } function isApi(u) { return String(u).indexOf('/api/') >= 0; } // ── 实例不可用(空闲回收 / 被关闭 / 崩溃)→ 自动唤醒并重建连接(档案 72)──────────── // 为什么需要:原来只认 401。实例被回收后代理对非导航请求返回 404 not_running, // 脚本不认识 → 页面上的请求静默失败、SSE 静默断流 → 用户只能**手动刷新**。 // // 注入脚本跑在实例子域上,而平台接口(/api/dsh/*)在门户域 → 必须跨域调用; // server.ts 的 CORS 白名单允许 baseDomain 及其子域 + Allow-Credentials,故带 cookie 可用。 function portalOrigin() { var l = location.hostname.split('.'); if (l.length > 2) return location.protocol + '//' + l.slice(1).join('.'); return location.origin; } var recovering = false; // 保留原始 fetch:探针与探活走它,避免被下面的 watch() 当成"业务请求"而误亮覆盖层。 var rawFetch = window.fetch; // ── 就地恢复(档案 77)─────────────────────────────────────────────────── // 不再跳门户过渡页 wake.html:那会换域名(子域 → 门户),且它的 next 是空的, // 恢复后落回根地址、页面状态全丢 —— 用户明确反馈这样"不自然"。 // 改为:本页显示覆盖层 → 调一次平台 /api/dsh/enter(会拉起实例并返回**带新 token** // 的直达地址)→ 原地跳回,并尽量保留当前路径与 hash。 function withPath(u) { try { var n = new URL(u, location.href); if (n.hostname === location.hostname && location.pathname !== '/') n.pathname = location.pathname; if (location.hash) n.hash = location.hash; return n.href; } catch (e) { return u; } } // ── 有界恢复(档案 81 · R1-③)────────────────────────────────────────────── // 现场事故(2026-09-13 · guest):实例被 OOM 反复杀 → 页面「恢复 → 起来 → 又被杀」**无限循环** // (每 ~35 s 一次 GET /)。恢复逻辑本身没错,错在**没有上限**。 // 规则:窗口内恢复次数超限后**停止自动恢复**,停在明确的失败态 + 手动「重试」(不自动 reload)。 var RECOVER_WINDOW_MS = 10 * 60 * 1000; var RECOVER_MAX = 3; var recFirstAt = 0; var recCount = 0; function overRecoverBudget() { var now = Date.now(); if (now - recFirstAt > RECOVER_WINDOW_MS) { recFirstAt = now; recCount = 0; } recCount += 1; return recCount > RECOVER_MAX; } function addRetryButton() { var box = document.getElementById('__dshRecover'); if (!box || document.getElementById('__dshRetryBtn')) return; var b = document.createElement('button'); b.id = '__dshRetryBtn'; b.type = 'button'; b.textContent = '重试'; b.setAttribute( 'style', 'margin-top:14px;padding:8px 18px;border:0;border-radius:8px;cursor:pointer;' + 'font:14px/1 system-ui,-apple-system,sans-serif;color:#fff;' + 'background:linear-gradient(135deg,#58a6ff,#a371f7);box-shadow:0 0 18px rgba(88,166,255,.35)' ); b.addEventListener('click', function () { recFirstAt = 0; // 手动重试 = 重置预算 recCount = 0; recovering = false; b.parentNode && b.parentNode.removeChild(b); recover('正在重新连接…'); }); box.appendChild(b); } function showExhausted() { recovering = true; // 保持在"不再自动恢复"的状态 hideBar(); ensureBox(); // 只建覆盖层,不启动 showPending 的秒表 setMsg('工作区暂时不可用(已连续尝试恢复 ' + RECOVER_MAX + ' 次)。可能是实例反复重启或资源不足 —— 可点下方「重试」,或稍后再来。'); addRetryButton(); } function recover(reason) { if (recovering) return; if (overRecoverBudget()) { showExhausted(); return; } recovering = true; hideBar(); showPending(reason || '工作区正在恢复,请稍候…'); var go = function (url) { if (url) { setMsg('已就绪,正在返回你的工作区…'); setTimeout(function () { location.replace(withPath(url)); }, 700); return; } setMsg('正在重新连接…'); setTimeout(function () { location.reload(); }, 700); }; rawFetch.call(window, portalOrigin() + '/api/dsh/enter', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', credentials: 'include', }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { go(j && (j.url || j.redirect)); }) .catch(function () { go(null); }); } // ── 探针:页面 ↔ 实例的连通性(档案 77 新增,解决"看不到提示")──────────── // 为什么需要:门户口 /api/dsh/status 的 running 只说明「进程在不在」, // 而它把 starting(正在启动)也算在跑(dsh.ts 的 alive())。实测每次崩溃后新实例 // 1 秒内就起来 ⇒ 用户回到页面时几乎总是 starting/running ⇒ 旧判据永远不动手。 // 探针 GET / 走实例自己的鉴权入口:200 = 这个页面确实能用;超时 / 3xx / 异常 = 已脱节。 // 返回 404/405(官方改了入口形态)→ 'unknown' → 退回旧判据,**绝不误报**。 function poke() { var ctl = typeof AbortController === 'function' ? new AbortController() : null; var t = setTimeout(function () { try { if (ctl) ctl.abort(); } catch (e) {} }, 2500); return rawFetch .call(window, '/', { credentials: 'same-origin', cache: 'no-store', redirect: 'manual', signal: ctl ? ctl.signal : undefined, }) .then(function (r) { clearTimeout(t); if (r.status === 200) return 'ok'; if (r.status === 404 || r.status === 405) return 'unknown'; return 'bad'; }) .catch(function () { clearTimeout(t); return 'bad'; }); } // 主动探活。必要性:实例被回收后,页面上的存量请求不一定失败(SSE 静默断流), // 所以"用户回到页面"这件事本身要主动问一次。 // withNotice:真的离开过一会儿又回来 → 先亮顶部提示条(看得见的反馈)。 var lastProbe = 0; // soft = true:**静默触发面**(心跳 / 流断)用 —— 必须**连续两次**失败才动手。 // 为什么:恢复 = 原地 location.replace,页面内未保存的输入会丢;一次网络抖动不该触发它。 var softFails = 0; function probe(withNotice, soft) { if (recovering) return; if (Date.now() - lastProbe < 15000) return; lastProbe = Date.now(); if (withNotice) showBar('正在检查工作区连接…'); var go = function (reason) { if (soft !== true) { recover(reason); return; } softFails += 1; if (softFails >= 2) recover(reason); }; rawFetch .call(window, portalOrigin() + '/api/dsh/status', { credentials: 'include' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { if (!j) { hideBar(); return; } if (j.running === false) { go('工作区已休眠,正在唤醒…'); return; } return poke().then(function (verdict) { if (verdict === 'ok' || verdict === 'unknown') { softFails = 0; hideBar(); return; } go('工作区正在恢复,请稍候…'); }); }) .catch(function () { hideBar(); }); } function hit(u, s, readBody) { if (s === 401 && isApi(u)) { expire(); return; } // 404 not_running = 实例已被回收或关闭。原先没人管这种情况,用户只能手动刷新。 if (s === 404 && isApi(u) && typeof readBody === 'function') { readBody(function (text) { if (String(text).indexOf('not_running') >= 0) { recover('工作区正在恢复,请稍候…'); } }); } } // 服务端在"实例未就绪"时会 hold 住请求(最长 20 秒)等拉起,期间浏览器端原本没有任何反馈 // —— 用户点了重连却看不出在等什么。这里给挂起的 API 请求加计时:≥3 秒就亮出覆盖层。 // 不会误伤流式接口:SSE / ReadableStream 在**响应头到达**时 promise 就已 resolve,计时已经清掉。 var SLOW_MS = 3000; function watch(u) { if (!isApi(u)) return function () {}; var shown = false; var t = setTimeout(function () { shown = true; showPending('实例正在启动,请稍候…'); }, SLOW_MS); return function () { clearTimeout(t); if (shown) hidePending(); }; } var of = window.fetch; if (of) { window.fetch = function (i) { var u = typeof i === 'string' ? i : (i && i.url) || ''; var end = watch(u); return of.apply(this, arguments).then( function (r) { end(); try { hit(u, r.status, function (cb) { // clone 后再读:不能消费原响应的 body,否则调用方拿不到数据 try { r.clone() .text() .then(cb) .catch(function () {}); } catch (e) {} }); } catch (e) {} return r; }, function (e) { end(); throw e; } ); }; } var oo = XMLHttpRequest.prototype.open; var os = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (m, u) { this.__u = u; return oo.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { var x = this; var end = watch(x.__u); x.addEventListener('loadend', function () { end(); try { hit(x.__u, x.status, function (cb) { try { cb(String(x.responseText || '')); } catch (e) {} }); } catch (e) {} }); return os.apply(this, arguments); }; // 「用户回到会话页面」= 切回标签页 / 点回窗口 / 从缓存恢复页面。 // 只有"真的离开过一会儿又回来"(≥20 秒)才亮顶部提示条 —— 瞬时切换不打扰; // 但无论亮不亮,探针都会跑:一旦判定页面已与实例脱节就恢复并给覆盖层。 var LEFT_MS = 20000; var leftAt = 0; document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { var away = leftAt > 0 && Date.now() - leftAt >= LEFT_MS; leftAt = 0; probe(away); } else { leftAt = Date.now(); } }); window.addEventListener('focus', function () { probe(false); }); window.addEventListener('pageshow', function (e) { if (e.persisted) probe(true); }); // ── 档案 77 补丁②(2026-09-13):补齐「连接悄悄断掉」的两条触发面 ───────── // 现场故障:用户**一直盯着页面**(不切标签、不 focus),实例侧连接断了(模型流挂掉), // 页面显示「连接异常」,而上面那些事件一个都不会来 ⇒ 既不提示也不恢复,只能手动刷新。 // 说明:原设计(档案 77 §五 方案 B「不做周期性探活」)的前提是"用户在场却不操作没有收益", // 实测证明该前提不成立 —— 于是补两条**静默**触发面;两者都走 soft 模式(连续两次失败才恢复)。 // ① 心跳:页面**可见**时每 25 秒静默探一次(不可见时完全不付出成本) // ② 流断:包装 EventSource / WebSocket 的 error / close,立即探一次(比心跳更早发现) var HEARTBEAT_MS = 25000; setInterval(function () { if (document.visibilityState !== 'visible') return; probe(false, true); }, HEARTBEAT_MS); function wrapStreams() { var ES = window.EventSource; if (typeof ES === 'function') { var ES2 = function (url, opts) { var es = new ES(url, opts); es.addEventListener('error', function () { probe(false, true); }); return es; }; ES2.prototype = ES.prototype; if (ES.CONNECTING !== undefined) { ES2.CONNECTING = ES.CONNECTING; ES2.OPEN = ES.OPEN; ES2.CLOSED = ES.CLOSED; } window.EventSource = ES2; } var WS = window.WebSocket; if (typeof WS === 'function') { var WS2 = function (url, protocols) { var ws = protocols === undefined ? new WS(url) : new WS(url, protocols); ws.addEventListener('close', function () { probe(false, true); }); ws.addEventListener('error', function () { probe(false, true); }); return ws; }; WS2.prototype = WS.prototype; if (WS.CONNECTING !== undefined) { WS2.CONNECTING = WS.CONNECTING; WS2.OPEN = WS.OPEN; WS2.CLOSING = WS.CLOSING; WS2.CLOSED = WS.CLOSED; } window.WebSocket = WS2; } } wrapStreams(); })();