Files
ObsiGate/frontend/js/ai.js
T
bruno ce2f0b6a6c
CI / lint (push) Successful in 1m17s
CI / security (push) Successful in 56s
CI / test (push) Successful in 2m58s
CI / build (push) Successful in 47s
CI / e2e (push) Successful in 10m38s
fix(ai): la liste des fournisseurs de l'assistant suit la configuration (BUG-043)
Le picker lit /api/ai/status une seule fois, à sa construction, et le panneau
de l'assistant est un singleton monté pour toute la session : ajouter ou
supprimer une clé API dans la configuration du projet laissait la liste des
fournisseurs figée jusqu'à un rechargement de page.

- ai.js : nouveau refreshAIPickers() qui reconstruit chaque picker monté dans
  son emplacement .ai-picker-slot (constante PICKER_SLOT_CLASS) ; le slot est
  conservé même sans fournisseur configuré, donc le premier fournisseur ajouté
  s'y monte aussi ; une sélection persistée dont le fournisseur n'est plus
  configuré est purgée de obsigate_ai_picker (retour au défaut, plus de modèle
  fantôme dans le déclencheur).
- bookslm.js : l'emplacement .bookslm-picker-host porte la classe
  ai-picker-slot et reste dans la barre (replaceChildren au lieu de replaceWith).
- config.js : refreshAIPickers() après saveAIKeys() et deleteAIKey().
- tests : +4 tests JSDOM (ajout/retrait dans la barre, premier montage dans un
  slot vide, purge de la sélection orpheline, câblage save/delete).

Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM, validate-imports 36
modules, pytest 963 passed / 6 skipped, ruff 0, et contrôle navigateur
(Playwright) sur l'instance de test — ajout de nvidia visible sans rechargement,
retrait effectif + sélection réinitialisée.
2026-09-14 13:59:08 -04:00

1027 lines
37 KiB
JavaScript
Raw Permalink 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. 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 = `
<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,
refreshAIPickers,
PICKER_SLOT_CLASS,
_promptRewriteInstruction,
};