1046 lines
37 KiB
JavaScript
1046 lines
37 KiB
JavaScript
/* 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;
|
||
}
|
||
|
||
/**
|
||
* Render only the *enabled* capabilities as colored tags (#104). Used by the
|
||
* config panel where a compact read-only summary reads better than checkboxes.
|
||
*/
|
||
function renderCapabilityBadges(caps) {
|
||
const box = document.createElement('div');
|
||
box.className = 'ai-caps-badges';
|
||
if (!caps) return box;
|
||
AI_CAPABILITY_KEYS.forEach((key) => {
|
||
if (!caps[key]) return;
|
||
const item = document.createElement('span');
|
||
item.className = 'ai-cap-badge';
|
||
item.textContent = 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,
|
||
renderCapabilityBadges,
|
||
_buildPickerUI as buildAIPickerUI,
|
||
refreshAIPickers,
|
||
PICKER_SLOT_CLASS,
|
||
_promptRewriteInstruction,
|
||
};
|