pbl_scense_ext/wwwroot/overlay/pbl_overlay_transport.js
2026-09-21 11:53:56 +08:00

267 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* pbl_overlay_transport.js —— 覆盖层传输层(M9 / pbl_scense_ext)
*
* 职责:① 契约调用(bootstrap / 意图上行 / 证据 / 反馈);② 广播通道(SSE 优先,长轮询降级,
* Q-OPEN-6 定稿);③ seq 幂等去重 + 掉线补齐(get_state_snapshot + poll_events(since));
* ④ 广播渲染时延预算 ≤500ms 的计时与降级。
*
* 纪律:本层只搬运数据并派发 pbl:broadcast-received 事件,DOM 更新由 pbl_overlay.js 负责,
* 不触碰 scense/scense_game 渲染主循环。
*/
(function (global) {
'use strict';
var Core = global.PblOverlayCore;
if (!Core) { throw new Error('pbl_overlay_core.js 必须先于 pbl_overlay_transport.js 加载'); }
var BUDGETS = Core.BUDGETS;
// 契约端点表(模块自动路由 /{module}/api/{name}.dspy)
var ENDPOINTS = {
'bootstrap': '/pbl_scense_ext/api/pbl_overlay_bootstrap.dspy',
'page_registry': '/pbl_scense_ext/api/pbl_page_registry_list.dspy',
'session_ctx': '/pbl_scense_ext/api/pbl_session_context_get.dspy',
'feedback_save': '/pbl_scense_ext/api/pbl_playtest_feedback_save.dspy',
// 运行时(pbl_runtime_ext,M11a/M11b 权威实现)
'event_poll': '/pbl_runtime_ext/api/pbl_runtime_event_poll.dspy',
'broadcast_pull': '/pbl_runtime_ext/api/pbl_runtime_broadcast_pull.dspy',
'entity_state': '/pbl_runtime_ext/api/pbl_entity_state_get.dspy',
'world_broadcast':'/pbl_runtime_ext/api/pbl_world_broadcast.dspy',
// 意图上行 / 证据 / 评估(其余 pbl_* 模块契约)
'submit_intent': '/pbl_runtime_ext/api/pbl_world_broadcast.dspy',
'entity_apply': '/pbl_runtime_ext/api/pbl_entity_state_apply.dspy',
'evidence_collect': '/pbl_evidence/api/pbl_collect_evidence.dspy',
'artifact_create': '/pbl_evidence/api/pbl_create_artifact.dspy',
'artifact_submit': '/pbl_evidence/api/pbl_submit_artifact.dspy',
'assessment_report': '/pbl_assessment/api/pbl_assessment_report.dspy'
};
function apiBase() {
// 去掉页面路由尾部,得到应用根(与 scense_game game.js 同策略,不改基线)
return (global.__PBL_API_BASE__ || String(location.pathname).replace(/\/(pbl_overlay|game|scense_game)\/.*$/, '')).replace(/\/$/, '');
}
function Transport(opts) {
opts = opts || {};
this.sessionId = opts.session_id || null;
this.blueprintId = opts.blueprint_id || null;
this.lastSeq = Number(opts.since_seq || 0);
this.pending = {}; // seq -> true(去重)
this.sse = null;
this.timer = null;
this.stopped = false;
this.cache = {}; // 断网时供降级态展示缓存数据
this.onEvent = opts.onEvent || function () {};
this.onDegrade = opts.onDegrade || function () {};
this.onPerf = opts.onPerf || function () {};
}
/* ───────────── 1. 契约调用(POST,参数进 params_kw) ───────────── */
Transport.prototype.call = function (name, params) {
var url = ENDPOINTS[name];
if (!url) { return Promise.reject({ code: 'PBL_E_NOT_FOUND', message: '未知契约 ' + name }); }
var body = Object.assign({}, params || {});
if (this.sessionId && body.session_id === undefined) { body.session_id = this.sessionId; }
if (this.blueprintId && body.blueprint_id === undefined) { body.blueprint_id = this.blueprintId; }
var ctrl = ('AbortController' in global) ? new AbortController() : null;
var to = ctrl ? setTimeout(function () { ctrl.abort(); }, BUDGETS.intent_timeout_ms) : null;
var self = this;
return fetch(apiBase() + url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
body: JSON.stringify(body),
signal: ctrl ? ctrl.signal : undefined
}).then(function (r) {
if (to) { clearTimeout(to); }
if (r.status === 401 || r.status === 403) { return { status: 'fail', code: 'PBL_E_FORBIDDEN', message: 'HTTP ' + r.status }; }
if (r.status === 429) { return { status: 'fail', code: 'PBL_RATE_LIMIT', message: '请求过于频繁' }; }
if (!r.ok) { return { status: 'fail', code: 'PBL_E_NETWORK', message: 'HTTP ' + r.status }; }
return r.json();
}).then(function (res) {
if (res && res.status === 'fail') {
self.onDegrade(res.code || 'PBL_E_NETWORK', res.message || res.msg || '');
var e = new Error(res.message || 'contract fail');
e.code = res.code; e.payload = res;
throw e;
}
self.cache[name] = { at: Date.now(), data: res };
return res;
}).catch(function (err) {
if (err && err.name === 'AbortError') { self.onDegrade('PBL_E_TIMEOUT', '契约超时 ' + name); }
else if (err && !err.code) { self.onDegrade('PBL_E_NETWORK', '网络中断'); }
throw err;
});
};
/* ───────────── 2. S1 进入世界:一次 bootstrap 拿齐上下文 ───────────── */
Transport.prototype.bootstrap = function (extra) {
var self = this;
return this.call('bootstrap', Object.assign({ recent_events: 20 }, extra || {})).then(function (ctx) {
var d = (ctx && ctx.data) || ctx || {};
self.context = d;
if (Array.isArray(d.recent_events)) {
d.recent_events.forEach(function (ev) { self._accept(ev, true); });
}
if (d.max_seq !== undefined) { self.lastSeq = Math.max(self.lastSeq, Number(d.max_seq) || 0); }
return d;
});
};
Transport.prototype.pageRegistry = function () {
return this.call('page_registry', { page_size: 50 }).then(function (r) {
var d = (r && r.data) || r || {};
return d.rows || d.data || d || [];
});
};
/* ───────────── 3. S2 意图上行(前端无权威状态,仅发起) ───────────── */
Transport.prototype.submitIntent = function (intent) {
var payload = {
intent_type: intent.type || 'action',
action: intent.action || '',
entity_id: intent.entity_id || '',
payload_json: JSON.stringify(intent.payload || {}),
client_ts: Date.now()
};
var self = this;
Core.Bus.emit('pbl:intent-submitted', payload);
return this.call(intent.channel || 'submit_intent', payload).then(function (r) {
var d = (r && r.data) || r || {};
// 服务端权威:只有回传 seq 后才允许更新覆盖层(不预测渲染)
if (d.seq !== undefined) { self._accept({ seq: d.seq, event_type: intent.action || 'ack', payload_json: d }, true); }
return d;
});
};
/* ───────────── 4. 广播通道:SSE 优先,长轮询降级 ───────────── */
Transport.prototype.subscribe = function () {
var self = this;
if (this.stopped) { return; }
var sseUrl = apiBase() + '/pbl_runtime_ext/api/pbl_runtime_broadcast_pull.dspy?session_id=' +
encodeURIComponent(this.sessionId || '') + '&since_seq=' + this.lastSeq;
var ok = false;
if (typeof global.EventSource === 'function' && !this.sse) {
try {
this.sse = new global.EventSource(sseUrl);
this.sse.onmessage = function (m) {
var ev = null;
try { ev = JSON.parse(m.data); } catch (e) { ev = null; }
if (ev) { self._accept(ev); }
};
this.sse.onerror = function () {
self.closeSse();
self.onDegrade('PBL_RENDER_DEGRADED', '实时通道降级为长轮询');
self.poll();
};
ok = true;
} catch (e) { ok = false; }
}
if (!ok) { this.poll(); }
};
Transport.prototype.closeSse = function () {
if (this.sse) { try { this.sse.close(); } catch (e) { } this.sse = null; }
};
/** 长轮询降级:固定节拍拉取增量(seq 单调,去重幂等) */
Transport.prototype.poll = function () {
var self = this;
if (this.stopped) { return; }
if (this.timer) { clearInterval(this.timer); }
this.timer = setInterval(function () { self.pollOnce(); }, BUDGETS.poll_interval_ms);
this.pollOnce();
};
Transport.prototype.pollOnce = function () {
var self = this;
return this.call('broadcast_pull', { since_seq: this.lastSeq, limit: 50 })
.then(function (r) {
var d = (r && r.data) || r || {};
var evs = d.events || d.rows || [];
for (var i = 0; i < evs.length; i++) { self._accept(evs[i]); }
if (d.max_seq !== undefined) { self.lastSeq = Math.max(self.lastSeq, Number(d.max_seq) || 0); }
})
.catch(function () { self.onDegrade('PBL_E_NETWORK', '广播拉取失败,使用缓存数据'); });
};
/** seq 去重 + 计时(广播到达 → 交给上层渲染,≤500ms 预算) */
Transport.prototype._accept = function (ev, silent) {
if (!ev) { return false; }
var seq = Number(ev.seq !== undefined ? ev.seq : (ev.event_seq || 0));
if (seq > 0) {
if (seq <= this.lastSeq || this.pending[seq]) { return false; }
this.pending[seq] = true;
this.lastSeq = Math.max(this.lastSeq, seq);
}
var payload = ev.payload_json;
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { payload = { raw: payload }; } }
var item = {
seq: seq,
event_type: ev.event_type || ev.type || 'unknown',
entity_id: ev.entity_id || (payload && payload.entity_id) || '',
payload: payload || {},
received_at: Core.now()
};
if (!silent) { Core.Bus.emit('pbl:broadcast-received', item); }
this.onEvent(item, !!silent);
return true;
};
/* ───────────── 5. 断线重连补齐(Q4:快照 + since 补发) ───────────── */
Transport.prototype.reconnect = function () {
var self = this;
return this.call('entity_state', { session_id: this.sessionId })
.then(function (r) {
var d = (r && r.data) || r || {};
self.snapshot = d;
Core.Bus.emit('pbl:snapshot-applied', d);
return self.call('event_poll', { since_event_id: self.lastSeq, limit: 100 });
})
.then(function (r) {
var d = (r && r.data) || r || {};
(d.events || d.rows || []).forEach(function (ev) { self._accept(ev); });
self.subscribe();
return true;
})
.catch(function () { self.onDegrade('PBL_E_NETWORK', '重连失败,保持只读缓存'); return false; });
};
/* ───────────── 6. S3/S4 产出物、证据、反馈 ───────────── */
Transport.prototype.createArtifact = function (a) {
return this.call('artifact_create', {
title: a.title || '', artifact_type: a.artifact_type || 'document',
content_txt: a.content_txt || '', session_id: this.sessionId
});
};
Transport.prototype.submitArtifact = function (artifactId) {
return this.call('artifact_submit', { artifact_id: artifactId, session_id: this.sessionId });
};
Transport.prototype.collectEvidence = function (e) {
return this.call('evidence_collect', {
evidence_type: e.evidence_type || 'artifact_version',
source_id: e.source_id || '', session_id: this.sessionId
});
};
Transport.prototype.saveFeedback = function (f) {
return this.call('feedback_save', {
feedback_type: f.feedback_type || 'feedback',
rating: f.rating === undefined ? '' : f.rating,
content_txt: f.content_txt || '',
context_json: f.context_json || {}
});
};
Transport.prototype.stop = function () {
this.stopped = true;
this.closeSse();
if (this.timer) { clearInterval(this.timer); this.timer = null; }
};
global.PblOverlayTransport = {
ENDPOINTS: ENDPOINTS,
apiBase: apiBase,
Transport: Transport
};
})(window);