From f09de98406b418c61f84c09383b05c049bd1f164 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 10 Sep 2026 11:24:21 -0400 Subject: [PATCH] feat(v5.9.0): AI Writing Assist - slash /ai, autocompletion, AI properties - Service app/services/ai_writing.py: 6 actions sans outils (write, summarize, translate, continue, autocomplete, properties) + replis deterministes offline - Endpoints POST /api/agent/writing et /api/agent/writing/properties - Editeur: groupe slash AI (Write/Summarize/Translate/Continue) via E.aiSlash - Autocompletion inline AIAC (suggestion ~900ms, Tab accepte, Escape rejette) - Database: bouton AI fill (suggestions de proprietes Status/Priority/Resume) - Tests tests/test_ai_writing.py (+29) ; version 5.9.0 (VERSION, main.py) - CHANGELOG + ROADMAP v5.9.0 completes --- CHANGELOG.md | 28 ++ ROADMAP.md | 15 +- VERSION | 2 +- app/main.py | 4 +- app/routers/agent.py | 76 +++++ app/services/ai_writing.py | 330 +++++++++++++++++++++ app/templates/_database_table_scripts.html | 58 +++- app/templates/_page_editor_scripts.html | 95 +++++- tests/test_ai_writing.py | 292 ++++++++++++++++++ 9 files changed, 885 insertions(+), 15 deletions(-) create mode 100644 app/services/ai_writing.py create mode 100644 tests/test_ai_writing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index baa450e..7311627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog - FlowDeck +## v5.9.0 (2026-09-10) — AI Writing Assist (éditeur) + +> L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne +> et remplissage automatique des propriétés d'une base, au-dessus du moteur +> FlowDeck Agent (v4.10.0) et de l'endpoint headless `/api/agent/generate`. + +- **Service `app/services/ai_writing.py`** — `AIWritingService` sans outils, + six actions (`write`, `summarize`, `translate`, `continue`, `autocomplete`, + `properties`). Prompt système strict (Markdown léger, aucun préambule) et + replis déterministes hors-ligne (aucun appel réseau sans clé API). +- **Commandes slash IA** — nouveau groupe « AI » dans le menu `/` de + l'éditeur : **Write with AI**, **Summarize**, **Translate**, **Continue + writing**. `Write` ouvre le composeur inline existant ; `Summarize` et + `Continue` insèrent le résultat sous le bloc ; `Translate` remplace le + contenu de la page dans la langue demandée. +- **Autocomplétion** — module `AIAC` : après ~900 ms d'inactivité pendant la + frappe dans un bloc texte, une suggestion courte est proposée dans une + pastille « Tab » ancrée au bloc ; `Tab` l'insère, `Escape` la rejette. +- **AI properties** — bouton ✨ sur chaque ligne de la table database : + `POST /api/agent/writing/properties` propose des valeurs (Status, Priority, + Résumé…) à partir du titre/contenu, appliquées et persistées via l'API + collection existante. +- **Endpoints** — `POST /api/agent/writing` (action + prompt/contexte/langue/ + préfixe) et `POST /api/agent/writing/properties` (suggestions structurées), + provider/modèle par utilisateur via les clés LLM existantes. +- **Tests** — `tests/test_ai_writing.py` (+29) : service hors-ligne, coercition + des valeurs, parsing JSON, routes et câblage des templates. Version 5.9.0. + ## v5.5.0 (2026-09-08) — Partage : réorganisation & indicateurs > Vue complète du partage : sidebar « Shared » scindée en « Par moi » / diff --git a/ROADMAP.md b/ROADMAP.md index c57178c..4c66e8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -547,12 +547,15 @@ app/ - [ ] **Centre de notifications** — cloche in-app (mentions, assignations, commentaires, rappels) - [ ] **Template Meeting Notes** — Attendees, Agenda, Notes, Action Items -### v5.9.0 — AI Writing Assist (éditeur) -> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.9.0 (Agent IA). +### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10) +> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**. -- [ ] **Slash AI commands** — `/ai write`, `/ai summarize`, `/ai translate`, `/ai continue` -- [ ] **Autocomplétion** — suggestions de fin de ligne pendant la frappe -- [ ] **AI properties** — auto-remplissage de propriétés (résumé, catégorisation) via l'Agent Engine +- [x] **Slash AI commands** — groupe « AI » dans le menu `/` : Write with AI, Summarize, Translate, Continue writing (`E.aiSlash`) +- [x] **Autocomplétion** — module `AIAC` : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au `Tab`, rejet à `Escape` +- [x] **AI properties** — bouton ✨ par ligne de la table database : `POST /api/agent/writing/properties` propose Status/Priority/Résumé et applique les valeurs +- [x] **Service** `app/services/ai_writing.py` — 6 actions sans outils, replis déterministes hors-ligne +- [x] **Endpoints** — `POST /api/agent/writing` + `POST /api/agent/writing/properties` +- [x] **29 tests** `tests/test_ai_writing.py` ; version 5.9.0 --- @@ -636,4 +639,4 @@ Quality DB views, Agent IA Palette → Realtime + S DB avancée, redo, drag&drop, Calendrier, AI duplicate) -*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests) puis v5.10.0 Interactions de bloc livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, sync realtime, 9 tests, suite 307 verte) puis v5.5.0 (repo) Partage v2 livré (fenêtre Share rechargée, sidebar « Par moi »/« Avec moi », Library dir=made/received, badge 👥 Shared, 👥 workspace local, 6 tests, suite 325 verte) ; reste v5.4 → v6.0* +*Dernière mise à jour: 2026-09-10 — v5.9.0 AI Writing Assist livré (slash `/ai`, autocomplétion `AIAC`, AI properties, service `ai_writing.py`, 2 endpoints, 29 tests) ; reste v5.4, v5.11 → v6.0* diff --git a/VERSION b/VERSION index c7ba1e8..b3d91f9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0 \ No newline at end of file +5.9.0 diff --git a/app/main.py b/app/main.py index 031f1bd..58cdc76 100644 --- a/app/main.py +++ b/app/main.py @@ -53,7 +53,7 @@ async def lifespan(_app: FastAPI): from app.services.automations import automation_scheduler automation_task = asyncio.create_task(automation_scheduler()) - logger.info("FlowDeck v5.5.0 started on port %d", settings.app_port) + logger.info("FlowDeck v5.9.0 started on port %d", settings.app_port) try: yield finally: @@ -71,7 +71,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.5.0", + version="5.9.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/agent.py b/app/routers/agent.py index 16b454f..31d0e0f 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -367,6 +367,82 @@ async def agent_generate(request: Request): } +# ── AI Writing Assist (v5.9.0) ── + + +@router.post("/writing") +async def agent_writing(request: Request): + """Headless writing actions for the editor (v5.9.0). + + `action` is one of: write, summarize, translate, continue, autocomplete. + Returns plain Markdown (`text`) plus `model`/`offline` metadata. The + `properties` action is served by `/writing/properties`. + """ + from app.services.ai_writing import AIWritingService, WRITING_ACTIONS + + user_id = await _current_user_id(request) + body = await request.json() if request.headers.get("content-type") else {} + action = (body.get("action") or "").strip().lower() + if not action: + raise HTTPException(status_code=400, detail="action est requis") + if action == "properties": + raise HTTPException(status_code=400, detail="Utilisez /writing/properties pour l'action properties") + if action not in WRITING_ACTIONS: + raise HTTPException(status_code=400, detail=f"Action inconnue: {action}") + + prompt = (body.get("prompt") or "").strip() + if action == "write" and not prompt and not (body.get("title") or "").strip(): + raise HTTPException(status_code=400, detail="prompt est requis pour l'action write") + + service = AIWritingService( + user_id=user_id, + provider=(body.get("provider") or "").strip() or None, + model=(body.get("model") or "").strip() or None, + ) + result = await service.run( + action, + prompt=prompt, + context=(body.get("context") or ""), + target_language=(body.get("target_language") or "English"), + prefix=(body.get("prefix") or ""), + title=(body.get("title") or ""), + ) + return result + + +@router.post("/writing/properties") +async def agent_writing_properties(request: Request): + """Suggest values for a collection page's properties (v5.9.0). + + Body: `{title, content?, properties: [{name, type}], provider?, model?}`. + Returns `{ok, suggestions: {property_name: value}, model, offline}`. + """ + from app.services.ai_writing import AIWritingService + + user_id = await _current_user_id(request) + body = await request.json() if request.headers.get("content-type") else {} + properties = body.get("properties") or [] + if not isinstance(properties, list) or not properties: + raise HTTPException(status_code=400, detail="properties est requis (liste non vide)") + + service = AIWritingService( + user_id=user_id, + provider=(body.get("provider") or "").strip() or None, + model=(body.get("model") or "").strip() or None, + ) + suggestions = await service.suggest_properties( + context=(body.get("content") or ""), + title=(body.get("title") or ""), + properties=properties, + ) + return { + "ok": True, + "suggestions": suggestions, + "model": service.model or "", + "offline": service._offline_hint(), + } + + # ── Audit & rollback ── diff --git a/app/services/ai_writing.py b/app/services/ai_writing.py new file mode 100644 index 0000000..8010ce9 --- /dev/null +++ b/app/services/ai_writing.py @@ -0,0 +1,330 @@ +"""FlowDeck — AI Writing Assist (v5.9.0). + +Headless, tool-free writing helpers used by the editor (slash commands, +inline autocomplete) and the database table (AI property suggestions). + +All actions share one entry point, :meth:`AIWritingService.run`, which builds a +tight prompt, calls the configured LLM (or the deterministic offline mock) and +returns plain Markdown. `properties` additionally returns a structured +``suggestions`` mapping so the caller can fill collection properties. + +The service never talks to the DB directly — the router resolves the caller's +provider/key and the page context before delegating here. +""" +from __future__ import annotations + +import json +import logging +import re + +from app.services.llm_client import LLMClient + +logger = logging.getLogger(__name__) + +WRITING_ACTIONS = ("write", "summarize", "translate", "continue", "autocomplete", "properties") + +_MAX_CONTEXT = 20000 + +# Property name → (type, offline default) used by the deterministic fallback so +# the feature stays useful without a connected provider. +_OFFLINE_PROPERTY_DEFAULTS = ( + (("status", "état", "etat", "stage"), "select", "To do"), + (("priority", "priorité", "priorite"), "select", "Medium"), + (("done", "terminé", "termine", "complété", "complete"), "checkbox", False), + (("summary", "résumé", "resume", "description", "notes"), "text", ""), +) + +_SYSTEM_WRITING = ( + "Tu es l'assistant d'écriture de FlowDeck. " + "Réponds UNIQUEMENT avec le contenu demandé, en Markdown léger " + "(paragraphes, listes à puces, titres si utile). " + "N'ajoute aucun préambule, aucun commentaire, aucun bloc de code autour du texte." +) + + +class AIWritingService: + """Deterministic, provider-agnostic writing assistant.""" + + def __init__(self, user_id: int | None = None, provider: str | None = None, + model: str | None = None, api_key: str | None = None, + api_base: str | None = None): + self.user_id = user_id + self.provider = (provider or "").strip().lower() or None + self.model = (model or "").strip() or None + self._api_key = api_key + self._api_base = api_base + + # ── LLM plumbing ── + + def _client(self) -> LLMClient: + provider = self.provider + api_key = self._api_key + api_base = self._api_base + if provider and self.user_id: + try: + from app.services.llm_config import get_user_llm_key + row = get_user_llm_key(self.user_id, provider) + if row and row.get("api_key"): + api_key = row["api_key"] + api_base = (row.get("api_base") or "").strip() or api_base + except Exception: # noqa: BLE001 — never fail on key lookup + pass + return LLMClient(provider=provider, api_key=api_key, api_base=api_base) + + async def _complete(self, prompt: str, context: str = "") -> tuple[str, str, bool]: + llm = self._client() + offline = llm.provider == "offline" or not llm._has_credentials() + user_content = prompt + if context and context.strip(): + user_content += "\n\n# Contexte\n" + context.strip()[:_MAX_CONTEXT] + messages = [ + {"role": "system", "content": _SYSTEM_WRITING}, + {"role": "user", "content": user_content}, + ] + resp = await llm.complete(messages, model=self.model, tools=None, stream=False) + return (resp.text or "").strip(), (resp.model or self.model or ""), offline + + # ── Public API ── + + async def run(self, action: str, *, prompt: str = "", context: str = "", + target_language: str = "English", prefix: str = "", + title: str = "", properties: list | None = None) -> dict: + action = (action or "").strip().lower() + if action not in WRITING_ACTIONS: + raise ValueError(f"Action inconnue: {action or '(vide)'}") + + if action == "properties": + suggestions = await self.suggest_properties( + context=context, title=title, properties=properties or []) + return {"ok": True, "action": action, "text": "", "suggestions": suggestions, + "model": self.model or "", "offline": self._offline_hint()} + + prompt_text = self._build_prompt( + action, prompt=prompt, context=context, + target_language=target_language, prefix=prefix, title=title) + + # No connected provider → deterministic, dependency-free output (the raw + # offline planner echoes the prompt, which is wrong for continue/autocomplete). + if self._offline_hint(): + return {"ok": True, "action": action, "model": "", + "offline": True, + "text": self._offline_text(action, prompt=prompt, context=context, + target_language=target_language, + prefix=prefix, title=title)} + try: + text, model, offline = await self._complete(prompt_text, context=context) + except Exception as exc: # noqa: BLE001 — surface provider errors to the UI + logger.warning("AI writing '%s' failed: %s", action, exc) + return {"ok": False, "action": action, "error": str(exc), + "text": "", "model": self.model or "", "offline": False} + if not text: + text = self._offline_text(action, prompt=prompt, context=context, + target_language=target_language, + prefix=prefix, title=title) + offline = True + return {"ok": True, "action": action, "text": text, + "model": model, "offline": offline} + + # ── Prompt building ── + + def _build_prompt(self, action: str, *, prompt: str, context: str, + target_language: str, prefix: str, title: str) -> str: + if action == "write": + subject = (prompt or title or "ce document").strip() + return (f"Rédige le contenu demandé : {subject}. " + "Fournis un texte structuré et directement utilisable.") + if action == "summarize": + return ("Résume le contenu fourni de façon structurée et concise : " + "un court paragraphe d'introduction puis 3 à 5 points clés à puces.") + if action == "translate": + lang = (target_language or "English").strip() + return (f"Traduis l'intégralité du contenu fourni en {lang}, " + "en conservant fidèlement sa structure (titres, listes, paragraphes). " + "Ne traduis pas les noms propres et les termes techniques.") + if action == "continue": + return ("Poursuis naturellement le texte fourni. " + "Écris un à trois paragraphes cohérents avec le style et le sujet, " + "sans répéter ce qui précède et sans introduction.") + if action == "autocomplete": + return (f"Complète la phrase en cours par une suite courte et pertinente " + f"(maximum 20 mots). Ne répète pas le texte déjà écrit, ne mets " + f"aucun préambule. Texte en cours : {prefix!r}") + return prompt + + # ── Offline deterministic fallbacks ── + + def _offline_hint(self) -> bool: + try: + llm = self._client() + return llm.provider == "offline" or not llm._has_credentials() + except Exception: # noqa: BLE001 + return True + + def _offline_text(self, action: str, *, prompt: str, context: str, + target_language: str, prefix: str, title: str) -> str: + if action == "summarize": + return self._offline_summary(context) + if action == "translate": + return (f"⚠️ **Traduction hors-ligne indisponible** — aucun modèle d'IA connecté.\n\n" + f"Connectez un fournisseur dans **Paramètres → Agent & IA** pour traduire " + f"ce document en {target_language or 'English'}.") + if action == "autocomplete": + return self._offline_autocomplete(prefix) + if action == "continue": + return ("Suite du contenu : développez ici le point précédent avec un exemple " + "concret, puis ouvrez la prochaine idée en une phrase de transition.") + subject = (prompt or title or "ce document").strip() + return (f"## {subject}\n" + "\n" + "Présentation générale du sujet : objectif, contexte et public visé en " + "quelques phrases. (Contenu généré hors-ligne — connectez une clé API " + "pour une rédaction complète.)\n" + "\n" + "## Points clés\n" + "• Idée principale 1 et son argument.\n" + "• Idée principale 2 avec un exemple concret.\n" + "\n" + "## Prochaines étapes\n" + "• Relire, compléter et mettre en forme ce contenu.") + + @staticmethod + def _offline_summary(context: str) -> str: + text = (context or "").strip() + if not text: + return "Résumé : aucun contenu fourni à résumer." + headings = re.findall(r"^#{1,4}\s+(.+)$", text, flags=re.MULTILINE) + sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text)) + lead = next((s.strip() for s in sentences if len(s.strip()) > 40), sentences[0].strip()) + out = ["**Résumé**", "", lead[:400], ""] + bullets = [] + if headings: + bullets = [f"• {h.strip()}" for h in headings[:5]] + else: + for s in sentences[1:6]: + s = s.strip() + if len(s) > 30: + bullets.append(f"• {s[:180]}") + if bullets: + out.append("**Points clés**") + out.extend(bullets) + return "\n".join(out) + + @staticmethod + def _offline_autocomplete(prefix: str) -> str: + prefix = (prefix or "").strip() + if len(prefix) < 8: + return "" + return " Cette section détaille les points clés à retenir." + + # ── AI properties ── + + async def suggest_properties(self, *, context: str = "", title: str = "", + properties: list | None = None) -> dict: + """Return ``{property_name: value}`` suggestions for a collection page. + + ``properties`` is a list of ``{name, type}`` dicts. With a connected + provider the model is asked for a JSON object; offline we derive + deterministic defaults from the property names so the UI stays useful. + """ + properties = properties or [] + if not properties: + return {} + names = [str(p.get("name", "")).strip() for p in properties if isinstance(p, dict)] + names = [n for n in names if n] + + llm = self._client() + offline = llm.provider == "offline" or not llm._has_credentials() + if not offline: + schema = {str(p.get("name")): str(p.get("type", "text")) for p in properties if isinstance(p, dict)} + prompt = ( + "À partir du titre et du contenu du document, propose une valeur pour " + "chaque propriété. Réponds STRICTEMENT par un objet JSON " + "{\"nom_propriété\": valeur} sans texte autour.\n" + f"Propriétés attendues : {json.dumps(schema, ensure_ascii=False)}\n" + f"Titre : {title or '(sans titre)'}" + ) + try: + text, _, _ = await self._complete(prompt, context=context) + parsed = self._parse_json_object(text) + if parsed: + return self._coerce_suggestions(parsed, properties) + except Exception as exc: # noqa: BLE001 + logger.warning("AI properties failed: %s", exc) + # fall through to deterministic defaults + return self._offline_suggestions(title, context, properties) + + @staticmethod + def _parse_json_object(text: str) -> dict: + text = (text or "").strip() + if not text: + return {} + m = re.search(r"\{.*\}", text, flags=re.DOTALL) + if not m: + return {} + try: + data = json.loads(m.group(0)) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + def _coerce_suggestions(self, parsed: dict, properties: list) -> dict: + out: dict = {} + by_name = {str(p.get("name", "")).strip().lower(): p for p in properties if isinstance(p, dict)} + for key, value in parsed.items(): + prop = by_name.get(str(key).strip().lower()) + if not prop: + continue + out[str(prop.get("name"))] = self._coerce_value(value, prop.get("type", "text")) + return out + + @staticmethod + def _coerce_value(value, prop_type: str): + ptype = (prop_type or "text").lower() + if ptype == "checkbox": + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("1", "true", "yes", "oui", "vrai", "x") + if ptype == "number": + try: + num = float(value) + return int(num) if num.is_integer() else num + except (TypeError, ValueError): + return value + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return value + + @staticmethod + def _offline_suggestions(title: str, context: str, properties: list) -> dict: + out: dict = {} + summary_text = "" + if context: + summary_text = re.sub(r"\s+", " ", context).strip() + first = re.split(r"(?<=[.!?])\s+", summary_text) + summary_text = next((s for s in first if len(s) > 40), summary_text)[:180] + for prop in properties: + if not isinstance(prop, dict): + continue + name = str(prop.get("name", "")).strip() + if not name: + continue + low = name.lower() + ptype = (prop.get("type") or "text").lower() + matched = False + for keys, _ptype, default in _OFFLINE_PROPERTY_DEFAULTS: + if any(k in low for k in keys): + if "summary" in keys or "résumé" in keys or "resume" in keys or "description" in keys or "notes" in keys: + out[name] = summary_text or (title or "") + else: + out[name] = default + matched = True + break + if not matched and ptype in ("text", "title"): + if "name" in low or "titre" in low or "title" in low: + out[name] = title or "" + return out + + +async def run_action(action: str, **kwargs) -> dict: + """Module-level convenience wrapper (used by tests and simple callers).""" + return await AIWritingService().run(action, **kwargs) diff --git a/app/templates/_database_table_scripts.html b/app/templates/_database_table_scripts.html index 3a49931..ffffe4d 100644 --- a/app/templates/_database_table_scripts.html +++ b/app/templates/_database_table_scripts.html @@ -84,7 +84,7 @@ h += '
'; } h += ''; - h += ''; + h += ''; h += ''; // Body @@ -104,7 +104,7 @@ } h += ''; } - h += ''; + h += ''; h += ''; } // Empty rows + New Page at bottom @@ -141,6 +141,13 @@ openPageInSidePeek(parseInt(this.dataset.pageId)); }; }); + // AI fill properties + containerEl.querySelectorAll('.db-ai-fill-btn').forEach(btn => { + btn.onclick = function(e) { + e.stopPropagation(); + aiFillRow(parseInt(this.dataset.pageId)); + }; + }); // Hover containerEl.querySelectorAll('.db-row').forEach(row => { row.onmouseenter = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'visible'); }; @@ -249,6 +256,53 @@ cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); }; } + // ── v5.9.0: AI property suggestions ── + async function aiFillRow(pageId) { + const cp = state.pages.find(p => p.id === pageId); + if (!cp) return; + const props = state.properties.filter(p => p.prop_type !== 'title'); + if (!props.length) { + if (typeof window.showToast === 'function') window.showToast('Aucune propriété à remplir'); + return; + } + const payload = { + title: cp.title || '', + content: cp.content || '', + properties: props.map(p => ({ name: p.name, type: p.prop_type })) + }; + if (typeof window.showToast === 'function') window.showToast('✨ FlowDeck AI : analyse en cours…'); + try { + const r = await fetch('/api/agent/writing/properties', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, + body: JSON.stringify(payload) + }); + const d = await r.json(); + if (!d || !d.ok) { + if (typeof window.showToast === 'function') window.showToast((d && d.error) || 'Échec de la génération'); + return; + } + let pv; + try { pv = typeof cp.property_values_json === 'string' ? JSON.parse(cp.property_values_json) : (cp.property_values_json || {}); } + catch (e) { pv = {}; } + let filled = 0; + for (const p of props) { + const v = d.suggestions ? d.suggestions[p.name] : undefined; + if (v !== undefined && v !== null && v !== '') { pv[String(p.id)] = v; filled++; } + } + cp.property_values_json = pv; + await fetch('/db/pages/' + pageId + '/api', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() }, + body: JSON.stringify({ title: cp.title, properties: pv }) + }); + render(); + if (typeof window.showToast === 'function') window.showToast(filled ? ('✨ ' + filled + ' propriété(s) remplie(s)') : 'Aucune suggestion disponible'); + } catch (e) { + if (typeof window.showToast === 'function') window.showToast('Erreur IA'); + } + } + async function addNewPage() { try { const r = await fetch('/api/collections/'+collectionId+'/pages',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:''})}); diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index ee4b385..ac6ecf5 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -10,7 +10,11 @@ {id:'callout',name:'Callout',icon:'💡'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'}, {id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'}, {id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'}, - ]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]}, + ]},{name:'AI',items:[ + {id:'ai_write',name:'Write with AI',icon:'✨'},{id:'ai_summarize',name:'Summarize',icon:'📝'}, + {id:'ai_translate',name:'Translate',icon:'🌐'},{id:'ai_continue',name:'Continue writing',icon:'➡️'}, + ]}, + {name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]}, {name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}, {name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}]; @@ -195,8 +199,8 @@ } // ── Block type shortcuts (shown on the right of each slash item) ── - const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'}; - const _GROUP_LABEL={BASIC:'Basic blocks',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'}; + const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'}; + const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'}; // ── Hover preview card (matches Notion's visual hint) ── function _previewHtml(blockId){ const p=function(inner,cls){return '
'+inner+'
';}; @@ -356,6 +360,49 @@ }; window.AIC=AIC; + // ═══════════ AI Autocomplete (v5.9.0) ═══════════ + // After a short idle while typing in a text block, ask the AI for a short + // continuation and surface it as a "Tab to accept" pill anchored to the + // block. Tab inserts the suggestion; Escape dismisses it. + const AIAC={_timer:null,_sug:'',_bid:null,_busy:false,_enabled:true,_req:0, + ROOT:null, + _allowed:function(b){ if(!b)return false; return ['paragraph','heading_1','heading_2','heading_3','heading_4','bulleted_list','numbered_list','quote','callout','to_do'].indexOf(b.type)>=0; }, + mk(){ if(this.ROOT)return this.ROOT; const d=document.createElement('div'); d.id='_aiAC'; d.style.cssText='position:fixed;z-index:1150;display:none;max-width:min(520px,90vw)'; document.body.appendChild(d); this.ROOT=d; + document.addEventListener('mousedown',function(ev){ if(AIAC.ROOT&&!AIAC.ROOT.contains(ev.target))AIAC.clear(); }); + return d; }, + schedule(bid){ if(!this._enabled)return; const E=window.E; if(!E)return; const idx=E.getIdx(bid); const b=idx>=0?E.blocks[idx]:null; if(!this._allowed(b))return this.clear(); + clearTimeout(this._timer); const self=this; this._timer=setTimeout(function(){ self.fetch(bid); },900); }, + fetch(bid){ const E=window.E; if(!E)return; const idx=E.getIdx(bid); const b=idx>=0?E.blocks[idx]:null; if(!this._allowed(b))return this.clear(); + const el=E.getEl(bid); const prefix=((el&&el.textContent)||b.content||'').trim(); if(prefix.length<8)return this.clear(); + const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):''; + const req=++this._req; this._busy=true; const self=this; const csrf=(E.csrfTok?E.csrfTok():''); + fetch('/api/agent/writing',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({action:'autocomplete',prefix:prefix,context:ctx})}) + .then(function(r){return r.json();}) + .then(function(d){ self._busy=false; if(req!==self._req)return; if(d&&d.ok&&d.text){ self.show(bid,d.text); } else { self.clear(); } }) + .catch(function(){ self._busy=false; if(req===self._req)self.clear(); }); + }, + show(bid,text){ text=(text||'').trim(); if(!text){return this.clear();} const E=window.E; const el=E&&E.getEl(bid); if(!el)return; + this._sug=text; this._bid=bid; this.mk(); const r=el.getBoundingClientRect(); + this.ROOT.innerHTML='
' + +'✨'+esc(text)+'' + +'Tab' + +'
'; + const btn=this.ROOT.querySelector('.aiac-x'); if(btn)btn.onclick=function(){ AIAC.clear(); }; + let left=Math.min(Math.max(8,r.left+24),window.innerWidth-360); let top=r.bottom+4; + if(top+40>window.innerHeight)top=r.top-36; + this.ROOT.style.left=left+'px'; this.ROOT.style.top=Math.max(8,top)+'px'; this.ROOT.style.display='block'; + }, + accept(){ if(!this._sug)return false; const E=window.E; const idx=E&&E.getIdx(this._bid); if(idx<0){ this.clear(); return false; } + const b=E.blocks[idx]; const el=E.getEl(this._bid); E.sync(); E.pushHistory(); + const cur=((el&&el.textContent)||b.content||''); const next=(cur+this._sug).replace(/\s+$/,' '); + b.content=next; if(el){ el.textContent=next; } this.clear(); E.dirty=true; E.autoSave(); if(window.__fdRT&&window.__fdRT.syncNow)window.__fdRT.syncNow(); + if(el){ el.focus(); ce(el); } return true; + }, + clear(){ this._sug=''; this._bid=null; this._req++; if(this.ROOT){ this.ROOT.style.display='none'; this.ROOT.innerHTML=''; } }, + get active(){ return !!this._sug; } + }; + window.AIAC=AIAC; + function renderBlock(b,idx){ if(b.type==='meeting')return renderMeetingBlock(b,idx); if(b.type==='table')return renderTableBlock(b,idx); @@ -929,7 +976,7 @@ setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50); // Initialize AI Meeting Note widgets (block type 'meeting') setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50); - ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());})}); + ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})}); ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());}); _selApply(); }, @@ -1374,6 +1421,11 @@ applyAIBlocks(text){ if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;} return; } + if(window.AIAC&&window.AIAC.active){ + if(e.key==='Tab'){e.preventDefault();e.stopPropagation();window.AIAC.accept();return;} + if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;} + if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();} + } if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);} if(e.key===' '){ if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){ @@ -1420,6 +1472,7 @@ applyAIBlocks(text){ applySlash(id){ const idx=SM._i;if(idx<0||idx>=this.blocks.length)return; if(id==='database'){ this.openDbTemplatePicker(idx); return; } + if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; } if(id==='duplicate'){this.duplicateBlock(idx);return;} if(id==='delete'){this.removeBlock(idx);return;} if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;} @@ -1433,6 +1486,40 @@ applyAIBlocks(text){ else if(id==='columns'){setTimeout(()=>{const ne=this.getEl(block.children[0].id);if(ne)ne.focus();},60);} else{setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);} }, + // ── v5.9.0: AI writing slash commands ── + aiSlash(id,idx){ + const action=id.slice(3); const block=this.blocks[idx]; + if(block){ this.sync(); this.pushHistory(); block.content=''; } + if(action==='write'){ this.render(); this.dirty=true; this.autoSave(); if(window.AIC)window.AIC.open(idx); return; } + if(action==='translate'){ this.render(); this.dirty=true; this.autoSave(); + const lang=window.prompt('Traduire en…','English'); if(!lang){return;} + this._aiWritingRun('translate',idx,{target_language:lang},'replace'); return; } + this.render(); this.dirty=true; this.autoSave(); + this._aiWritingRun(action,idx,{},'insert'); + }, + _aiWritingRun(action,idx,extra,mode){ + const self=this; this.sync(); + const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):''; + const payload={action:action,context:ctx}; + if(action==='continue'){ let parts=[]; for(let i=0;i{ const el=this.getEl(this.blocks[Math.min(idx,this.blocks.length-1)]?.id); if(el){el.focus();ce(el);} },60); + }, _doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].checked=v;this.dirty=true;this.autoSave();_rtSync();}}, _doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].expanded=!this.blocks[idx].expanded;this.render();this.dirty=true;this.autoSave();_rtSync();}}, _addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);}, diff --git a/tests/test_ai_writing.py b/tests/test_ai_writing.py new file mode 100644 index 0000000..88bfeeb --- /dev/null +++ b/tests/test_ai_writing.py @@ -0,0 +1,292 @@ +"""FlowDeck — AI Writing Assist tests (v5.9.0). + +Covers the offline writing service (write / summarize / translate / continue / +autocomplete / properties), the `/api/agent/writing` + `/api/agent/writing/properties` +endpoints, and the editor/database front-end wiring (slash commands, inline +autocomplete, AI property fill). +""" +import asyncio +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + db_path = db_file.name + db_file.close() + + os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" + os.environ["APP_SECRET_KEY"] = "test-secret-for-ai-writing" + os.environ["RATE_LIMIT_ENABLED"] = "false" + os.environ["LLM_PROVIDER"] = "offline" + + from app.main import app + from app.db import init_db, get_conn + from app.password_utils import hash_password + from app.config import settings + + settings.database_url = f"sqlite:///{db_path}" + settings.llm_provider = "offline" + + init_db() + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) " + "VALUES ('admin', 'Admin', '', ?, 1)", + (hash_password("test"),), + ) + conn.commit() + + yield TestClient(app) + + try: + os.unlink(db_path) + except FileNotFoundError: + pass + + +def _run(coro): + return asyncio.run(coro) + + +# ── Service: constants & validation ── + +def test_writing_actions_constant(client): + from app.services.ai_writing import WRITING_ACTIONS + for action in ("write", "summarize", "translate", "continue", "autocomplete", "properties"): + assert action in WRITING_ACTIONS + + +def test_unknown_action_raises(client): + from app.services.ai_writing import AIWritingService + svc = AIWritingService() + with pytest.raises(ValueError): + _run(svc.run("nope")) + + +# ── Service: offline fallbacks ── + +def test_write_offline(client): + from app.services.ai_writing import AIWritingService + res = _run(AIWritingService().run("write", prompt="Guide de démarrage")) + assert res["ok"] is True + assert res["action"] == "write" + assert res["text"].strip() + assert res["offline"] is True + + +def test_summarize_offline(client): + from app.services.ai_writing import AIWritingService + context = "# Titre\n\nCeci est un paragraphe détaillé qui explique le sujet du document.\n\n## Section\n\nEncore du contenu pour le résumé automatique." + res = _run(AIWritingService().run("summarize", context=context)) + assert res["ok"] is True + assert "Résumé" in res["text"] + assert "Points clés" in res["text"] + + +def test_summarize_offline_empty_context(client): + from app.services.ai_writing import AIWritingService + res = _run(AIWritingService().run("summarize", context="")) + assert res["ok"] is True + assert res["text"].strip() + + +def test_translate_offline(client): + from app.services.ai_writing import AIWritingService + res = _run(AIWritingService().run("translate", context="Bonjour le monde", + target_language="English")) + assert res["ok"] is True + assert "English" in res["text"] or "Traduction" in res["text"] + + +def test_continue_offline(client): + from app.services.ai_writing import AIWritingService + res = _run(AIWritingService().run("continue", context="Intro du document.")) + assert res["ok"] is True + assert res["text"].strip() + + +def test_autocomplete_offline_short_prefix(client): + from app.services.ai_writing import AIWritingService + res = _run(AIWritingService().run("autocomplete", prefix="court")) + assert res["ok"] is True + assert res["text"] == "" + + +def test_autocomplete_offline_long_prefix(client): + from app.services.ai_writing import AIWritingService + res = _run(AIWritingService().run("autocomplete", + prefix="Voici une phrase suffisamment longue pour déclencher")) + assert res["ok"] is True + assert res["text"].strip() + # The completion must NOT echo the prompt prefix. + assert "suffisamment longue" not in res["text"] + + +# ── Service: AI properties ── + +def test_properties_offline_defaults(client): + from app.services.ai_writing import AIWritingService + props = [{"name": "Status", "type": "select"}, + {"name": "Priority", "type": "select"}, + {"name": "Résumé", "type": "text"}] + res = _run(AIWritingService().run("properties", title="Refonte du site", + context="Refonte complète du site vitrine de l'entreprise.", + properties=props)) + assert res["ok"] is True + assert res["suggestions"]["Status"] == "To do" + assert res["suggestions"]["Priority"] == "Medium" + assert res["suggestions"]["Résumé"] + + +def test_properties_offline_ignores_unknown(client): + from app.services.ai_writing import AIWritingService + props = [{"name": "Couleur préférée", "type": "text"}] + res = _run(AIWritingService().run("properties", title="T", properties=props)) + assert res["ok"] is True + assert res["suggestions"] == {} + + +def test_properties_empty_list(client): + from app.services.ai_writing import AIWritingService + res = _run(AIWritingService().suggest_properties(properties=[])) + assert res == {} + + +def test_coerce_value_types(client): + from app.services.ai_writing import AIWritingService + svc = AIWritingService() + assert svc._coerce_value("true", "checkbox") is True + assert svc._coerce_value("non", "checkbox") is False + assert svc._coerce_value("42", "number") == 42 + assert svc._coerce_value("3.5", "number") == 3.5 + assert svc._coerce_value("hello", "text") == "hello" + + +def test_parse_json_object(client): + from app.services.ai_writing import AIWritingService + assert AIWritingService._parse_json_object('```json\n{"Status": "Done"}\n```') == {"Status": "Done"} + assert AIWritingService._parse_json_object("not json") == {} + assert AIWritingService._parse_json_object("") == {} + + +# ── Router: /api/agent/writing ── + +def test_route_write(client): + r = client.post("/api/agent/writing", json={"action": "write", "prompt": "Un guide"}) + assert r.status_code == 200 + d = r.json() + assert d["ok"] is True + assert d["action"] == "write" + assert d["text"].strip() + + +def test_route_summarize(client): + r = client.post("/api/agent/writing", json={ + "action": "summarize", + "context": "# Titre\n\nUn long paragraphe explicatif sur le fonctionnement du produit."}) + assert r.status_code == 200 + assert r.json()["ok"] is True + assert r.json()["text"].strip() + + +def test_route_autocomplete(client): + r = client.post("/api/agent/writing", json={ + "action": "autocomplete", + "prefix": "Une phrase assez longue pour lancer la completion"}) + assert r.status_code == 200 + assert r.json()["ok"] is True + + +def test_route_translate(client): + r = client.post("/api/agent/writing", json={ + "action": "translate", "context": "Bonjour", "target_language": "English"}) + assert r.status_code == 200 + assert r.json()["ok"] is True + + +def test_route_missing_action(client): + r = client.post("/api/agent/writing", json={}) + assert r.status_code == 400 + + +def test_route_unknown_action(client): + r = client.post("/api/agent/writing", json={"action": "bogus"}) + assert r.status_code == 400 + + +def test_route_write_requires_prompt(client): + r = client.post("/api/agent/writing", json={"action": "write"}) + assert r.status_code == 400 + + +def test_route_write_with_title_only(client): + r = client.post("/api/agent/writing", json={"action": "write", "title": "Mon titre"}) + assert r.status_code == 200 + assert r.json()["text"].strip() + + +def test_route_properties_action_rejected_here(client): + r = client.post("/api/agent/writing", json={ + "action": "properties", "properties": [{"name": "Status", "type": "select"}]}) + assert r.status_code == 400 + + +# ── Router: /api/agent/writing/properties ── + +def test_route_properties(client): + r = client.post("/api/agent/writing/properties", json={ + "title": "Tâche importante", + "content": "Il faut absolument traiter ce point avant la fin de la semaine.", + "properties": [{"name": "Status", "type": "select"}, + {"name": "Priority", "type": "select"}]}) + assert r.status_code == 200 + d = r.json() + assert d["ok"] is True + assert d["suggestions"]["Status"] == "To do" + assert d["suggestions"]["Priority"] == "Medium" + + +def test_route_properties_requires_properties(client): + r = client.post("/api/agent/writing/properties", json={"title": "x"}) + assert r.status_code == 400 + + +def test_route_properties_empty_list(client): + r = client.post("/api/agent/writing/properties", json={"properties": []}) + assert r.status_code == 400 + + +# ── Front-end wiring (templates) ── + +def _read_template(name: str) -> str: + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + with open(os.path.join(base, "app", "templates", name), encoding="utf-8") as fh: + return fh.read() + + +def test_editor_has_ai_slash_commands(client): + src = _read_template("_page_editor_scripts.html") + for cmd in ("ai_write", "ai_summarize", "ai_translate", "ai_continue"): + assert cmd in src + assert "AI writing" in src + assert "aiSlash" in src + assert "/api/agent/writing" in src + + +def test_editor_has_autocomplete(client): + src = _read_template("_page_editor_scripts.html") + assert "AIAC" in src + assert "autocomplete" in src + assert "AIAC.schedule" in src + + +def test_database_has_ai_properties(client): + src = _read_template("_database_table_scripts.html") + assert "db-ai-fill-btn" in src + assert "aiFillRow" in src + assert "/api/agent/writing/properties" in src