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