From b92fd3da082f7a061f64d09d9d28d87208f1f522 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 30 May 2026 16:44:42 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20Editor=20=E2=80=94=20toolbar=20ave?= =?UTF-8?q?c=20menus=20dropdown,=20multi-provider=20(DeepSeek/OpenRouter/G?= =?UTF-8?q?emini)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 ++ backend/ai.py | 253 +++++++++++++++++++++++++++ backend/ai_routes.py | 144 ++++++++++++++++ backend/main.py | 2 + backend/requirements.txt | 1 + frontend/index.html | 4 +- frontend/js/ai.js | 362 +++++++++++++++++++++++++++++++++++++++ frontend/js/utils.js | 14 ++ 8 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 backend/ai.py create mode 100644 backend/ai_routes.py create mode 100644 frontend/js/ai.js diff --git a/.env.example b/.env.example index f021f7a..b322b73 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,20 @@ # Backup # OBSIGATE_BACKUP_DIR=.obsigate-backup + +# ── AI Provider Configuration ── +# Définir au moins un provider pour activer les fonctionnalités AI dans l'éditeur + +# AI_DEFAULT_PROVIDER=deepseek # deepseek | openrouter | gemini + +# DeepSeek (recommandé, bon marché) +# DEEPSEEK_API_KEY=sk-... +# DEEPSEEK_MODEL=deepseek-chat + +# OpenRouter (accès à plusieurs modèles) +# OPENROUTER_API_KEY=sk-or-v1-... +# OPENROUTER_MODEL=openai/gpt-4o-mini + +# Google Gemini +# GEMINI_API_KEY=AIza... +# GEMINI_MODEL=gemini-2.0-flash diff --git a/backend/ai.py b/backend/ai.py new file mode 100644 index 0000000..88ce59e --- /dev/null +++ b/backend/ai.py @@ -0,0 +1,253 @@ +"""ObsiGate AI — Multi-provider AI service for editor enhancement. + +Supports: DeepSeek, OpenRouter, Google Gemini. +Configured via environment variables. +""" + +import os +import json +import logging +import httpx +from typing import Optional, Literal + +logger = logging.getLogger("obsigate.ai") + +ProviderName = Literal["deepseek", "openrouter", "gemini"] + +# Provider configurations +PROVIDERS = { + "deepseek": { + "api_key": os.getenv("DEEPSEEK_API_KEY", ""), + "base_url": "https://api.deepseek.com/v1", + "model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), + "auth_header": "Bearer {api_key}", + }, + "openrouter": { + "api_key": os.getenv("OPENROUTER_API_KEY", ""), + "base_url": "https://openrouter.ai/api/v1", + "model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"), + "auth_header": "Bearer {api_key}", + }, + "gemini": { + "api_key": os.getenv("GEMINI_API_KEY", ""), + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"), + "auth_header": None, # Uses query param ?key= + }, +} + +DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore + + +def _get_provider_config(provider: Optional[ProviderName] = None) -> dict: + """Get provider config, falling back to default if requested provider unavailable.""" + p = provider or DEFAULT_PROVIDER + if p not in PROVIDERS: + p = DEFAULT_PROVIDER + cfg = PROVIDERS[p] + if not cfg["api_key"]: + # Try next available provider + for alt in PROVIDERS: + if PROVIDERS[alt]["api_key"]: + p = alt + cfg = PROVIDERS[alt] + break + return {"name": p, **cfg} + + +async def _call_deepseek_openrouter(prompt: str, system: str, provider: Optional[ProviderName] = None, + temperature: float = 0.7, max_tokens: int = 2048) -> str: + """Call OpenAI-compatible API (DeepSeek, OpenRouter).""" + cfg = _get_provider_config(provider) + headers = { + "Authorization": cfg["auth_header"].format(api_key=cfg["api_key"]), + "Content-Type": "application/json", + } + payload = { + "model": cfg["model"], + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{cfg['base_url']}/chat/completions", + headers=headers, + json=payload, + ) + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"].strip() + + +async def _call_gemini(prompt: str, system: str, temperature: float = 0.7, max_tokens: int = 2048) -> str: + """Call Google Gemini API.""" + cfg = PROVIDERS["gemini"] + url = f"{cfg['base_url']}/models/{cfg['model']}:generateContent?key={cfg['api_key']}" + payload = { + "system_instruction": {"parts": [{"text": system}]}, + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": { + "temperature": temperature, + "maxOutputTokens": max_tokens, + }, + } + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + return data["candidates"][0]["content"]["parts"][0]["text"].strip() + + +async def ai_complete(prompt: str, provider: Optional[ProviderName] = None) -> str: + """Generic AI completion. Routes to appropriate provider.""" + cfg = _get_provider_config(provider) + if cfg["name"] == "gemini": + return await _call_gemini(prompt, "You are a helpful assistant.") + return await _call_deepseek_openrouter(prompt, "You are a helpful assistant.", provider) + + +# ── Specialized AI actions ── + +SYSTEM_PROMPT = """You are an AI assistant integrated into ObsiGate, a knowledge management tool. +Your responses should be direct and concise. When editing text, return ONLY the modified text, +no explanations or markdown fences.""" + + +async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None) -> str: + """Improve writing quality while preserving meaning.""" + return await _call_deepseek_openrouter( + f"Improve the following text. Fix grammar, clarity, and flow. Preserve the original language and meaning.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, + ) + + +async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) -> str: + """Fix spelling and grammar errors.""" + return await _call_deepseek_openrouter( + f"Fix all spelling and grammar errors in this text. Return only the corrected text.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.1, + ) + + +async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) -> str: + """Make text more concise.""" + return await _call_deepseek_openrouter( + f"Make this text shorter and more concise while preserving the key information.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, + ) + + +async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) -> str: + """Expand text with more detail.""" + return await _call_deepseek_openrouter( + f"Expand this text with more detail, examples, or explanation while keeping the same tone.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.7, max_tokens=4096, + ) + + +async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str: + """Simplify language.""" + return await _call_deepseek_openrouter( + f"Simplify this text. Use clearer, more straightforward language. Avoid jargon.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, + ) + + +async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName] = None) -> str: + """Change the tone of the text.""" + return await _call_deepseek_openrouter( + f"Rewrite this text in a {tone} tone. Preserve the original meaning.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.5, + ) + + +async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderName] = None) -> str: + """Translate text to target language.""" + # Gemini is better at translation + if provider is None and PROVIDERS["gemini"]["api_key"]: + return await _call_gemini( + f"Translate the following text to {target_lang}. Return only the translation.\n\n{text}", + "You are a professional translator. Translate accurately and naturally.", + temperature=0.1, + ) + return await _call_deepseek_openrouter( + f"Translate the following text to {target_lang}. Return only the translation.\n\n{text}", + "You are a professional translator. Translate accurately and naturally.", + provider, temperature=0.1, + ) + + +async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str: + """Explain the selected text.""" + return await _call_deepseek_openrouter( + f"Explain the following text clearly and concisely:\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, + ) + + +async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> str: + """Summarize the selected text.""" + return await _call_deepseek_openrouter( + f"Summarize the following text concisely:\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, + ) + + +async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None) -> str: + """Continue writing from the selected text.""" + return await _call_deepseek_openrouter( + f"Continue writing from where this text leaves off. Match the style and tone:\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.7, max_tokens=4096, + ) + + +async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[ProviderName] = None) -> str: + """Rewrite text based on a custom instruction.""" + return await _call_deepseek_openrouter( + f"Rewrite the following text according to this instruction: {instruction}\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.5, + ) + + +async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None) -> str: + """Convert paragraph text to a markdown list.""" + return await _call_deepseek_openrouter( + f"Convert this text into a well-organized markdown bullet list. Extract key points.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.2, + ) + + +async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None) -> str: + """Convert text to a markdown table.""" + return await _call_deepseek_openrouter( + f"Convert this information into a markdown table. Choose appropriate columns.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.2, + ) + + +async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] = None) -> str: + """Generate YAML frontmatter for a markdown document.""" + return await _call_deepseek_openrouter( + f"Generate YAML frontmatter for this markdown document. Include: titre, tags (as list), catégorie, statut, date. Return ONLY the YAML between --- markers.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, + ) + + +async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None) -> str: + """Inline completion — suggest continuation.""" + return await _call_deepseek_openrouter( + f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, max_tokens=512, + ) + + +async def ai_convert_to_canvas(text: str, provider: Optional[ProviderName] = None) -> str: + """Convert text to a Mermaid diagram or canvas representation.""" + return await _call_deepseek_openrouter( + f"Convert this content into a Mermaid.js diagram if applicable, or a structured outline. Choose the best format.\n\n{text}", + SYSTEM_PROMPT, provider, temperature=0.3, max_tokens=4096, + ) diff --git a/backend/ai_routes.py b/backend/ai_routes.py new file mode 100644 index 0000000..5638d62 --- /dev/null +++ b/backend/ai_routes.py @@ -0,0 +1,144 @@ +"""ObsiGate AI — API routes for AI-powered editor features.""" + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field +from typing import Optional +import logging + +from backend.ai import ( + ai_improve_writing, ai_fix_spelling, ai_make_shorter, ai_make_longer, + ai_simplify, ai_change_tone, ai_translate, ai_explain, ai_summarize, + ai_continue_writing, ai_custom_rewrite, ai_convert_to_list, + ai_convert_to_table, ai_generate_frontmatter, ai_inline_complete, + ai_convert_to_canvas, +) + +logger = logging.getLogger("obsigate.ai_routes") +router = APIRouter(prefix="/api/ai", tags=["AI"]) + + +class AIRequest(BaseModel): + text: str = Field(..., description="Input text to process", min_length=1) + instruction: Optional[str] = Field(None, description="Custom instruction for rewrite") + target_lang: Optional[str] = Field(None, description="Target language for translation") + tone: Optional[str] = Field(None, description="Target tone (professional, casual, etc.)") + provider: Optional[str] = Field(None, description="AI provider override") + + +class AIResponse(BaseModel): + result: str = Field(..., description="Processed text result") + provider: str = Field(..., description="AI provider used") + + +async def _handle(action, request: AIRequest): + """Wrapper with error handling.""" + try: + result = await action(request.text, request.provider) + return AIResponse(result=result, provider=request.provider or "default") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"AI error: {e}") + raise HTTPException(status_code=500, detail=f"AI service error: {str(e)}") + + +@router.post("/improve", response_model=AIResponse) +async def api_improve(req: AIRequest): + """Improve writing quality.""" + return await _handle(ai_improve_writing, req) + + +@router.post("/fix-spelling", response_model=AIResponse) +async def api_fix_spelling(req: AIRequest): + """Fix spelling and grammar.""" + return await _handle(ai_fix_spelling, req) + + +@router.post("/make-shorter", response_model=AIResponse) +async def api_make_shorter(req: AIRequest): + """Make text more concise.""" + return await _handle(ai_make_shorter, req) + + +@router.post("/make-longer", response_model=AIResponse) +async def api_make_longer(req: AIRequest): + """Expand text with more detail.""" + return await _handle(ai_make_longer, req) + + +@router.post("/simplify", response_model=AIResponse) +async def api_simplify(req: AIRequest): + """Simplify language.""" + return await _handle(ai_simplify, req) + + +@router.post("/tone", response_model=AIResponse) +async def api_tone(req: AIRequest): + """Change text tone. Requires `tone` field (e.g., 'professional', 'casual').""" + if not req.tone: + raise HTTPException(status_code=400, detail="Field 'tone' is required (e.g., 'professional', 'casual')") + return await _handle(lambda text, p: ai_change_tone(text, req.tone, p), req) + + +@router.post("/translate", response_model=AIResponse) +async def api_translate(req: AIRequest): + """Translate text. Requires `target_lang` field (e.g., 'French', 'English', 'Japanese').""" + if not req.target_lang: + raise HTTPException(status_code=400, detail="Field 'target_lang' is required") + return await _handle(lambda text, p: ai_translate(text, req.target_lang, p), req) + + +@router.post("/explain", response_model=AIResponse) +async def api_explain(req: AIRequest): + """Explain the selected text.""" + return await _handle(ai_explain, req) + + +@router.post("/summarize", response_model=AIResponse) +async def api_summarize(req: AIRequest): + """Summarize text.""" + return await _handle(ai_summarize, req) + + +@router.post("/continue", response_model=AIResponse) +async def api_continue(req: AIRequest): + """Continue writing from the selected text.""" + return await _handle(ai_continue_writing, req) + + +@router.post("/rewrite", response_model=AIResponse) +async def api_rewrite(req: AIRequest): + """Custom rewrite with instruction. Requires `instruction` field.""" + if not req.instruction: + raise HTTPException(status_code=400, detail="Field 'instruction' is required") + return await _handle(lambda text, p: ai_custom_rewrite(text, req.instruction, p), req) + + +@router.post("/to-list", response_model=AIResponse) +async def api_to_list(req: AIRequest): + """Convert text to a markdown list.""" + return await _handle(ai_convert_to_list, req) + + +@router.post("/to-table", response_model=AIResponse) +async def api_to_table(req: AIRequest): + """Convert text to a markdown table.""" + return await _handle(ai_convert_to_table, req) + + +@router.post("/frontmatter", response_model=AIResponse) +async def api_frontmatter(req: AIRequest): + """Generate YAML frontmatter.""" + return await _handle(ai_generate_frontmatter, req) + + +@router.post("/inline-complete", response_model=AIResponse) +async def api_inline_complete(req: AIRequest): + """Inline completion.""" + return await _handle(ai_inline_complete, req) + + +@router.post("/to-canvas", response_model=AIResponse) +async def api_to_canvas(req: AIRequest): + """Convert to Mermaid diagram or outline.""" + return await _handle(ai_convert_to_canvas, req) diff --git a/backend/main.py b/backend/main.py index 0bf55d6..9b0f2fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -594,8 +594,10 @@ except OSError: from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares # noqa: E402 from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402 from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402 +from backend.ai_routes import router as ai_router # noqa: E402 app.include_router(auth_router) +app.include_router(ai_router) # Resolve frontend path relative to this file FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" diff --git a/backend/requirements.txt b/backend/requirements.txt index 82eb51c..c63bcd9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ python-jose>=3.3.0 sortedcontainers>=2.4.0 snowballstemmer>=2.2.0 weasyprint>=60.0 +httpx>=0.27.0 diff --git a/frontend/index.html b/frontend/index.html index 4ca9b2a..41b4526 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -495,7 +495,9 @@ -
+
+
+
diff --git a/frontend/js/ai.js b/frontend/js/ai.js new file mode 100644 index 0000000..6d6835a --- /dev/null +++ b/frontend/js/ai.js @@ -0,0 +1,362 @@ +/* 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'; + +// ── API call helper ── +async function aiAction(endpoint, text, extra = {}) { + const body = { text, ...extra }; + const data = await api(`/api/ai/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return data.result; +} + +// ── 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 the global showToast if available (from ui.js) + if (typeof window._showToast === 'function') { + window._showToast(msg, type); + } +} + +// ── Dropdown menu builder ── +function createMenu(items, parentEl) { + // Remove any existing menu + const existing = parentEl.querySelector('.ai-dropdown-menu'); + if (existing) existing.remove(); + + const menu = document.createElement('div'); + menu.className = 'ai-dropdown-menu'; + Object.assign(menu.style, { + position: 'absolute', + top: '100%', + left: '0', + background: 'var(--bg-primary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + padding: '4px 0', + minWidth: '200px', + zIndex: '100', + 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', + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '0.8rem', + color: 'var(--text-primary)', + whiteSpace: 'nowrap', + }); + 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 = 'var(--bg-secondary)'; + // 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 = ''; + }); + el.addEventListener('click', (e) => { + e.stopPropagation(); + if (item.action) item.action(); + menu.remove(); + }); + menu.appendChild(el); + }); + + parentEl.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-color)', + 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 = 'var(--bg-secondary)'; }); + el.addEventListener('mouseleave', () => { el.style.background = ''; }); + 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 ── +export function createAIToolbar(container, getEditorView) { + 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-color)', + background: 'var(--bg-secondary)', + flexWrap: 'wrap', + }); + + 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 = 'AI Inline Completion (Ctrl+J)'; + aiBtn.addEventListener('click', async () => { + const v = ev(); + if (!v) return; + const text = getSelection(v); + if (!text.trim()) return; + try { + const result = await aiAction('inline-complete', text); + replaceSelection(v, result, 'append'); + } catch (e) { showToast('AI: ' + e.message, 'error'); } + }); + + // ── Edit menu ── + const editBtn = createDropdownBtn('Éditer', [ + { label: '🪄 Improve writing', action: () => action('improve') }, + { label: '🔤 Fix spelling & grammar', action: () => action('fix-spelling') }, + { label: '📏 Make shorter', action: () => action('make-shorter') }, + { label: '📐 Make longer', action: () => action('make-longer') }, + { label: '📋 Simplify language', action: () => action('simplify') }, + ]); + + // ── Tone menu ── + const toneBtn = createDropdownBtn('Ton', [ + { label: '💼 Professional tone', action: () => action('tone', { tone: 'professional' }) }, + { label: '💬 Casual tone', action: () => action('tone', { tone: 'casual' }) }, + ]); + + // ── Translate menu ── + const translateBtn = createDropdownBtn('Traduire', [ + { 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' }) }, + ]); + + // ── Generate menu ── + const genBtn = createDropdownBtn('Générer', [ + { label: 'ℹ️ Explain this', action: () => action('explain', {}, 'append') }, + { label: '📝 Summarize', action: () => action('summarize', {}, 'append') }, + { label: '✏️ Continue writing', action: () => action('continue', {}, 'append') }, + ]); + + // ── Custom Rewrite ── + const rewriteBtn = document.createElement('button'); + rewriteBtn.className = 'ai-toolbar-btn'; + rewriteBtn.innerHTML = '💬 Réécrire'; + Object.assign(rewriteBtn.style, { + fontSize: '0.7rem', + background: 'none', + 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); + const instruction = prompt('Instruction de réécriture :', ''); + if (!instruction) return; + try { + const result = await aiAction('rewrite', text, { instruction }); + replaceSelection(v, result); + } catch (e) { showToast('AI: ' + e.message, 'error'); } + }); + + // ── Toolbox menu ── + const toolboxBtn = createDropdownBtn('🧰 Boîte', [ + { 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') }, + ]); + + 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); + + 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()) return; + try { + const result = await aiAction(endpoint, text, extra); + replaceSelection(v, result, mode); + } catch (e) { showToast('AI: ' + e.message, 'error'); } + } + + // ── 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(); + } + }); + + return toolbar; +} + +function createDropdownBtn(text, items) { + const btn = document.createElement('button'); + btn.className = 'ai-toolbar-btn'; + btn.innerHTML = text + ' ▾'; + 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 = 'var(--bg-hover)'; }); + btn.addEventListener('mouseleave', () => { btn.style.background = ''; }); + return btn; +} + +function createSeparator() { + const sep = document.createElement('span'); + sep.style.cssText = 'width:1px;height:16px;background:var(--border-color);margin:0 2px'; + return sep; +} + +// Export showToast for the module +export function setToast(fn) { window._showToast = fn; } diff --git a/frontend/js/utils.js b/frontend/js/utils.js index cc4132c..ce21c86 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -2,6 +2,10 @@ import { state } from './state.js'; import { api } from './auth.js'; import { openFile, showWelcome } from './viewer.js'; import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js'; +import { createAIToolbar, setToast } from './ai.js'; +import { showToast } from './ui.js'; + +let _aiToolbarCreated = false; // --------------------------------------------------------------------------- // File extension → Lucide icon mapping @@ -363,6 +367,16 @@ async function openEditor(vaultName, filePath) { state: cmState, parent: bodyEl, }); + + // Set up AI toolbar (once) + if (!_aiToolbarCreated) { + setToast(showToast); + const container = document.getElementById('ai-toolbar-container'); + if (container) { + createAIToolbar(container, () => state.editorView); + _aiToolbarCreated = true; + } + } } catch (err) { console.error("CodeMirror init failed, falling back to textarea:", err); state.fallbackEditorEl = document.createElement("textarea");