- Captions read message bodies only (the scraper was concatenating avatar, author and worklog chips); multi-segment interim turns are now speakable and drive clean speak-to-thinking-to-speak cycles when playback drains mid-turn. - Dynamic endpointing: complete-looking partials (3+ words or terminal punctuation) endpoint at the base window; the long hold remains only for one-two-word fragments. A stale-busy 10s settle wait on every post-error send is gone. - Both overlay captions are bounded, touch-scrollable regions with follow-tail; caps raised for long turns. - Error envelopes are never spoken or captioned; errored turns run resyncCapture (fresh STT session on the hot mic). - Workspace toggle now lives in the sidebar rail (floating button only below the rail breakpoint). - False 'session unavailable' toast root-caused: the router continuity guard shows it on a 409 that fired when a transient profile-listing failure failed closed into a fake cross-profile mismatch; the patcher now answers from the alias cache and never claims a default-vs-named mismatch while aliases are unconfirmed. - Barge-in sends carry a one-line cut-point marker with the last spoken sentence; visible-history truncation judged infeasible client-side. - Language switching works end-to-end: sticky per-session STT language hint (restarting an unused next session on switch), reply voice from script evidence, STT detection, then stopword heuristic; cues and WAV fallback share the turn language. - Legacy CI guards: node skip for the DOM probe, ffmpeg/codec skips for the container-fallback test. 272 voice-lane tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
471 lines
24 KiB
JavaScript
471 lines
24 KiB
JavaScript
/* node:coverage disable */
|
|
(function (root, factory) {
|
|
'use strict';
|
|
const api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else {
|
|
root.HermesHuxBootstrap = api;
|
|
api.autoStart(root);
|
|
}
|
|
}(typeof globalThis === 'object' ? globalThis : this, function () {
|
|
/* node:coverage enable */
|
|
'use strict';
|
|
|
|
const ACCEPT = 'application/vnd.hermes.hux+json; version=1';
|
|
const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
|
const SLOT = /^slot-[0-9]{1,3}$/;
|
|
const USER = /^usr_[0-9a-f]{16,64}$/;
|
|
const WEBUI_SESSION = /^[A-Za-z0-9._:-]{4,160}$/;
|
|
const PROJECT_SOURCE = /^[A-Za-z0-9._:@+-]{1,200}$/;
|
|
const STREAM = /^[A-Za-z0-9._:-]{1,120}$/;
|
|
const TRUST = new Set(['router', 'relay', 'worker']);
|
|
const SURFACE = new Set(['chat', 'worker', 'telegram', 'voice', 'api']);
|
|
const CARD_FLAGS = Object.freeze({
|
|
'HUX-11': 'hux.foundation', 'HUX-01': 'hux.activity_timeline',
|
|
'HUX-02': 'hux.memory_control', 'HUX-03': 'hux.projects',
|
|
'HUX-04': 'hux.artifacts', 'HUX-05': 'hux.autonomy',
|
|
'HUX-06': 'hux.friendly_modes', 'HUX-07': 'hux.multimodal',
|
|
'HUX-08': 'hux.research', 'HUX-09': 'hux.onboarding',
|
|
'HUX-10': 'hux.privacy', 'HUX-12': 'hux.release_followthrough',
|
|
});
|
|
const REQUIRED_ROUTES = Object.freeze({
|
|
'HUX-11': [['HUX-11', '/hux/v1/capabilities'],
|
|
['HUX-11', '/hux/v1/context/bootstrap']],
|
|
'HUX-01': [['HUX-01', '/hux/v1/conversations/{id}/events']],
|
|
'HUX-02': [['HUX-02', '/hux/v1/memory'], ['HUX-02', '/hux/v1/memory/{id}/{action}']],
|
|
'HUX-03': [['HUX-03', '/hux/v1/projects/{id}'], ['HUX-03', '/hux/v1/conversations'],
|
|
['HUX-03', '/hux/v1/conversations/{id}'], ['HUX-03', '/hux/v1/conversations/{id}/lineage']],
|
|
'HUX-04': [['HUX-04', '/hux/v1/artifacts'], ['HUX-04', '/hux/v1/artifacts/{id}/versions'],
|
|
['HUX-04', '/hux/v1/artifacts/{id}/promote']],
|
|
'HUX-05': [['HUX-05', '/hux/v1/policy'], ['HUX-05', '/hux/v1/approvals'],
|
|
['HUX-05', '/hux/v1/approvals/{id}'], ['HUX-05', '/hux/v1/runs/{id}/stop'],
|
|
['HUX-05', '/hux/v1/runs/{id}/budget'], ['HUX-05', '/hux/v1/runs/{id}/gate']],
|
|
'HUX-06': [['HUX-06', '/hux/v1/modes'],
|
|
['HUX-06', '/hux/v1/projects/{project_id}/conversations/{id}/mode']],
|
|
'HUX-07': [['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items'],
|
|
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}'],
|
|
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections'],
|
|
['HUX-07', '/hux/v1/projects/{project_id}/conversations/{id}/capture-intents']],
|
|
'HUX-08': [['HUX-08', '/hux/v1/messages/{id}/citations'], ['HUX-08', '/hux/v1/notebooks'],
|
|
['HUX-08', '/hux/v1/notebooks/{id}']],
|
|
'HUX-09': [['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate'],
|
|
['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions'],
|
|
['HUX-09', '/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states']],
|
|
'HUX-10': [['HUX-10', '/hux/v1/privacy/policy'], ['HUX-10', '/hux/v1/privacy/notices'],
|
|
['HUX-10', '/hux/v1/conversations/{id}/forget'], ['HUX-10', '/hux/v1/privacy/audit']],
|
|
'HUX-12': [['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases'],
|
|
['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}'],
|
|
['HUX-12', '/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions']],
|
|
});
|
|
const WAVES = Object.freeze({
|
|
a: ['HUX-01', 'HUX-02'], b: ['HUX-03', 'HUX-04', 'HUX-06', 'HUX-08'],
|
|
governance: ['HUX-05', 'HUX-10'], c: ['HUX-07', 'HUX-09', 'HUX-12'],
|
|
});
|
|
|
|
function record(value) {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
}
|
|
|
|
function immutable(value) {
|
|
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
Object.values(value).forEach(immutable);
|
|
return Object.freeze(value);
|
|
}
|
|
|
|
function opaque(value, prefix) {
|
|
return typeof value === 'string' && ID.test(value) && value.startsWith(`${prefix}_`);
|
|
}
|
|
|
|
function normalizeIdentity(value) {
|
|
if (!exact(value, ['tenant_slot', 'subject', 'surface', 'trust']) ||
|
|
!SLOT.test(value.tenant_slot) || !USER.test(value.subject) ||
|
|
!SURFACE.has(value.surface) || !TRUST.has(value.trust)) return null;
|
|
return immutable({tenantSlot: value.tenant_slot, userRef: value.subject,
|
|
surface: value.surface, trust: value.trust});
|
|
}
|
|
|
|
function normalizeContext(session) {
|
|
const value = record(session);
|
|
const context = value && record(value.hux_context);
|
|
const contextKeys = new Set(['schema', 'webui_session_id', 'session_id', 'conversation_id',
|
|
'project_id', 'project_source', 'identity', 'run_id', 'message_id',
|
|
'branch_point_message_id', 'notebook_id']);
|
|
if (!context || context.schema !== 'hux.webui_context.v1' ||
|
|
Object.keys(context).some((key) => !contextKeys.has(key)) ||
|
|
!WEBUI_SESSION.test(String(value.session_id || '')) ||
|
|
context.webui_session_id !== value.session_id || !opaque(context.session_id, 'ses') ||
|
|
!opaque(context.conversation_id, 'conv') || !opaque(context.project_id, 'prj') ||
|
|
!PROJECT_SOURCE.test(String(context.project_source || ''))) return null;
|
|
const identity = normalizeIdentity(context.identity);
|
|
if (!identity) return null;
|
|
const optional = {runId: context.run_id, messageId: context.message_id,
|
|
branchPointMessageId: context.branch_point_message_id, notebookId: context.notebook_id};
|
|
if (optional.runId !== undefined && !opaque(optional.runId, 'run')) return null;
|
|
for (const name of ['messageId', 'branchPointMessageId']) {
|
|
if (optional[name] !== undefined && !opaque(optional[name], 'msg')) return null;
|
|
}
|
|
if (optional.notebookId !== undefined && !opaque(optional.notebookId, 'nb')) return null;
|
|
return immutable({webuiSessionId: value.session_id, sessionId: context.session_id,
|
|
conversationId: context.conversation_id, projectId: context.project_id,
|
|
projectSource: context.project_source, identity, ...optional});
|
|
}
|
|
|
|
function sameIdentity(raw, expected) {
|
|
return Boolean(exact(raw, ['tenant_slot', 'subject', 'surface', 'trust']) &&
|
|
raw.tenant_slot === expected.tenantSlot &&
|
|
raw.subject === expected.userRef && raw.surface === expected.surface && raw.trust === expected.trust);
|
|
}
|
|
|
|
function exact(value, keys) {
|
|
return Boolean(record(value) && Object.keys(value).length === keys.length &&
|
|
keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)));
|
|
}
|
|
|
|
function normalizeBootstrap(payload, context, status) {
|
|
const keys = ['schema', 'contract_version', 'identity', 'session_id', 'project_id',
|
|
'conversation_id', 'revisions', 'created'];
|
|
if (!exact(payload, keys) || payload.schema !== 'hux.context_bootstrap.v1' ||
|
|
payload.contract_version !== '1.1.0' || !sameIdentity(payload.identity, context.identity) ||
|
|
payload.session_id !== context.sessionId || payload.project_id !== context.projectId ||
|
|
payload.conversation_id !== context.conversationId ||
|
|
!exact(payload.revisions, ['project', 'conversation']) ||
|
|
!exact(payload.created, ['project', 'conversation'])) return null;
|
|
const revisions = payload.revisions; const created = payload.created;
|
|
if (!Number.isSafeInteger(revisions.project) || revisions.project < 1 ||
|
|
!Number.isSafeInteger(revisions.conversation) || revisions.conversation < 1 ||
|
|
typeof created.project !== 'boolean' || typeof created.conversation !== 'boolean' ||
|
|
(status === 201) !== (created.project || created.conversation)) return null;
|
|
return immutable({revisions: {...revisions}, created: {...created}});
|
|
}
|
|
|
|
function normalizeCapabilities(payload, expected) {
|
|
if (!exact(payload, ['schema', 'contract_version', 'identity', 'cards', 'server']) ||
|
|
payload.schema !== 'hux.capabilities.v1' ||
|
|
!/^1\.\d+\.\d+$/.test(String(payload.contract_version)) ||
|
|
!sameIdentity(payload.identity, expected) || !Array.isArray(payload.cards) ||
|
|
payload.cards.length > Object.keys(CARD_FLAGS).length || !record(payload.server) ||
|
|
Object.keys(payload.server).some((key) => !['commit', 'image_digest'].includes(key)) ||
|
|
Object.values(payload.server).some((item) => typeof item !== 'string' || item.length > 160)) return null;
|
|
const cards = {};
|
|
for (const item of payload.cards) {
|
|
if (!exact(item, ['card', 'flag', 'enabled', 'routes']) ||
|
|
!Object.prototype.hasOwnProperty.call(CARD_FLAGS, item.card) ||
|
|
CARD_FLAGS[item.card] !== item.flag || typeof item.enabled !== 'boolean' ||
|
|
!Array.isArray(item.routes) || cards[item.card]) return null;
|
|
const routes = item.routes.filter((route) => typeof route === 'string' &&
|
|
/^\/hux\/v1(?:\/[A-Za-z0-9._:{}-]+)*$/.test(route));
|
|
if (routes.length !== item.routes.length || new Set(routes).size !== routes.length) return null;
|
|
cards[item.card] = immutable({enabled: item.enabled, routes});
|
|
}
|
|
if (!cardComplete(cards, 'HUX-11')) return null;
|
|
return immutable(cards);
|
|
}
|
|
|
|
function cardComplete(cards, card) {
|
|
const current = cards[card];
|
|
if (!current || !current.enabled) return false;
|
|
return REQUIRED_ROUTES[card].every(([owner, route]) =>
|
|
cards[owner] && cards[owner].enabled && cards[owner].routes.includes(route));
|
|
}
|
|
|
|
function enabledWaves(cards) {
|
|
const result = [];
|
|
Object.entries(WAVES).forEach(([wave, members]) => {
|
|
const enabled = members.filter((card) => cards[card] && cards[card].enabled);
|
|
if (enabled.length && enabled.every((card) => cardComplete(cards, card))) result.push(wave);
|
|
});
|
|
return Object.freeze(result);
|
|
}
|
|
|
|
function safeCsrf(config) {
|
|
const value = record(config) && config.csrfToken;
|
|
return typeof value === 'string' && value.length >= 16 && value.length <= 512 &&
|
|
!/[\u0000-\u001f\u007f]/.test(value) ? value : null;
|
|
}
|
|
|
|
function scopedFetcher(fetcher, csrfToken) {
|
|
return function huxFetch(url, init) {
|
|
const target = String(url || '');
|
|
if (!/^\/hux\/v1(?:[/?]|$)/.test(target) || target.startsWith('//') || target.includes('..')) {
|
|
return Promise.reject(new TypeError('HUX browser requests must remain same-origin'));
|
|
}
|
|
const options = init || {};
|
|
const method = String(options.method || 'GET').toUpperCase();
|
|
const headers = {...(options.headers || {})};
|
|
if (!['GET', 'HEAD'].includes(method)) headers['X-Hermes-CSRF-Token'] = csrfToken;
|
|
return fetcher(target, {...options, cache: 'no-store', credentials: 'same-origin', headers});
|
|
};
|
|
}
|
|
|
|
async function preflight(fetcher, context) {
|
|
try {
|
|
const response = await fetcher('/hux/v1/capabilities', {method: 'GET',
|
|
headers: {Accept: ACCEPT}});
|
|
if (!response || response.status === 404 || !response.ok) return null;
|
|
const cards = normalizeCapabilities(await response.json(), context.identity);
|
|
const waves = cards && enabledWaves(cards);
|
|
return waves && waves.length ? immutable({cards, waves}) : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function bootstrapContext(fetcher, context) {
|
|
try {
|
|
const response = await fetcher('/hux/v1/context/bootstrap', {method: 'POST', headers: {
|
|
Accept: ACCEPT, 'Content-Type': 'application/json',
|
|
'Idempotency-Key': `hux:context:${context.sessionId}`,
|
|
}, body: JSON.stringify({raw_session_id: context.webuiSessionId,
|
|
project_source: context.projectSource, session_id: context.sessionId,
|
|
conversation_id: context.conversationId, project_id: context.projectId})});
|
|
if (!response || ![200, 201].includes(response.status) || !response.ok) return null;
|
|
return normalizeBootstrap(await response.json(), context, response.status);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function node(doc, tag, attributes, text) {
|
|
const value = doc.createElement(tag);
|
|
Object.entries(attributes).forEach(([name, item]) => value.setAttribute(name, item));
|
|
if (text !== undefined) value.textContent = String(text);
|
|
return value;
|
|
}
|
|
|
|
function railHost(doc) {
|
|
// The app's left icon rail (chat, tasks, telegram, …). Optional: bundles
|
|
// without it (and headless harnesses) keep the floating toggle.
|
|
if (typeof doc.querySelector !== 'function') return null;
|
|
const rail = doc.querySelector('nav.rail');
|
|
return rail && typeof rail.insertBefore === 'function' ? rail : null;
|
|
}
|
|
|
|
function railIcon(doc) {
|
|
try {
|
|
if (typeof doc.createElementNS !== 'function') return null;
|
|
const svgNS = 'http://www.w3.org/2000/svg';
|
|
const svg = doc.createElementNS(svgNS, 'svg');
|
|
[['width', '20'], ['height', '20'], ['viewBox', '0 0 24 24'], ['fill', 'none'],
|
|
['stroke', 'currentColor'], ['stroke-width', '1.5'], ['stroke-linecap', 'round'],
|
|
['stroke-linejoin', 'round'], ['aria-hidden', 'true'],
|
|
].forEach(([name, value]) => svg.setAttribute(name, value));
|
|
const path = doc.createElementNS(svgNS, 'path');
|
|
path.setAttribute('d', 'M3 5a2 2 0 0 1 2-2h6v18H5a2 2 0 0 1-2-2V5Zm10-2h6a2 2 0 0 1 2 2v5h-8V3Zm0 11h8v5a2 2 0 0 1-2 2h-6v-7Z');
|
|
svg.appendChild(path);
|
|
return svg;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function createChrome(doc) {
|
|
const rail = railHost(doc);
|
|
const open = node(doc, 'button', {'class': rail ?
|
|
'hux-workspace-toggle hux-workspace-toggle--railed' : 'hux-workspace-toggle',
|
|
type: 'button', 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false'}, 'Workspace');
|
|
let railToggle = null;
|
|
if (rail) {
|
|
// Rail placement: an icon-only item beside the app's own tabs, styled
|
|
// by the upstream .rail-btn/.has-tooltip rules. The floating button
|
|
// stays in the DOM as the below-rail-breakpoint fallback and is hidden
|
|
// by bootstrap.css wherever the rail is visible, so nothing floats
|
|
// over the chat. Drawer behavior is unchanged.
|
|
railToggle = node(doc, 'button', {'class': 'rail-btn has-tooltip hux-workspace-toggle-rail',
|
|
type: 'button', 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false',
|
|
'data-tooltip': 'Workspace', 'aria-label': 'Workspace'});
|
|
const icon = railIcon(doc);
|
|
if (icon) railToggle.appendChild(icon);
|
|
else railToggle.textContent = '⧉';
|
|
const spacer = typeof rail.querySelector === 'function' ? rail.querySelector('.rail-spacer') : null;
|
|
rail.insertBefore(railToggle, spacer || null);
|
|
}
|
|
const drawer = node(doc, 'aside', {id: 'huxWorkspaceDrawer', 'class': 'hux-workspace-drawer',
|
|
'aria-labelledby': 'huxWorkspaceTitle'});
|
|
const header = node(doc, 'header', {'class': 'hux-workspace-drawer__header'});
|
|
const title = node(doc, 'h2', {id: 'huxWorkspaceTitle'}, 'Hermes workspace');
|
|
const close = node(doc, 'button', {type: 'button', 'aria-label': 'Close Hermes workspace'}, 'Close');
|
|
const content = node(doc, 'div', {'class': 'hux-workspace-drawer__content'});
|
|
drawer.hidden = true;
|
|
header.appendChild(title); header.appendChild(close); drawer.appendChild(header); drawer.appendChild(content);
|
|
doc.body.appendChild(open); doc.body.appendChild(drawer);
|
|
function setOpen(value) {
|
|
drawer.hidden = !value; open.setAttribute('aria-expanded', value ? 'true' : 'false');
|
|
if (railToggle) railToggle.setAttribute('aria-expanded', value ? 'true' : 'false');
|
|
if (value && typeof close.focus === 'function') close.focus();
|
|
}
|
|
const onOpen = () => setOpen(true);
|
|
const onClose = () => setOpen(false);
|
|
const onKey = (event) => { if (event.key === 'Escape' && !drawer.hidden) setOpen(false); };
|
|
open.addEventListener('click', onOpen); close.addEventListener('click', onClose);
|
|
if (railToggle) railToggle.addEventListener('click', onOpen);
|
|
doc.addEventListener('keydown', onKey);
|
|
return Object.freeze({content, drawer, open, railToggle, setOpen, destroy() {
|
|
doc.removeEventListener('keydown', onKey);
|
|
if (open.parentNode) open.parentNode.removeChild(open);
|
|
if (railToggle && railToggle.parentNode) railToggle.parentNode.removeChild(railToggle);
|
|
if (drawer.parentNode) drawer.parentNode.removeChild(drawer);
|
|
}});
|
|
}
|
|
|
|
async function defaultRuntimeFactory(root, target, context, fetcher, waves, controls) {
|
|
const runtimes = [];
|
|
const common = {document: root.document, fetcher, expectedIdentity: context.identity,
|
|
projectId: context.projectId, conversationId: context.conversationId,
|
|
sessionId: context.sessionId, runId: context.runId, messageId: context.messageId,
|
|
branchPointMessageId: context.branchPointMessageId, notebookId: context.notebookId,
|
|
stopModelResponse: controls && controls.stopModelResponse,
|
|
canStopModelResponse: controls && controls.canStopModelResponse};
|
|
const constructors = {
|
|
a: root.HermesHuxWaveA && root.HermesHuxWaveA.createWaveARuntime,
|
|
b: root.HermesHuxWaveBRuntime && root.HermesHuxWaveBRuntime.createWaveBRuntime,
|
|
governance: root.HermesHuxAutonomyPrivacy &&
|
|
root.HermesHuxAutonomyPrivacy.createAutonomyPrivacyRuntime,
|
|
c: root.HermesHuxWaveC && root.HermesHuxWaveC.createWaveCRuntime,
|
|
};
|
|
try {
|
|
for (const wave of waves) {
|
|
if (typeof constructors[wave] !== 'function') throw new Error('HUX runtime is incomplete');
|
|
const host = node(root.document, 'section', {'class': `hux-workspace-wave hux-workspace-wave--${wave}`});
|
|
target.appendChild(host);
|
|
const runtime = constructors[wave](common);
|
|
runtimes.push(runtime);
|
|
await runtime.mount(host);
|
|
}
|
|
} catch (error) {
|
|
runtimes.reverse().forEach((runtime) => runtime.destroy());
|
|
throw error;
|
|
}
|
|
return Object.freeze({destroy() { runtimes.reverse().forEach((runtime) => runtime.destroy()); }});
|
|
}
|
|
|
|
function ownedActiveStream(session, activeStreamId, expected) {
|
|
const current = normalizeContext(session);
|
|
const raw = record(session);
|
|
return current && expected && current.webuiSessionId === expected.webuiSessionId &&
|
|
current.conversationId === expected.conversationId &&
|
|
current.identity.userRef === expected.identity.userRef && STREAM.test(String(activeStreamId || '')) &&
|
|
raw.active_stream_id === activeStreamId ? activeStreamId : null;
|
|
}
|
|
|
|
function createOwnedStop(root, sessionReader, activeStreamReader, context, timeoutMs) {
|
|
const bounded = Number.isSafeInteger(timeoutMs) && timeoutMs >= 250 && timeoutMs <= 10000 ? timeoutMs : 3000;
|
|
const canStopModelResponse = () => Boolean(ownedActiveStream(sessionReader(), activeStreamReader(), context) &&
|
|
typeof root.cancelStream === 'function');
|
|
async function stopModelResponse() {
|
|
const streamId = ownedActiveStream(sessionReader(), activeStreamReader(), context);
|
|
if (!streamId || typeof root.cancelStream !== 'function') return Object.freeze({accepted: false});
|
|
let timer = null;
|
|
try {
|
|
const timeout = new Promise((resolve) => { timer = root.setTimeout(() => resolve(false), bounded); });
|
|
const accepted = await Promise.race([Promise.resolve(root.cancelStream('hux-stop')), timeout]);
|
|
if (timer !== null) root.clearTimeout(timer);
|
|
return Object.freeze({accepted: accepted === true, sessionId: context.webuiSessionId, streamId});
|
|
} catch (_) {
|
|
if (timer !== null) root.clearTimeout(timer);
|
|
return Object.freeze({accepted: false});
|
|
}
|
|
}
|
|
return Object.freeze({canStopModelResponse, stopModelResponse});
|
|
}
|
|
|
|
function createCoordinator(options) {
|
|
const settings = options || {};
|
|
const root = settings.root;
|
|
const doc = settings.document || (root && root.document);
|
|
const rawFetch = settings.fetcher || (root && root.fetch && root.fetch.bind(root));
|
|
const sessionReader = settings.sessionReader;
|
|
const activeStreamReader = settings.activeStreamReader || (() => browserActiveStreamReader());
|
|
const configReader = settings.configReader || (() => root.__HERMES_CONFIG__);
|
|
const runtimeFactory = settings.runtimeFactory || ((target, context, fetcher, waves, controls) =>
|
|
defaultRuntimeFactory(root, target, context, fetcher, waves, controls));
|
|
if (!root || !doc || !rawFetch || typeof sessionReader !== 'function') {
|
|
throw new TypeError('HUX coordinator requires browser, fetch, and trusted session reader');
|
|
}
|
|
let fingerprint = null; let pendingFingerprint = null; let generation = 0;
|
|
let chrome = null; let runtime = null; let timer = null;
|
|
|
|
function clear() {
|
|
if (runtime) runtime.destroy();
|
|
if (chrome) chrome.destroy();
|
|
runtime = null; chrome = null;
|
|
}
|
|
|
|
async function refresh() {
|
|
const context = normalizeContext(sessionReader());
|
|
const activeStream = context ? ownedActiveStream(sessionReader(), activeStreamReader(), context) : null;
|
|
const next = context ? `${context.webuiSessionId}\0${context.conversationId}\0${context.identity.userRef}` +
|
|
`\0${context.runId || ''}\0${activeStream || ''}` : '';
|
|
if (next === fingerprint || next === pendingFingerprint) return false;
|
|
pendingFingerprint = next; const current = ++generation; clear();
|
|
if (!context) { fingerprint = ''; pendingFingerprint = null; return false; }
|
|
const csrf = safeCsrf(configReader());
|
|
if (!csrf) { pendingFingerprint = null; return false; }
|
|
const fetcher = scopedFetcher(rawFetch, csrf);
|
|
const capability = await preflight(fetcher, context);
|
|
if (current !== generation) return false;
|
|
if (!capability) { pendingFingerprint = null; return false; }
|
|
const binding = await bootstrapContext(fetcher, context);
|
|
if (current !== generation) return false;
|
|
if (!binding) { pendingFingerprint = null; return false; }
|
|
const nextChrome = createChrome(doc);
|
|
try {
|
|
const controls = createOwnedStop(root, sessionReader, activeStreamReader, context, settings.stopTimeoutMs);
|
|
const nextRuntime = await runtimeFactory(nextChrome.content, context, fetcher, capability.waves, controls);
|
|
if (current !== generation) { nextRuntime.destroy(); nextChrome.destroy(); return false; }
|
|
chrome = nextChrome; runtime = nextRuntime; fingerprint = next;
|
|
pendingFingerprint = null; return true;
|
|
} catch (_) {
|
|
nextChrome.destroy(); pendingFingerprint = null; return false;
|
|
}
|
|
}
|
|
|
|
function start() {
|
|
if (timer !== null) return;
|
|
void refresh();
|
|
timer = root.setInterval(() => { void refresh(); }, settings.pollMs || 750);
|
|
root.addEventListener('pageshow', refresh);
|
|
}
|
|
|
|
function destroy() {
|
|
generation += 1; fingerprint = null; pendingFingerprint = null;
|
|
if (timer !== null) root.clearInterval(timer);
|
|
timer = null; root.removeEventListener('pageshow', refresh); clear();
|
|
}
|
|
|
|
return Object.freeze({destroy, refresh, start, getState: () => ({active: Boolean(runtime), fingerprint})});
|
|
}
|
|
|
|
function browserSessionReader() {
|
|
return typeof S === 'object' ? S.session : null;
|
|
}
|
|
|
|
function browserActiveStreamReader() {
|
|
return typeof S === 'object' ? S.activeStreamId : null;
|
|
}
|
|
|
|
function mergeTrustedContext(session, payload) {
|
|
const current = record(session); const response = record(payload);
|
|
if (!current || !response || response.session_id !== current.session_id ||
|
|
!record(response.hux_context)) return false;
|
|
const next = normalizeContext({...current, hux_context: response.hux_context});
|
|
if (!next || next.webuiSessionId !== current.session_id) return false;
|
|
current.hux_context = response.hux_context;
|
|
return true;
|
|
}
|
|
|
|
function autoStart(root) {
|
|
if (!root || !root.document || typeof root.fetch !== 'function') return null;
|
|
const coordinator = createCoordinator({root, sessionReader: browserSessionReader});
|
|
if (root.document.readyState === 'loading') {
|
|
root.document.addEventListener('DOMContentLoaded', () => coordinator.start(), {once: true});
|
|
} else coordinator.start();
|
|
return coordinator;
|
|
}
|
|
|
|
return Object.freeze({ACCEPT, autoStart, browserActiveStreamReader, cardComplete, createChrome,
|
|
createCoordinator, createOwnedStop, ownedActiveStream,
|
|
bootstrapContext, defaultRuntimeFactory, enabledWaves, normalizeBootstrap,
|
|
normalizeCapabilities, normalizeContext, normalizeIdentity, mergeTrustedContext,
|
|
preflight, safeCsrf, scopedFetcher});
|
|
}));
|