Files
ObsiGate/frontend/js/ai.js
T
bruno cb07b92543
CI / lint (push) Successful in 1m14s
CI / security (push) Successful in 53s
CI / test (push) Successful in 2m16s
CI / build (push) Successful in 41s
CI / e2e (push) Successful in 10m59s
style(ai): selecteurs Fournisseur/Modele alignes a droite et reordonnes (#82)
2026-09-12 21:42:01 -04:00

983 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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
if (pickerState.provider && providerNames.includes(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;
}
// ── 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 = `
<h3 class="ai-modal-title">${t('ai.custom_rewrite')}</h3>
<p class="ai-modal-hint">${t('ai.rewrite_prompt')}</p>
<textarea class="ai-modal-input" rows="3" placeholder="${t('ai.rewrite_placeholder')}"></textarea>
<div class="ai-modal-actions">
<button type="button" class="ai-modal-btn ai-modal-cancel">${t('button.cancel')}</button>
<button type="button" class="ai-modal-btn primary ai-modal-ok">${t('ai.rewrite')}</button>
</div>`;
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 = '<span class="ai-spinner"></span> 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,
AI_CAPABILITY_KEYS,
getModelCapabilities,
renderCapabilityList,
_buildPickerUI as buildAIPickerUI,
_promptRewriteInstruction,
};