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

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:
2026-09-11 01:40:21 -04:00
parent 75ef597ece
commit 62023acd4d
18 changed files with 2001 additions and 154 deletions
+110 -33
View File
@@ -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,
};
+76 -47
View File
@@ -2,6 +2,7 @@
import { t } from './i18n.js';
import { buildAIPickerUI } from './ai.js';
import { safeCreateIcons } from './utils.js';
import { api, AuthManager } from './auth.js';
class BooksLM {
constructor() {
@@ -58,15 +59,14 @@ class BooksLM {
});
this._isOpen = true;
// Load context
// Load context (authenticated — `api` injects the bearer token and
// transparently refreshes it once on 401).
try {
const resp = await fetch('/api/ai/bookslm/context', {
const data = await api('/api/ai/bookslm/context', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vault, directory })
body: JSON.stringify({ vault, directory }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this._contextFiles = data.files || [];
this._updateStatus(data);
this._showSuggestions();
@@ -241,10 +241,11 @@ class BooksLM {
if (data && data.files) {
const count = data.files.length;
const chars = data.total_chars || 0;
const pct = Math.min(100, Math.round((chars / 100000) * 100));
const maxChars = data.max_total_chars || 200000;
const pct = Math.min(100, Math.round((chars / maxChars) * 100));
status.innerHTML = `
<span>${t('bookslm.files_indexed', { count })}, ${t('bookslm.chars_loaded', { chars: Math.round(chars / 1000) + 'K' })}</span>
<div class="bookslm-context-bar"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
<div class="bookslm-context-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><div class="bookslm-context-fill" style="width:${pct}%"></div></div>
`;
} else if (this._isLoading) {
status.textContent = '⏳ ...';
@@ -330,6 +331,12 @@ class BooksLM {
const sugEl = this._panel.querySelector('.bookslm-suggestions');
if (sugEl) sugEl.innerHTML = '';
// Snapshot the prior turns BEFORE appending the new user message so the
// backend receives the correct `message` + `conversation_history` split.
const history = this._messages
.filter((m) => m.content && m.content.trim())
.map((m) => ({ role: m.role, content: m.content }));
// Add user message
this._messages.push({ role: 'user', content: text });
this._renderMessages();
@@ -356,33 +363,70 @@ class BooksLM {
model = picker.model || null;
} catch { /* */ }
const resp = await fetch('/api/ai/bookslm/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vault: this._vault,
directory: this._directory,
// The backend expects `message` + `conversation_history`,
// not a generic `messages` array. Convert before sending.
message: this._messages.length
? this._messages[this._messages.length - 1].content
: '',
conversation_history: this._messages
.slice(0, -1)
.filter((m) => m.content && m.content.trim())
.map((m) => ({ role: m.role, content: m.content })),
context_files: this._contextFiles.map((f) => f.path || f),
provider,
model,
}),
signal: this._abortCtrl.signal
});
const payload = {
vault: this._vault,
directory: this._directory,
message: text,
conversation_history: history,
context_files: this._contextFiles.map((f) => f.path || f),
provider,
model,
};
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
// Authenticated streaming request (raw fetch so we can read the SSE
// body; `api()` would consume the JSON response instead).
const doFetch = () => {
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch('/api/ai/bookslm/chat', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(payload),
signal: this._abortCtrl.signal,
});
};
let resp = await doFetch();
if (resp.status === 401 && AuthManager._authEnabled) {
await AuthManager.refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
let detail = `HTTP ${resp.status}`;
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
throw new Error(detail);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = 'message';
const handleLine = (line) => {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
return;
}
if (!line.startsWith('data:')) return;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') return;
let data;
try {
data = JSON.parse(raw);
} catch {
assistantMsg.content += raw;
return;
}
if (currentEvent === 'error' || data.error) {
const msg = data.error || t('bookslm.error');
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
return;
}
// The backend streams `{token: "..."}`; accept `content` too for
// forward compatibility.
const chunk = data.token || data.content || '';
if (chunk) assistantMsg.content += chunk;
if (data.sources) assistantMsg.sources = data.sources;
};
while (true) {
const { done, value } = await reader.read();
@@ -392,25 +436,10 @@ class BooksLM {
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') continue;
try {
const data = JSON.parse(payload);
if (data.content) {
assistantMsg.content += data.content;
}
if (data.sources) {
assistantMsg.sources = data.sources;
}
} catch {
// Raw text token
assistantMsg.content += payload;
}
this._renderMessages();
}
for (const line of lines) handleLine(line);
this._renderMessages();
}
if (buffer) handleLine(buffer);
// Extract sources from context files if mentioned
if (!assistantMsg.sources.length) {
+9
View File
@@ -216,6 +216,15 @@ export function initHeaderMenu() {
menuDropdown.addEventListener("click", (e) => {
e.stopPropagation();
});
// API documentation (#72) — opens the OpenAPI/Swagger page in a new tab.
const apiRow = document.getElementById("api-docs-menu-row");
if (apiRow) {
apiRow.addEventListener("click", () => {
closeHeaderMenu();
window.open("/docs", "_blank", "noopener");
});
}
}
export function closeHeaderMenu() {