atlas-iac/dockerfiles/hermes-webui-hux/runtime/wave_b_projects_modes.js

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});
}));