feat(api,ai): #72 OpenAPI 3.1 enrichie + fiabilisation de l'outil AI UI
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 55s
CI / security (push) Successful in 37s
CI / test (push) Successful in 1m19s
CI / build (push) Successful in 34s
CI / e2e (push) Successful in 10m27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
API (#72): - backend/openapi_docs.py: 18 tags documentes, assignation auto par prefixe, securite bearerAuth/cookieAuth, erreurs 401/403/404/422/500, exemples, page /api autonome (liens /docs, /redoc, /openapi.json) - backend/schemas.py: response_model Pydantic pour ~35 endpoints sans modele - main.py: FastAPI enrichi + override app.openapi; routes /api et /api/ - ai_routes/bookslm_routes: response_model + doc SSE - frontend: entree 'API' du menu (i18n FR/EN) - tests/test_openapi.py (58 tests) AI UI: - BooksLM: requetes authentifiees (AuthManager), parsing SSE {token}/error, message utilisateur correct (plus le placeholder vide), barre de contexte - AI Editor: Ctrl/Cmd+J lie une seule fois, libelles i18n, modale de reecriture accessible a la place de window.prompt() - tests/frontend/ai.test.mjs (7 tests) + CI
This commit is contained in:
+110
-33
@@ -376,6 +376,76 @@ function createSubMenu(items, parentEl) {
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
@@ -424,6 +494,7 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
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;
|
||||
@@ -445,22 +516,22 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
});
|
||||
|
||||
// ── Edit menu ──
|
||||
const editBtn = createDropdownBtn(t('ai.edit'), [
|
||||
{ label: '🪄 Improve writing', action: () => action('improve'), hint: t('ai.hint_improve') },
|
||||
{ label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling'), hint: 'Corrige les fautes' },
|
||||
{ label: '📏 Make shorter', action: () => action('make-shorter'), hint: 'Rend le texte plus concis' },
|
||||
{ label: '📐 Make longer', action: () => action('make-longer'), hint: t('ai.hint_add_details') },
|
||||
{ label: '📋 Simplify language', action: () => action('simplify'), hint: 'Simplifie le langage' },
|
||||
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('Ton', [
|
||||
{ label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) },
|
||||
{ label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) },
|
||||
], 'Change le ton du texte');
|
||||
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('Traduire', [
|
||||
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' }) },
|
||||
@@ -470,17 +541,18 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
], t('ai.hint_translate'));
|
||||
|
||||
// ── Generate menu ──
|
||||
const genBtn = createDropdownBtn(t('ai.generate'), [
|
||||
{ label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') },
|
||||
{ label: '📝 Summarize', action: () => action('summarize', {}, 'append') },
|
||||
{ label: '✏️ Continue writing', action: () => action('continue', {}, 'append') },
|
||||
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.rewrite');
|
||||
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',
|
||||
@@ -494,24 +566,31 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
const v = ev();
|
||||
if (!v) return;
|
||||
const text = getSelection(v);
|
||||
const instruction = prompt(t('ai.rewrite_prompt'), '');
|
||||
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.toolbox'), [
|
||||
{ label: '📋 Convert to list', action: () => action('to-list') },
|
||||
{ label: '📊 Convert to table', action: () => action('to-table') },
|
||||
{ label: '⚙️ Generate frontmatter', action: () => action('frontmatter', {}, 'before') },
|
||||
{ label: '🔷 Convert to canvas', action: () => action('to-canvas', {}, 'append') },
|
||||
], 'Outils de conversion');
|
||||
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());
|
||||
@@ -577,9 +656,10 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
el.id = 'ai-processing-indicator';
|
||||
el.className = 'ai-processing-indicator';
|
||||
el.innerHTML = '<span class="ai-spinner"></span> AI...';
|
||||
el.title = 'Traitement AI en cours';
|
||||
el.title = t('ai.processing');
|
||||
el.setAttribute('role', 'status');
|
||||
const header = document.querySelector('#editor-modal .editor-header');
|
||||
if (header) header.appendChild(el);
|
||||
(header || toolbar).appendChild(el);
|
||||
}
|
||||
el.style.display = 'inline-flex';
|
||||
} else if (el) {
|
||||
@@ -587,14 +667,8 @@ export async function createAIToolbar(container, getEditorView) {
|
||||
}
|
||||
}
|
||||
// ── Keyboard shortcut Ctrl+J for inline completion ──
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'j') {
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
e.preventDefault();
|
||||
aiBtn.click();
|
||||
}
|
||||
});
|
||||
_activeAiBtn = aiBtn;
|
||||
_bindAiShortcut();
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
@@ -604,6 +678,8 @@ function createDropdownBtn(text, items, tooltip = '') {
|
||||
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',
|
||||
@@ -637,4 +713,5 @@ export {
|
||||
_writePicker,
|
||||
PICKER_STORAGE_KEY,
|
||||
_buildPickerUI as buildAIPickerUI,
|
||||
_promptRewriteInstruction,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user