/* 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'; 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 { /* */ } } /** * 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. */ 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'; Object.assign(wrap.style, { display: 'inline-flex', alignItems: 'center', gap: '4px', marginLeft: '8px', paddingLeft: '8px', borderLeft: '1px solid var(--border)', fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: '0', zIndex: '1', }); // Provider select const providerLabel = document.createElement('span'); providerLabel.textContent = t('ai.provider') + ':'; wrap.appendChild(providerLabel); const providerSelect = document.createElement('select'); providerSelect.className = 'ai-picker-select'; Object.assign(providerSelect.style, { fontSize: '0.7rem', background: 'var(--surface)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: '4px', padding: '2px 4px', cursor: 'pointer', }); 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); }); // Model select (sibling of provider) const modelSelect = document.createElement('select'); modelSelect.className = 'ai-picker-model'; Object.assign(modelSelect.style, { fontSize: '0.7rem', background: 'var(--surface)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: '4px', padding: '2px 4px', cursor: 'pointer', minWidth: '120px', maxWidth: '220px', }); const placeholderOpt = document.createElement('option'); placeholderOpt.value = ''; placeholderOpt.textContent = t('ai.model_default'); modelSelect.appendChild(placeholderOpt); async function _loadModels(provider) { modelSelect.innerHTML = ''; const ph = document.createElement('option'); ph.value = ''; ph.textContent = t('ai.model_loading'); modelSelect.appendChild(ph); try { const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`); modelSelect.innerHTML = ''; const def = document.createElement('option'); def.value = ''; def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : ''); modelSelect.appendChild(def); (data.models || []).forEach((m) => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; if (m === pickerState.model && provider === pickerState.provider) opt.selected = true; modelSelect.appendChild(opt); }); } catch (err) { modelSelect.innerHTML = ''; const opt = document.createElement('option'); opt.value = ''; opt.textContent = t('ai.model_load_error'); modelSelect.appendChild(opt); } } providerSelect.addEventListener('change', () => { pickerState.provider = providerSelect.value || null; // Reset model when provider changes pickerState.model = null; _writePicker(pickerState); if (pickerState.provider) { _loadModels(pickerState.provider); } else { modelSelect.innerHTML = ''; const ph = document.createElement('option'); ph.value = ''; ph.textContent = t('ai.model_default'); modelSelect.appendChild(ph); } }); modelSelect.addEventListener('change', () => { pickerState.model = modelSelect.value || null; _writePicker(pickerState); }); // Load models for the initial provider selection if (pickerState.provider && providerNames.includes(pickerState.provider)) { _loadModels(pickerState.provider); } wrap.appendChild(providerSelect); wrap.appendChild(modelSelect); return wrap; } // ── 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.custom_rewrite')}

${t('ai.rewrite_prompt')}

`; overlay.appendChild(modal); document.body.appendChild(overlay); const input = modal.querySelector('.ai-modal-input'); const close = (value) => { overlay.remove(); document.removeEventListener('keydown', onKey, true); resolve(value); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(null); } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); close(input.value.trim() || null); } }; document.addEventListener('keydown', onKey, true); modal.querySelector('.ai-modal-cancel').addEventListener('click', () => close(null)); modal.querySelector('.ai-modal-ok').addEventListener('click', () => close(input.value.trim() || null)); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); }); input.focus(); }); } export async function createAIToolbar(container, getEditorView) { // Check if AI is configured let aiConfigured = false; try { const status = await api('/api/ai/status'); aiConfigured = status.configured; } catch { aiConfigured = false; } if (!aiConfigured) { const hint = document.createElement('div'); hint.style.cssText = 'padding:6px 12px;font-size:0.7rem;color:var(--text-muted);border-bottom:1px solid var(--border)'; hint.textContent = t('ai.not_configured'); container.appendChild(hint); return; } const toolbar = document.createElement('div'); toolbar.className = 'ai-toolbar'; Object.assign(toolbar.style, { display: 'flex', alignItems: 'center', gap: '2px', padding: '4px 8px', borderBottom: '1px solid var(--border)', background: 'var(--bg-secondary)', flexWrap: 'wrap', position: 'sticky', top: '0', zIndex: '5', }); function ev() { return getEditorView(); } // ── Inline completion button ── const aiBtn = document.createElement('button'); aiBtn.className = 'ai-toolbar-btn'; aiBtn.innerHTML = '✦ AI'; Object.assign(aiBtn.style, { color: '#60a5fa', fontWeight: '600', fontSize: '0.75rem', background: 'none', border: 'none', cursor: 'pointer', padding: '3px 8px', borderRadius: '4px', }); aiBtn.title = t('ai.inline_completion_title'); aiBtn.setAttribute('aria-label', t('ai.inline_completion_title')); aiBtn.addEventListener('click', async () => { const v = ev(); if (!v) return; const text = getSelection(v); if (!text.trim()) return; const origHTML = aiBtn.innerHTML; aiBtn.innerHTML = '⏳ AI...'; aiBtn.disabled = true; try { const result = await aiAction('inline-complete', text); replaceSelection(v, result, 'append'); showToast(t('ai.completion_added'), 'success'); } catch (e) { showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error'); } finally { aiBtn.innerHTML = origHTML; aiBtn.disabled = false; } }); // ── Edit menu ── const editBtn = createDropdownBtn(t('ai.toolbar_edit'), [ { label: '🪄 ' + t('ai.improve'), action: () => action('improve'), hint: t('ai.hint_improve') }, { label: '🔤 ' + t('ai.fix_spelling'), action: () => action('fix-spelling'), hint: t('ai.fix_spelling_hint') }, { label: '📏 ' + t('ai.shorter'), action: () => action('make-shorter'), hint: t('ai.shorter_hint') }, { label: '📐 ' + t('ai.longer'), action: () => action('make-longer'), hint: t('ai.hint_add_details') }, { label: '📋 ' + t('ai.simplify'), action: () => action('simplify'), hint: t('ai.simplify_hint') }, ], t('ai.hint_edit_selection')); // ── Tone menu ── const toneBtn = createDropdownBtn(t('ai.toolbar_tone'), [ { label: '💼 ' + t('ai.professional'), action: () => action('tone', { tone: 'professional' }) }, { label: '💬 ' + t('ai.casual'), action: () => action('tone', { tone: 'casual' }) }, ], t('ai.tone_hint')); // ── Translate menu ── const translateBtn = createDropdownBtn(t('ai.toolbar_translate'), [ { label: '🇬🇧 English', action: () => action('translate', { target_lang: 'English' }) }, { label: '🇨🇳 Chinese', action: () => action('translate', { target_lang: 'Chinese' }) }, { label: '🇯🇵 Japanese', action: () => action('translate', { target_lang: 'Japanese' }) }, { label: '🇩🇪 German', action: () => action('translate', { target_lang: 'German' }) }, { label: '🇫🇷 French', action: () => action('translate', { target_lang: 'French' }) }, { label: '🇪🇸 Spanish', action: () => action('translate', { target_lang: 'Spanish' }) }, ], t('ai.hint_translate')); // ── Generate menu ── const genBtn = createDropdownBtn(t('ai.toolbar_generate'), [ { label: 'ℹ️ ' + t('ai.explain'), action: () => action('explain', {}, 'append') }, { label: '📝 ' + t('ai.summarize'), action: () => action('summarize', {}, 'append') }, { label: '✏️ ' + t('ai.continue'), action: () => action('continue', {}, 'append') }, ], t('ai.hint_generate')); // ── Custom Rewrite ── const rewriteBtn = document.createElement('button'); rewriteBtn.className = 'ai-toolbar-btn'; rewriteBtn.innerHTML = t('ai.toolbar_rewrite'); rewriteBtn.title = t('ai.rewrite_title'); rewriteBtn.setAttribute('aria-label', t('ai.rewrite_title')); Object.assign(rewriteBtn.style, { fontSize: '0.7rem', background: 'transparent', border: 'none', cursor: 'pointer', padding: '3px 6px', borderRadius: '4px', color: 'var(--text-primary)', }); rewriteBtn.addEventListener('click', async () => { const v = ev(); if (!v) return; const text = getSelection(v); if (!text.trim()) { showToast(t('ai.select_text'), 'warning'); return; } const instruction = await _promptRewriteInstruction(); if (!instruction) return; rewriteBtn.disabled = true; try { const result = await aiAction('rewrite', text, { instruction }); replaceSelection(v, result); showToast(t('ai.text_rewritten'), 'success'); } catch (e) { showToast('AI: ' + (String(e.message || e).includes('401') ? t('ai.error_invalid_key') : e.message), 'error'); } finally { rewriteBtn.disabled = false; } }); // ── Toolbox menu ── const toolboxBtn = createDropdownBtn(t('ai.toolbar_toolbox'), [ { label: '📋 ' + t('ai.to_list'), action: () => action('to-list') }, { label: '📊 ' + t('ai.to_table'), action: () => action('to-table') }, { label: '⚙️ ' + t('ai.frontmatter'), action: () => action('frontmatter', {}, 'before') }, { label: '🔷 ' + t('ai.to_canvas'), action: () => action('to-canvas', {}, 'append') }, ], t('ai.toolbox_hint')); toolbar.appendChild(aiBtn); toolbar.appendChild(createSeparator()); toolbar.appendChild(editBtn); toolbar.appendChild(toneBtn); toolbar.appendChild(translateBtn); toolbar.appendChild(createSeparator()); toolbar.appendChild(genBtn); toolbar.appendChild(rewriteBtn); toolbar.appendChild(toolboxBtn); // Append the per-section provider/model picker on the right. const picker = await _buildPickerUI(); if (picker) { toolbar.appendChild(createSeparator()); toolbar.appendChild(picker); } container.insertBefore(toolbar, container.firstChild); // ── Action helper ── async function action(endpoint, extra = {}, mode = 'replace') { const v = ev(); if (!v) return; const text = getSelection(v); if (!text.trim()) { showToast(t('ai.select_text'), 'warning'); return; } showToast(t('ai.processing'), 'info'); // Show processing indicator in the editor header _showProcessingIndicator(true); // Dispatch AI processing start event document.dispatchEvent(new CustomEvent('ai-processing-start', { detail: { endpoint, mode } })); try { const result = await aiAction(endpoint, text, extra); replaceSelection(v, result, mode); showToast(t('ai.text_processed'), 'success'); } catch (e) { const msg = String(e.message || e); if (msg.includes('401')) { showToast(t('ai.invalid_api_key'), 'error'); } else if (msg.includes('402') || msg.includes('429')) { showToast(t('ai.quota_exceeded'), 'error'); } else { showToast('AI: ' + msg, 'error'); } } finally { _showProcessingIndicator(false); // Dispatch AI processing end event document.dispatchEvent(new CustomEvent('ai-processing-end')); } } // ── In-editor processing indicator ── function _showProcessingIndicator(show) { let el = document.getElementById('ai-processing-indicator'); if (show) { if (!el) { el = document.createElement('span'); el.id = 'ai-processing-indicator'; el.className = 'ai-processing-indicator'; el.innerHTML = ' AI...'; el.title = t('ai.processing'); el.setAttribute('role', 'status'); const header = document.querySelector('#editor-modal .editor-header'); (header || toolbar).appendChild(el); } el.style.display = 'inline-flex'; } else if (el) { el.style.display = 'none'; } } // ── Keyboard shortcut Ctrl+J for inline completion ── _activeAiBtn = aiBtn; _bindAiShortcut(); return toolbar; } function createDropdownBtn(text, items, tooltip = '') { const btn = document.createElement('button'); btn.className = 'ai-toolbar-btn'; btn.innerHTML = text + ' ▾'; btn.title = tooltip || text; btn.setAttribute('aria-haspopup', 'true'); btn.setAttribute('aria-expanded', 'false'); Object.assign(btn.style, { position: 'relative', fontSize: '0.7rem', background: 'none', border: 'none', cursor: 'pointer', padding: '3px 6px', borderRadius: '4px', color: 'var(--text-primary)', }); btn.addEventListener('click', (e) => { e.stopPropagation(); createMenu(items, btn); }); btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.08)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); return btn; } function createSeparator() { const sep = document.createElement('span'); sep.style.cssText = 'width:1px;height:16px;background:var(--border);margin:0 2px'; return sep; } // ── Public exports ──────────────────────────────────────────────────────── // Other modules (e.g. BooksLM) can import the picker helpers to render the // same per-section provider/model picker in their own toolbar. export { _readPicker, _writePicker, PICKER_STORAGE_KEY, _buildPickerUI as buildAIPickerUI, _promptRewriteInstruction, };