diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cf374e7..ac364d1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: node desktop.test.mjs node toolbar-order.test.mjs node editor-inline.test.mjs + node ai-quick-actions.test.mjs else echo "tests/frontend/node_modules missing - installing jsdom" npm install --no-audit --no-fund --silent @@ -74,6 +75,7 @@ jobs: node desktop.test.mjs node toolbar-order.test.mjs node editor-inline.test.mjs + node ai-quick-actions.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index dc748ea..bda9035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.13.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.14.0**. --- @@ -14,6 +14,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.14.0] — 2026-09-19 + +### Ajouté + +- **#106 - Assistant IA : actions instantanées contextuelles + catalogue de prompts** : + la zone d'accueil remplace les 3 suggestions statiques par des **actions suggérées + selon le contexte détecté** (sélection dans l'éditeur → concis/corriger/expliquer ; + fichier de code → expliquer/bugs/tests ; 2+ docs → fusionner/comparer/frictions ; + 1 doc → résumé 3 points/checklist/frontmatter ; répertoire ou général sinon), avec + badge de contexte dans l'en-tête. Un bouton **« Toutes les actions »** ouvre un + tiroir catalogue (recherche instantanée, 6 catégories, 25 actions i18n FR/EN). + Nouveau prompt **« Générer le frontmatter YAML »** au format complet du vault + (titre, auteur, dates ISO-8601, tags, aliases, booléens, NomDeVoute, Description) + et **« Mettre à jour le frontmatter »** (conserve les champs existants, actualise + `modification_date`, recalcule tags/aliases/Description, complète les manquants) — + tous deux basculent en mode agent et appliquent le résultat via la carte de + confirmation. Détail : [features/ai-quick-actions.md](./features/ai-quick-actions.md). + +--- + ## [2.13.1] — 2026-09-18 --- diff --git a/README.fr.md b/README.fr.md index ac675f5..09eb36a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.13.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.14.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -927,8 +927,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.13.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.14.0). --- -*Projet : ObsiGate | Version : 2.13.1 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.14.0 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index a1b1332..989dd18 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.13.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.14.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1096,8 +1096,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.13.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.14.0). --- -*Project: ObsiGate | Version: 2.13.1 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.14.0 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 94f15e9..edcfe40 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.13.1 +2.14.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 7e0e951..3151b47 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.13.1" +version = "2.14.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 5d49c80..13e6dc6 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.13.1" +version = "2.14.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 2cc914a..f247b71 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.13.1", + "version": "2.14.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c22b49..6b66d5f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.13.1 | **Dernière mise à jour :** 2026-09-18 +> **Version :** 2.14.0 | **Dernière mise à jour :** 2026-09-19 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -188,6 +188,7 @@ | 103 | Configuration — clés utilisateur des sources connectées & recherche à clé (page Configurations, `data/api_keys.json`, priorité sur l'env) | 2.11.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) | | 104 | Configuration — Redesign UI de la section « Clés API IA » : recherche fournisseurs, carte défaut 2 colonnes + badges de capacités, cartes dépliables, footer d'actions sticky | 2.12.0 | [features/ai-keys-ui.md](./features/ai-keys-ui.md) | | 105 | Guide d'utilisation — audit de couverture complet, téléchargement Markdown/PDF, guide desktop élargi, section Architecture (Mermaid) + BUG-067 | 2.13.0 | [features/guide-coverage-105.md](./features/guide-coverage-105.md) | +| 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) | --- @@ -195,7 +196,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–104, #92 | ~115 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–106, #92 | ~115 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours | diff --git a/docs/features/ai-quick-actions.md b/docs/features/ai-quick-actions.md new file mode 100644 index 0000000..e35351b --- /dev/null +++ b/docs/features/ai-quick-actions.md @@ -0,0 +1,89 @@ +# #106 — Assistant IA : actions instantanées contextuelles & catalogue de prompts + +> **Statut :** livré en 2.14.0 · **Domaine :** Assistant IA (frontend) · +> **Fichiers :** `frontend/js/ai-quick-actions.js`, `frontend/js/bookslm.js`, +> `frontend/style.css`, `frontend/locales/{fr,en}.json`, +> `tests/frontend/ai-quick-actions.test.mjs` + +## Problème + +La zone d'accueil de l'assistant affichait 3 suggestions **statiques** par mode +(résumé / thèmes / contradictions), sans lien avec ce que l'utilisateur regarde +réellement (un fichier de code ? plusieurs documents ? une sélection dans +l'éditeur ?), et sans point d'accès au reste des prompts utiles. + +## Solution + +### Catalogue (`frontend/js/ai-quick-actions.js`, module pur sans DOM) + +25 actions en 6 catégories, chacune = `{id, cat, icon (lucide), labelKey, +promptKey, agent?}` — libellés **et** prompts i18n FR/EN (clés `qa.*`) : + +| Catégorie | Actions | +|---|---| +| 📝 Synthèse & Analyse | Résumer en 3 points clés · Frictions/contradictions · Vulgariser · FAQ | +| ✅ Productivité & Structuration | Checklist d'actions · Plan d'action · Mémo exécutif · **Générer le frontmatter YAML** · **Mettre à jour le frontmatter** · Liens/backlinks · Sections hiérarchiques | +| 💻 Code & Scripts | Expliquer · Bugs & failles · Docstrings/types · Tests unitaires | +| 🔗 Cross-documents | Comparer · Fusionner en note de synthèse · Chronologie | +| ✍️ Édition & Reformulation | Concis · Corriger le style · Reformuler · Traduire · Expliquer la sélection | +| ❔ Assistant (général) | Que sais-tu faire · Rechercher efficacement · Créer une note de réunion | + +Les deux actions frontmatter sont marquées `agent: true` : un clic bascule +transparentement le panneau en **mode agent** (comme Deep Research) puis envoie +le prompt — l'assistant lit le document, propose la mutation via l'outil +`edit_file`/`append_to_file`, et la **carte de confirmation** (#79) applique le +nouveau bloc en tête du fichier. + +Le prompt « Générer le frontmatter YAML » demande le format complet du vault de +Bruno (titre, auteur, creation_date/modification_date ISO-8601 avec fuseau, +catégorie, tags en liste inline, aliases, status, publish, favoris, template, +task, archive, draft, private, NomDeVoute, Description). « Mettre à jour le +frontmatter » **conserve les champs existants** : actualise +`modification_date`, recalcule titre/tags/aliases/catégorie/NomDeVoute/Description +d'après le contenu, complète les champs manquants. + +### Sélection contextuelle (triage par défaut dans l'UI) + +`detectContext({mode, docCount, currentPath, hasSelection})` — précédence : +**sélection > code > multi-docs > doc unique > répertoire > général**. + +| Contexte | Boutons suggérés | +|---|---| +| 1 doc texte | Résumer 3 points · Checklist · Générer le frontmatter · Mettre à jour le frontmatter | +| 2+ docs | Fusionner · Comparer · Frictions/contradictions | +| Fichier de code (.py, .ts, .sh…) | Expliquer · Bugs & failles · Tests unitaires | +| Sélection dans l'éditeur | Concis · Corriger le style · Expliquer la sélection | +| Répertoire | Résumer le répertoire · Thèmes · Checklist | +| Général | Que sais-tu faire · Rechercher · Créer une note | + +### Présentation (calquée sur le POC `ai_assistant_quick_actions_poc.html`) + +- Badge de contexte dans l'en-tête (« 1 doc ouvert », « Fichier de code », + « Sélection active », « 3 docs ouverts »…). +- Ligne « Actions suggérées » + bouton **« Toutes les actions »** ouvrant un + **tiroir bottom-sheet** : recherche instantanée (insensible aux accents/casse + via `normalizeSearch`) + catalogue groupé par catégorie, Échap/backdrop + pour fermer. +- Boutons icône + libellé + flèche au survol ; clic = **envoi immédiat** du + prompt dans le composer (chemin `_sendMessage()` : skills, images et mode + agent continuent de fonctionner). +- La rangée se masque dès le premier message du fil (comportement historique) ; + le rafraîchissement du contexte à la sélection éditeur est throttled 250 ms + (`selectionchange` + `mouseup`/`keyup`, CodeMirror n'émettant pas + `selectionchange` natif). +- CSS 100 % variables de thème (`--surface`, `--accent`, `--accent-bg`…) → + conforme aux 15 thèmes ; transitions désactivées en `prefers-reduced-motion`. + +## Tests + +`tests/frontend/ai-quick-actions.test.mjs` (13 tests, câblé au job lint CI) : +précédence des contextes, presets 3-4 actions résolvables, table de conception, +agent-flag des actions frontmatter, **complétude FR+EN** de chaque clé +catalogue/badge contre les vrais JSON de locales. + +## Vérification live (obsigate-test :2020, Playwright) + +badge « 1 doc ouvert », 4 suggestions dont les 2 frontmatter, tiroir à 6 +catégories / 25 actions, recherche « frontmatter » → 2 résultats, clic → +`aria-pressed=true` sur le bouton agent + message envoyé avec le nouveau +prompt + réponse de l'assistant. diff --git a/frontend/js/ai-quick-actions.js b/frontend/js/ai-quick-actions.js new file mode 100644 index 0000000..8fb734b --- /dev/null +++ b/frontend/js/ai-quick-actions.js @@ -0,0 +1,159 @@ +// ObsiGate — #106 : Actions instantanées de l'assistant IA. +// +// Two responsibilities, deliberately framework-free and DOM-free so the +// context rules stay unit-testable (tests/frontend/ai-quick-actions.test.mjs): +// +// 1. ACTION_CATALOG — every prompt action grouped by category. Each action +// carries an id, a lucide icon name, an i18n label key and an i18n +// prompt key; both strings are resolved through t() at render time so +// the FR/EN switch is live. +// 2. detectContext / suggestionsFor — a pure function of the assistant's +// live state (mode, open documents, current file, editor selection) +// returning the context key, then the 3 top action ids for that context +// (contextual triage table, see docs/features/ai-quick-actions.md). +// +// Context precedence (first match wins): +// selection — the user has a live text selection in the open editor +// code — the focused document is a source file (.py, .js, .sh…) +// multi_doc — two or more documents are open in tabs/panes +// single_doc — exactly one text document (.md, .txt, …) is open +// directory — a vault folder was opened from the tree context menu +// general — nothing open: app-help assistant +import { t } from './i18n.js'; + +/** Categories of the action catalogue (order = drawer display order). */ +export const CATEGORIES = Object.freeze([ + { id: 'synthesis', icon: 'sparkles', labelKey: 'qa.cat_synthesis' }, + { id: 'structure', icon: 'list-checks', labelKey: 'qa.cat_structure' }, + { id: 'code', icon: 'code', labelKey: 'qa.cat_code' }, + { id: 'cross', icon: 'git-compare', labelKey: 'qa.cat_cross' }, + { id: 'edition', icon: 'pen-tool', labelKey: 'qa.cat_edition' }, + { id: 'general', icon: 'help-circle', labelKey: 'qa.cat_general' }, +]); + +/** + * The full catalogue. `labelKey` is the button text, `promptKey` the message + * actually sent to the assistant (kept richer than the label on purpose: + * labels stay scannable, prompts stay precise). + */ +export const ACTION_CATALOG = Object.freeze([ + // ── Synthèse & Analyse ────────────────────────────────────────────── + { id: 'summarize_3', cat: 'synthesis', icon: 'align-left', labelKey: 'qa.summarize_3', promptKey: 'qa.summarize_3.prompt' }, + { id: 'frictions', cat: 'synthesis', icon: 'alert-triangle', labelKey: 'qa.frictions', promptKey: 'qa.frictions.prompt' }, + { id: 'vulgarize', cat: 'synthesis', icon: 'lightbulb', labelKey: 'qa.vulgarize', promptKey: 'qa.vulgarize.prompt' }, + { id: 'faq', cat: 'synthesis', icon: 'help-circle', labelKey: 'qa.faq', promptKey: 'qa.faq.prompt' }, + // ── Productivité & Structuration ──────────────────────────────────── + { id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' }, + { id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' }, + { id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' }, + { id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true }, + { id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true }, + { id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' }, + { id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' }, + // ── Code & Scripts ─────────────────────────────────────────────────── + { id: 'explain_code', cat: 'code', icon: 'file-code', labelKey: 'qa.explain_code', promptKey: 'qa.explain_code.prompt' }, + { id: 'audit_code', cat: 'code', icon: 'bug', labelKey: 'qa.audit_code', promptKey: 'qa.audit_code.prompt' }, + { id: 'doc_code', cat: 'code', icon: 'braces', labelKey: 'qa.doc_code', promptKey: 'qa.doc_code.prompt' }, + { id: 'test_code', cat: 'code', icon: 'flask-conical', labelKey: 'qa.test_code', promptKey: 'qa.test_code.prompt' }, + // ── Cross-documents ────────────────────────────────────────────────── + { id: 'compare', cat: 'cross', icon: 'git-compare', labelKey: 'qa.compare', promptKey: 'qa.compare.prompt' }, + { id: 'merge', cat: 'cross', icon: 'layers', labelKey: 'qa.merge', promptKey: 'qa.merge.prompt' }, + { id: 'timeline', cat: 'cross', icon: 'history', labelKey: 'qa.timeline', promptKey: 'qa.timeline.prompt' }, + // ── Édition & Reformulation (sélection active) ─────────────────────── + { id: 'concise', cat: 'edition', icon: 'scissors', labelKey: 'qa.concise', promptKey: 'qa.concise.prompt' }, + { id: 'fix_style', cat: 'edition', icon: 'spell-check', labelKey: 'qa.fix_style', promptKey: 'qa.fix_style.prompt' }, + { id: 'rephrase', cat: 'edition', icon: 'pen-tool', labelKey: 'qa.rephrase', promptKey: 'qa.rephrase.prompt' }, + { id: 'translate', cat: 'edition', icon: 'languages', labelKey: 'qa.translate', promptKey: 'qa.translate.prompt' }, + // ── Général (aucun document ouvert) — reprises des anciennes suggestions + { id: 'capabilities', cat: 'general', icon: 'sparkles', labelKey: 'qa.capabilities', promptKey: 'qa.capabilities.prompt' }, + { id: 'search_help', cat: 'general', icon: 'search', labelKey: 'qa.search_help', promptKey: 'qa.search_help.prompt' }, + { id: 'create_note', cat: 'general', icon: 'notebook-pen', labelKey: 'qa.create_note', promptKey: 'qa.create_note.prompt' }, +]); + +/** id → action record, for O(1) lookup by the preset tables. */ +export const ACTIONS_BY_ID = Object.freeze( + ACTION_CATALOG.reduce((acc, a) => { acc[a.id] = a; return acc; }, {}), +); + +/** File extensions treated as source code (drive the `code` context). */ +export const CODE_EXT_RE = /\.(?:py|js|mjs|cjs|ts|tsx|jsx|vue|svelte|go|rs|java|kt|c|h|cpp|hpp|cc|cs|php|rb|swift|sh|bash|zsh|ps1|bat|sql|lua|pl|r|dart|scala)$/i; + +/** Text-ish document extensions (everything else stays "single_doc"). */ +export const TEXT_DOC_EXT_RE = /\.(?:md|markdown|mdx|txt|rst|org|adoc)$/i; + +/** + * Map a set of live facts to one context key. + * + * @param {object} facts + * @param {string} facts.mode assistant mode ('directory'|'documents'|'general') + * @param {number} facts.docCount number of open documents (tabs/panes) + * @param {string|null} facts.currentPath path of the focused document, if any + * @param {boolean} facts.hasSelection true when the open editor holds a text selection + * @param {number} [facts.fileCount] indexed files of the current directory context + * @returns {'selection'|'code'|'multi_doc'|'single_doc'|'directory'|'general'} + */ +export function detectContext(facts) { + const { mode, docCount = 0, currentPath = null, hasSelection = false } = facts || {}; + // A live editor selection is the strongest intent: act on the selection. + if (hasSelection) return 'selection'; + if (mode === 'documents' || docCount > 0) { + if (currentPath && CODE_EXT_RE.test(currentPath)) return 'code'; + if (docCount >= 2) return 'multi_doc'; + return 'single_doc'; + } + if (mode === 'directory') return 'directory'; + return 'general'; +} + +/** + * Contextual triage: the 3 top action ids per context (the "boutons 1-3" of + * the design table). Everything not shown stays reachable via the drawer. + */ +export const CONTEXT_PRESETS = Object.freeze({ + single_doc: ['summarize_3', 'checklist', 'frontmatter', 'frontmatter_update'], + multi_doc: ['merge', 'compare', 'frictions'], + code: ['explain_code', 'audit_code', 'test_code'], + selection: ['concise', 'fix_style', 'explain_selection'], + directory: ['summarize_dir', 'themes', 'checklist'], + general: ['capabilities', 'search_help', 'create_note'], +}); + +// Preset ids that are context phrasings rather than catalogue entries +// (their prompt needs the directory/list framing, so they are synthesized). +const EXTRA_ACTIONS = Object.freeze({ + explain_selection: { id: 'explain_selection', cat: 'edition', icon: 'lightbulb', labelKey: 'qa.explain_selection', promptKey: 'qa.explain_selection.prompt' }, + summarize_dir: { id: 'summarize_dir', cat: 'synthesis', icon: 'align-left', labelKey: 'bookslm.suggestion_summary', promptKey: 'bookslm.suggestion_summary' }, + themes: { id: 'themes', cat: 'synthesis', icon: 'library', labelKey: 'bookslm.suggestion_themes', promptKey: 'bookslm.suggestion_themes' }, +}); + +/** Resolve an action id (catalogue or preset extra) to its record. */ +export function getAction(id) { + return ACTIONS_BY_ID[id] || EXTRA_ACTIONS[id] || null; +} + +/** + * The ordered action records suggested for one context key. + * Unknown keys fall back to the general preset (never throws). + */ +export function suggestionsFor(contextKey) { + const ids = CONTEXT_PRESETS[contextKey] || CONTEXT_PRESETS.general; + return ids.map((id) => getAction(id)).filter(Boolean); +} + +/** Translated label + prompt for an action record (empty string when absent). */ +export function actionTexts(action) { + if (!action) return { label: '', prompt: '' }; + return { label: t(action.labelKey), prompt: t(action.promptKey) }; +} + +/** Badge text for the contextual header chip. */ +export function contextBadgeKey(contextKey, docCount) { + switch (contextKey) { + case 'selection': return 'qa.badge_selection'; + case 'code': return 'qa.badge_code'; + case 'multi_doc': return 'qa.badge_multi'; + case 'single_doc': return 'qa.badge_single'; + case 'directory': return 'qa.badge_directory'; + default: return docCount > 0 ? 'qa.badge_single' : 'qa.badge_general'; + } +} diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index cf55e9f..712cb1e 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -10,6 +10,15 @@ import { safeCreateIcons } from './utils.js'; import { api, AuthManager } from './auth.js'; import { showToast } from './ui.js'; import { state } from './state.js'; +// #106 — contextual quick actions (catalogue + triage rules live in ai-quick-actions.js). +import { + detectContext, + suggestionsFor, + actionTexts, + contextBadgeKey, + ACTION_CATALOG, + CATEGORIES, +} from './ai-quick-actions.js'; export const MODE = Object.freeze({ DIRECTORY: 'directory', @@ -920,6 +929,7 @@ class BooksLM { +
@@ -1071,6 +1081,25 @@ class BooksLM { }); textarea.addEventListener('paste', (e) => this._onPaste(e)); + // #106 — The suggested row flips to "selection" actions while the user + // selects text in the open editor; refresh (throttled) on selectionchange. + let qaSelTimer = null; + const onSelectionChange = () => { + if (qaSelTimer) return; + qaSelTimer = setTimeout(() => { + qaSelTimer = null; + if (!this._panel || this._panel.classList.contains('hidden')) return; + if (this._messages.length) return; + const ctx = this._quickContext(); + if (ctx !== this._qaContext) this._showSuggestions(); + }, 250); + }; + document.addEventListener('selectionchange', onSelectionChange); + // CodeMirror owns its selection (no native selectionchange on drag), so + // the same throttled refresh is bound to pointer/keyboard release too. + document.addEventListener('mouseup', onSelectionChange); + document.addEventListener('keyup', onSelectionChange); + // Menu navigation is handled at the panel level (capture phase) so the // arrow keys keep working even if focus is not exactly on the textarea // (e.g. after interacting with the menu). It runs before the textarea @@ -2015,26 +2044,52 @@ class BooksLM { } } - _suggestionsForContext() { - if (this._mode === MODE.GENERAL) { - return [ - t('ai.suggestion_capabilities'), - t('ai.suggestion_search'), - t('ai.suggestion_create_file'), - ]; - } - if (this._mode === MODE.DOCUMENTS) { - return [ - t('ai.suggestion_docs_summary'), - t('ai.suggestion_docs_keypoints'), - t('ai.suggestion_docs_contradictions'), - ]; - } - return [ - t('bookslm.suggestion_summary'), - t('bookslm.suggestion_themes'), - t('bookslm.suggestion_contradictions'), - ]; + // ── #106 — Actions instantanées contextuelles ─────────────────────── + // + // The welcome zone above the composer shows the 3 top actions for the + // *detected* context (selection > code > multi-doc > single-doc > + // directory > general, rules in ai-quick-actions.js); the full catalogue + // stays reachable through the "Toutes les actions" drawer. + + /** True when the open editor (CM6, or the mobile textarea fallback) + * holds a non-empty text selection. Best-effort, never throws. */ + _hasEditorSelection() { + try { + const view = state.editorView; + if (view && view.state && view.state.selection) { + const sel = view.state.selection.main; + if (sel && sel.from !== sel.to) return true; + } + const ta = state.fallbackEditorEl; + if (ta && document.body.contains(ta) + && ta.selectionStart != null && ta.selectionEnd > ta.selectionStart) { + return true; + } + } catch { /* editor not ready */ } + return false; + } + + /** The live context key driving the suggested-actions row. */ + _quickContext() { + const docs = this._mode === MODE.DOCUMENTS ? this._documents : []; + return detectContext({ + mode: this._mode, + docCount: docs.length, + currentPath: docs.length ? (docs[0].path || null) : null, + hasSelection: this._hasEditorSelection(), + }); + } + + /** Header chip describing the detected context ("1 doc ouvert", …). */ + _updateQaBadge() { + if (!this._panel) return; + const badge = this._panel.querySelector('.bookslm-qa-badge'); + if (!badge) return; + const ctx = this._qaContext || this._quickContext(); + const docCount = this._mode === MODE.DOCUMENTS ? this._documents.length : 0; + badge.textContent = t(contextBadgeKey(ctx, docCount), { count: docCount }); + badge.dataset.qaContext = ctx; + badge.classList.remove('hidden'); } _showSuggestions() { @@ -2042,21 +2097,185 @@ class BooksLM { const sugEl = this._panel.querySelector('.bookslm-suggestions'); if (!sugEl) return; sugEl.innerHTML = ''; - if (this._messages.length) return; - - for (const s of this._suggestionsForContext()) { - const btn = document.createElement('button'); - btn.className = 'bookslm-suggestion'; - btn.textContent = s; - btn.addEventListener('click', () => { - const textarea = this._panel.querySelector('textarea'); - if (textarea) { - textarea.value = s; - this._sendMessage(); - } - }); - sugEl.appendChild(btn); + if (this._messages.length) { + sugEl.classList.add('hidden'); + return; } + sugEl.classList.remove('hidden'); + const ctx = this._quickContext(); + this._qaContext = ctx; + this._updateQaBadge(); + + // Welcome hint (mirrors the POC copy: actions or free question). + const hint = document.createElement('p'); + hint.className = 'bookslm-qa-hint'; + hint.textContent = t('qa.empty_hint'); + sugEl.appendChild(hint); + + // Row head: label + access to the full catalogue. + const head = document.createElement('div'); + head.className = 'bookslm-qa-head'; + const label = document.createElement('span'); + label.className = 'bookslm-qa-label'; + label.textContent = t('qa.header_suggested'); + head.appendChild(label); + const more = document.createElement('button'); + more.type = 'button'; + more.className = 'bookslm-qa-more'; + more.innerHTML = `${t('qa.all_actions')}` + + ''; + more.addEventListener('click', (e) => { + e.stopPropagation(); + this._toggleActionDrawer(); + }); + head.appendChild(more); + sugEl.appendChild(head); + + const list = document.createElement('div'); + list.className = 'bookslm-qa-list'; + for (const action of suggestionsFor(ctx)) { + list.appendChild(this._renderQuickActionBtn(action)); + } + sugEl.appendChild(list); + if (typeof safeCreateIcons === 'function') safeCreateIcons(); + } + + /** One action button (icon + label + hover arrow), used in both the row + * and the drawer catalogue. */ + _renderQuickActionBtn(action, opts = {}) { + const { label, prompt } = actionTexts(action); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'bookslm-qa-btn' + (opts.compact ? ' compact' : ''); + btn.dataset.qaAction = action.id; + btn.innerHTML = ` + + ${label} + + `; + btn.title = prompt; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this._runQuickAction(action); + }); + return btn; + } + + /** Immediate-send the action prompt through the composer (same path as a + * typed message: skills, agent mode and images all keep working). Actions + * flagged `agent` (they mutate the document) transparently switch the + * assistant to agent mode first — same pattern as Deep Research. */ + _runQuickAction(action) { + if (!this._panel || !action) return; + const { prompt } = actionTexts(action); + if (!prompt) return; + this._closeActionDrawer(); + if (action.agent && !this._agentMode) this._toggleAgentMode(); + const textarea = this._panel.querySelector('textarea'); + if (!textarea) return; + textarea.value = prompt; + this._sendMessage(); + } + + // ── Drawer "Toutes les actions" (bottom sheet, searchable) ────────── + + _toggleActionDrawer() { + const drawer = this._ensureActionDrawer(); + if (!drawer) return; + if (drawer.classList.contains('open')) this._closeActionDrawer(); + else this._openActionDrawer(); + } + + _ensureActionDrawer() { + if (!this._panel) return null; + let drawer = this._panel.querySelector('.bookslm-qa-drawer'); + if (drawer) return drawer; + drawer = document.createElement('div'); + drawer.className = 'bookslm-qa-drawer hidden'; + drawer.innerHTML = ` + + `; + this._panel.appendChild(drawer); + drawer.querySelector('.bookslm-qa-sheet-close').addEventListener('click', () => this._closeActionDrawer()); + // Clicking the backdrop (outside the sheet) closes the drawer. + drawer.addEventListener('click', (e) => { + if (e.target === drawer) this._closeActionDrawer(); + }); + const input = drawer.querySelector('.bookslm-qa-search input'); + input.addEventListener('input', () => this._renderActionDrawerList(input.value)); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this._closeActionDrawer(); + } + }); + if (typeof safeCreateIcons === 'function') safeCreateIcons(); + return drawer; + } + + _openActionDrawer() { + const drawer = this._ensureActionDrawer(); + if (!drawer) return; + drawer.classList.remove('hidden'); + requestAnimationFrame(() => drawer.classList.add('open')); + this._renderActionDrawerList(''); + const input = drawer.querySelector('.bookslm-qa-search input'); + if (input) { input.value = ''; input.focus(); } + } + + _closeActionDrawer() { + if (!this._panel) return; + const drawer = this._panel.querySelector('.bookslm-qa-drawer'); + if (!drawer) return; + drawer.classList.remove('open'); + // Let the slide-down transition finish before hiding. + setTimeout(() => drawer.classList.add('hidden'), 220); + } + + _renderActionDrawerList(filter) { + const drawer = this._panel && this._panel.querySelector('.bookslm-qa-drawer'); + if (!drawer) return; + const body = drawer.querySelector('.bookslm-qa-sheet-list'); + if (!body) return; + body.innerHTML = ''; + const q = normalizeSearch(filter || ''); + let shown = 0; + for (const cat of CATEGORIES) { + const actions = ACTION_CATALOG.filter((a) => a.cat === cat.id + && (!q || normalizeSearch(actionTexts(a).label).includes(q))); + if (!actions.length) continue; + const head = document.createElement('p'); + head.className = 'bookslm-qa-cat'; + head.innerHTML = ` ${t(cat.labelKey)}`; + body.appendChild(head); + for (const action of actions) { + body.appendChild(this._renderQuickActionBtn(action, { compact: true })); + shown++; + } + } + if (!shown) { + const empty = document.createElement('div'); + empty.className = 'bookslm-qa-empty'; + empty.textContent = t('qa.no_match'); + body.appendChild(empty); + } + if (typeof safeCreateIcons === 'function') safeCreateIcons(); } _renderMessages(opts = {}) { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 1ce39d8..3b00f8b 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1479,6 +1479,76 @@ "pwa.install_button": "Install", "pwa.install_desc": "Install this app on your device for quick access.", "pwa.install_title": "Install ObsiGate", + "qa.all_actions": "All actions", + "qa.audit_code": "Detect bugs and potential flaws", + "qa.audit_code.prompt": "Identify potential bugs, unhandled edge cases and security flaws in this code, with proposed fixes.", + "qa.backlinks": "Suggest vault links and backlinks", + "qa.backlinks.prompt": "Suggest relevant [[wikilinks]] to other notes in the vault and backlinks to add to this document.", + "qa.badge_code": "Code file", + "qa.badge_directory": "Directory", + "qa.badge_general": "General mode", + "qa.badge_multi": "{count} docs open", + "qa.badge_selection": "Active selection", + "qa.badge_single": "1 doc open", + "qa.capabilities": "What can you do?", + "qa.capabilities.prompt": "What can you do? Present your capabilities on this vault.", + "qa.cat_code": "Code & Scripts", + "qa.cat_cross": "Cross-documents", + "qa.cat_edition": "Editing & Rewriting", + "qa.cat_general": "Assistant", + "qa.cat_structure": "Productivity & Structuring", + "qa.cat_synthesis": "Synthesis & Analysis", + "qa.checklist": "Extract the action checklist", + "qa.checklist.prompt": "Extract every concrete action to take as a Markdown to-do list with [ ] checkboxes.", + "qa.compare": "Compare differences and convergences", + "qa.compare.prompt": "Compare all open documents and summarise their convergences, divergences and oppositions.", + "qa.concise": "Make it more concise and punchy", + "qa.concise.prompt": "Rewrite the selection to make it more concise and punchy without losing the essentials.", + "qa.create_note": "Create a meeting note", + "qa.create_note.prompt": "Create a meeting notes file in the vault.", + "qa.doc_code": "Add documentation and types", + "qa.doc_code.prompt": "Add the appropriate docstrings, JSDoc and type annotations to every function in this file.", + "qa.drawer_title": "Action library", + "qa.empty_hint": "Pick a context-aware quick action, or ask a question directly.", + "qa.explain_code": "Explain the script logic", + "qa.explain_code.prompt": "Analyse and explain step by step the structure and algorithm of this source code.", + "qa.explain_selection": "Explain the selection", + "qa.explain_selection.prompt": "Explain the selected passage: its role, its context and what it implies.", + "qa.faq": "Generate a FAQ / key questions", + "qa.faq.prompt": "Generate a list of 5 key questions and answers to check comprehension of this text.", + "qa.fix_style": "Fix and improve the style", + "qa.fix_style.prompt": "Fix spelling and grammar mistakes and improve the syntactic flow of this passage.", + "qa.frictions": "Spot frictions and contradictions", + "qa.frictions.prompt": "Analyse this document and point out inconsistencies, blind spots or contradictions.", + "qa.frontmatter": "Generate the YAML frontmatter", + "qa.frontmatter.prompt": "Generate a complete YAML frontmatter block in the vault's format and apply it to the open document (insert it at the top of the file, or replace the existing block, keeping non-empty values already present): titre, auteur, creation_date and modification_date in ISO-8601 with timezone, catégorie, tags (inline list [a, b]), aliases, status, publish, favoris, template, task, archive, draft, private (booleans), NomDeVoute (current vault name), Description (a one-sentence summary of the content).", + "qa.frontmatter_update": "Update the frontmatter", + "qa.frontmatter_update.prompt": "Update the YAML frontmatter of the open document without deleting existing fields: refresh modification_date (current ISO-8601 timestamp with timezone), recompute titre, tags, aliases, catégorie, NomDeVoute and Description from the current content, fill in any missing metadata field (auteur, creation_date, status, publish, favoris, template, task, archive, draft, private) and apply the change to the file.", + "qa.header_suggested": "Suggested actions", + "qa.memo": "Write a shareable executive memo", + "qa.memo.prompt": "Write a shareable executive memo based on this document: context, findings, recommendations.", + "qa.merge": "Merge into one synthesis note", + "qa.merge.prompt": "Merge the essential elements of all open documents into one unified, flowing synthesis note.", + "qa.no_match": "No matching action.", + "qa.plan": "Create a step-by-step action plan", + "qa.plan.prompt": "Turn this content into a step-by-step action plan with priorities and estimates.", + "qa.rephrase": "Rephrase this passage", + "qa.rephrase.prompt": "Rephrase this passage keeping the meaning but using different wording.", + "qa.search_help": "Search my notes effectively", + "qa.search_help.prompt": "How do I search my notes effectively?", + "qa.search_placeholder": "Search an action...", + "qa.sections": "Structure into hierarchical sections", + "qa.sections.prompt": "Restructure this document with a logical hierarchy of Markdown headings (H2, H3) and clean bullet lists.", + "qa.summarize_3": "Summarize in 3 key points", + "qa.summarize_3.prompt": "Provide a concise summary of this document in 3 clear key points.", + "qa.test_code": "Generate unit tests", + "qa.test_code.prompt": "Write a unit test suite covering nominal and error cases for this code.", + "qa.timeline": "Build a cross-document timeline", + "qa.timeline.prompt": "Build a cross-document timeline of the dated events mentioned in these documents.", + "qa.translate": "Translate the selection", + "qa.translate.prompt": "Translate this passage into the appropriate language (English if the text is in French, and vice versa).", + "qa.vulgarize": "Plain-language explainer", + "qa.vulgarize.prompt": "Explain the content of this document in simple, accessible language without jargon.", "search.advanced_operators": "Advanced operators", "search.aria_label": "Search suggestions", "search.case_sensitive": "Case sensitive", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index b7b7471..781b8ed 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1479,6 +1479,76 @@ "pwa.install_button": "Installer", "pwa.install_desc": "Installez cette application sur votre appareil pour un accès rapide.", "pwa.install_title": "Installer ObsiGate", + "qa.all_actions": "Toutes les actions", + "qa.audit_code": "Détecter les bugs et failles potentielles", + "qa.audit_code.prompt": "Identifie les bugs potentiels, cas limites non gérés et failles de sécurité dans ce code, avec les correctifs proposés.", + "qa.backlinks": "Suggérer des liens et backlinks du vault", + "qa.backlinks.prompt": "Suggère des liens [[wikilinks]] pertinents vers d'autres notes du vault et des backlinks à ajouter à ce document.", + "qa.badge_code": "Fichier de code", + "qa.badge_directory": "Répertoire", + "qa.badge_general": "Mode général", + "qa.badge_multi": "{count} docs ouverts", + "qa.badge_selection": "Sélection active", + "qa.badge_single": "1 doc ouvert", + "qa.capabilities": "Que sais-tu faire ?", + "qa.capabilities.prompt": "Que sais-tu faire ? Présente tes capacités sur ce vault.", + "qa.cat_code": "Code & Scripts", + "qa.cat_cross": "Cross-documents", + "qa.cat_edition": "Édition & Reformulation", + "qa.cat_general": "Assistant", + "qa.cat_structure": "Productivité & Structuration", + "qa.cat_synthesis": "Synthèse & Analyse", + "qa.checklist": "Extraire la checklist d'actions", + "qa.checklist.prompt": "Extrais toutes les actions concrètes à mener sous forme de to-do list Markdown avec cases à cocher [ ].", + "qa.compare": "Comparer différences et convergences", + "qa.compare.prompt": "Compare l'ensemble des documents ouverts et résume leurs convergences, divergences et oppositions.", + "qa.concise": "Rendre plus concis et percutant", + "qa.concise.prompt": "Reformule la sélection pour la rendre plus concise et percutante, sans perdre l'essentiel.", + "qa.create_note": "Créer une note de réunion", + "qa.create_note.prompt": "Crée un fichier de notes de réunion dans le vault.", + "qa.doc_code": "Ajouter la documentation et les types", + "qa.doc_code.prompt": "Ajoute les docstrings, JSDoc et annotations de type appropriés à toutes les fonctions de ce fichier.", + "qa.drawer_title": "Bibliothèque d'actions", + "qa.empty_hint": "Sélectionnez une action instantanée adaptée à votre contexte, ou posez une question directe.", + "qa.explain_code": "Expliquer la logique du script", + "qa.explain_code.prompt": "Analyse et explique pas à pas la structure et l'algorithme de ce code source.", + "qa.explain_selection": "Expliquer la sélection", + "qa.explain_selection.prompt": "Explique le passage sélectionné : son rôle, son contexte et ce qu'il implique.", + "qa.faq": "Générer une FAQ / questions-clés", + "qa.faq.prompt": "Génère une liste de 5 questions-réponses clés pour évaluer la compréhension de ce texte.", + "qa.fix_style": "Corriger et améliorer le style", + "qa.fix_style.prompt": "Corrige les fautes d'orthographe, de grammaire et améliore la fluidité syntaxique de ce passage.", + "qa.frictions": "Relever les frictions et contradictions", + "qa.frictions.prompt": "Analyse ce document et relève les incohérences, zones d'ombre ou contradictions.", + "qa.frontmatter": "Générer le frontmatter YAML", + "qa.frontmatter.prompt": "Génère un bloc frontmatter YAML complet au format du vault et applique-le au document ouvert (insère-le en tête du fichier ou remplace le bloc existant, en conservant les valeurs non vides déjà présentes) : titre, auteur, creation_date et modification_date en ISO-8601 avec fuseau, catégorie, tags (liste en ligne [a, b]), aliases, status, publish, favoris, template, task, archive, draft, private (booléens), NomDeVoute (nom du vault courant), Description (résumé en une phrase du contenu).", + "qa.frontmatter_update": "Mettre à jour le frontmatter", + "qa.frontmatter_update.prompt": "Mets à jour le frontmatter YAML du document ouvert sans supprimer les champs existants : actualise modification_date (horodatage courant ISO-8601 avec fuseau), recalcule titre, tags, aliases, catégorie, NomDeVoute et Description d'après le contenu actuel, complète tout champ de la section métadonnée manquant (auteur, creation_date, status, publish, favoris, template, task, archive, draft, private) et applique la modification au fichier.", + "qa.header_suggested": "Actions suggérées", + "qa.memo": "Rédiger un mémo exécutif partageable", + "qa.memo.prompt": "Rédige un mémo exécutif partageable basé sur ce document : contexte, constats, recommandations.", + "qa.merge": "Fusionner en une note de synthèse", + "qa.merge.prompt": "Fusionne les éléments essentiels de tous les documents ouverts en une note de synthèse unifiée et fluide.", + "qa.no_match": "Aucune action ne correspond.", + "qa.plan": "Créer un plan d'action par étapes", + "qa.plan.prompt": "Transforme ce contenu en un plan d'action structuré par étapes, avec priorités et estimations.", + "qa.rephrase": "Reformuler ce passage", + "qa.rephrase.prompt": "Reformule ce passage en gardant le sens mais avec une tournure différente.", + "qa.search_help": "Rechercher efficacement dans mes notes", + "qa.search_help.prompt": "Comment rechercher efficacement dans mes notes ?", + "qa.search_placeholder": "Rechercher une action...", + "qa.sections": "Structurer en sections hiérarchiques", + "qa.sections.prompt": "Restructure ce document avec une hiérarchie logique de titres Markdown (H2, H3) et des listes à puces propres.", + "qa.summarize_3": "Résumer en 3 points clés", + "qa.summarize_3.prompt": "Fais un résumé synthétique de ce document en 3 points clés, clairs et concis.", + "qa.test_code": "Générer les tests unitaires", + "qa.test_code.prompt": "Rédige une suite de tests unitaires couvrant les cas nominaux et d'erreur pour ce code.", + "qa.timeline": "Construire une chronologie transversale", + "qa.timeline.prompt": "Construis une chronologie transversale des événements datés mentionnés dans ces documents.", + "qa.translate": "Traduire la sélection", + "qa.translate.prompt": "Traduis ce passage dans la langue appropriée (anglais si le texte est en français, et inversement).", + "qa.vulgarize": "Vulgariser ce document", + "qa.vulgarize.prompt": "Explique le contenu de ce document de manière simple, accessible et sans jargon.", "search.advanced_operators": "Opérateurs avancés", "search.aria_label": "Suggestions de recherche", "search.case_sensitive": "Respecter la casse", diff --git a/frontend/style.css b/frontend/style.css index cfa6a0f..e7b230e 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9925,10 +9925,71 @@ details[open] > summary .bookslm-chevron::before { content: '▼'; } .bookslm-diff-add { color: #4ade80; display: block; } .bookslm-diff-del { color: #f87171; display: block; } .bookslm-diff-ctx { color: var(--text-secondary); display: block; } +/* #106 — Actions instantanées contextuelles de l'assistant. */ .bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; } -.bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); - background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; } -.bookslm-suggestion:hover { background: var(--surface2); color: var(--text-primary); border-color: var(--accent); } +.bookslm-suggestions.hidden { display: none; } +.bookslm-qa-badge { flex-shrink: 0; font-size: 10px; text-transform: uppercase; + letter-spacing: 0.5px; font-weight: 600; color: var(--accent-text, var(--accent)); + background: var(--accent-bg); + border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; } +.bookslm-qa-badge.hidden { display: none; } +.bookslm-qa-hint { margin: 0 0 2px; font-size: 12px; color: var(--text-secondary); line-height: 1.45; } +.bookslm-qa-head { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; } +.bookslm-qa-label { font-size: 10px; font-weight: 700; letter-spacing: 0.8px; + text-transform: uppercase; color: var(--text-muted, var(--text-secondary)); opacity: 0.85; } +.bookslm-qa-more { display: inline-flex; align-items: center; gap: 5px; background: none; + border: none; color: var(--accent); font-size: 12px; cursor: pointer; padding: 2px 4px; + border-radius: 6px; } +.bookslm-qa-more:hover { background: var(--bg-hover); } +.bookslm-qa-list { display: flex; flex-direction: column; gap: 6px; } +.bookslm-qa-btn { display: flex; align-items: center; gap: 10px; width: 100%; + text-align: left; padding: 9px 12px; border-radius: 10px; cursor: pointer; + background: var(--surface); border: 1px solid var(--border); color: var(--text-secondary); + font-size: 13px; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; } +.bookslm-qa-btn:hover { background: var(--surface2); border-color: var(--accent); color: var(--text-primary); } +.bookslm-qa-btn .bookslm-qa-icon { color: var(--text-muted, var(--text-secondary)); flex-shrink: 0; } +.bookslm-qa-btn:hover .bookslm-qa-icon { color: var(--accent); } +.bookslm-qa-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; font-weight: 500; } +.bookslm-qa-go { opacity: 0; transform: translateX(-3px); transition: opacity 0.15s ease, + transform 0.15s ease; color: var(--accent); flex-shrink: 0; } +.bookslm-qa-btn:hover .bookslm-qa-go { opacity: 1; transform: translateX(0); } +/* Tiroir catalogue ("Toutes les actions") : bottom-sheet dans le panneau. */ +.bookslm-qa-drawer { position: absolute; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.45); + display: flex; align-items: flex-end; opacity: 0; transition: opacity 0.2s ease; } +.bookslm-qa-drawer.hidden { display: none; } +.bookslm-qa-drawer.open { opacity: 1; } +.bookslm-qa-sheet { width: 100%; max-height: 82%; display: flex; flex-direction: column; + background: var(--bg-primary); border-top: 1px solid var(--border); + border-radius: 14px 14px 0 0; padding: 12px 14px 10px; box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.35); + transform: translateY(100%); transition: transform 0.22s ease; } +.bookslm-qa-drawer.open .bookslm-qa-sheet { transform: translateY(0); } +.bookslm-qa-sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.bookslm-qa-sheet-title { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; + font-weight: 600; color: var(--text-primary); } +.bookslm-qa-sheet-title i { color: var(--accent); } +.bookslm-qa-sheet-close { display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer; + background: var(--surface); color: var(--text-secondary); } +.bookslm-qa-sheet-close:hover { background: var(--surface2); color: var(--text-primary); } +.bookslm-qa-search { position: relative; margin-bottom: 10px; } +.bookslm-qa-search i { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); + color: var(--text-secondary); pointer-events: none; } +.bookslm-qa-search input { width: 100%; box-sizing: border-box; padding: 7px 10px 7px 30px; + border-radius: 8px; border: 1px solid var(--border); background: var(--surface); + color: var(--text-primary); font-size: 13px; outline: none; } +.bookslm-qa-search input:focus { border-color: var(--accent); } +.bookslm-qa-sheet-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; + padding-bottom: 8px; } +.bookslm-qa-cat { margin: 8px 2px 2px; display: flex; align-items: center; gap: 6px; + font-size: 10px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase; + color: var(--text-secondary); position: sticky; top: 0; background: var(--bg-primary); + padding: 4px 0; z-index: 1; } +.bookslm-qa-btn.compact { padding: 7px 10px; border-radius: 8px; font-size: 12.5px; } +.bookslm-qa-empty { padding: 18px 8px; text-align: center; font-size: 12px; color: var(--text-secondary); } +@media (prefers-reduced-motion: reduce) { + .bookslm-qa-drawer, .bookslm-qa-sheet, .bookslm-qa-btn, .bookslm-qa-go { transition: none; } +} /* Formatted markdown inside assistant answers. */ .bookslm-bubble.assistant > *:first-child { margin-top: 0; } .bookslm-bubble.assistant > *:last-child { margin-bottom: 0; } diff --git a/package.json b/package.json index 90fbff5..35c8dc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.13.1", + "version": "2.14.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/ai-quick-actions.test.mjs b/tests/frontend/ai-quick-actions.test.mjs new file mode 100644 index 0000000..55346ba --- /dev/null +++ b/tests/frontend/ai-quick-actions.test.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * ObsiGate — Frontend unit tests for #106 (assistant contextual quick actions). + * + * Covers the pure logic of frontend/js/ai-quick-actions.js: + * - detectContext precedence (selection > code > multi-doc > single-doc > + * directory > general) + * - every preset id resolves to a real action (label + prompt keys exist) + * - i18n completeness: every qa.* key referenced by the catalogue exists in + * BOTH fr.json and en.json (FR/EN contract from AGENTS.md) + * + * Usage: node tests/frontend/ai-quick-actions.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, "..", ".."); + +// Minimal browser shim: i18n.js exposes `window.t = t` at module top-level. +// Strings are not loaded here, so t() falls back to the key — enough for the +// pure-logic contract asserted below (keys existence is checked against the +// real locale JSON files instead). +globalThis.window = globalThis.window || globalThis; + +const { + detectContext, + suggestionsFor, + getAction, + contextBadgeKey, + ACTION_CATALOG, + CATEGORIES, + CONTEXT_PRESETS, +} = await import(pathToFileURL(path.join(ROOT, "frontend", "js", "ai-quick-actions.js")).href); + +let testCount = 0; +let passCount = 0; + +async function test(name, fn) { + testCount++; + try { + await fn(); + passCount++; + console.log(` ✓ ${name}`); + } catch (err) { + console.error(` ✗ ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +// ── detectContext precedence ─────────────────────────────────────────────── + +await test("selection wins over every other context", () => { + assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "a.md", hasSelection: true }), "selection"); + assert.equal(detectContext({ mode: "documents", docCount: 3, currentPath: "x.py", hasSelection: true }), "selection"); +}); + +await test("code file drives the code context", () => { + assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "backend/main.py" }), "code"); + assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "script.SH" }), "code"); + assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "app.tsx" }), "code"); +}); + +await test("2+ documents drive the multi_doc context", () => { + assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "a.md" }), "multi_doc"); +}); + +await test("one text document drives the single_doc context", () => { + assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "note.md" }), "single_doc"); + assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "plan.txt" }), "single_doc"); + assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "image.png" }), "single_doc"); +}); + +await test("directory and general fall back correctly", () => { + assert.equal(detectContext({ mode: "directory", docCount: 0 }), "directory"); + assert.equal(detectContext({ mode: "general", docCount: 0 }), "general"); + assert.equal(detectContext(), "general"); +}); + +// ── presets & catalogue integrity ────────────────────────────────────────── + +await test("every context has 3-4 suggested actions, all resolvable", () => { + for (const [ctx, ids] of Object.entries(CONTEXT_PRESETS)) { + assert.ok(ids.length >= 3 && ids.length <= 4, `preset ${ctx} must hold 3-4 actions`); + for (const id of ids) assert.ok(getAction(id), `preset ${ctx}: unknown action id ${id}`); + assert.equal(suggestionsFor(ctx).length, ids.length); + } +}); + +await test("unknown context falls back to the general preset", () => { + assert.deepEqual(suggestionsFor("nonsense").map((a) => a.id), suggestionsFor("general").map((a) => a.id)); +}); + +await test("catalogue actions have unique ids, a known category, icon and keys", () => { + const catIds = new Set(CATEGORIES.map((c) => c.id)); + const seen = new Set(); + for (const a of ACTION_CATALOG) { + assert.ok(!seen.has(a.id), `duplicate action id ${a.id}`); + seen.add(a.id); + assert.ok(catIds.has(a.cat), `action ${a.id}: unknown category ${a.cat}`); + assert.ok(a.icon, `action ${a.id}: missing icon`); + assert.ok(a.labelKey && a.promptKey, `action ${a.id}: missing i18n keys`); + } +}); + +await test("the 4 scenarios of the design table map to the expected actions", () => { + assert.deepEqual(suggestionsFor("single_doc").map((a) => a.id), + ["summarize_3", "checklist", "frontmatter", "frontmatter_update"]); + assert.deepEqual(suggestionsFor("multi_doc").map((a) => a.id), ["merge", "compare", "frictions"]); + assert.deepEqual(suggestionsFor("code").map((a) => a.id), ["explain_code", "audit_code", "test_code"]); + assert.deepEqual(suggestionsFor("selection").map((a) => a.id), ["concise", "fix_style", "explain_selection"]); +}); + +await test("frontmatter actions are agent-mode (they mutate the document)", () => { + assert.equal(getAction("frontmatter").agent, true); + assert.equal(getAction("frontmatter_update").agent, true); + assert.ok(!getAction("summarize_3").agent); +}); + +// ── i18n completeness (FR + EN) ──────────────────────────────────────────── + +const FR = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8")); +const EN = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8")); + +await test("every catalogue/preset key exists in FR and EN (label + prompt)", () => { + const keys = new Set(); + for (const a of ACTION_CATALOG) { keys.add(a.labelKey); keys.add(a.promptKey); } + for (const id of Object.values(CONTEXT_PRESETS).flat()) { + const a = getAction(id); + keys.add(a.labelKey); keys.add(a.promptKey); + } + for (const c of CATEGORIES) keys.add(c.labelKey); + for (const k of ["qa.header_suggested", "qa.all_actions", "qa.drawer_title", + "qa.search_placeholder", "qa.no_match", "qa.empty_hint", "qa.badge_single", + "qa.badge_multi", "qa.badge_code", "qa.badge_selection", "qa.badge_directory", + "qa.badge_general"]) keys.add(k); + for (const k of keys) { + assert.ok(typeof FR[k] === "string" && FR[k].length > 0, `fr.json missing ${k}`); + assert.ok(typeof EN[k] === "string" && EN[k].length > 0, `en.json missing ${k}`); + } +}); + +await test("context badge keys resolve per context", () => { + assert.equal(contextBadgeKey("code"), "qa.badge_code"); + assert.equal(contextBadgeKey("selection"), "qa.badge_selection"); + assert.equal(contextBadgeKey("multi_doc"), "qa.badge_multi"); + assert.equal(contextBadgeKey("single_doc"), "qa.badge_single"); + assert.equal(contextBadgeKey("directory"), "qa.badge_directory"); + assert.equal(contextBadgeKey("general"), "qa.badge_general"); +}); + +await test("multi-doc badge carries the {count} param in both locales", () => { + assert.ok(FR["qa.badge_multi"].includes("{count}")); + assert.ok(EN["qa.badge_multi"].includes("{count}")); +}); + +console.log(`\n${passCount} passed, ${testCount - passCount} failed`); +if (passCount !== testCount) process.exit(1);