552 lines
18 KiB
JavaScript
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`);
|