777 lines
33 KiB
JavaScript
777 lines
33 KiB
JavaScript
/* ============================================================================
|
||
* 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);
|