- 新增 web/mark-glow-192.png(抠底透明版,浮层专用)及 favicon.ico/.png/-180/-192 - portal/login/admin/register 四页 head 由 /favicon.svg 改为 ico+png+180 三行 - portal.html 顶栏品牌位 /favicon.svg -> /favicon-192.png - 浮层 assets/inject/recovery.js:三辐条 hub 改为抠底标记(光环 + 虚线环, 青色 #38d6d0 / 紫 #7c58ff),标记 96->132px;发光由 box-shadow 改为 filter:drop-shadow(box-shadow 沿元素矩形绘制,会在抠底图外画出一圈方形光晕) - 顺带清掉旧品牌蓝 #4d7cfe / rgba(77,124,254) -> #7c58ff 标记真源 = 用户 2026-09-20 提供的六边形芯片图标。旧三辐条 favicon.svg 至此无引用。
602 lines
27 KiB
JavaScript
602 lines
27 KiB
JavaScript
/* ─────────────────────────────────────────────────────────────────────────────
|
||
* 状态机(单一来源,档案 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 回退)。
|
||
// 2026-09-21 二次改造:浮层标记换成**用户提供的标记本体**(六边形芯片 + 节点网络 + 青紫辉光)。
|
||
// 上一版是按 web/favicon.svg 复刻的三辐条 hub —— 那是错源:该 svg 已不是站点图标,
|
||
// 页面 head 早已改引 /favicon.png。现在浮层直接引用**与站点图标同一份**静态资源
|
||
// (/favicon-192.png),从根上消除"浮层图标 ≠ 站点图标"。
|
||
// 动画职责改由标记外围承担:虚线环缓转 + 青紫光晕呼吸 + 标记脉冲;
|
||
// 进入 = 缩放淡入;失败 = .__dsh-fail(标记转灰 + 环/晕转 --danger #e5484d)。
|
||
// ⛔ 不再出现旧 DeepSeek 品牌蓝 #4d7cfe(紫色统一用新标记的 #7c58ff)。
|
||
// ⚠️ 本文件是注入脚本的模板字面量 —— 内容不得含反引号 / 美元花括号;
|
||
// 故 CSS 里的字体名一律用单引号('Segoe UI'),JS 字符串用双引号。
|
||
st.textContent = [
|
||
"@keyframes __dshr-in{0%{transform:scale(.8)}60%{transform:scale(1.05)}100%{transform:scale(1)}}",
|
||
"@keyframes __dshr-spin{to{transform:rotate(360deg)}}",
|
||
"@keyframes __dshr-halo{0%,100%{opacity:.42;transform:scale(.94)}50%{opacity:.85;transform:scale(1.08)}}",
|
||
"@keyframes __dshr-scan{0%{transform:translateX(-115%)}100%{transform:translateX(275%)}}",
|
||
"@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(880px 460px at 50% 26%, rgba(56,214,208,.12), transparent 62%),radial-gradient(720px 420px at 50% 86%, rgba(124,88,255,.12), transparent 64%),rgba(8,15,28,.92);",
|
||
"}",
|
||
".__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(18,31,52,.88), rgba(10,18,33,.92));",
|
||
"box-shadow:0 1px 0 rgba(255,255,255,.05) inset,0 0 0 1px rgba(56,214,208,.20),0 18px 48px rgba(0,0,0,.55);",
|
||
"-webkit-backdrop-filter:blur(9px);backdrop-filter:blur(9px);",
|
||
"}",
|
||
".__dsh-hub{position:relative;width:132px;height:132px;display:flex;align-items:center;justify-content:center}",
|
||
".__dsh-hub-halo{position:absolute;inset:-20px;border-radius:50%;background:radial-gradient(circle, rgba(56,214,208,.30), rgba(124,88,255,.16) 46%, transparent 70%);animation:__dshr-halo 2.8s ease-in-out infinite}",
|
||
".__dsh-hub-ring{position:absolute;inset:-9px;border-radius:50%;border:1px dashed rgba(56,214,208,.42);animation:__dshr-spin 9s linear infinite}",
|
||
"/* 标记上只挂一个动画:两个动画抢同一属性时后声明的会覆盖前者,会让标记停在 0% 不可见 */",
|
||
"/* (2026-09-21 实测:pulse 覆盖 in 的 opacity 后 opacity=0,图片已加载也照样看不见)。 */",
|
||
"/* 呼吸感由 .__dsh-hub-halo 的 __dshr-halo 负责,旋转感由 .__dsh-hub-ring 负责。 */",
|
||
"/* ⛔ 抠底标记禁用 box-shadow:它沿**元素矩形边界**绘制 ⇒ 会在透明图外面画出一圈方形光晕, */",
|
||
"/* 看起来就像又加了个矩形底(2026-09-21 实测)。发光必须用 filter:drop-shadow,它跟随 alpha 轮廓。 */",
|
||
".__dsh-hub-icon{position:relative;z-index:2;display:block;width:132px;height:132px;filter:drop-shadow(0 0 16px rgba(56,214,208,.55)) drop-shadow(0 0 40px rgba(124,88,255,.42));animation:__dshr-in .62s cubic-bezier(.22,.9,.24,1) both}",
|
||
".__dsh-fail .__dsh-hub-icon{filter:grayscale(.75) brightness(.62) contrast(1.05) drop-shadow(0 0 16px rgba(229,72,77,.5));animation:none}",
|
||
".__dsh-fail .__dsh-hub-ring{border-color:rgba(229,72,77,.5);animation:none}",
|
||
".__dsh-fail .__dsh-hub-halo{background:radial-gradient(circle, rgba(229,72,77,.24), transparent 70%);animation:none}",
|
||
".__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(214,247,245,.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(140,192,196,.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, #38d6d0 45%, #7c58ff 70%, transparent);animation:__dshr-scan 1.45s ease-in-out infinite}",
|
||
"@media (prefers-reduced-motion: reduce){",
|
||
".__dsh-hub-icon,.__dsh-hub-ring,.__dsh-hub-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';
|
||
// 品牌标记 = 与站点 favicon 同一份静态资源(web/favicon-192.png,平台在实例子域同源提供)。
|
||
// 全部 createElement 构建(不用 innerHTML —— 实例页可能启用 Trusted Types)。
|
||
var card = document.createElement('div');
|
||
card.className = '__dsh-card';
|
||
var orb = document.createElement('div');
|
||
orb.className = '__dsh-hub';
|
||
var halo = document.createElement('div');
|
||
halo.className = '__dsh-hub-halo';
|
||
orb.appendChild(halo);
|
||
var ring = document.createElement('div');
|
||
ring.className = '__dsh-hub-ring';
|
||
orb.appendChild(ring);
|
||
// 静态资源取不到时只隐藏标记、保留光环(不出现破图)—— halo/ring 负责兜住存在感。
|
||
var mark = document.createElement('img');
|
||
mark.className = '__dsh-hub-icon';
|
||
// 用**抠底发光版**(无方形底),不是带深色矩形底的 favicon-192.png —— 后者在浮层里
|
||
// 是一块方块,与"发光标记"的观感不符(用户 2026-09-21 指正)。资源 = 用户标记的
|
||
// transparent-glow 加工版(luma 斜坡抠底,只适合叠在深/中低明度底上,浮层正好是深底)。
|
||
// ⚠️ 不能用子域相对路径:实例子域只放行 /favicon.svg,其它静态资源会被鉴权拦成 401
|
||
// (实测 guest.ai1net.com/favicon-192.png = 401、design.css = 401)。改取**门户根域**
|
||
// (实测 https://ai1net.com/favicon-192.png = 200,且无 CORP/CORS 限制 ⇒ 跨域 img 可加载)。
|
||
// 根域由当前 host 去掉首段推出(IP 或两级以内域名则原样使用)。
|
||
var _hn = location.hostname, _hp = _hn.split('.');
|
||
var _root = location.protocol + '//' + (/^[\d.]+$/.test(_hn) || _hp.length <= 2 ? _hn : _hp.slice(-2).join('.'))
|
||
+ (location.port ? ':' + location.port : '');
|
||
mark.src = _root + '/mark-glow-192.png';
|
||
mark.alt = '';
|
||
mark.decoding = 'async';
|
||
mark.onerror = function () { mark.style.display = 'none'; };
|
||
orb.appendChild(mark);
|
||
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:#062028;font-weight:600;' +
|
||
'background:linear-gradient(135deg,#38d6d0,#8af0e6);box-shadow:0 0 18px rgba(56,214,208,.4)'
|
||
);
|
||
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 的秒表
|
||
// 视觉切「失败态」:中心节点转警示红、辐条与外环节点熄灭(配色见 ensureCss 的 .__dsh-fail)。
|
||
// 用宽匹配(不带 __dsh-hub 前缀)以兼容 hub 容器同时带两个类名的情况。
|
||
var hub = document.getElementById('__dshRecover');
|
||
hub = hub && hub.querySelector('.__dsh-hub');
|
||
if (hub) hub.className = '__dsh-hub __dsh-fail';
|
||
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();
|
||
})();
|