254 lines
14 KiB
JavaScript
254 lines
14 KiB
JavaScript
/* node:coverage disable */
|
|
(function (root, factory) {
|
|
'use strict';
|
|
const contract = typeof module === 'object' && module.exports ? require('./wave_b_contract.js') : root.HermesHuxWaveBContract;
|
|
const api = factory(contract);
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.HermesHuxWaveBProjectsModes = api;
|
|
}(typeof globalThis === 'object' ? globalThis : this, function (contract) {
|
|
/* node:coverage enable */
|
|
'use strict';
|
|
|
|
function el(doc, tag, attrs, value) {
|
|
const node = doc.createElement(tag);
|
|
Object.entries(attrs || {}).forEach(([key, item]) => node.setAttribute(key, item));
|
|
if (value !== undefined) node.textContent = String(value);
|
|
return node;
|
|
}
|
|
function status(doc, message, alert) {
|
|
return el(doc, 'p', {class: 'hux-wave-b__status', role: alert ? 'alert' : 'status',
|
|
'aria-live': 'polite'}, message);
|
|
}
|
|
function heading(doc, title, description) {
|
|
const head = el(doc, 'header', {class: 'hux-wave-b__heading'});
|
|
head.appendChild(el(doc, 'h3', {}, title));
|
|
head.appendChild(el(doc, 'p', {}, description));
|
|
return head;
|
|
}
|
|
function exactEtag(result, revision) {
|
|
if (String(result.etag || '').replaceAll('"', '') !== String(revision)) {
|
|
throw new contract.WaveBContractError('ETag does not match the record revision');
|
|
}
|
|
}
|
|
function key(action, target) { return `webui:${action}:${target}`.slice(0, 120); }
|
|
function object(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null; }
|
|
function modeContract(raw) {
|
|
if (!object(raw) || raw.schema !== 'hux.mode.v1' || !contract.MODE_IDS.has(raw.mode) ||
|
|
typeof raw.label !== 'string' || !raw.label || raw.label.length > 40 ||
|
|
typeof raw.intent !== 'string' || !raw.intent || raw.intent.length > 400 ||
|
|
!object(raw.constraints) || !object(raw.switchyard) ||
|
|
typeof raw.switchyard.route_id !== 'string' ||
|
|
!/^atlas\/(auto|manual|fallback|worker)\/[a-z0-9/-]+$/.test(raw.switchyard.route_id) ||
|
|
(raw.mode !== 'private' && raw.switchyard.route_id.startsWith('atlas/manual/'))) {
|
|
throw new contract.WaveBContractError('Invalid friendly mode contract');
|
|
}
|
|
if (/[\u0000-\u001f\u007f]/.test(raw.label + raw.intent)) {
|
|
throw new contract.WaveBContractError('Friendly mode text is invalid');
|
|
}
|
|
return Object.freeze({id: raw.mode, label: raw.label, description: raw.intent});
|
|
}
|
|
function normalizeModes(raw) {
|
|
if (!object(raw) || !Array.isArray(raw.items) || raw.items.length !== contract.MODES.length || raw.next !== null) {
|
|
throw new contract.WaveBContractError('Invalid friendly mode page');
|
|
}
|
|
const modes = raw.items.map(modeContract);
|
|
if (new Set(modes.map((mode) => mode.id)).size !== contract.MODES.length) {
|
|
throw new contract.WaveBContractError('Friendly modes are incomplete');
|
|
}
|
|
return Object.freeze(modes);
|
|
}
|
|
function normalizeSelection(raw, identity, projectId, conversationId) {
|
|
if (!object(raw) || raw.schema !== 'hux.mode_selection.v1' || !contract.isId(raw.id, 'mode') ||
|
|
raw.owner !== identity.userRef || raw.project_id !== projectId ||
|
|
raw.conversation_id !== conversationId || !Number.isSafeInteger(raw.revision) || raw.revision < 1) {
|
|
throw new contract.WaveBContractError('Mode selection crossed its scope');
|
|
}
|
|
return Object.freeze({id: raw.id, mode: modeContract(raw.mode), revision: raw.revision});
|
|
}
|
|
|
|
function createProjectsExtension(options) {
|
|
const settings = options || {};
|
|
const doc = settings.document;
|
|
const projectId = settings.projectId;
|
|
const conversationId = settings.conversationId;
|
|
const branchPoint = settings.branchPointMessageId;
|
|
if (!doc || !contract.MESSAGE.test(branchPoint || 'message-default') ||
|
|
!contract.isId(projectId, 'prj') || !contract.isId(conversationId, 'conv')) {
|
|
throw new TypeError('Projects runtime context is invalid');
|
|
}
|
|
|
|
function render(context) {
|
|
const root = el(doc, 'div', {class: 'hux-wave-b hux-wave-b--projects'});
|
|
context.container.replaceChildren(root);
|
|
let project = null; let conversations = []; let lineage = null; let generation = 0;
|
|
|
|
async function mutate(path, method, body, revision, idempotencyKey) {
|
|
const headers = {};
|
|
if (revision !== null) headers['If-Match'] = String(revision);
|
|
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
|
await context.client.request(path, {method, headers, body});
|
|
await load();
|
|
}
|
|
|
|
function conversationCard(item) {
|
|
const card = el(doc, 'article', {class: 'hux-wave-b__card'});
|
|
const title = el(doc, 'h5', {}, item.title);
|
|
if (item.id === conversationId) title.appendChild(el(doc, 'span', {class: 'hux-wave-b__current'}, ' Current'));
|
|
card.appendChild(title);
|
|
card.appendChild(el(doc, 'p', {}, `${item.mode || 'automatic'} · ${item.artifactIds.length} artifacts`));
|
|
const tags = el(doc, 'ul', {class: 'hux-wave-b__tags', 'aria-label': 'Conversation tags'});
|
|
item.tags.forEach((tag) => tags.appendChild(el(doc, 'li', {}, tag)));
|
|
card.appendChild(tags);
|
|
const pin = el(doc, 'button', {type: 'button'}, item.pinned ? 'Unpin conversation' : 'Pin conversation');
|
|
pin.addEventListener('click', () => mutate(`/conversations/${item.id}`, 'PATCH',
|
|
{pinned: !item.pinned}, item.revision).catch(showError));
|
|
card.appendChild(pin);
|
|
return card;
|
|
}
|
|
|
|
function showError() {
|
|
root.replaceChildren(heading(doc, 'Projects and conversations',
|
|
'Organize the current project without crossing its tenant boundary.'),
|
|
status(doc, 'This project workspace is temporarily unavailable.', true));
|
|
}
|
|
|
|
function draw() {
|
|
root.replaceChildren(heading(doc, 'Projects and conversations',
|
|
'Organize, search, pin, and inspect branches inside the current project.'));
|
|
const projectCard = el(doc, 'section', {class: 'hux-wave-b__card', 'aria-label': 'Current project'});
|
|
projectCard.appendChild(el(doc, 'h4', {}, project.name));
|
|
projectCard.appendChild(el(doc, 'p', {}, project.description || 'No project description.'));
|
|
const pin = el(doc, 'button', {type: 'button'}, project.pinned ? 'Unpin project' : 'Pin project');
|
|
pin.addEventListener('click', () => mutate(`/projects/${project.id}`, 'PATCH',
|
|
{pinned: !project.pinned}, project.revision).catch(showError));
|
|
projectCard.appendChild(pin);
|
|
root.appendChild(projectCard);
|
|
|
|
const search = el(doc, 'form', {class: 'hux-wave-b__form', role: 'search'});
|
|
const label = el(doc, 'label', {}, 'Search this project');
|
|
const input = el(doc, 'input', {name: 'q', type: 'search', maxlength: '200'});
|
|
const submit = el(doc, 'button', {type: 'submit'}, 'Search');
|
|
label.appendChild(input); search.appendChild(label); search.appendChild(submit);
|
|
search.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const query = input.value.trim();
|
|
if (!query) return;
|
|
submit.disabled = true;
|
|
try {
|
|
const result = await context.client.request(`/search?q=${encodeURIComponent(query)}&project_id=${projectId}`);
|
|
conversations = contract.normalizeSearch(result.body, context.client.identity, projectId).items;
|
|
draw();
|
|
} catch (_) { showError(); }
|
|
});
|
|
root.appendChild(search);
|
|
root.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'Search covers titles, tags, project name, and artifact titles. Message text is not indexed by this backend.'));
|
|
|
|
const list = el(doc, 'section', {'aria-label': 'Conversations'});
|
|
list.appendChild(el(doc, 'h4', {}, 'Conversations'));
|
|
if (!conversations.length) list.appendChild(status(doc, 'No matching conversations in this project.'));
|
|
conversations.forEach((item) => list.appendChild(conversationCard(item)));
|
|
root.appendChild(list);
|
|
|
|
const tree = el(doc, 'section', {class: 'hux-wave-b__lineage', 'aria-label': 'Conversation branches'});
|
|
tree.appendChild(el(doc, 'h4', {}, 'Branch lineage'));
|
|
const chain = [...lineage.ancestors, lineage.conversation];
|
|
const path = el(doc, 'ol');
|
|
chain.forEach((item) => path.appendChild(el(doc, 'li', {}, item.title)));
|
|
tree.appendChild(path);
|
|
const children = el(doc, 'ul', {'aria-label': 'Direct child branches'});
|
|
lineage.children.forEach((item) => children.appendChild(el(doc, 'li', {}, item.title)));
|
|
if (!lineage.children.length) children.appendChild(el(doc, 'li', {}, 'No direct child branches.'));
|
|
tree.appendChild(children);
|
|
if (branchPoint) {
|
|
const branch = el(doc, 'button', {type: 'button'}, 'Branch from current message');
|
|
branch.addEventListener('click', () => mutate(`/conversations/${conversationId}/branch`, 'POST',
|
|
{branch_point_message_id: branchPoint}, null, key('branch', `${conversationId}:${branchPoint}`))
|
|
.catch(showError));
|
|
tree.appendChild(branch);
|
|
} else {
|
|
tree.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'Choose a message before creating a branch; the backend requires an exact branch point.'));
|
|
}
|
|
root.appendChild(tree);
|
|
}
|
|
|
|
async function load() {
|
|
const current = ++generation;
|
|
root.replaceChildren(heading(doc, 'Projects and conversations',
|
|
'Organize the current project without crossing its tenant boundary.'), status(doc, 'Loading project…'));
|
|
try {
|
|
const [projectResult, conversationsResult, lineageResult] = await Promise.all([
|
|
context.client.request(`/projects/${projectId}`),
|
|
context.client.request(`/conversations?project_id=${projectId}`),
|
|
context.client.request(`/conversations/${conversationId}/lineage`),
|
|
]);
|
|
if (current !== generation) return;
|
|
project = contract.normalizeProject(projectResult.body, context.client.identity, projectId);
|
|
exactEtag(projectResult, project.revision);
|
|
conversations = contract.normalizeConversationPage(conversationsResult.body, context.client.identity, projectId);
|
|
lineage = contract.normalizeLineage(lineageResult.body, context.client.identity, projectId, conversationId);
|
|
draw();
|
|
} catch (_) { if (current === generation) showError(); }
|
|
}
|
|
|
|
load();
|
|
}
|
|
return Object.freeze({id: 'projects', flag: 'hux.projects', label: 'Projects', order: 30, render});
|
|
}
|
|
|
|
function createModesExtension(options) {
|
|
const settings = options || {}; const doc = settings.document;
|
|
const projectId = settings.projectId; const conversationId = settings.conversationId;
|
|
if (!doc || !contract.isId(projectId, 'prj') || !contract.isId(conversationId, 'conv')) {
|
|
throw new TypeError('Modes runtime context is invalid');
|
|
}
|
|
function render(context) {
|
|
const root = el(doc, 'div', {class: 'hux-wave-b hux-wave-b--modes'});
|
|
context.container.replaceChildren(root);
|
|
let selection = null; let modes = [];
|
|
function draw(message) {
|
|
root.replaceChildren(heading(doc, 'Conversation mode',
|
|
'Choose an intent; Switchyard remains responsible for the provider and exact route.'));
|
|
root.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'Automatic choices express intent; Switchyard selects from the available provider pool.'));
|
|
if (message) root.appendChild(status(doc, message));
|
|
const choices = el(doc, 'div', {class: 'hux-wave-b__choices', role: 'radiogroup',
|
|
'aria-label': 'Friendly modes'});
|
|
modes.forEach((mode) => {
|
|
const button = el(doc, 'button', {type: 'button', role: 'radio',
|
|
'aria-checked': selection && selection.mode.id === mode.id ? 'true' : 'false'}, mode.label);
|
|
button.appendChild(el(doc, 'span', {}, mode.description));
|
|
button.addEventListener('click', async () => {
|
|
button.disabled = true;
|
|
try {
|
|
const path = `/projects/${projectId}/conversations/${conversationId}/mode`;
|
|
const result = await context.client.request(path, {method: 'PUT', headers: {
|
|
'If-Match': String(selection ? selection.revision : 0),
|
|
'Idempotency-Key': key('mode', `${conversationId}:${mode.id}`)},
|
|
body: {project_id: projectId, mode: mode.id, advanced: false}});
|
|
selection = normalizeSelection(result.body, context.client.identity, projectId, conversationId);
|
|
exactEtag(result, selection.revision); draw(`${mode.label} mode selected.`);
|
|
} catch (_) { draw('The mode could not be changed. Refresh and try again.'); }
|
|
});
|
|
choices.appendChild(button);
|
|
});
|
|
root.appendChild(choices);
|
|
}
|
|
draw('Loading friendly modes…');
|
|
context.client.request('/modes').then((result) => {
|
|
modes = normalizeModes(result.body);
|
|
return context.client.request(`/projects/${projectId}/conversations/${conversationId}/mode`)
|
|
.then((current) => { selection = normalizeSelection(current.body, context.client.identity,
|
|
projectId, conversationId); exactEtag(current, selection.revision); })
|
|
.catch(() => { selection = null; });
|
|
}).then(() => draw(selection ? 'Current mode loaded.' : 'Choose a mode for this conversation.'))
|
|
.catch(() => draw('The friendly mode catalog could not be verified.'));
|
|
}
|
|
return Object.freeze({id: 'friendly-modes', flag: 'hux.friendly_modes', label: 'Mode', order: 60, render});
|
|
}
|
|
|
|
return Object.freeze({createModesExtension, createProjectsExtension, exactEtag, key,
|
|
modeContract, normalizeModes, normalizeSelection});
|
|
}));
|