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
+24
View File
@@ -627,6 +627,30 @@
>
</span>
</button>
<button
class="menu-list-row menu-list-button"
id="api-docs-menu-row"
type="button"
role="menuitem"
>
<span
class="menu-list-icon"
aria-hidden="true"
>
<i
data-lucide="code-2"
style="width: 17px; height: 17px"
></i>
</span>
<span class="menu-list-content">
<span class="menu-list-title" data-i18n="header.menu_api"
>API</span
>
<span class="menu-list-subtitle" data-i18n="header.menu_api_desc"
>Documentation OpenAPI</span
>
</span>
</button>
<button
class="menu-list-row menu-list-button"
id="config-open-btn"
+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() {
+9
View File
@@ -87,6 +87,7 @@
"ai.error_invalid_key": "Invalid API key",
"ai.explain": "Explain",
"ai.fix_spelling": "Fix spelling",
"ai.fix_spelling_hint": "Fixes spelling and grammar",
"ai.frontmatter": "Generate frontmatter",
"ai.generate": "Generate",
"ai.hint_add_details": "Adds details",
@@ -113,16 +114,20 @@
"ai.rewrite": "💬 Rewrite",
"ai.rewrite_done": "AI: text rewritten",
"ai.rewrite_prompt": "Rewrite instruction:",
"ai.rewrite_placeholder": "e.g. make the tone more formal and add a conclusion",
"ai.rewrite_title": "Rewrite text according to your instructions",
"ai.select_text": "AI: select text to process",
"ai.shorter": "Shorter",
"ai.shorter_hint": "Makes the text more concise",
"ai.simplify": "Simplify",
"ai.simplify_hint": "Simplifies the language",
"ai.summarize": "Summarize",
"ai.text_processed": "AI: text processed ✓",
"ai.text_rewritten": "AI: text rewritten",
"ai.to_canvas": "Convert to Canvas",
"ai.to_list": "Convert to list",
"ai.to_table": "Convert to table",
"ai.tone_hint": "Changes the text tone",
"ai.toolbar_edit": "Edit",
"ai.toolbar_generate": "Generate",
"ai.toolbar_rewrite": "Rewrite",
@@ -130,6 +135,7 @@
"ai.toolbar_toolbox": "Toolbox",
"ai.toolbar_translate": "Translate",
"ai.toolbox": "🧰 Toolbox",
"ai.toolbox_hint": "Conversion tools",
"ai.translate_to": "Translate to {lang}",
"auth.cannot_delete_self": "Cannot delete your own account",
"auth.logged_out": "Signed out",
@@ -624,6 +630,8 @@
"header.menu_about_desc": "Version, credits & info",
"header.menu_admin": "Administration",
"header.menu_admin_desc": "Manage users",
"header.menu_api": "API",
"header.menu_api_desc": "OpenAPI documentation",
"header.menu_config": "Settings",
"header.menu_config_desc": "Application settings",
"header.menu_help": "User guide",
@@ -1597,6 +1605,7 @@
"bookslm.export": "Export conversation",
"bookslm.toggle_sidebar": "Hide/Show AI sidebar",
"bookslm.copy": "Copy",
"bookslm.error": "AI service error",
"bookslm.regenerate": "Regenerate",
"bookslm.suggestion_summary": "Summarize this directory",
"bookslm.suggestion_themes": "What are the main themes?",
+9
View File
@@ -87,6 +87,7 @@
"ai.error_invalid_key": "clé API invalide",
"ai.explain": "Expliquer",
"ai.fix_spelling": "Corriger l'orthographe",
"ai.fix_spelling_hint": "Corrige les fautes",
"ai.frontmatter": "Générer frontmatter",
"ai.generate": "Générer",
"ai.hint_add_details": "Ajoute des détails",
@@ -113,16 +114,20 @@
"ai.rewrite": "💬 Réécrire",
"ai.rewrite_done": "AI: texte réécrit",
"ai.rewrite_prompt": "Instruction de réécriture :",
"ai.rewrite_placeholder": "Ex. : rends le ton plus formel et ajoute une conclusion",
"ai.rewrite_title": "Réécrit le texte selon vos instructions",
"ai.select_text": "AI: sélectionnez du texte à traiter",
"ai.shorter": "Raccourcir",
"ai.shorter_hint": "Rend le texte plus concis",
"ai.simplify": "Simplifier",
"ai.simplify_hint": "Simplifie le langage",
"ai.summarize": "Résumer",
"ai.text_processed": "AI: texte traité ✓",
"ai.text_rewritten": "AI: texte réécrit",
"ai.to_canvas": "Convertir en Canvas",
"ai.to_list": "Convertir en liste",
"ai.to_table": "Convertir en tableau",
"ai.tone_hint": "Change le ton du texte",
"ai.toolbar_edit": "Éditer",
"ai.toolbar_generate": "Générer",
"ai.toolbar_rewrite": "Réécrire",
@@ -130,6 +135,7 @@
"ai.toolbar_toolbox": "Toolbox",
"ai.toolbar_translate": "Traduire",
"ai.toolbox": "🧰 Boîte",
"ai.toolbox_hint": "Outils de conversion",
"ai.translate_to": "Traduire en {lang}",
"auth.cannot_delete_self": "Impossible de supprimer son propre compte",
"auth.logged_out": "Déconnecté",
@@ -624,6 +630,8 @@
"header.menu_about_desc": "Version, crédits & info",
"header.menu_admin": "Administration",
"header.menu_admin_desc": "Gérer les utilisateurs",
"header.menu_api": "API",
"header.menu_api_desc": "Documentation OpenAPI",
"header.menu_config": "Configurations",
"header.menu_config_desc": "Paramètres de l'application",
"header.menu_help": "Guide d'utilisation",
@@ -1597,6 +1605,7 @@
"bookslm.export": "Exporter la conversation",
"bookslm.toggle_sidebar": "Masquer/Afficher la sidebar AI",
"bookslm.copy": "Copier",
"bookslm.error": "Erreur du service AI",
"bookslm.regenerate": "Régénérer",
"bookslm.suggestion_summary": "Résume ce répertoire",
"bookslm.suggestion_themes": "Quels sont les thèmes principaux ?",
+82
View File
@@ -2675,6 +2675,88 @@ select {
50% { opacity: 0.6; }
}
/* AI custom-rewrite modal — replaces the browser prompt() for a consistent,
theme-aware and keyboard-accessible experience. */
.ai-modal-overlay {
position: fixed;
inset: 0;
z-index: 10050;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.ai-modal {
width: 100%;
max-width: 460px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 18px 14px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
}
.ai-modal-title {
margin: 0 0 4px;
font-size: 0.95rem;
color: var(--text-primary);
}
.ai-modal-hint {
margin: 0 0 10px;
font-size: 0.78rem;
color: var(--text-muted);
}
.ai-modal-input {
width: 100%;
resize: vertical;
min-height: 72px;
padding: 8px 10px;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-primary);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
}
.ai-modal-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.25);
}
.ai-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.ai-modal-btn {
padding: 6px 14px;
font-size: 0.8rem;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
}
.ai-modal-btn:hover { background: var(--bg-hover); }
.ai-modal-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.ai-modal-btn.primary:hover { filter: brightness(1.08); }
.editor-title {
font-family: "JetBrains Mono", monospace;
font-size: 0.9rem;