286 lines
16 KiB
JavaScript
286 lines
16 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.HermesHuxWaveBArtifactsResearch = 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 etag(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 createArtifactsExtension(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('Artifact runtime context is invalid');
|
|
}
|
|
function render(context) {
|
|
const root = el(doc, 'div', {class: 'hux-wave-b hux-wave-b--artifacts'});
|
|
context.container.replaceChildren(root);
|
|
let artifacts = []; let generation = 0;
|
|
function fail() {
|
|
root.replaceChildren(heading(doc, 'Artifact workspace', 'Preview durable outputs and their history.'),
|
|
status(doc, 'This artifact workspace is temporarily unavailable.', true));
|
|
}
|
|
async function reload() {
|
|
const current = ++generation;
|
|
root.replaceChildren(heading(doc, 'Artifact workspace', 'Preview durable outputs and their history.'),
|
|
status(doc, 'Loading artifacts…'));
|
|
try {
|
|
const result = await context.client.request(`/artifacts?conversation_id=${conversationId}&cursor=0`);
|
|
if (current !== generation) return;
|
|
artifacts = contract.normalizeArtifactPage(result.body, context.client.identity, conversationId, projectId).items;
|
|
draw();
|
|
} catch (_) { if (current === generation) fail(); }
|
|
}
|
|
async function showPreview(artifact, number, area) {
|
|
area.replaceChildren(status(doc, `Loading version ${number}…`));
|
|
try {
|
|
const result = await context.client.request(`/artifacts/${artifact.id}/versions/${number}`);
|
|
etag(result, artifact.revision);
|
|
const content = contract.normalizeArtifactContent(result.body, artifact, number);
|
|
area.replaceChildren();
|
|
if (content.preview === null) area.appendChild(status(doc, content.reason));
|
|
else {
|
|
area.appendChild(el(doc, 'pre', {class: 'hux-wave-b__preview', tabindex: '0'}, content.preview));
|
|
if (content.reason) area.appendChild(status(doc, content.reason));
|
|
}
|
|
} catch (_) { area.replaceChildren(status(doc, 'This version could not be previewed.', true)); }
|
|
}
|
|
async function showDiff(artifact, area) {
|
|
const from = artifact.currentVersion - 1; const to = artifact.currentVersion;
|
|
area.replaceChildren(status(doc, 'Loading version comparison…'));
|
|
try {
|
|
const result = await context.client.request(`/artifacts/${artifact.id}/versions/${to}/diff?from=${from}`);
|
|
const diff = contract.normalizeDiff(result.body, from, to);
|
|
area.replaceChildren(el(doc, 'pre', {class: 'hux-wave-b__preview', tabindex: '0'}, diff.text));
|
|
} catch (_) { area.replaceChildren(status(doc, 'This comparison could not be loaded.', true)); }
|
|
}
|
|
async function promote(artifact) {
|
|
await context.client.request(`/artifacts/${artifact.id}/promote`, {method: 'POST',
|
|
headers: {'If-Match': String(artifact.revision)}, body: {project_id: projectId,
|
|
version: artifact.currentVersion}});
|
|
await reload();
|
|
}
|
|
async function addVersion(artifact, content, button) {
|
|
if (!content.trim()) return;
|
|
button.disabled = true;
|
|
await context.client.request(`/artifacts/${artifact.id}/versions`, {method: 'POST', headers: {
|
|
'If-Match': String(artifact.revision),
|
|
'Idempotency-Key': key('artifact-version', `${artifact.id}:${artifact.revision + 1}`),
|
|
}, body: {content, mime: artifact.versions.at(-1).mime, diff_from: artifact.currentVersion}});
|
|
await reload();
|
|
}
|
|
function artifactCard(artifact) {
|
|
const card = el(doc, 'article', {class: 'hux-wave-b__card'});
|
|
card.appendChild(el(doc, 'h4', {}, artifact.title));
|
|
card.appendChild(el(doc, 'p', {}, `${artifact.type} · ${artifact.sensitivity} · ${artifact.versions.length} versions`));
|
|
const history = el(doc, 'ol', {class: 'hux-wave-b__history', 'aria-label': `${artifact.title} versions`});
|
|
const preview = el(doc, 'div', {class: 'hux-wave-b__preview-area', 'aria-live': 'polite'});
|
|
artifact.versions.forEach((version) => {
|
|
const row = el(doc, 'li');
|
|
const open = el(doc, 'button', {type: 'button'}, `Version ${version.version}`);
|
|
open.addEventListener('click', () => showPreview(artifact, version.version, preview));
|
|
row.appendChild(open); row.appendChild(el(doc, 'span', {}, ` ${version.bytes} bytes · ${version.mime}`));
|
|
history.appendChild(row);
|
|
});
|
|
card.appendChild(history);
|
|
const download = el(doc, 'a', {href: context.client.endpoint(`/artifacts/${artifact.id}/versions/${artifact.currentVersion}`),
|
|
download: '', rel: 'nofollow'}, 'Download current version');
|
|
card.appendChild(download);
|
|
if (artifact.currentVersion > 1) {
|
|
const diff = el(doc, 'button', {type: 'button'}, 'Compare latest versions');
|
|
diff.addEventListener('click', () => showDiff(artifact, preview)); card.appendChild(diff);
|
|
}
|
|
if (!artifact.promoted) {
|
|
const button = el(doc, 'button', {type: 'button'}, 'Promote to current project');
|
|
button.addEventListener('click', () => promote(artifact).catch(fail)); card.appendChild(button);
|
|
}
|
|
if (contract.isTextType(artifact.type)) {
|
|
const form = el(doc, 'form', {class: 'hux-wave-b__form'});
|
|
const label = el(doc, 'label', {}, 'New version content');
|
|
const input = el(doc, 'textarea', {maxlength: String(25 * 1024 * 1024), rows: '5'});
|
|
const save = el(doc, 'button', {type: 'submit'}, 'Add version');
|
|
label.appendChild(input); form.appendChild(label); form.appendChild(save);
|
|
form.addEventListener('submit', (event) => {
|
|
event.preventDefault(); addVersion(artifact, input.value, save).catch(fail);
|
|
});
|
|
card.appendChild(form);
|
|
}
|
|
card.appendChild(preview);
|
|
return card;
|
|
}
|
|
function draw() {
|
|
root.replaceChildren(heading(doc, 'Artifact workspace',
|
|
'Preview, compare, download, version, and promote outputs from this conversation.'));
|
|
root.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'Sharing is not exposed by the canonical backend; artifacts remain owner-only.'));
|
|
if (!artifacts.length) root.appendChild(status(doc, 'No artifacts belong to this conversation.'));
|
|
artifacts.forEach((artifact) => root.appendChild(artifactCard(artifact)));
|
|
}
|
|
reload();
|
|
}
|
|
return Object.freeze({id: 'artifacts', flag: 'hux.artifacts', label: 'Artifacts', order: 40, render});
|
|
}
|
|
|
|
function createResearchExtension(options) {
|
|
const settings = options || {}; const doc = settings.document;
|
|
const conversationId = settings.conversationId; const messageId = settings.messageId;
|
|
let notebookId = settings.notebookId;
|
|
if (!doc || !contract.isId(conversationId, 'conv') ||
|
|
(messageId !== undefined && !contract.MESSAGE.test(messageId)) ||
|
|
(notebookId !== undefined && !contract.isId(notebookId, 'nb'))) {
|
|
throw new TypeError('Research runtime context is invalid');
|
|
}
|
|
function render(context) {
|
|
const root = el(doc, 'div', {class: 'hux-wave-b hux-wave-b--research'});
|
|
context.container.replaceChildren(root);
|
|
let citations = []; let notebook = null; let generation = 0;
|
|
function fail() {
|
|
root.replaceChildren(heading(doc, 'Research notebook', 'Inspect bounded evidence and unresolved questions.'),
|
|
status(doc, 'This research workspace is temporarily unavailable.', true));
|
|
}
|
|
function sourceItem(source, passage) {
|
|
const item = el(doc, 'li');
|
|
const title = source.url ? el(doc, 'a', {href: source.url, target: '_blank', rel: 'noopener noreferrer'}, source.title) :
|
|
el(doc, 'span', {}, source.title);
|
|
item.appendChild(title);
|
|
item.appendChild(el(doc, 'span', {}, ` · ${source.classification}${source.publisher ? ` · ${source.publisher}` : ''}`));
|
|
if (!source.url) item.appendChild(el(doc, 'span', {class: 'hux-wave-b__gap'}, ' · link withheld by URL policy'));
|
|
item.appendChild(el(doc, 'blockquote', {}, passage.text));
|
|
return item;
|
|
}
|
|
function citationCard(item) {
|
|
const details = el(doc, 'details', {class: 'hux-wave-b__card'});
|
|
details.appendChild(el(doc, 'summary', {}, `${item.support.replaceAll('_', ' ')}: ${item.claim}`));
|
|
if (item.note) details.appendChild(el(doc, 'p', {}, item.note));
|
|
const list = el(doc, 'ol', {'aria-label': 'Supporting passages'});
|
|
item.passages.forEach((passage) => {
|
|
const source = item.sources.find((candidate) => candidate.id === passage.sourceId);
|
|
list.appendChild(sourceItem(source, passage));
|
|
});
|
|
details.appendChild(list); return details;
|
|
}
|
|
async function patchNotebook(body) {
|
|
const result = await context.client.request(`/notebooks/${notebook.id}`, {method: 'PATCH',
|
|
headers: {'If-Match': String(notebook.revision)}, body});
|
|
notebook = contract.normalizeNotebook(result.body, conversationId, result.etag); draw('Notebook updated.');
|
|
}
|
|
function notebookView() {
|
|
const section = el(doc, 'section', {class: 'hux-wave-b__notebook', 'aria-label': 'Research notebook'});
|
|
section.appendChild(el(doc, 'h4', {}, notebook.question));
|
|
section.appendChild(el(doc, 'p', {}, `Status: ${notebook.status}`));
|
|
const groups = [
|
|
['Assumptions', notebook.assumptions], ['Unresolved questions', notebook.unresolvedQuestions],
|
|
['Notes', notebook.notes.map((note) => note.text)],
|
|
];
|
|
groups.forEach(([label, items]) => {
|
|
section.appendChild(el(doc, 'h5', {}, label)); const list = el(doc, 'ul');
|
|
items.forEach((item) => list.appendChild(el(doc, 'li', {}, item)));
|
|
if (!items.length) list.appendChild(el(doc, 'li', {}, 'None recorded.'));
|
|
section.appendChild(list);
|
|
});
|
|
if (notebook.passageIds.length) section.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'The backend has no passage-by-id endpoint; passage text is shown only when attached to the selected message.'));
|
|
if (notebook.status === 'open') {
|
|
const actions = el(doc, 'div', {class: 'hux-wave-b__actions'});
|
|
['answered', 'abandoned'].forEach((next) => {
|
|
const button = el(doc, 'button', {type: 'button'}, `Mark ${next}`);
|
|
button.addEventListener('click', () => patchNotebook({status: next}).catch(fail));
|
|
actions.appendChild(button);
|
|
});
|
|
section.appendChild(actions);
|
|
}
|
|
const form = el(doc, 'form', {class: 'hux-wave-b__form'});
|
|
const label = el(doc, 'label', {}, 'Add research note');
|
|
const input = el(doc, 'textarea', {maxlength: '2000', rows: '3'});
|
|
const save = el(doc, 'button', {type: 'submit'}, 'Add note');
|
|
label.appendChild(input); form.appendChild(label); form.appendChild(save);
|
|
form.addEventListener('submit', (event) => {
|
|
event.preventDefault(); const value = input.value.trim();
|
|
if (value) patchNotebook({add_notes: [{text: value}]}).catch(fail);
|
|
});
|
|
section.appendChild(form); return section;
|
|
}
|
|
async function createNotebook(question) {
|
|
const result = await context.client.request('/notebooks', {method: 'POST', headers: {
|
|
'Idempotency-Key': key('notebook', conversationId),
|
|
}, body: {conversation_id: conversationId, question}});
|
|
notebook = contract.normalizeNotebook(result.body, conversationId, result.etag);
|
|
notebookId = notebook.id; draw('Notebook created.');
|
|
}
|
|
function createNotebookForm() {
|
|
const form = el(doc, 'form', {class: 'hux-wave-b__form'});
|
|
const label = el(doc, 'label', {}, 'Research question');
|
|
const input = el(doc, 'textarea', {maxlength: '1000', rows: '3'});
|
|
const save = el(doc, 'button', {type: 'submit'}, 'Create notebook');
|
|
label.appendChild(input); form.appendChild(label); form.appendChild(save);
|
|
form.addEventListener('submit', (event) => {
|
|
event.preventDefault(); const value = input.value.trim();
|
|
if (value) createNotebook(value).catch(fail);
|
|
});
|
|
return form;
|
|
}
|
|
function draw(message) {
|
|
root.replaceChildren(heading(doc, 'Research notebook',
|
|
'Inspect sources, supporting passages, assumptions, notes, and unresolved questions.'));
|
|
if (message) root.appendChild(status(doc, message));
|
|
root.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'Research records carry no owner identity field; isolation relies on the verified tenant-scoped HUX session.'));
|
|
const strip = el(doc, 'section', {'aria-label': 'Citations for selected message'});
|
|
strip.appendChild(el(doc, 'h4', {}, 'Citations'));
|
|
if (!messageId) strip.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'Select a message to load citations; the backend exposes citations per message, not per conversation.'));
|
|
else if (!citations.length) strip.appendChild(status(doc, 'No citations are attached to the selected message.'));
|
|
citations.forEach((item) => strip.appendChild(citationCard(item)));
|
|
root.appendChild(strip);
|
|
root.appendChild(notebook ? notebookView() : createNotebookForm());
|
|
if (!notebookId) root.appendChild(el(doc, 'p', {class: 'hux-wave-b__gap'},
|
|
'Existing notebooks cannot be discovered because the backend has no conversation notebook-list endpoint.'));
|
|
}
|
|
async function load() {
|
|
const current = ++generation;
|
|
root.replaceChildren(heading(doc, 'Research notebook', 'Inspect bounded evidence and unresolved questions.'),
|
|
status(doc, 'Loading research…'));
|
|
try {
|
|
const citationRequest = messageId ? context.client.request(`/messages/${messageId}/citations`) :
|
|
Promise.resolve({body: {items: [], next: null}});
|
|
const notebookRequest = notebookId ? context.client.request(`/notebooks/${notebookId}`) : Promise.resolve(null);
|
|
const [citationResult, notebookResult] = await Promise.all([citationRequest, notebookRequest]);
|
|
if (current !== generation) return;
|
|
citations = contract.normalizeCitationPage(citationResult.body, messageId || 'message-default', conversationId);
|
|
notebook = notebookResult ? contract.normalizeNotebook(notebookResult.body, conversationId, notebookResult.etag) : null;
|
|
draw();
|
|
} catch (_) { if (current === generation) fail(); }
|
|
}
|
|
load();
|
|
}
|
|
return Object.freeze({id: 'research', flag: 'hux.research', label: 'Research', order: 80, render});
|
|
}
|
|
|
|
return Object.freeze({createArtifactsExtension, createResearchExtension, etag, key});
|
|
}));
|