Files
client/www/js/ai-ui.mjs
T
2026-04-17 16:52:51 -04:00

552 lines
18 KiB
JavaScript

import { HamburgerMorphing } from '../hamburger_morphing/hamburger.mjs';
const VERSION = '0.0.4';
const AI_CONFIG_KEY = 'ai_chat_openai_config_v1';
export function getDefaultAiConfig() {
return {
provider: 'ppq.ai',
api_key: '',
credit_id: '',
model: 'claude-haiku-4.5',
base_url: 'https://api.ppq.ai',
max_tokens: 4096,
temperature: 0.7,
providers: [
{ name: 'ppq.ai', url: 'https://api.ppq.ai' },
{ name: 'Routstr', url: 'https://api.routstr.com' }
],
favorites: []
};
}
export function normalizeBaseUrl(baseUrlRaw) {
const raw = String(baseUrlRaw || '').trim().replace(/\/+$/, '');
if (!raw) return '';
return raw;
}
export function getChatCompletionsUrl(baseUrlRaw) {
const base = normalizeBaseUrl(baseUrlRaw);
if (!base) return '';
if (base.endsWith('/v1')) return `${base}/chat/completions`;
return `${base}/v1/chat/completions`;
}
export function normalizeAiConfig(input = {}, fallbackProviders = []) {
const providersFallback = Array.isArray(fallbackProviders) ? fallbackProviders : [];
return {
provider: String(input?.provider || 'ppq.ai').trim(),
api_key: String(input?.api_key || '').trim(),
credit_id: String(input?.credit_id || input?.creditId || '').trim(),
model: String(input?.model || '').trim(),
base_url: String(input?.base_url || '').trim(),
max_tokens: Math.max(1, Math.floor(Number(input?.max_tokens ?? 4096))),
temperature: Number(input?.temperature ?? 0.7),
providers: Array.isArray(input?.providers) ? input.providers : providersFallback,
favorites: Array.isArray(input?.favorites) ? input.favorites : []
};
}
export function mergeProvidersPreservingSecrets(existingProviders = [], incomingProviders = []) {
const existingByName = new Map(
(Array.isArray(existingProviders) ? existingProviders : [])
.map((p) => [String(p?.name || '').trim(), p])
.filter(([name]) => Boolean(name))
);
return (Array.isArray(incomingProviders) ? incomingProviders : []).map((p) => {
const name = String(p?.name || '').trim();
const existing = existingByName.get(name) || {};
return {
...p,
api_key: String(p?.api_key || p?.apiKey || existing?.api_key || existing?.apiKey || '').trim(),
credit_id: String(p?.credit_id || p?.creditId || existing?.credit_id || existing?.creditId || '').trim(),
model: String(p?.model || existing?.model || '').trim(),
max_tokens: Number(p?.max_tokens ?? p?.maxTokens ?? existing?.max_tokens ?? existing?.maxTokens ?? 4096),
temperature: Number(p?.temperature ?? existing?.temperature ?? 0.7)
};
});
}
export function mergeAiConfigFromSettings(currentConfig, settingsAiRaw = {}) {
const incoming = normalizeAiConfig(settingsAiRaw, currentConfig?.providers || []);
const mergedProviders = mergeProvidersPreservingSecrets(currentConfig?.providers || [], incoming.providers || []);
// Propagate top-level credentials to the matching provider entry
const activeProviderName = incoming.provider || currentConfig?.provider;
const updatedProviders = mergedProviders.map(p => {
if (p.name === activeProviderName) {
return {
...p,
api_key: incoming.api_key || p.api_key,
credit_id: incoming.credit_id || p.credit_id,
model: incoming.model || p.model,
max_tokens: incoming.max_tokens || p.max_tokens,
temperature: incoming.temperature ?? p.temperature
};
}
return p;
});
return {
...currentConfig,
...incoming,
api_key: incoming.api_key || String(currentConfig?.api_key || '').trim(),
credit_id: incoming.credit_id || String(currentConfig?.credit_id || '').trim(),
providers: updatedProviders
};
}
export function loadAiConfigLocal(currentConfig = getDefaultAiConfig(), storageKey = AI_CONFIG_KEY) {
let nextConfig = { ...getDefaultAiConfig(), ...(currentConfig || {}) };
try {
const raw = localStorage.getItem(storageKey);
const parsed = raw ? JSON.parse(raw) : null;
if (parsed && typeof parsed === 'object') {
nextConfig = {
...nextConfig,
...parsed,
max_tokens: Number(parsed.max_tokens ?? nextConfig.max_tokens),
temperature: Number(parsed.temperature ?? nextConfig.temperature)
};
}
} catch (_error) {
// keep defaults/current
}
return nextConfig;
}
export function saveAiConfigLocal(config, storageKey = AI_CONFIG_KEY) {
const normalized = normalizeAiConfig(config, config?.providers || []);
localStorage.setItem(storageKey, JSON.stringify(normalized));
return normalized;
}
export function getProviderEntries(config) {
return (config?.providers || [])
.map((p) => ({
name: String(p?.name || '').trim(),
url: String(p?.url || '').trim(),
api_key: String(p?.api_key || p?.apiKey || '').trim(),
credit_id: String(p?.credit_id || p?.creditId || '').trim(),
model: String(p?.model || '').trim(),
max_tokens: Number(p?.max_tokens ?? p?.maxTokens ?? NaN),
temperature: Number(p?.temperature ?? NaN)
}))
.filter((p) => p.name && p.url);
}
let aiSectionToggleInitialized = false;
let aiSectionToggleIcon = null;
let latestOptions = null;
let providerToggleIcons = new Map();
let subGroupToggleIcons = new Map();
let expandedProviderName = '';
let sourcePanelRefs = {
config: null,
prompt: null,
payments: null
};
function normalizeOptions(options = {}) {
return {
sectionId: String(options.sectionId || 'divAiSection'),
titleId: String(options.titleId || 'divAiSectionTitle'),
listId: String(options.listId || 'divAiList'),
collapsedKey: String(options.collapsedKey || 'sidenav-ai-collapsed'),
configPanelId: String(options.configPanelId || 'divAiConfigPanel'),
promptPanelId: String(options.promptPanelId || 'divAiPromptPanel'),
paymentsPanelId: String(options.paymentsPanelId || 'divAiPaymentsPanel'),
getProviders: typeof options.getProviders === 'function' ? options.getProviders : (() => []),
getActiveProviderName: typeof options.getActiveProviderName === 'function' ? options.getActiveProviderName : (() => ''),
onSelectProvider: typeof options.onSelectProvider === 'function' ? options.onSelectProvider : (() => {})
};
}
function escapeHtml(value) {
const span = document.createElement('span');
span.textContent = String(value || '');
return span.innerHTML;
}
function getStorageBool(key, defaultValue) {
const raw = localStorage.getItem(key);
if (raw === null) return Boolean(defaultValue);
return raw === 'true';
}
function setStorageBool(key, value) {
localStorage.setItem(key, value ? 'true' : 'false');
}
function ensureAiSectionToggle(options) {
if (aiSectionToggleInitialized) return;
const sectionEl = document.getElementById(options.sectionId);
const titleEl = document.getElementById(options.titleId);
if (!sectionEl || !titleEl) return;
if (localStorage.getItem(options.collapsedKey) === 'true') {
sectionEl.classList.add('collapsed');
}
let iconEl = document.getElementById('divAiSectionToggleIcon');
if (!iconEl) {
iconEl = document.createElement('div');
iconEl.id = 'divAiSectionToggleIcon';
iconEl.className = 'sidenavSectionToggleIcon';
titleEl.appendChild(iconEl);
}
try {
aiSectionToggleIcon = new HamburgerMorphing('#divAiSectionToggleIcon', {
size: 16,
foreground: 'var(--muted-color)',
background: 'transparent',
hover: 'var(--primary-color)'
});
aiSectionToggleIcon.animateTo(sectionEl.classList.contains('collapsed') ? 'burger' : 'arrow_down');
} catch (error) {
console.warn('[ai-ui] Failed to initialize AI section toggle icon:', error);
}
titleEl.addEventListener('click', () => {
sectionEl.classList.toggle('collapsed');
const isCollapsed = sectionEl.classList.contains('collapsed');
localStorage.setItem(options.collapsedKey, isCollapsed ? 'true' : 'false');
if (aiSectionToggleIcon) {
aiSectionToggleIcon.animateTo(isCollapsed ? 'burger' : 'arrow_down');
}
});
aiSectionToggleInitialized = true;
}
function clearIcons() {
for (const icon of providerToggleIcons.values()) {
try {
icon.destroy();
} catch (_error) {
// no-op
}
}
providerToggleIcons.clear();
for (const icon of subGroupToggleIcons.values()) {
try {
icon.destroy();
} catch (_error) {
// no-op
}
}
subGroupToggleIcons.clear();
}
function makeSlug(value) {
return String(value || '').replace(/[^a-zA-Z0-9]/g, '-');
}
function resolveSourcePanel(options, key) {
const idByKey = {
config: options.configPanelId,
prompt: options.promptPanelId,
payments: options.paymentsPanelId
};
if (sourcePanelRefs[key]) return sourcePanelRefs[key];
const panel = document.getElementById(idByKey[key]);
if (panel) sourcePanelRefs[key] = panel;
return panel || null;
}
function attachSubGroup({ detailsEl, providerName, groupKey, label, panelEl, defaultExpanded = false }) {
if (!detailsEl || !panelEl) return;
const providerSlug = makeSlug(providerName);
const headerId = `ai-provider-${providerSlug}-${groupKey}-header`;
const bodyId = `ai-provider-${providerSlug}-${groupKey}-body`;
const toggleId = `ai-provider-${providerSlug}-${groupKey}-toggle`;
const collapseKey = `sidenav-ai-provider-${providerSlug}-${groupKey}-collapsed`;
const groupWrap = document.createElement('div');
groupWrap.className = 'aiCompactGroup';
const headerEl = document.createElement('div');
headerEl.id = headerId;
headerEl.className = 'aiCompactGroupHeader';
const titleEl = document.createElement('span');
titleEl.className = 'aiCompactGroupTitle';
titleEl.textContent = label;
const toggleEl = document.createElement('div');
toggleEl.id = toggleId;
toggleEl.className = 'aiCompactGroupToggle';
const bodyEl = document.createElement('div');
bodyEl.id = bodyId;
bodyEl.className = 'aiCompactGroupBody';
const isCollapsed = getStorageBool(collapseKey, !defaultExpanded);
bodyEl.classList.toggle('collapsed', isCollapsed);
headerEl.appendChild(titleEl);
headerEl.appendChild(toggleEl);
groupWrap.appendChild(headerEl);
groupWrap.appendChild(bodyEl);
detailsEl.appendChild(groupWrap);
bodyEl.appendChild(panelEl);
const initSubgroupIcon = () => {
try {
const icon = new HamburgerMorphing(`#${toggleId}`, {
size: 14,
foreground: 'var(--muted-color)',
background: 'transparent',
hover: 'var(--primary-color)'
});
icon.animateTo(isCollapsed ? 'burger' : 'arrow_down');
subGroupToggleIcons.set(toggleId, icon);
} catch (error) {
console.warn('[ai-ui] Failed to render subgroup toggle icon:', error);
}
};
setTimeout(initSubgroupIcon, 0);
headerEl.addEventListener('click', (event) => {
event.stopPropagation();
bodyEl.classList.toggle('collapsed');
const collapsed = bodyEl.classList.contains('collapsed');
setStorageBool(collapseKey, collapsed);
const icon = subGroupToggleIcons.get(toggleId);
if (icon) icon.animateTo(collapsed ? 'burger' : 'arrow_down');
});
}
function renderProviderTools({ detailsEl, providerName, options, isExpanded }) {
if (!detailsEl || !isExpanded) return;
const toolsWrap = document.createElement('div');
toolsWrap.className = 'aiCompactTools';
const configPanelEl = resolveSourcePanel(options, 'config');
const promptPanelEl = resolveSourcePanel(options, 'prompt');
const paymentsPanelEl = resolveSourcePanel(options, 'payments');
attachSubGroup({
detailsEl: toolsWrap,
providerName,
groupKey: 'config',
label: 'Config',
panelEl: configPanelEl,
defaultExpanded: true
});
attachSubGroup({
detailsEl: toolsWrap,
providerName,
groupKey: 'prompt',
label: 'Prompt',
panelEl: promptPanelEl,
defaultExpanded: false
});
attachSubGroup({
detailsEl: toolsWrap,
providerName,
groupKey: 'payments',
label: 'Payments',
panelEl: paymentsPanelEl,
defaultExpanded: false
});
detailsEl.appendChild(toolsWrap);
}
function renderAiSectionList(options) {
const listEl = document.getElementById(options.listId);
if (!listEl) return;
const providers = (options.getProviders() || [])
.map((p) => ({ name: String(p?.name || '').trim() }))
.filter((p) => p.name);
const activeName = String(options.getActiveProviderName() || '').trim();
const allowedNames = new Set(providers.map((p) => p.name));
// Case-insensitive match for active provider
const activeNameLower = activeName.toLowerCase();
const matchedActive = providers.find(p => p.name.toLowerCase() === activeNameLower);
const effectiveActiveName = matchedActive ? matchedActive.name : activeName;
if (!expandedProviderName || !allowedNames.has(expandedProviderName)) {
expandedProviderName = effectiveActiveName && allowedNames.has(effectiveActiveName) ? effectiveActiveName : '';
}
clearIcons();
if (providers.length === 0) {
listEl.innerHTML = '<div class="aiSavedProviderEmpty">No saved providers yet.</div>';
return;
}
listEl.innerHTML = '';
providers.forEach((provider, index) => {
const providerName = provider.name;
const safeName = escapeHtml(providerName);
const isActive = providerName === activeName;
const isExpanded = providerName === expandedProviderName;
const toggleId = `ai-provider-toggle-${index}-${makeSlug(providerName)}`;
const rowWrap = document.createElement('div');
rowWrap.className = `aiProviderEntry${isActive ? ' active' : ''}`;
const row = document.createElement('div');
row.className = 'aiProviderRow';
row.setAttribute('data-provider-name', providerName);
const nameEl = document.createElement('span');
nameEl.className = `aiProviderName${isActive ? ' active' : ''}`;
nameEl.innerHTML = safeName;
const toggleEl = document.createElement('div');
toggleEl.id = toggleId;
toggleEl.className = 'aiProviderToggle';
toggleEl.title = isExpanded ? 'Hide provider settings' : 'Show provider settings';
row.appendChild(nameEl);
row.appendChild(toggleEl);
const details = document.createElement('div');
details.className = `aiProviderDetails${isExpanded ? ' expanded' : ''}`;
row.addEventListener('click', () => {
options.onSelectProvider(providerName);
expandedProviderName = providerName;
renderAiSectionList(options);
});
toggleEl.addEventListener('click', (event) => {
event.stopPropagation();
const nextExpanded = expandedProviderName === providerName ? '' : providerName;
expandedProviderName = nextExpanded;
if (nextExpanded) {
options.onSelectProvider(providerName);
}
renderAiSectionList(options);
});
if (isExpanded) {
renderProviderTools({ detailsEl: details, providerName, options, isExpanded: true });
}
rowWrap.appendChild(row);
rowWrap.appendChild(details);
listEl.appendChild(rowWrap);
try {
const icon = new HamburgerMorphing(`#${toggleId}`, {
size: 16,
foreground: 'var(--muted-color)',
background: 'transparent',
hover: 'var(--primary-color)'
});
icon.animateTo(isExpanded ? 'arrow_down' : 'burger');
providerToggleIcons.set(toggleId, icon);
} catch (error) {
console.warn('[ai-ui] Failed to render provider toggle icon:', error);
}
});
}
export function initAiSection(options = {}) {
latestOptions = normalizeOptions(options);
ensureAiSectionToggle(latestOptions);
renderAiSectionList(latestOptions);
}
export function updateAiSection(options = null) {
if (options) latestOptions = normalizeOptions(options);
if (!latestOptions) return;
ensureAiSectionToggle(latestOptions);
renderAiSectionList(latestOptions);
}
let localAiSectionConfig = getDefaultAiConfig();
function selectProviderInConfig(config, providerName) {
const selectedName = String(providerName || '').trim();
if (!selectedName) return config;
const providers = getProviderEntries(config);
const selected = providers.find((p) =>
String(p.name || '').trim().toLowerCase() === selectedName.toLowerCase()
);
if (!selected) {
return {
...config,
provider: selectedName
};
}
return {
...config,
provider: selected.name,
base_url: selected.url,
api_key: selected.api_key,
credit_id: selected.credit_id,
model: selected.model || config.model,
max_tokens: Number.isFinite(selected.max_tokens) && selected.max_tokens > 0 ? Math.floor(selected.max_tokens) : config.max_tokens,
temperature: Number.isFinite(selected.temperature) ? selected.temperature : config.temperature
};
}
export function initAiSectionWithLocalConfig(options = {}) {
const storageKey = String(options.storageKey || AI_CONFIG_KEY);
localAiSectionConfig = loadAiConfigLocal(localAiSectionConfig, storageKey);
const mergedOptions = {
...options,
listId: String(options.listId || 'divAiProvidersList'),
getProviders: () => getProviderEntries(localAiSectionConfig),
getActiveProviderName: () => String(localAiSectionConfig.provider || '').trim(),
onSelectProvider: (providerName) => {
localAiSectionConfig = selectProviderInConfig(localAiSectionConfig, providerName);
localAiSectionConfig = saveAiConfigLocal(localAiSectionConfig, storageKey);
if (typeof options.onSelectProvider === 'function') {
options.onSelectProvider(providerName, { ...localAiSectionConfig });
}
}
};
initAiSection(mergedOptions);
return { ...localAiSectionConfig };
}
export function updateAiSectionWithLocalConfig(options = {}) {
const storageKey = String(options.storageKey || AI_CONFIG_KEY);
localAiSectionConfig = loadAiConfigLocal(localAiSectionConfig, storageKey);
const mergedOptions = {
...options,
listId: String(options.listId || 'divAiProvidersList'),
getProviders: () => getProviderEntries(localAiSectionConfig),
getActiveProviderName: () => String(localAiSectionConfig.provider || '').trim(),
onSelectProvider: (providerName) => {
localAiSectionConfig = selectProviderInConfig(localAiSectionConfig, providerName);
localAiSectionConfig = saveAiConfigLocal(localAiSectionConfig, storageKey);
if (typeof options.onSelectProvider === 'function') {
options.onSelectProvider(providerName, { ...localAiSectionConfig });
}
}
};
updateAiSection(mergedOptions);
return { ...localAiSectionConfig };
}
console.log(`[ai-ui v${VERSION}] Module loaded`);