// 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' }, { id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt' }, { 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'; } }