259 lines
7.8 KiB
JavaScript
259 lines
7.8 KiB
JavaScript
|
|
const FEATURE_DEFAULTS = {
|
|||
|
|
image_generation: false,
|
|||
|
|
code_interpreter: false,
|
|||
|
|
web_search: false,
|
|||
|
|
memory: true,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const FEATURE_METADATA = {
|
|||
|
|
image_generation: {
|
|||
|
|
label: 'Image Generation',
|
|||
|
|
description: 'Request image outputs from supported models. When enabled the assistant may produce images alongside text responses.',
|
|||
|
|
},
|
|||
|
|
code_interpreter: {
|
|||
|
|
label: 'Code Interpreter',
|
|||
|
|
description: 'Grant access to the sandboxed runtime for running Python snippets and data transformations during the task.',
|
|||
|
|
},
|
|||
|
|
web_search: {
|
|||
|
|
label: 'Web Search',
|
|||
|
|
description: 'Allow the assistant to perform outbound web searches to enrich the response with current information.',
|
|||
|
|
},
|
|||
|
|
memory: {
|
|||
|
|
label: 'Memory',
|
|||
|
|
description: 'Persist relevant conversation context for future automations so runs can recall prior outcomes.',
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const FEATURE_SECTION_TAG = 'Feature';
|
|||
|
|
|
|||
|
|
function renderFeatureList(featureState) {
|
|||
|
|
const container = document.querySelector('#features-select');
|
|||
|
|
const hiddenInput = document.querySelector('#features-select-input');
|
|||
|
|
if (!container || !hiddenInput) return;
|
|||
|
|
|
|||
|
|
const baseState = normalizeFeatureState(featureState),
|
|||
|
|
featureKeys = Object.keys(baseState);
|
|||
|
|
|
|||
|
|
container.innerHTML = '';
|
|||
|
|
container.setAttribute('data-has-features', String(featureKeys.length > 0));
|
|||
|
|
|
|||
|
|
const selection = { ...baseState },
|
|||
|
|
syncHidden = () => {
|
|||
|
|
hiddenInput.value = JSON.stringify(selection);
|
|||
|
|
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|||
|
|
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
featureKeys.forEach((id) => {
|
|||
|
|
const meta = getFeatureMeta(id);
|
|||
|
|
const pill = document.createElement('div');
|
|||
|
|
pill.className = 'feature-pill';
|
|||
|
|
pill.dataset.featureId = id;
|
|||
|
|
pill.setAttribute('role', 'option');
|
|||
|
|
pill.setAttribute('tabindex', '-1');
|
|||
|
|
|
|||
|
|
const toggleBtn = document.createElement('button');
|
|||
|
|
toggleBtn.type = 'button';
|
|||
|
|
toggleBtn.className = 'feature-pill-toggle';
|
|||
|
|
toggleBtn.textContent = meta.label;
|
|||
|
|
toggleBtn.setAttribute('aria-pressed', String(Boolean(selection[id])));
|
|||
|
|
toggleBtn.addEventListener('click', () => {
|
|||
|
|
selection[id] = !selection[id];
|
|||
|
|
const isSelected = Boolean(selection[id]);
|
|||
|
|
pill.classList.toggle('feature-pill--selected', isSelected);
|
|||
|
|
pill.setAttribute('aria-selected', String(isSelected));
|
|||
|
|
toggleBtn.setAttribute('aria-pressed', String(isSelected));
|
|||
|
|
syncHidden();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const infoBtn = document.createElement('button');
|
|||
|
|
infoBtn.type = 'button';
|
|||
|
|
infoBtn.className = 'feature-pill-info';
|
|||
|
|
infoBtn.setAttribute('aria-label', `Show info for ${meta.label}`);
|
|||
|
|
infoBtn.innerHTML = '<span aria-hidden="true">i</span>';
|
|||
|
|
infoBtn.addEventListener('click', (event) => {
|
|||
|
|
event.stopPropagation();
|
|||
|
|
generateFeatureCard({ id, ...meta, selected: Boolean(selection[id]) }, { trigger: infoBtn });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const isSelected = Boolean(selection[id]);
|
|||
|
|
pill.classList.toggle('feature-pill--selected', isSelected);
|
|||
|
|
pill.setAttribute('aria-selected', String(isSelected));
|
|||
|
|
|
|||
|
|
pill.appendChild(toggleBtn);
|
|||
|
|
pill.appendChild(infoBtn);
|
|||
|
|
container.appendChild(pill);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
syncHidden();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeFeatureState(featureState) {
|
|||
|
|
const normalized = { ...FEATURE_DEFAULTS };
|
|||
|
|
if (featureState && typeof featureState === 'object' && !Array.isArray(featureState)) {
|
|||
|
|
for (const [key, value] of Object.entries(featureState)) {
|
|||
|
|
if (Object.prototype.hasOwnProperty.call(FEATURE_DEFAULTS, key) || Object.prototype.hasOwnProperty.call(FEATURE_METADATA, key)) {
|
|||
|
|
normalized[key] = Boolean(value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return normalized;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getFeatureMeta(id) {
|
|||
|
|
if (Object.prototype.hasOwnProperty.call(FEATURE_METADATA, id)) {
|
|||
|
|
return FEATURE_METADATA[id];
|
|||
|
|
}
|
|||
|
|
const label = id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|||
|
|
return {
|
|||
|
|
label,
|
|||
|
|
description: 'Toggle this capability for the scheduled run.',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generateFeatureCard(feature, options = {}) {
|
|||
|
|
const mount = options.mountSelector ? document.querySelector(options.mountSelector) : document.body;
|
|||
|
|
if (!mount) throw new Error('mount element not found');
|
|||
|
|
|
|||
|
|
const overlay = document.createElement('div');
|
|||
|
|
overlay.className = 'fc-overlay';
|
|||
|
|
overlay.setAttribute('data-testid', 'feature-card-overlay');
|
|||
|
|
|
|||
|
|
const dialog = document.createElement('div');
|
|||
|
|
dialog.className = 'fc-dialog';
|
|||
|
|
dialog.setAttribute('role', 'dialog');
|
|||
|
|
dialog.setAttribute('aria-modal', 'true');
|
|||
|
|
|
|||
|
|
const titleId = `fc-title-${Math.random().toString(36).slice(2)}`;
|
|||
|
|
const descId = `fc-desc-${Math.random().toString(36).slice(2)}`;
|
|||
|
|
dialog.setAttribute('aria-labelledby', titleId);
|
|||
|
|
dialog.setAttribute('aria-describedby', descId);
|
|||
|
|
|
|||
|
|
const header = document.createElement('header');
|
|||
|
|
header.className = 'fc-header';
|
|||
|
|
|
|||
|
|
const titleWrap = document.createElement('div');
|
|||
|
|
titleWrap.className = 'fc-titlewrap';
|
|||
|
|
|
|||
|
|
const h1 = document.createElement('h1');
|
|||
|
|
h1.id = titleId;
|
|||
|
|
h1.className = 'fc-title';
|
|||
|
|
h1.textContent = feature.label;
|
|||
|
|
|
|||
|
|
const tag = document.createElement('span');
|
|||
|
|
tag.className = 'fc-tag';
|
|||
|
|
tag.textContent = FEATURE_SECTION_TAG;
|
|||
|
|
tag.setAttribute('data-testid', 'feature-tag');
|
|||
|
|
|
|||
|
|
titleWrap.appendChild(h1);
|
|||
|
|
titleWrap.appendChild(tag);
|
|||
|
|
|
|||
|
|
const closeBtn = document.createElement('button');
|
|||
|
|
closeBtn.className = 'fc-close';
|
|||
|
|
closeBtn.type = 'button';
|
|||
|
|
closeBtn.setAttribute('aria-label', 'close');
|
|||
|
|
closeBtn.textContent = '×';
|
|||
|
|
|
|||
|
|
header.appendChild(titleWrap);
|
|||
|
|
header.appendChild(closeBtn);
|
|||
|
|
|
|||
|
|
const body = document.createElement('div');
|
|||
|
|
body.className = 'fc-body';
|
|||
|
|
|
|||
|
|
const desc = document.createElement('p');
|
|||
|
|
desc.id = descId;
|
|||
|
|
desc.className = 'fc-desc';
|
|||
|
|
desc.textContent = feature.description;
|
|||
|
|
|
|||
|
|
const details = document.createElement('dl');
|
|||
|
|
details.className = 'fc-details';
|
|||
|
|
|
|||
|
|
const pairs = [
|
|||
|
|
['Identifier', feature.id],
|
|||
|
|
['Current state', feature.selected ? 'Enabled' : 'Disabled'],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
pairs.forEach(([label, value]) => {
|
|||
|
|
const dt = document.createElement('dt');
|
|||
|
|
dt.textContent = label;
|
|||
|
|
const dd = document.createElement('dd');
|
|||
|
|
dd.textContent = value;
|
|||
|
|
details.appendChild(dt);
|
|||
|
|
details.appendChild(dd);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
body.appendChild(desc);
|
|||
|
|
body.appendChild(details);
|
|||
|
|
|
|||
|
|
dialog.appendChild(header);
|
|||
|
|
dialog.appendChild(body);
|
|||
|
|
overlay.appendChild(dialog);
|
|||
|
|
mount.appendChild(overlay);
|
|||
|
|
|
|||
|
|
const opener = options.trigger instanceof HTMLElement ? options.trigger : document.activeElement;
|
|||
|
|
|
|||
|
|
const focusableSelectors = [
|
|||
|
|
'a[href]',
|
|||
|
|
'button:not([disabled])',
|
|||
|
|
'input:not([disabled])',
|
|||
|
|
'select:not([disabled])',
|
|||
|
|
'textarea:not([disabled])',
|
|||
|
|
'[tabindex]:not([tabindex="-1"])'
|
|||
|
|
].join(',');
|
|||
|
|
|
|||
|
|
const getFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors)));
|
|||
|
|
|
|||
|
|
const focusFirst = () => {
|
|||
|
|
const items = getFocusable();
|
|||
|
|
(items[0] || closeBtn).focus();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onKeydown = (event) => {
|
|||
|
|
if (event.key === 'Tab') {
|
|||
|
|
const items = getFocusable();
|
|||
|
|
if (items.length === 0) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
closeBtn.focus();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const first = items[0];
|
|||
|
|
const last = items[items.length - 1];
|
|||
|
|
const active = /** @type {HTMLElement} */(document.activeElement);
|
|||
|
|
if (!event.shiftKey && active === last) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
first.focus();
|
|||
|
|
} else if (event.shiftKey && active === first) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
last.focus();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (event.key === 'Escape') {
|
|||
|
|
event.preventDefault();
|
|||
|
|
close();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onOverlayClick = (event) => {
|
|||
|
|
if (event.target === overlay) close();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
overlay.addEventListener('click', onOverlayClick);
|
|||
|
|
document.addEventListener('keydown', onKeydown);
|
|||
|
|
closeBtn.addEventListener('click', () => close());
|
|||
|
|
|
|||
|
|
const prevOverflow = document.documentElement.style.overflow;
|
|||
|
|
document.documentElement.style.overflow = 'hidden';
|
|||
|
|
|
|||
|
|
setTimeout(focusFirst, 0);
|
|||
|
|
|
|||
|
|
function close() {
|
|||
|
|
overlay.removeEventListener('click', onOverlayClick);
|
|||
|
|
document.removeEventListener('keydown', onKeydown);
|
|||
|
|
document.documentElement.style.overflow = prevOverflow;
|
|||
|
|
overlay.remove();
|
|||
|
|
if (opener && typeof opener.focus === 'function') opener.focus();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { close, root: overlay };
|
|||
|
|
}
|