/* ObsiGate — AI Editor Toolbar Provides AI-powered text editing features: - Inline completion (Ctrl+J) - Edit (improve, fix spelling, shorter, longer, simplify) - Tone (professional, casual) - Translate (multiple languages) - Generate (explain, summarize, continue) - AI Custom Rewrite - AI Toolbox (to list, to table, frontmatter, to canvas) */ import { api } from './auth.js'; import { t } from './i18n.js'; // ── API call helper ── async function aiAction(endpoint, text, extra = {}) { // Inject current provider + model from the per-section picker. const pickerState = _readPicker(); const body = { text, ...extra, ...(pickerState.provider ? { provider: pickerState.provider } : {}), ...(pickerState.model ? { model: pickerState.model } : {}), }; const data = await api(`/api/ai/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return data.result; } // ── Per-section provider/model picker ─────────────────────────────────────── // State is stored in localStorage so the user choice persists across sections. const PICKER_STORAGE_KEY = 'obsigate_ai_picker'; // Capability flags exposed by GET /api/ai/model-capabilities. const AI_CAPABILITY_KEYS = [ 'chat', 'embeddings', 'rerank', 'images', 'video', 'audio_speech', 'audio_transcription', 'vision', ]; // Maximum number of model options rendered at once. Providers such as // OpenRouter expose hundreds of models; rendering them all makes the popover // heavy and hard to scan, so the search box is the primary way to narrow down. const MODEL_RENDER_LIMIT = 200; function _readPicker() { try { return JSON.parse(localStorage.getItem(PICKER_STORAGE_KEY) || '{}'); } catch { return {}; } } function _writePicker(state) { try { localStorage.setItem(PICKER_STORAGE_KEY, JSON.stringify(state)); } catch { /* */ } } /** Fetch the curated capability flags for a provider/model pair. */ async function getModelCapabilities(provider, model) { if (!provider) return null; try { const data = await api( `/api/ai/model-capabilities?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model || '')}` ); return data.capabilities || null; } catch { return null; } } /** Render the capability checklist (☑/□) for a model. */ function renderCapabilityList(caps) { const box = document.createElement('div'); box.className = 'ai-picker-caps'; if (!caps) return box; AI_CAPABILITY_KEYS.forEach((key) => { const item = document.createElement('span'); const on = !!caps[key]; item.className = 'ai-cap-item' + (on ? ' on' : ''); item.textContent = `${on ? '☑' : '□'} ${t(`ai.cap_${key}`)}`; box.appendChild(item); }); return box; } /** Accent-insensitive, case-insensitive normalization for model search. */ function _normalizeText(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); } // One delegated document listener closes every picker popover when the user // clicks outside it. Bound once, whatever the number of pickers mounted. let _pickerOutsideBound = false; function _bindPickerOutside() { if (_pickerOutsideBound) return; _pickerOutsideBound = true; document.addEventListener('click', (e) => { document.querySelectorAll('.ai-picker-caps-host').forEach((host) => { if (host.contains(e.target)) return; const pop = host.querySelector('.ai-picker-caps-pop'); if (pop) pop.classList.add('hidden'); }); document.querySelectorAll('.ai-picker-model-combo').forEach((combo) => { if (combo.contains(e.target)) return; const pop = combo.querySelector('.ai-picker-model-pop'); const trigger = combo.querySelector('.ai-picker-model-trigger'); if (pop) pop.classList.add('hidden'); if (trigger) trigger.setAttribute('aria-expanded', 'false'); }); }); } /** * Build a provider+model picker that appears in every section's AI toolbar * (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover * available providers, then /api/config/ai-models?provider=X to list models. * * Discreet layout: a compact provider select, a searchable model combobox and * an info button (ⓘ) that reveals the model capabilities in a bubble on hover, * click or long-press (mobile) instead of always listing every endpoint type. */ async function _buildPickerUI() { const pickerState = _readPicker(); // Fetch configured providers let availableProviders = {}; try { const status = await api('/api/ai/status'); availableProviders = status.providers || {}; } catch { /* */ } const providerNames = Object.keys(availableProviders).filter( (p) => availableProviders[p]?.available ); if (!providerNames.length) return null; const wrap = document.createElement('div'); wrap.className = 'ai-picker'; // ── Provider select (compact, no label to keep the toolbar discreet) ── const providerSelect = document.createElement('select'); providerSelect.className = 'ai-picker-select'; providerSelect.setAttribute('aria-label', t('ai.provider')); const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = t('ai.provider_default'); providerSelect.appendChild(defaultOpt); providerNames.forEach((p) => { const opt = document.createElement('option'); opt.value = p; opt.textContent = p; if (p === pickerState.provider) opt.selected = true; providerSelect.appendChild(opt); }); // Hidden native select kept as the source of truth so the assistant admin // commands (`/model`) and the stored picker state keep working unchanged. const modelSelect = document.createElement('select'); modelSelect.className = 'ai-picker-model'; modelSelect.hidden = true; // ── Searchable model combobox ── const combo = document.createElement('div'); combo.className = 'ai-picker-model-combo'; const modelTrigger = document.createElement('button'); modelTrigger.type = 'button'; modelTrigger.className = 'ai-picker-model-trigger'; modelTrigger.setAttribute('aria-haspopup', 'listbox'); modelTrigger.setAttribute('aria-expanded', 'false'); const modelName = document.createElement('span'); modelName.className = 'ai-picker-model-name'; modelName.textContent = pickerState.model || t('ai.model_default'); const chevron = document.createElement('span'); chevron.className = 'ai-picker-model-chevron'; chevron.textContent = '▾'; modelTrigger.appendChild(modelName); modelTrigger.appendChild(chevron); const modelPop = document.createElement('div'); modelPop.className = 'ai-picker-model-pop hidden'; // Critical layout is applied inline too: if a stale stylesheet is served, the // popover must still stack vertically instead of flowing as a block of text. modelPop.style.cssText = 'position:absolute;top:calc(100% + 4px);right:0;z-index:60;' + 'min-width:220px;width:340px;max-width:calc(100vw - 24px);background:var(--bg-primary);' + 'border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.35);padding:6px;'; const modelSearch = document.createElement('input'); modelSearch.type = 'text'; modelSearch.className = 'ai-picker-model-search'; modelSearch.autocomplete = 'off'; modelSearch.placeholder = t('ai.model_search'); modelSearch.setAttribute('aria-label', t('ai.model_search')); modelSearch.style.cssText = 'width:100%;box-sizing:border-box;'; const modelList = document.createElement('div'); modelList.className = 'ai-picker-model-list'; modelList.setAttribute('role', 'listbox'); modelList.style.cssText = 'max-height:200px;overflow-y:auto;display:flex;flex-direction:column;'; modelPop.appendChild(modelSearch); modelPop.appendChild(modelList); combo.appendChild(modelTrigger); combo.appendChild(modelPop); // ── Capabilities info button + bubble ── const capsHost = document.createElement('div'); capsHost.className = 'ai-picker-caps-host'; const infoBtn = document.createElement('button'); infoBtn.type = 'button'; infoBtn.className = 'ai-picker-info hidden'; infoBtn.textContent = 'ℹ️'; infoBtn.setAttribute('aria-label', t('ai.model_info')); infoBtn.title = t('ai.model_info'); const capsPop = document.createElement('div'); capsPop.className = 'ai-picker-caps-pop hidden'; capsPop.style.cssText = 'position:absolute;top:calc(100% + 4px);left:0;z-index:60;' + 'min-width:220px;max-width:300px;background:var(--bg-primary);border:1px solid var(--border);' + 'border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.35);padding:8px 10px;'; capsHost.appendChild(infoBtn); capsHost.appendChild(capsPop); let _hideCapsTimer = null; let _longPressTimer = null; let _longPressFired = false; const showCaps = () => { clearTimeout(_hideCapsTimer); _closePop(); // never overlap the model dropdown capsPop.classList.remove('hidden'); }; const hideCaps = () => { _hideCapsTimer = setTimeout(() => capsPop.classList.add('hidden'), 140); }; infoBtn.addEventListener('mouseenter', showCaps); infoBtn.addEventListener('focus', showCaps); infoBtn.addEventListener('mouseleave', hideCaps); infoBtn.addEventListener('blur', hideCaps); infoBtn.addEventListener('click', (e) => { e.stopPropagation(); // A long-press already opened the bubble: swallow the synthetic click. if (_longPressFired) { _longPressFired = false; return; } capsPop.classList.toggle('hidden'); }); capsPop.addEventListener('mouseenter', showCaps); capsPop.addEventListener('mouseleave', hideCaps); infoBtn.addEventListener('touchstart', () => { _longPressFired = false; _longPressTimer = setTimeout(() => { _longPressFired = true; showCaps(); }, 450); }, { passive: true }); const _cancelLongPress = () => clearTimeout(_longPressTimer); infoBtn.addEventListener('touchend', _cancelLongPress); infoBtn.addEventListener('touchcancel', () => { _cancelLongPress(); _longPressFired = false; }); _bindPickerOutside(); // ── Model list state ── let _models = []; let _activeIndex = -1; const _visibleOptions = () => Array.from(modelList.querySelectorAll('.ai-picker-model-option')); function _syncTrigger() { modelName.textContent = modelSelect.value || t('ai.model_default'); } function _highlight(index) { const options = _visibleOptions(); if (!options.length) { _activeIndex = -1; return; } _activeIndex = ((index % options.length) + options.length) % options.length; options.forEach((el, i) => el.classList.toggle('active', i === _activeIndex)); const active = options[_activeIndex]; if (active && typeof active.scrollIntoView === 'function') { active.scrollIntoView({ block: 'nearest' }); } } function _renderList() { const q = _normalizeText(modelSearch.value); const matches = _models.filter((m) => !q || _normalizeText(m).includes(q)); const shown = matches.slice(0, MODEL_RENDER_LIMIT); modelList.innerHTML = ''; _activeIndex = -1; if (!matches.length) { const empty = document.createElement('div'); empty.className = 'ai-picker-model-empty'; empty.textContent = t('ai.no_results'); modelList.appendChild(empty); return; } shown.forEach((model) => { const option = document.createElement('button'); option.type = 'button'; option.className = 'ai-picker-model-option'; option.dataset.value = model; option.textContent = model || t('ai.model_default'); option.title = model; option.style.cssText = 'display:block;width:100%;text-align:left;white-space:normal;overflow-wrap:anywhere;'; if (model === modelSelect.value) option.classList.add('active'); option.addEventListener('mousedown', (e) => { e.preventDefault(); _pick(model); }); modelList.appendChild(option); }); if (matches.length > shown.length) { const more = document.createElement('div'); more.className = 'ai-picker-model-more'; more.textContent = t('ai.model_more', { count: matches.length - shown.length }); more.style.cssText = 'font-size:0.7rem;color:var(--text-secondary);padding:6px 8px;text-align:center;'; modelList.appendChild(more); } _activeIndex = _visibleOptions().findIndex((el) => el.dataset.value === modelSelect.value); } function _pick(model) { modelSelect.value = model; delete pickerState.capabilities; _writePicker(pickerState); _syncTrigger(); _closePop(); modelSelect.dispatchEvent(new Event('change')); } function _openPop() { capsPop.classList.add('hidden'); // never overlap the capability bubble modelPop.classList.remove('hidden'); modelTrigger.setAttribute('aria-expanded', 'true'); modelSearch.value = ''; _renderList(); modelSearch.focus(); } function _closePop() { modelPop.classList.add('hidden'); modelTrigger.setAttribute('aria-expanded', 'false'); modelSearch.value = ''; } modelTrigger.addEventListener('click', (e) => { e.stopPropagation(); if (modelPop.classList.contains('hidden')) _openPop(); else _closePop(); }); modelSearch.addEventListener('input', _renderList); modelSearch.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); _highlight(_activeIndex < 0 ? 0 : _activeIndex + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); _highlight(_activeIndex < 0 ? -1 : _activeIndex - 1); } else if (e.key === 'Enter') { e.preventDefault(); const options = _visibleOptions(); const chosen = options[_activeIndex] || options[0]; if (chosen) _pick(chosen.dataset.value); } else if (e.key === 'Escape') { e.preventDefault(); _closePop(); } }); async function _updateCaps() { capsPop.innerHTML = ''; infoBtn.classList.add('hidden'); if (!pickerState.provider || !pickerState.model) { delete pickerState.capabilities; _writePicker(pickerState); return; } const caps = await getModelCapabilities(pickerState.provider, pickerState.model); if (!caps) return; pickerState.capabilities = caps; _writePicker(pickerState); const title = document.createElement('div'); title.className = 'ai-picker-caps-title'; title.textContent = t('ai.model_info'); capsPop.appendChild(title); capsPop.appendChild(renderCapabilityList(caps)); infoBtn.classList.remove('hidden'); } async function _loadModels(provider) { _models = []; modelSelect.innerHTML = ''; _syncTrigger(); if (!provider) { _renderList(); await _updateCaps(); return; } try { const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`); const def = document.createElement('option'); def.value = ''; def.textContent = t('ai.model_default'); modelSelect.appendChild(def); _models = ['']; (data.models || []).forEach((m) => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; modelSelect.appendChild(opt); _models.push(m); }); // Keep an explicitly stored model selectable even if the API omits it. if (pickerState.model && !_models.includes(pickerState.model)) { const opt = document.createElement('option'); opt.value = pickerState.model; opt.textContent = pickerState.model; modelSelect.appendChild(opt); _models.push(pickerState.model); } // If the provider default model is known, reflect it so capabilities // are shown even when the user keeps the "default" placeholder. if (!pickerState.model && data.models && data.models.length) { pickerState.model = data.models[0]; _writePicker(pickerState); } modelSelect.value = pickerState.model || ''; _syncTrigger(); await _updateCaps(); } catch { _models = ['']; _syncTrigger(); await _updateCaps(); } } providerSelect.addEventListener('change', () => { pickerState.provider = providerSelect.value || null; // Reset model when provider changes pickerState.model = null; delete pickerState.capabilities; _writePicker(pickerState); _closePop(); _loadModels(pickerState.provider); }); modelSelect.addEventListener('change', () => { pickerState.model = modelSelect.value || null; delete pickerState.capabilities; _writePicker(pickerState); _syncTrigger(); _updateCaps(); }); // Load models for the initial provider selection. A provider that is no // longer configured (its API key was removed) is dropped from the stored // selection so the toolbar never advertises a stale provider/model. if (pickerState.provider && !providerNames.includes(pickerState.provider)) { pickerState.provider = null; pickerState.model = null; delete pickerState.capabilities; _writePicker(pickerState); } if (pickerState.provider) { _loadModels(pickerState.provider); } else { _syncTrigger(); _renderList(); } // Visual order: capability info, provider menu, model menu. wrap.appendChild(capsHost); wrap.appendChild(providerSelect); wrap.appendChild(modelSelect); wrap.appendChild(combo); return wrap; } // Mount point that survives even when no picker can be built (no configured // provider). Keeping it lets a later refresh mount the picker at the same spot. const PICKER_SLOT_CLASS = 'ai-picker-slot'; /** * Rebuild every mounted provider/model picker so the provider list matches the * current AI configuration of the project. * * A picker reads `/api/ai/status` once, when it is built — and the assistant * panel is a singleton mounted for the whole session. Without this refresh, * adding or removing a provider key in the project config left a stale * provider list in the assistant sidebar until a full page reload. */ async function refreshAIPickers() { const mounts = Array.from(document.querySelectorAll(`.${PICKER_SLOT_CLASS}, .ai-picker`)); let mounted = 0; for (const mount of mounts) { // A picker living inside a slot is detached once the slot is refilled. if (!mount.isConnected) continue; const fresh = await _buildPickerUI(); if (fresh) { if (mount.classList.contains(PICKER_SLOT_CLASS)) mount.replaceChildren(fresh); else mount.replaceWith(fresh); mounted += 1; } else if (!mount.classList.contains(PICKER_SLOT_CLASS)) { // Every provider was removed: keep an inert mount point in place. const slot = document.createElement('span'); slot.className = PICKER_SLOT_CLASS; mount.replaceWith(slot); } } return mounted; } // ── Get selected text from CodeMirror ── function getSelection(editorView) { if (!editorView) return ''; const { from, to } = editorView.state.selection.main; if (from === to) { // No selection — get entire document return editorView.state.doc.toString(); } return editorView.state.doc.sliceString(from, to); } // ── Replace or insert text ── function replaceSelection(editorView, newText, mode = 'replace') { if (!editorView) return; const { from, to } = editorView.state.selection.main; if (mode === 'append') { // Insert at end of selection editorView.dispatch({ changes: { from: to, insert: '\n' + newText }, selection: { anchor: to + 1 + newText.length }, }); } else if (mode === 'before') { editorView.dispatch({ changes: { from: from, insert: newText + '\n' }, }); } else { // Replace selection editorView.dispatch({ changes: { from, to, insert: newText }, selection: { anchor: from + newText.length }, }); } } // ── Show toast notification ── function showToast(msg, type = 'info') { // Use global showToast if available, otherwise console if (typeof window._obsigateShowToast === 'function') { window._obsigateShowToast(msg, type); } } // ── Dropdown menu builder ── function createMenu(items, parentEl) { // Remove any existing menu const existing = document.querySelector('.ai-dropdown-menu'); if (existing) existing.remove(); const menu = document.createElement('div'); menu.className = 'ai-dropdown-menu'; // Position relative to parent, but attached to body to avoid // being clipped by overflow:hidden on the editor container. const rect = parentEl.getBoundingClientRect(); const menuTop = rect.bottom + 4; const menuLeft = rect.left; Object.assign(menu.style, { position: 'fixed', top: menuTop + 'px', left: menuLeft + 'px', background: 'var(--bg-primary)', border: '1px solid var(--border)', borderRadius: '6px', padding: '4px 0', minWidth: '200px', maxHeight: '60vh', overflowY: 'auto', zIndex: '10000', boxShadow: '0 4px 16px rgba(0,0,0,0.4)', }); // If menu overflows bottom of viewport, flip above the button requestAnimationFrame(() => { const menuRect = menu.getBoundingClientRect(); if (menuRect.bottom > window.innerHeight - 8) { menu.style.top = Math.max(8, rect.top - menuRect.height - 4) + 'px'; } // If menu overflows right edge, align right if (menuRect.right > window.innerWidth - 8) { menu.style.left = Math.max(8, window.innerWidth - menuRect.width - 8) + 'px'; } }); items.forEach(item => { const el = document.createElement('div'); el.className = 'ai-menu-item'; Object.assign(el.style, { padding: '6px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', background: 'transparent', transition: 'background 0.1s', }); el.innerHTML = item.label; if (item.hint) { const hint = document.createElement('span'); hint.style.cssText = 'margin-left:auto;font-size:0.65rem;color:var(--text-muted)'; hint.textContent = item.hint; el.appendChild(hint); } if (item.children) { const arrow = document.createElement('span'); arrow.style.cssText = 'margin-left:auto;font-size:0.7rem;color:var(--text-muted)'; arrow.textContent = '▶'; el.appendChild(arrow); } el.addEventListener('mouseenter', () => { el.style.background = 'rgba(255,255,255,0.06)'; // Show submenu if (item.children) { const sub = el.querySelector('.ai-submenu'); if (!sub) { const subMenu = createSubMenu(item.children, el); el.appendChild(subMenu); } } }); el.addEventListener('mouseleave', (e) => { el.style.background = 'transparent'; }); el.addEventListener('click', (e) => { e.stopPropagation(); if (item.action) item.action(); menu.remove(); }); menu.appendChild(el); }); document.body.appendChild(menu); // Close on outside click const closeHandler = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } function createSubMenu(items, parentEl) { const menu = document.createElement('div'); menu.className = 'ai-submenu'; Object.assign(menu.style, { position: 'absolute', left: '100%', top: '0', background: 'var(--bg-primary)', border: '1px solid var(--border)', borderRadius: '6px', padding: '4px 0', minWidth: '180px', zIndex: '101', boxShadow: '0 4px 16px rgba(0,0,0,0.4)', }); items.forEach(item => { const el = document.createElement('div'); el.className = 'ai-menu-item'; Object.assign(el.style, { padding: '6px 12px', cursor: 'pointer', fontSize: '0.78rem', color: 'var(--text-primary)', whiteSpace: 'nowrap', }); el.innerHTML = item.label; el.addEventListener('mouseenter', () => { el.style.background = 'rgba(255,255,255,0.06)'; }); el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; }); el.addEventListener('click', (e) => { e.stopPropagation(); if (item.action) item.action(); // Close all menus document.querySelectorAll('.ai-dropdown-menu, .ai-submenu').forEach(m => m.remove()); }); menu.appendChild(el); }); return menu; } // ── Main AI Toolbar ── let _activeAiBtn = null; let _aiShortcutBound = false; /** * Bind the Ctrl/Cmd+J inline-completion shortcut exactly once, regardless of * how many times the editor (and therefore the toolbar) is re-created. * Without this guard every editor open added a new listener, so pressing the * shortcut fired the AI request N times. */ function _bindAiShortcut() { if (_aiShortcutBound) return; _aiShortcutBound = true; document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') { const modal = document.getElementById('editor-modal'); if (!modal || !modal.classList.contains('active')) return; if (!_activeAiBtn) return; e.preventDefault(); _activeAiBtn.click(); } }); } /** * Professional replacement for `window.prompt()` for the custom-rewrite * instruction. Returns a Promise resolving to the entered instruction or * `null` when cancelled. Escape cancels, Ctrl/Cmd+Enter submits. */ function _promptRewriteInstruction() { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'ai-modal-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', t('ai.rewrite_title')); const modal = document.createElement('div'); modal.className = 'ai-modal'; modal.innerHTML = `
${t('ai.rewrite_prompt')}