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

777 lines
33 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_play.js —— M9 覆盖层装配 + 事件处理器(scense / scense_game 侧)
*
* 铁律:
* 1. 不修改 scense/scense_game 渲染主循环 —— 只做「外挂」:探测宿主只读钩子
* (window.scenseGame / CustomEvent),钩子缺失时降级为契约轮询,绝不
* 包裹/注入宿主帧函数(requestAnimationFrame 链不劫持)。
* 2. 广播到达 → 覆盖层 DOM 更新完成 ≤ 500ms(BUDGETS.broadcast_render_ms),
* 超预算打 RENDER_DEGRADED 横幅并自适应节流合并补丁,不丢数据、不静默。
* 3. 首屏 ≤ 1500ms(BUDGETS.first_screen_ms),超时走 LOAD_FAIL 骨架 + 局部重试。
* 4. 网络交互只走 pbl_overlay_transport.js 契约端点(pbl_* 模块 .dspy)。
*
* 依赖加载顺序:pbl_overlay_core.js → pbl_overlay_transport.js → 本文件
* API 对齐:Core.Bus(全局单例,on=window 监听 / emit=document CustomEvent)、
* Core.Chrono(label,budget,onOver).stop()、
* Core.DegradeManager(root).show(key,{detail})/.fromError(err)、
* Tpt.Transport({session_id,blueprint_id,onEvent,onDegrade,onPerf})
* ==========================================================================*/
(function (global) {
'use strict';
var Core = global.PblOverlayCore;
var Tpt = global.PblOverlayTransport;
if (!Core || !Tpt) {
if (global.console) { console.error('[pbl_overlay_play] core/transport 未加载'); }
return;
}
var BUDGETS = Core.BUDGETS;
var esc = Core.esc;
var now = Core.now;
/* ───────────────────────── 1. 页面路由解析(注册表 route → 实际 URL) ───────────────────────── */
// 设计注册表 route 形如 /pbl_overlay/mission_board.ui;
// 模块自动路由实际为 /pbl_scense_ext/pbl_overlay/mission_board.ui
var MODULE_PREFIX = '/pbl_scense_ext';
function pageUrl(page) {
var route = (page && page.route) || '';
if (!route) { return ''; }
if (route.indexOf(MODULE_PREFIX + '/') === 0) { return Tpt.apiBase() + route; }
if (route.charAt(0) === '/') { return Tpt.apiBase() + MODULE_PREFIX + route; }
return Tpt.apiBase() + MODULE_PREFIX + '/pbl_overlay/' + route;
}
function readQuery() {
var out = {};
var qs = String(location.search || '').replace(/^\?/, '');
if (!qs) { return out; }
qs.split('&').forEach(function (kv) {
if (!kv) { return; }
var i = kv.indexOf('=');
var k = i < 0 ? kv : kv.slice(0, i);
var v = i < 0 ? '' : kv.slice(i + 1);
try {
out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' '));
} catch (e) { out[k] = v; }
});
return out;
}
function hostPageKey() {
var el = document.querySelector('[data-pbl-page]');
if (el && el.getAttribute('data-pbl-page')) { return el.getAttribute('data-pbl-page'); }
var m = String(location.pathname).match(/\/pbl_overlay\/([a-z_]+)\.ui/);
if (m) { return m[1]; }
return readQuery().page || 'play_preview';
}
function readContext() {
// 上下文优先级:宿主注入 __PBL_OVERLAY_CTX__ > URL query > 覆盖层本地草稿
var inj = global.__PBL_OVERLAY_CTX__ || {};
var q = readQuery();
var draft = {};
try { draft = JSON.parse(global.localStorage.getItem('pbl.overlay.ctx') || '{}') || {}; } catch (e) { draft = {}; }
var ctx = {
page: q.page || inj.page || null,
session_id: q.session_id || inj.session_id || draft.session_id || null,
blueprint_id: q.blueprint_id || inj.blueprint_id || draft.blueprint_id || null,
world_id: q.world_id || inj.world_id || draft.world_id || null,
role: inj.role || q.role || null
};
try {
global.localStorage.setItem('pbl.overlay.ctx', JSON.stringify({
session_id: ctx.session_id, blueprint_id: ctx.blueprint_id, world_id: ctx.world_id
}));
} catch (e) { /* 隐私模式忽略 */ }
return ctx;
}
/* ───────────────────────── 2. scense / scense_game 只读桥(不改渲染主循环) ───────────────────────── */
var ScenseBridge = {
renderer: function () {
return global.scenseGame || global.SCENSE_GAME || global.__SCENSE_RUNTIME__ || null;
},
// 仅探测宿主状态,不写回
hasRenderer: function () { return !!this.renderer(); },
rendererStatus: function () {
var g = this.renderer();
if (!g) { return { available: false, paused: null, entities: 0 }; }
var n = 0;
if (typeof g.entityCount === 'number') { n = g.entityCount; }
else if (g.entities && g.entities.length !== undefined) { n = g.entities.length; }
return { available: true, paused: (typeof g.paused === 'boolean') ? g.paused : null, entities: n };
},
// 订阅宿主广播的 CustomEvent(宿主没有该事件时零副作用,靠 transport 通道兜底)
onHostEvent: function (handler) {
var names = ['scense:entity_updated', 'scense:broadcast', 'scense:state-applied', 'pbl:broadcast'];
names.forEach(function (n) {
try {
global.addEventListener(n, function (ev) { handler((ev && ev.detail) || {}); }, false);
} catch (e) { /* 忽略 */ }
});
},
// Play 控制:优先宿主 API(只调用不修改),否则回落 scense_game 契约端点
control: function (action, ctx) {
var g = this.renderer();
if (g && typeof g[action] === 'function') {
try {
var r = g[action]();
if (r && typeof r.then === 'function') { return r; }
return Promise.resolve({ status: 'ok', via: 'host', action: action });
} catch (e) { /* 继续走契约 */ }
}
var map = {
start: 'game_start', pause: 'game_pause', resume: 'game_resume',
restart: 'game_restart', end: 'game_end'
};
var ep = map[action];
if (!ep) { return Promise.reject({ code: 'PBL_E_NOT_FOUND', message: '未知控制 ' + action }); }
return fetch(Tpt.apiBase() + '/scense_game/game/api/' + ep + '.dspy', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
body: JSON.stringify({
session_id: (ctx && ctx.session_id) || '',
world_id: (ctx && ctx.world_id) || ''
})
}).then(function (r) {
if (!r.ok) { return { status: 'fail', code: 'PBL_E_NETWORK', message: 'HTTP ' + r.status }; }
return r.json();
});
}
};
/* ───────────────────────── 3. 覆盖层实例 ───────────────────────── */
function Overlay(opts) {
opts = opts || {};
this.pageKey = opts.page || hostPageKey() || 'play_preview';
this.page = Core.byKey(this.pageKey) || Core.byKey('play_preview');
this.ctx = opts.context || readContext();
this.chrono = new Core.Chrono('first_screen', BUDGETS.first_screen_ms, function (ms) {
if (global.__PBL_OVERLAY__) {
global.__PBL_OVERLAY__.degrade.show('RENDER_DEGRADED', { detail: '首屏 ' + Math.round(ms) + 'ms 超预算 ' + BUDGETS.first_screen_ms + 'ms' });
}
});
this.degrade = new Core.DegradeManager(opts.root || this.pageRoot());
this.components = new Core.Components();
this.transport = null;
this.model = {};
this.patchQueue = [];
this.patchScheduled = false;
this.lastPatchCost = 0;
this.throttleMs = 0; // 超预算后自适应节流
this.mounted = false;
this.destroyed = false;
this.perf = { first_screen_ms: null, broadcast_render_ms: null };
this.emitter = document; // 局部事件走 document CustomEvent(pbl-ov:* 前缀)
this._unsubs = [];
this.registerBuiltinComponents();
this.registerEventHandlers();
}
Overlay.prototype.pageRoot = function () {
return document.querySelector('[data-pbl-overlay-root]') || document.body;
};
/* ── 局部事件总线(不占用全局 Bus 命名空间,避免与宿主事件混淆) ── */
Overlay.prototype.on = function (name, fn) {
var h = function (ev) { fn((ev && ev.detail) || {}); };
this.emitter.addEventListener(name, h, false);
this._unsubs.push([name, h]);
return this;
};
Overlay.prototype.emit = function (name, detail) {
return Core.Bus.emit('pbl-ov:' + name, detail);
};
Overlay.prototype.mount = function () {
if (this.mounted) { return Promise.resolve(this); }
this.mounted = true;
var self = this;
this.transport = new Tpt.Transport({
session_id: this.ctx.session_id,
blueprint_id: this.ctx.blueprint_id,
since_seq: 0,
onEvent: function (item) { self.ingestTransport(item); },
onDegrade: function (code, msg) { self.degrade.show(self.degrade.mapCode(code), { detail: msg || '' }); },
onPerf: function (name, ms) { self.perf[name] = ms; }
});
// S1:bootstrap → 首屏渲染(≤1500ms 门禁由 Chrono 自动判定)
return this.transport.bootstrap({ world_id: this.ctx.world_id, page: this.pageKey })
.then(function (d) {
self.model = normalizeBootstrap(d);
self.degrade.hide('LOAD_FAIL');
self.renderAll();
self.chrono.stop();
self.startStreams();
self.writePerfBadge();
return self;
})
.catch(function (err) {
self.degrade.fromError(err);
// 断网/超时:用本地缓存模型渲染骨架,保证首屏不完全空白 + 局部重试按钮
self.model = cachedModel(self.pageKey);
self.renderAll(true);
self.chrono.stop();
self.startStreams();
self.writePerfBadge();
return self;
});
};
// 事件流:transport(SSE→长轮询自动降级)+ 宿主 CustomEvent(只读旁路)
Overlay.prototype.startStreams = function () {
var self = this;
this.transport.subscribe();
ScenseBridge.onHostEvent(function (detail) { self.ingestHost(detail); });
// 重试按钮(core DegradeManager banner 事件委托:data-act=retry 会派发 pbl:degrade-retry)
Core.Bus.on('pbl:degrade-retry', function () {
self.retryLast();
});
this.on('navigate', function (p) { self.navigate(p && p.page); });
this.on('control', function (p) {
ScenseBridge.control(p && p.action, self.ctx).then(function (res) {
if (res && res.status === 'fail') {
self.degrade.show(self.degrade.mapCode(res.code || 'PBL_E_NETWORK'), { detail: res.message || '' });
} else {
self.degrade.show('SESSION_ENDED', { detail: '控制指令已下发:' + (p && p.action), transient: true });
}
}).catch(function (e) { self.degrade.fromError(e); });
});
};
Overlay.prototype.retryLast = function () {
var self = this;
return this.transport.bootstrap({ world_id: this.ctx.world_id, page: this.pageKey })
.then(function (d) {
self.model = normalizeBootstrap(d);
self.degrade.hide('LOAD_FAIL');
self.degrade.hide('NET_FAIL');
self.renderAll();
return d;
})
.catch(function (e) { self.degrade.fromError(e); });
};
/* ───────────────────────── 4. 广播 → 覆盖层补丁(≤500ms 门禁 + 自适应节流) ───────────────────────── */
// transport 通道:item = {seq,event_type,entity_id,payload,received_at}
Overlay.prototype.ingestTransport = function (item) {
var patch = normalizeTransportPatch(item);
if (!patch) { return; }
this.patchQueue.push(patch);
this.schedulePatch();
};
// 宿主 CustomEvent 旁路(无 seq,仅做展示层增量,不参与权威状态)
Overlay.prototype.ingestHost = function (detail) {
if (!detail || (detail.seq !== undefined && detail.seq !== null)) { return; } // 带 seq 的走 transport
this.patchQueue.push(normalizeHostPatch(detail));
this.schedulePatch();
};
Overlay.prototype.schedulePatch = function () {
var self = this;
if (this.patchScheduled) { return; }
this.patchScheduled = true;
var wait = this.throttleMs;
var raf = global.requestAnimationFrame || function (fn) { return global.setTimeout(fn, 16); };
global.setTimeout(function () { raf(function () { self.flushPatches(); }); }, wait);
};
Overlay.prototype.flushPatches = function () {
this.patchScheduled = false;
if (this.destroyed || !this.patchQueue.length) { return; }
var batch = this.patchQueue.splice(0, this.patchQueue.length);
var t0 = now();
for (var i = 0; i < batch.length; i++) { this.applyPatch(batch[i]); }
this.renderDynamic();
persistModel(this.pageKey, this.model);
var cost = Math.round(now() - t0);
this.lastPatchCost = cost;
this.perf.broadcast_render_ms = cost;
this.writePerfBadge();
if (cost > BUDGETS.broadcast_render_ms) {
// 超预算:横幅 + 自适应节流(合并更多补丁),不丢数据
this.throttleMs = Math.min(2000, Math.max(200, this.throttleMs + 200));
this.degrade.show('RENDER_DEGRADED', {
detail: '广播渲染 ' + cost + 'ms 超预算 ' + BUDGETS.broadcast_render_ms + 'ms,已合并补丁(节流 ' + this.throttleMs + 'ms)'
});
} else if (this.throttleMs > 0) {
this.throttleMs = Math.max(0, this.throttleMs - 100);
}
};
Overlay.prototype.applyPatch = function (patch) {
var m = this.model;
if (!m || !patch) { return; }
if (patch.entity_id) {
if (!m.entities) { m.entities = {}; }
var prev = m.entities[patch.entity_id] || {};
m.entities[patch.entity_id] = Object.assign({}, prev, patch.state || {});
m.entities[patch.entity_id].__rev = (prev.__rev || 0) + 1;
m.entity_count = Object.keys(m.entities).length;
}
if (patch.event_type) {
if (!m.events) { m.events = []; }
m.events.unshift({ type: patch.event_type, text: patch.text || patch.event_type, at: patch.at });
if (m.events.length > 50) { m.events.length = 50; }
}
if (patch.progress !== undefined && patch.progress !== null) { m.progress = Number(patch.progress); }
if (patch.quality_state) { m.quality_state = patch.quality_state; }
if (patch.artifact) { (m.artifacts = m.artifacts || []).unshift(patch.artifact); }
m.lastEvent = patch;
this.emit('model-patched', patch);
};
function normalizeTransportPatch(item) {
if (!item) { return null; }
var p = item.payload || {};
return {
source: 'broadcast',
seq: item.seq,
event_type: item.event_type || 'patch',
entity_id: item.entity_id || p.entity_id || null,
state: p.state || p.patch || null,
text: p.text || p.message || item.event_type || '',
progress: (p.progress !== undefined) ? p.progress : undefined,
quality_state: p.quality_state || null,
artifact: p.artifact || null,
at: item.received_at || now()
};
}
function normalizeHostPatch(detail) {
detail = detail || {};
return {
source: 'host',
seq: null,
event_type: detail.event_type || detail.type || 'host_patch',
entity_id: detail.entity_id || null,
state: detail.state || detail.patch || null,
text: detail.text || detail.message || '',
progress: detail.progress,
quality_state: detail.quality_state || null,
artifact: detail.artifact || null,
at: detail.at || now()
};
}
/* ───────────────────────── 5. 11 组件注册 + 渲染(覆盖层内 DOM,不触碰渲染层 canvas) ───────────────────────── */
Overlay.prototype.registerBuiltinComponents = function () {
var self = this;
var C = this.components;
C.register('TopBar', function (host, m) {
host.innerHTML =
'<div class="pbl-topbar-in">' +
'<span class="pbl-title">' + esc(m.page_title || '') + '</span>' +
'<span class="pbl-sub">' + esc(m.blueprint_name || '') + (m.version ? ' v' + esc(m.version) : '') + '</span>' +
'<span class="pbl-spacer"></span>' +
'<span class="pbl-badge" data-badge="quality">' + esc(m.quality_state || '-') + '</span>' +
'<span class="pbl-badge" data-badge="perf">-</span>' +
'</div>';
});
C.register('NavRail', function (host, m) {
var pages = Core.pagesOfRole(m.role || 'logined');
var html = '<nav class="pbl-navrail-in">';
for (var i = 0; i < pages.length; i++) {
var on = pages[i].key === m.page_key ? ' is-active' : '';
html += '<a class="pbl-navitem' + on + '" href="' + esc(self.navHref(pages[i].key)) +
'" data-pbl-nav="' + esc(pages[i].key) + '">' + esc(pages[i].title) + '</a>';
}
host.innerHTML = html + '</nav>';
});
C.register('GoalCard', function (host, m) {
var g = m.goal || {};
host.insertAdjacentHTML('beforeend',
'<section class="pbl-card pbl-goal"><h4>驱动问题 / 目标</h4>' +
'<p class="pbl-goal-q">' + esc(g.question || '暂无目标(等待蓝图编译)') + '</p>' +
'<p class="pbl-goal-m">' + esc(g.milestone || '') + '</p></section>');
});
C.register('SceneCard', function (host, m) {
var s = m.scene || {};
host.insertAdjacentHTML('beforeend',
'<section class="pbl-card pbl-scene"><h4>当前场景</h4>' +
'<p>' + esc(s.name || '-') + ' <em>' + esc(s.kind || '') + '</em></p>' +
'<p class="pbl-dim">实体 ' + Number(m.entity_count || 0) + ' · 观察者 ' + Number(m.observer_count || 0) + '</p></section>');
});
C.register('StepList', function (host, m) {
var steps = m.steps || [];
var html = '<section class="pbl-card pbl-steps"><h4>任务步骤</h4><ol>';
for (var i = 0; i < steps.length; i++) {
html += '<li class="' + (steps[i].done ? 'is-done' : '') + '">' +
esc(steps[i].title || steps[i].name || ('步骤' + (i + 1))) + '</li>';
}
host.insertAdjacentHTML('beforeend', html + '</ol></section>');
});
C.register('RubricEditor', function (host, m) {
var rows = m.rubric || [];
var html = '<section class="pbl-card pbl-rubric"><h4>量规(Rubric 加权)</h4>' +
'<table><thead><tr><th>维度</th><th>权重</th><th>得分</th></tr></thead><tbody>';
for (var i = 0; i < rows.length; i++) {
html += '<tr><td>' + esc(rows[i].name || rows[i].dimension || '-') + '</td><td>' +
esc(String(rows[i].weight === undefined ? '' : rows[i].weight)) + '</td><td>' +
esc(String(rows[i].score === undefined ? '-' : rows[i].score)) + '</td></tr>';
}
host.insertAdjacentHTML('beforeend', html + '</tbody></table></section>');
});
C.register('ValidationBadge', function (host, m) {
var v = m.validation || {};
var tone = v.blocked ? 'block' : (v.passed ? 'ok' : 'warn');
host.insertAdjacentHTML('beforeend',
'<span class="pbl-vbadge is-' + tone + '" title="14 维校验">' +
esc(v.summary || v.state || '未校验') + '</span>');
});
C.register('ArtifactViewer', function (host, m) {
var list = m.artifacts || [];
var html = '<section class="pbl-card pbl-artifacts"><h4>产出物 / 证据</h4><div class="pbl-grid">';
for (var i = 0; i < list.length; i++) {
html += '<div class="pbl-tile"><strong>' + esc(list[i].name || list[i].artifact_id || ('#' + i)) + '</strong>' +
'<span>' + esc(list[i].kind || list[i].evidence_type || '') + '</span>' +
'<em>' + esc(list[i].state || list[i].status || '') + '</em></div>';
}
if (!list.length) { html += '<div class="pbl-empty">暂无产出物</div>'; }
html += '</div>' +
'<form data-pbl-form="artifact"><input name="artifact_id" placeholder="产出物ID(幂等)">' +
'<input name="name" placeholder="名称"><button type="submit">提交并采集证据</button></form></section>';
host.insertAdjacentHTML('beforeend', html);
});
C.register('ChatPanel', function (host, m) {
var msgs = m.agent_messages || [];
var html = '<section class="pbl-card pbl-chat"><h4>Agent 对话</h4><div class="pbl-chat-log">';
for (var i = 0; i < msgs.length; i++) {
var fb = msgs[i].fallback || msgs[i].template_fallback ? ' is-fallback' : '';
html += '<div class="pbl-msg is-' + esc(msgs[i].role || 'agent') + fb + '">' +
esc(msgs[i].content || msgs[i].text || '') + '</div>';
}
html += '</div><div class="pbl-chat-input">' +
'<input type="text" data-pbl-chat placeholder="向 Agent 提问…"><button data-pbl-send>发送</button></div></section>';
host.insertAdjacentHTML('beforeend', html);
});
C.register('ApprovalQueue', function (host, m) {
var items = m.approvals || [];
var html = '<section class="pbl-card pbl-approvals"><h4>待人工审批</h4><ul>';
for (var i = 0; i < items.length; i++) {
html += '<li><span>' + esc(items[i].title || items[i].approval_id || '-') + '</span><em>' +
esc(items[i].state || '') + '</em></li>';
}
if (!items.length) { html += '<li class="pbl-empty">暂无待审批项</li>'; }
host.insertAdjacentHTML('beforeend', html + '</ul></section>');
});
C.register('HudOverlay', function (host, m) {
var pct = Math.max(0, Math.min(100, Number(m.progress || 0)));
host.innerHTML =
'<div class="pbl-hud-in">' +
'<div class="pbl-hud-progress"><span style="width:' + pct + '%"></span></div>' +
'<div class="pbl-hud-role">' + esc(m.role_label || m.role || '') + '</div>' +
'<div class="pbl-hud-acts">' +
'<button data-pbl-act="start">开始</button><button data-pbl-act="pause">暂停</button>' +
'<button data-pbl-act="resume">继续</button><button data-pbl-act="restart">重来</button>' +
'<button data-pbl-act="end">结束</button>' +
'</div><div class="pbl-hud-feed"></div></div>';
});
};
Overlay.prototype.hostFor = function (comp) {
var root = this.pageRoot();
var cls = comp.host.replace(/^\./, '');
var el = root.querySelector('.' + cls);
if (!el) {
el = document.createElement('div');
el.className = cls;
if (cls === 'pbl-hud') {
var main = root.querySelector('.pbl-main') || ensure(root, 'pbl-main');
main.appendChild(el);
} else {
root.appendChild(el);
}
}
return el;
};
function ensure(parent, cls) {
var el = parent.querySelector('.' + cls);
if (!el) { el = document.createElement('div'); el.className = cls; parent.appendChild(el); }
return el;
}
Overlay.prototype.renderAll = function (skeletonOnly) {
var comps = Core.COMPONENTS;
var m = Object.assign({}, this.model, {
page_key: this.pageKey,
page_title: this.page.title,
role: this.ctx.role || this.model.role || 'logined'
});
var allowed = comps.filter(function (c) {
return c.pages.indexOf('*') >= 0 || c.pages.indexOf(m.page_key) >= 0;
});
resetHosts(this.pageRoot());
for (var i = 0; i < allowed.length; i++) {
var c = allowed[i];
if (!this.components.has(c.name)) { continue; }
try {
this.components.render(c.name, this.hostFor(c), m);
} catch (e) {
this.degrade.show('RENDER_DEGRADED', { detail: '组件 ' + c.name + ' 渲染失败:' + ((e && e.message) || e) });
}
}
if (skeletonOnly) {
ensure(this.pageRoot(), 'pbl-main').insertAdjacentHTML('beforeend', '<div class="pbl-skeleton"></div>');
}
this.bindDomHandlers();
return this;
};
// 增量重绘:只更新随广播变化的 HUD/徽标,保证单次 ≤500ms
Overlay.prototype.renderDynamic = function () {
var m = this.model;
var bar = document.querySelector('.pbl-hud-progress span');
if (bar) { bar.style.width = Math.max(0, Math.min(100, Number(m.progress || 0))) + '%'; }
var feed = document.querySelector('.pbl-hud-feed');
if (feed && m.lastEvent) {
var line = document.createElement('div');
line.className = 'pbl-feedline';
line.textContent = String(m.lastEvent.text || m.lastEvent.event_type || '');
feed.insertBefore(line, feed.firstChild);
while (feed.childNodes.length > 6) { feed.removeChild(feed.lastChild); }
}
var badge = document.querySelector('[data-badge="quality"]');
if (badge && m.quality_state) { badge.textContent = m.quality_state; }
this.emit('rendered', m);
};
function resetHosts(root) {
['.pbl-topbar', '.pbl-navrail', '.pbl-main'].forEach(function (sel) {
var el = root.querySelector(sel);
if (el) { el.innerHTML = ''; }
});
}
function cachedModel(key) {
try { return JSON.parse(global.localStorage.getItem('pbl.overlay.model.' + key) || '{}') || {}; }
catch (e) { return {}; }
}
function persistModel(key, model) {
try {
global.localStorage.setItem('pbl.overlay.model.' + key, JSON.stringify({
quality_state: model.quality_state, progress: model.progress,
artifacts: (model.artifacts || []).slice(0, 20),
events: (model.events || []).slice(0, 20)
}));
} catch (e) { /* 配额/隐私模式忽略 */ }
}
function normalizeBootstrap(d) {
d = d || {};
var entities = toEntityMap(d.entities);
return {
role: d.role || null,
role_label: d.role_label || d.role_name || null,
blueprint_name: d.blueprint_name || (d.blueprint && d.blueprint.name) || '',
version: d.version || (d.blueprint && d.blueprint.version) || '',
quality_state: d.quality_state || d.quality || '',
goal: d.goal || (d.mission ? { question: d.mission.question, milestone: d.mission.milestone } : {}),
scene: d.scene || d.current_scene || {},
steps: d.steps || d.tasks || [],
artifacts: d.artifacts || [],
rubric: d.rubric || [],
approvals: d.approvals || [],
agent_messages: d.agent_messages || d.messages || [],
validation: d.validation || {},
entities: entities,
entity_count: (d.entity_count !== undefined) ? d.entity_count : Object.keys(entities).length,
observer_count: d.observer_count || 0,
progress: Number(d.progress || 0),
recent_events: d.recent_events || []
};
}
function toEntityMap(list) {
var out = {};
if (!list) { return out; }
if (list instanceof Array) {
for (var i = 0; i < list.length; i++) {
var e = list[i];
if (e && e.entity_id) { out[e.entity_id] = e; }
}
} else if (typeof list === 'object') {
Object.keys(list).forEach(function (k) { out[k] = list[k]; });
}
return out;
}
/* ───────────────────────── 6. 4 组交互时序的事件处理器 ───────────────────────── */
Overlay.prototype.registerEventHandlers = function () {
var self = this;
// S2 做任务:意图上行(服务端权威回传 seq 后才落补丁,不预测渲染)
this.on('intent-submit', function (payload) {
return self.transport.submitIntent({
type: payload.intent_type || 'action',
action: payload.intent || 'interact',
entity_id: payload.target_entity_id || payload.entity_id || '',
payload: payload.payload || {}
}).catch(function (e) { self.degrade.fromError(e); });
});
// S3 产出物:create → submit → collect_evidence(artifact_id 幂等,失败可重试)
this.on('artifact-create', function (a) {
return self.transport.createArtifact(a)
.then(function (res) {
var id = (res && (res.artifact_id || (res.data && res.data.artifact_id))) || a.artifact_id;
return self.transport.submitArtifact(id);
})
.then(function () {
return self.transport.collectEvidence({
evidence_type: a.evidence_type || 'artifact_version', source_id: a.artifact_id || ''
});
})
.catch(function (e) { self.degrade.fromError(e); });
});
// S4 反思 / 反馈
this.on('feedback-save', function (f) {
return self.transport.saveFeedback(f).catch(function (e) { self.degrade.fromError(e); });
});
// 门禁跳转(VALIDATE_BLOCK / APPROVAL_PENDING 横幅「前往处理」)
Core.Bus.on('pbl:degrade', function (ev) {
var d = (ev && ev.detail) || {};
if (d.state === 'VALIDATE_BLOCK') { /* 用户点横幅按钮时才跳转,此处只标记 */ }
if (d.state === 'SESSION_ENDED') { self.degrade.setReadonly(true); }
});
this.on('goto-page', function (p) { self.navigate(p && p.page); });
};
Overlay.prototype.navHref = function (key) {
var p = Core.byKey(key);
if (!p) { return '#'; }
var url = pageUrl(p);
var sep = url.indexOf('?') >= 0 ? '&' : '?';
return url + sep + 'session_id=' + encodeURIComponent(this.ctx.session_id || '') +
'&blueprint_id=' + encodeURIComponent(this.ctx.blueprint_id || '') +
'&world_id=' + encodeURIComponent(this.ctx.world_id || '');
};
Overlay.prototype.navigate = function (key) {
var p = Core.byKey(key || '');
if (!p) { return; }
location.href = this.navHref(p.key);
};
Overlay.prototype.bindDomHandlers = function () {
var self = this;
var root = this.pageRoot();
if (root.getAttribute('data-pbl-bound') === '1') { return; }
root.setAttribute('data-pbl-bound', '1');
root.addEventListener('click', function (ev) {
var t = ev.target;
if (!t) { return; }
var act = closestAttr(t, 'data-pbl-act');
if (act) { ev.preventDefault(); self.emit('control', { action: act }); return; }
var nav = closestAttr(t, 'data-pbl-nav');
if (nav) { /* a[href] 已带上下文参数,交给浏览器跳转 */ return; }
var send = closestAttr(t, 'data-pbl-send');
if (send !== null) {
var inp = root.querySelector('[data-pbl-chat]');
var text = inp ? String(inp.value || '').trim() : '';
if (!text) { return; }
self.emit('intent-submit', { intent: 'ask_agent', payload: { text: text } });
if (inp) { inp.value = ''; }
}
}, false);
root.addEventListener('submit', function (ev) {
var kind = closestAttr(ev.target, 'data-pbl-form');
if (!kind) { return; }
ev.preventDefault();
var data = formToObj(ev.target);
if (kind === 'feedback') { self.emit('feedback-save', data); }
if (kind === 'artifact') { self.emit('artifact-create', data); }
}, false);
};
function closestAttr(el, attr) {
var node = el;
while (node && node !== document) {
if (node.getAttribute && node.getAttribute(attr) !== null) { return node.getAttribute(attr); }
node = node.parentNode;
}
return null;
}
function formToObj(form) {
var out = {};
var els = form.querySelectorAll('[name]');
for (var i = 0; i < els.length; i++) { out[els[i].name] = els[i].value; }
return out;
}
Overlay.prototype.writePerfBadge = function () {
var badge = document.querySelector('[data-badge="perf"]');
if (!badge) { return; }
var fs = this.perf.first_screen_ms === null ? null : Math.round(this.perf.first_screen_ms);
var br = this.perf.broadcast_render_ms === null ? null : Math.round(this.perf.broadcast_render_ms);
badge.textContent = '首屏' + (fs === null ? '-' : (fs + 'ms')) + ' / 广播' + (br === null ? '-' : (br + 'ms'));
var ok = (fs === null || fs <= BUDGETS.first_screen_ms) && (br === null || br <= BUDGETS.broadcast_render_ms);
badge.setAttribute('class', 'pbl-badge ' + (ok ? 'is-ok' : 'is-warn'));
global.__PBL_PERF__ = global.__PBL_PERF__ || {};
global.__PBL_PERF__.overlay = { page: this.pageKey, first_screen_ms: fs, broadcast_render_ms: br };
};
Overlay.prototype.destroy = function () {
this.destroyed = true;
if (this.transport) { this.transport.stop(); }
this.patchQueue.length = 0;
this._unsubs.forEach(function (p) { /* document 级监听随页面卸载回收 */ });
this._unsubs = [];
this.mounted = false;
};
/* ───────────────────────── 7. 自动装配入口 ───────────────────────── */
function boot(opts) {
opts = opts || {};
var root = opts.root || document.querySelector('[data-pbl-overlay-root]') || document.body;
if (!root.getAttribute('data-pbl-overlay-root')) { root.setAttribute('data-pbl-overlay-root', '1'); }
var ov = new Overlay(Object.assign({ root: root }, opts));
global.__PBL_OVERLAY__ = ov;
ov.mount();
return ov;
}
global.PblOverlayPlay = {
VERSION: '1.0.0',
Overlay: Overlay,
ScenseBridge: ScenseBridge,
pageUrl: pageUrl,
readContext: readContext,
boot: boot,
auto: function () {
// 只在带 data-pbl-page 标记的覆盖层页面壳里自动装配,普通业务页零副作用
if (!document.querySelector('[data-pbl-page]')) { return; }
if (global.__PBL_OVERLAY__ && global.__PBL_OVERLAY__.mounted) { return; }
boot();
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { global.PblOverlayPlay.auto(); }, false);
} else {
global.PblOverlayPlay.auto();
}
})(window);