feat: AI Editor — toolbar avec menus dropdown, multi-provider (DeepSeek/OpenRouter/Gemini)
This commit is contained in:
parent
ca84bfdf11
commit
b92fd3da08
17
.env.example
17
.env.example
@ -32,3 +32,20 @@
|
|||||||
|
|
||||||
# Backup
|
# Backup
|
||||||
# OBSIGATE_BACKUP_DIR=.obsigate-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
|
||||||
|
|||||||
253
backend/ai.py
Normal file
253
backend/ai.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
144
backend/ai_routes.py
Normal file
144
backend/ai_routes.py
Normal file
@ -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)
|
||||||
@ -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.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.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.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(auth_router)
|
||||||
|
app.include_router(ai_router)
|
||||||
|
|
||||||
# Resolve frontend path relative to this file
|
# Resolve frontend path relative to this file
|
||||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
|
|||||||
@ -11,3 +11,4 @@ python-jose>=3.3.0
|
|||||||
sortedcontainers>=2.4.0
|
sortedcontainers>=2.4.0
|
||||||
snowballstemmer>=2.2.0
|
snowballstemmer>=2.2.0
|
||||||
weasyprint>=60.0
|
weasyprint>=60.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
|||||||
@ -495,7 +495,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-body" id="editor-body"></div>
|
<div class="editor-body" id="editor-body">
|
||||||
|
<div id="ai-toolbar-container"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
362
frontend/js/ai.js
Normal file
362
frontend/js/ai.js
Normal file
@ -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; }
|
||||||
@ -2,6 +2,10 @@ import { state } from './state.js';
|
|||||||
import { api } from './auth.js';
|
import { api } from './auth.js';
|
||||||
import { openFile, showWelcome } from './viewer.js';
|
import { openFile, showWelcome } from './viewer.js';
|
||||||
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.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
|
// File extension → Lucide icon mapping
|
||||||
@ -363,6 +367,16 @@ async function openEditor(vaultName, filePath) {
|
|||||||
state: cmState,
|
state: cmState,
|
||||||
parent: bodyEl,
|
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) {
|
} catch (err) {
|
||||||
console.error("CodeMirror init failed, falling back to textarea:", err);
|
console.error("CodeMirror init failed, falling back to textarea:", err);
|
||||||
state.fallbackEditorEl = document.createElement("textarea");
|
state.fallbackEditorEl = document.createElement("textarea");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user