From 5258eea314259de786a74a759f92529a934e5424 Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Wed, 29 Jul 2026 16:47:29 +0200 Subject: [PATCH 1/3] feat(form-assistant): add local Grammarly-like field assistant (Phase 1) Introduces a content-script-based form assistant that detects eligible inputs on known connector platforms and proposes field values derived from the local user profile via Gemini Nano (on-device, free). Phase 1 is local-only with no backend; remote Eve engine is reserved for Phase 2. Core (pure, no I/O): - types/classify-field/sanitize-field-descriptor/select-engine - build-field-prompt/parse-field-proposal/redact-for-remote - 45 mock-free unit tests Shell: - settings.ts: isolated chrome.storage.local key (formAssist) - local-generator.ts: Gemini Nano proposal (8s timeout + retry) - 3 SW handlers (Machine B, local path only) - bridge messages + Zod .strict() (7 FORM_ASSIST_* messages) Content script (Machine A): - field-detector: DOM -> FieldDescriptor pipeline - widget: closed Shadow DOM, 4 states, viewport-aware anchoring - index.ts: focus detection, bridge, native-setter applyValue (React/Svelte compatible) Wiring: - meta.ts: formAssist?: boolean (Free-Work pilot opt-in) - vite.config.ts: content_scripts.matches derived from catalog - dev stubs: storage key + mocked proposals per FieldKind Models/specs: - form-assistant.model.md (3 machines A/B/C), review matrix, openspec proposal Follows Model -> Review -> Implement -> Verify. The LLM produces signals; the model decides. No auto-submission: values are applied only on explicit user acceptance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/extension/src/background/index.ts | 118 ++++++++ .../content/form-assistant/field-detector.ts | 112 +++++++ .../src/content/form-assistant/index.ts | 237 +++++++++++++++ .../src/content/form-assistant/widget.ts | 203 +++++++++++++ apps/extension/src/dev/chrome-stubs.ts | 83 ++++++ .../core/form-assistant/build-field-prompt.ts | 96 ++++++ .../lib/core/form-assistant/classify-field.ts | 155 ++++++++++ .../src/lib/core/form-assistant/index.ts | 7 + .../form-assistant/parse-field-proposal.ts | 37 +++ .../core/form-assistant/redact-for-remote.ts | 47 +++ .../sanitize-field-descriptor.ts | 44 +++ .../lib/core/form-assistant/select-engine.ts | 39 +++ .../src/lib/core/form-assistant/types.ts | 96 ++++++ .../src/lib/shell/connectors/meta.ts | 8 + .../shell/form-assistant/local-generator.ts | 72 +++++ .../src/lib/shell/form-assistant/settings.ts | 41 +++ .../src/lib/shell/messaging/bridge.ts | 27 +- .../src/lib/shell/messaging/schemas.ts | 81 +++++ .../src/models/form-assistant.model.md | 279 ++++++++++++++++++ .../src/models/form-assistant.review.md | 77 +++++ .../form-assistant/classify-field.test.ts | 92 ++++++ .../parse-field-proposal.test.ts | 32 ++ .../form-assistant/redact-for-remote.test.ts | 73 +++++ .../sanitize-field-descriptor.test.ts | 64 ++++ .../unit/form-assistant/select-engine.test.ts | 67 +++++ apps/extension/vite.config.ts | 46 +++ openspec/changes/form-assistant/proposal.md | 100 +++++++ 27 files changed, 2332 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/content/form-assistant/field-detector.ts create mode 100644 apps/extension/src/content/form-assistant/index.ts create mode 100644 apps/extension/src/content/form-assistant/widget.ts create mode 100644 apps/extension/src/lib/core/form-assistant/build-field-prompt.ts create mode 100644 apps/extension/src/lib/core/form-assistant/classify-field.ts create mode 100644 apps/extension/src/lib/core/form-assistant/index.ts create mode 100644 apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts create mode 100644 apps/extension/src/lib/core/form-assistant/redact-for-remote.ts create mode 100644 apps/extension/src/lib/core/form-assistant/sanitize-field-descriptor.ts create mode 100644 apps/extension/src/lib/core/form-assistant/select-engine.ts create mode 100644 apps/extension/src/lib/core/form-assistant/types.ts create mode 100644 apps/extension/src/lib/shell/form-assistant/local-generator.ts create mode 100644 apps/extension/src/lib/shell/form-assistant/settings.ts create mode 100644 apps/extension/src/models/form-assistant.model.md create mode 100644 apps/extension/src/models/form-assistant.review.md create mode 100644 apps/extension/tests/unit/form-assistant/classify-field.test.ts create mode 100644 apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts create mode 100644 apps/extension/tests/unit/form-assistant/redact-for-remote.test.ts create mode 100644 apps/extension/tests/unit/form-assistant/sanitize-field-descriptor.test.ts create mode 100644 apps/extension/tests/unit/form-assistant/select-engine.test.ts create mode 100644 openspec/changes/form-assistant/proposal.md diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index 46c99a5d..3d175306 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -2230,6 +2230,124 @@ chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse return true; } + // ── Form Assistant (content script ↔ SW) ── + // Source de vérité : src/models/form-assistant.model.md (Machine B). + // Phase 1 : chemin local uniquement. Le moteur remote (Eve/Vercel) est Phase 2. + if (message.type === 'FORM_ASSIST_STATUS') { + import('../lib/shell/form-assistant/settings') + .then(({ getFormAssistSettings }) => getFormAssistSettings()) + .then((settings) => { + sendResponse({ + type: 'FORM_ASSIST_STATUS_RESULT', + payload: { enabled: settings.enabled, engine: settings.engine }, + }); + }) + .catch((err) => { + console.warn('[MissionPulse] FORM_ASSIST_STATUS error:', err); + sendResponse({ + type: 'FORM_ASSIST_STATUS_RESULT', + payload: { enabled: false, engine: 'local' }, + }); + }); + return true; + } + + if (message.type === 'FORM_ASSIST_ENABLE') { + const { enabled } = message.payload; + import('../lib/shell/form-assistant/settings') + .then(({ setFormAssistEnabled }) => setFormAssistEnabled(enabled)) + .then((settings) => { + // Broadcast l'état à tous les contextes (panel + content scripts) + // pour que l'UI et les marqueurs de champ se resynchronisent. + chrome.runtime + .sendMessage({ + type: 'FORM_ASSIST_ENABLED', + payload: { enabled: settings.enabled, engine: settings.engine }, + }) + .catch(() => { + /* No listener available — ignore */ + }); + sendResponse({ + type: 'FORM_ASSIST_ENABLED', + payload: { enabled: settings.enabled, engine: settings.engine }, + }); + }) + .catch((err) => { + console.warn('[MissionPulse] FORM_ASSIST_ENABLE error:', err); + sendResponse({ + type: 'FORM_ASSIST_ENABLED', + payload: { enabled: false, engine: 'local' }, + }); + }); + return true; + } + + if (message.type === 'FORM_ASSIST_REQUEST') { + const { requestId, field } = message.payload; + + (async () => { + try { + const { generateFieldProposal } = + await import('../lib/shell/form-assistant/local-generator'); + const { getFormAssistSettings } = await import('../lib/shell/form-assistant/settings'); + const profile = await getProfile(); + + const settings = await getFormAssistSettings(); + if (!settings.enabled) { + sendResponse({ + type: 'FORM_ASSIST_ERROR', + payload: { + requestId, + code: 'unavailable', + message: 'Form Assistant désactivé', + }, + }); + return; + } + + if (!profile) { + sendResponse({ + type: 'FORM_ASSIST_ERROR', + payload: { + requestId, + code: 'failed', + message: 'Profil indisponible', + }, + }); + return; + } + + // Phase 1 : selectFormAssistEngine() est la source de vérité Core pour + // le choix du moteur. Ici seul le chemin local est câblé ; le chemin + // remote (Eve) est Phase 2 et renverra 'unavailable' tant que non impl. + const proposal = await generateFieldProposal(field, profile); + if (!proposal || proposal.text.length === 0) { + sendResponse({ + type: 'FORM_ASSIST_ERROR', + payload: { + requestId, + code: 'unavailable', + message: 'Aucune proposition disponible', + }, + }); + return; + } + + sendResponse({ + type: 'FORM_ASSIST_PROPOSAL', + payload: { requestId, text: proposal.text, engine: 'local' }, + }); + } catch (err) { + console.warn('[MissionPulse] FORM_ASSIST_REQUEST error:', err); + sendResponse({ + type: 'FORM_ASSIST_ERROR', + payload: { requestId, code: 'failed', message: 'Échec de génération' }, + }); + } + })(); + return true; + } + // ── Toast handler (forward to side panel) ── if (message.type === 'SHOW_TOAST') { diff --git a/apps/extension/src/content/form-assistant/field-detector.ts b/apps/extension/src/content/form-assistant/field-detector.ts new file mode 100644 index 00000000..a673bcc6 --- /dev/null +++ b/apps/extension/src/content/form-assistant/field-detector.ts @@ -0,0 +1,112 @@ +/** + * Content script — DOM → RawFieldInput → FieldDescriptor (sanitisé + classifié). + * + * Lit uniquement des métadonnées (label/placeholder/type/required), jamais la + * valeur courante du champ. Délègue la sanitisation + classification au Core + * (sanitizeFieldDescriptor). Aucune logique métier ici. + */ +import type { + FieldDescriptor, + FieldInputType, + RawFieldInput, +} from '../../lib/core/form-assistant/types'; +import { sanitizeFieldDescriptor } from '../../lib/core/form-assistant'; + +const MAX_TEXT_LEN = 200; + +function trimText(value: string, max = MAX_TEXT_LEN): string { + return value.replace(/\s+/g, ' ').trim().slice(0, max); +} + +/** + * Mappe un élément DOM vers un FieldInputType connu, ou `null` si le champ + * n'est pas éligible (mot de passe, checkbox, hidden, date, range, …). + */ +function resolveInputType(el: HTMLElement): FieldInputType | null { + const tag = el.tagName.toLowerCase(); + if (tag === 'textarea') { + return 'textarea'; + } + if (el.isContentEditable) { + return 'contenteditable'; + } + if (tag !== 'input') { + return null; + } + + const rawType = (el as HTMLInputElement).type.toLowerCase(); + switch (rawType) { + case 'email': + return 'email'; + case 'tel': + return 'tel'; + case 'url': + return 'url'; + case 'search': + return 'search'; + case 'text': + case '': + return 'text'; + default: + // password, checkbox, radio, hidden, date, number, range, file, color, … + return null; + } +} + +/** + * Résout le libellé humain d'un champ, par ordre de fiabilité décroissante. + * Ne lève jamais ; retourne une chaîne vide si rien n'est trouvé. + */ +function resolveLabel(el: HTMLElement): string { + if (el.id) { + const associated = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); + if (associated?.textContent) { + return trimText(associated.textContent); + } + } + const ariaLabel = el.getAttribute('aria-label'); + if (ariaLabel) { + return trimText(ariaLabel); + } + const labelledBy = el.getAttribute('aria-labelledby'); + if (labelledBy) { + const labeller = document.getElementById(labelledBy); + if (labeller?.textContent) { + return trimText(labeller.textContent); + } + } + const wrapping = el.closest('label'); + if (wrapping?.textContent) { + return trimText(wrapping.textContent); + } + return ''; +} + +function resolvePlaceholder(el: HTMLElement): string { + const ph = (el as HTMLInputElement).placeholder; + return ph ? trimText(ph) : ''; +} + +function resolveRequired(el: HTMLElement): boolean { + return el.hasAttribute('required') || el.getAttribute('aria-required') === 'true'; +} + +/** + * Construit le FieldDescriptor sanit-isé pour un élément focalisé, ou `null` + * si le champ n'est pas éligible au Form Assistant. + */ +export function detectFieldDescriptor(target: HTMLElement): FieldDescriptor | null { + const inputType = resolveInputType(target); + if (!inputType) { + return null; + } + + const raw: RawFieldInput = { + label: resolveLabel(target), + placeholder: resolvePlaceholder(target), + inputType, + required: resolveRequired(target), + }; + + return sanitizeFieldDescriptor(raw); +} diff --git a/apps/extension/src/content/form-assistant/index.ts b/apps/extension/src/content/form-assistant/index.ts new file mode 100644 index 00000000..2b7126d2 --- /dev/null +++ b/apps/extension/src/content/form-assistant/index.ts @@ -0,0 +1,237 @@ +/** + * Content script — Form Assistant orchestrator (Machine A). + * + * Source de vérité : src/models/formassistant.model.md (Machine A). + * + * États : disabled → idle → armed → requesting → ready → applying → filled + * + * Le content script ne décide JAMAIS de transitions métier : il délègue la + * génération au service worker (qui applique Machine B) et se contente + * d'afficher/insérer les propositions acceptées explicitement par l'utilisateur. + */ +import type { FieldDescriptor } from '../../lib/core/form-assistant/types'; +import { detectFieldDescriptor } from './field-detector'; +import { FormAssistWidget } from './widget'; + +type Phase = 'disabled' | 'idle' | 'armed' | 'requesting' | 'ready' | 'applying' | 'filled'; + +type FormAssistResponse = + | { + type: 'FORM_ASSIST_PROPOSAL'; + payload: { requestId: string; text: string; engine: 'local' | 'remote' }; + } + | { + type: 'FORM_ASSIST_ERROR'; + payload: { requestId: string; code: 'unavailable' | 'failed'; message: string }; + }; + +let booted = false; +let phase: Phase = 'disabled'; +let activeTarget: HTMLElement | null = null; +let activeDescriptor: FieldDescriptor | null = null; +let widget: FormAssistWidget | null = null; +let requestIdCounter = 0; + +function makeRequestId(): string { + requestIdCounter += 1; + return `fa-${Date.now().toString(36)}-${requestIdCounter}`; +} + +/** + * Applique une valeur à un champ en contournant les setters surchargés par les + * frameworks (React/Svelte) : on appelle le setter natif du prototype puis on + * émet l'événement `input` attendu par ces frameworks. + */ +function applyValue(element: HTMLElement, value: string): void { + if (element.isContentEditable) { + element.focus(); + try { + document.execCommand('selectAll'); + document.execCommand('insertText', false, value); + } catch { + element.textContent = value; + } + element.dispatchEvent(new InputEvent('input', { bubbles: true, data: value })); + return; + } + + const tag = element.tagName.toLowerCase(); + const proto = + tag === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); + if (descriptor?.set) { + descriptor.set.call(element, value); + } else { + (element as HTMLInputElement).value = value; + } + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); +} + +function ensureWidget(): FormAssistWidget { + if (!widget) { + widget = new FormAssistWidget({ + onTrigger: handleTrigger, + onAccept: handleAccept, + onDismiss: handleDismiss, + }); + } + return widget; +} + +function resetToIdle(): void { + phase = 'idle'; + activeTarget = null; + activeDescriptor = null; + widget?.hide(); +} + +function handleTrigger(): void { + if (phase !== 'armed' || !activeDescriptor || !activeTarget) { + return; + } + void requestProposal(activeTarget, activeDescriptor); +} + +function handleAccept(text: string): void { + if (phase !== 'ready' || !activeTarget) { + return; + } + phase = 'applying'; + try { + applyValue(activeTarget, text); + } catch (err) { + if (import.meta.env.DEV) { + console.warn('[MissionPulse FormAssistant] applyValue failed:', err); + } + } + phase = 'filled'; + resetToIdle(); +} + +function handleDismiss(): void { + resetToIdle(); +} + +async function requestProposal(target: HTMLElement, field: FieldDescriptor): Promise { + phase = 'requesting'; + const w = ensureWidget(); + w.show(target, { kind: 'requesting' }); + + const requestId = makeRequestId(); + const message = { + type: 'FORM_ASSIST_REQUEST' as const, + payload: { requestId, field }, + }; + + let response: FormAssistResponse | undefined; + try { + response = (await chrome.runtime.sendMessage(message)) as FormAssistResponse | undefined; + } catch (err) { + if (import.meta.env.DEV) { + console.warn('[MissionPulse FormAssistant] bridge error:', err); + } + } + + // L'utilisateur a peut-être changé de champ entre-temps. + if (phase !== 'requesting' || activeTarget !== target) { + return; + } + + if (!response) { + w.show(target, { kind: 'error', message: 'Service injoignable' }); + return; + } + + if (response.type === 'FORM_ASSIST_PROPOSAL') { + phase = 'ready'; + w.show(target, { kind: 'ready', text: response.payload.text }); + return; + } + + w.show(target, { + kind: 'error', + message: + response.payload.code === 'unavailable' ? 'IA locale indisponible' : 'Échec de génération', + }); +} + +function handleFocusIn(event: FocusEvent): void { + const target = event.target as HTMLElement | null; + if (!target || target === activeTarget) { + return; + } + + const descriptor = detectFieldDescriptor(target); + if (!descriptor) { + resetToIdle(); + return; + } + + activeTarget = target; + activeDescriptor = descriptor; + phase = 'armed'; + ensureWidget().show(target, { kind: 'armed' }); +} + +function handleKeyDown(event: KeyboardEvent): void { + if ( + event.key === 'Escape' && + (phase === 'armed' || phase === 'ready' || phase === 'requesting') + ) { + resetToIdle(); + } +} + +function arm(): void { + if (phase !== 'disabled') { + return; + } + phase = 'idle'; + document.addEventListener('focusin', handleFocusIn, true); + document.addEventListener('keydown', handleKeyDown, true); +} + +function disarm(): void { + document.removeEventListener('focusin', handleFocusIn, true); + document.removeEventListener('keydown', handleKeyDown, true); + resetToIdle(); + phase = 'disabled'; + widget?.destroy(); + widget = null; +} + +function applyEnabledState(enabled: boolean): void { + if (enabled) { + arm(); + } else { + disarm(); + } +} + +function init(): void { + if (booted) { + return; + } + booted = true; + + void chrome.runtime + .sendMessage({ type: 'FORM_ASSIST_STATUS' }) + .then((result: { enabled: boolean; engine: 'local' | 'remote' } | undefined) => { + applyEnabledState(Boolean(result?.enabled)); + }) + .catch(() => { + // SW injoignable (rare) → reste désactivé par sécurité. + applyEnabledState(false); + }); + + // Réagit aux changements de réglage venant du side panel. + chrome.runtime.onMessage.addListener((message: { type: string; payload?: unknown }) => { + if (message.type === 'FORM_ASSIST_ENABLED') { + const payload = message.payload as { enabled: boolean } | undefined; + applyEnabledState(Boolean(payload?.enabled)); + } + }); +} + +init(); diff --git a/apps/extension/src/content/form-assistant/widget.ts b/apps/extension/src/content/form-assistant/widget.ts new file mode 100644 index 00000000..6b6357e9 --- /dev/null +++ b/apps/extension/src/content/form-assistant/widget.ts @@ -0,0 +1,203 @@ +/** + * Content script — Form Assistant widget (Shadow DOM, closed). + * + * Floating UI anchored near the focused field. Isoled du CSS de la page hôte + * via un shadow root closed. Aucune logique métier : se contente d'afficher des + * états et d'émettre des callbacks (trigger / accept / dismiss). + * + * Les états reflètent la Machine A (src/models/form-assistant.model.md). + */ +export type WidgetViewState = + | { kind: 'armed' } + | { kind: 'requesting' } + | { kind: 'ready'; text: string } + | { kind: 'error'; message: string }; + +export interface WidgetCallbacks { + onTrigger: () => void; + onAccept: (text: string) => void; + onDismiss: () => void; +} + +const SHADOW_CSS = ` + :host { all: initial; } + .mp-root { + position: fixed; + z-index: 2147483646; + max-width: 340px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif; + font-size: 13px; + color: #0c0a09; + background: #ffffff; + border: 1px solid #f0efef; + border-radius: 10px; + box-shadow: 0 8px 28px rgba(12, 10, 9, 0.14), 0 2px 6px rgba(12, 10, 9, 0.06); + overflow: hidden; + } + .mp-root[hidden] { display: none; } + + .mp-trigger { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 12px; + background: transparent; + border: none; + cursor: pointer; + color: #0b64e9; + font-weight: 600; + font-size: 13px; + } + .mp-trigger:hover { background: #f5f5f4; } + .mp-trigger[disabled] { cursor: default; opacity: 0.7; } + .mp-dot { + width: 8px; height: 8px; border-radius: 50%; + background: #0b64e9; flex: 0 0 auto; + } + .mp-spinner { + width: 12px; height: 12px; border-radius: 50%; + border: 2px solid #d4d2d1; border-top-color: #0b64e9; + animation: mp-spin 0.7s linear infinite; flex: 0 0 auto; + } + @keyframes mp-spin { to { transform: rotate(360deg); } } + + .mp-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } + .mp-label { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; + color: #57534d; font-weight: 600; + } + .mp-text { + font-size: 13px; line-height: 1.45; color: #1c1917; + white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow: auto; + } + .mp-actions { display: flex; gap: 6px; justify-content: flex-end; } + .mp-btn { + border: 1px solid #d4d2d1; background: #ffffff; color: #1c1917; + padding: 6px 12px; border-radius: 7px; font-size: 12px; font-weight: 600; + cursor: pointer; + } + .mp-btn:hover { background: #f5f5f4; } + .mp-btn-primary { background: #0b64e9; border-color: #0b64e9; color: #ffffff; } + .mp-btn-primary:hover { background: #0a57c7; } + .mp-error { color: #f24149; font-size: 12px; } +`; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export class FormAssistWidget { + private readonly host: HTMLDivElement; + private readonly shadow: ShadowRoot; + private readonly root: HTMLDivElement; + private readonly callbacks: WidgetCallbacks; + + constructor(callbacks: WidgetCallbacks) { + this.callbacks = callbacks; + this.host = document.createElement('div'); + this.host.id = 'missionpulse-form-assist'; + this.host.style.all = 'initial'; + this.shadow = this.host.attachShadow({ mode: 'closed' }); + + const style = document.createElement('style'); + style.textContent = SHADOW_CSS; + this.shadow.appendChild(style); + + this.root = document.createElement('div'); + this.root.className = 'mp-root'; + this.root.setAttribute('hidden', ''); + this.shadow.appendChild(this.root); + + this.root.addEventListener('click', this.handleClick); + document.documentElement.appendChild(this.host); + } + + private handleClick = (event: Event): void => { + const target = event.target as HTMLElement; + const action = target.dataset.action; + if (action === 'trigger') { + this.callbacks.onTrigger(); + } else if (action === 'accept') { + const text = target.dataset.text ?? ''; + this.callbacks.onAccept(text); + } else if (action === 'dismiss') { + this.callbacks.onDismiss(); + } + }; + + /** Positionne le widget relativement au rect (viewport) de la cible. */ + private anchorTo(target: HTMLElement): void { + const rect = target.getBoundingClientRect(); + const margin = 8; + const top = rect.bottom + margin; + // Aligné à gauche du champ, replié si débordement à droite. + const preferredLeft = rect.left; + this.root.style.top = `${Math.round(top)}px`; + this.root.style.left = `${Math.round(Math.max(margin, preferredLeft))}px`; + // Si débordement vertical (champ en bas de page), on passe au-dessus. + const rootHeight = this.root.offsetHeight || 120; + if (top + rootHeight > window.innerHeight - margin) { + this.root.style.top = `${Math.round(Math.max(margin, rect.top - rootHeight - margin))}px`; + } + } + + show(target: HTMLElement, state: WidgetViewState): void { + this.root.removeAttribute('hidden'); + this.render(state); + this.anchorTo(target); + } + + hide(): void { + this.root.setAttribute('hidden', ''); + this.root.innerHTML = ''; + } + + private render(state: WidgetViewState): void { + switch (state.kind) { + case 'armed': + this.root.innerHTML = ` + `; + break; + case 'requesting': + this.root.innerHTML = ` + `; + break; + case 'ready': + this.root.innerHTML = ` +
+ Proposition MissionPulse +
${escapeHtml(state.text)}
+
+ + +
+
`; + break; + case 'error': + this.root.innerHTML = ` +
+ ${escapeHtml(state.message)} +
+ +
+
`; + break; + } + } + + destroy(): void { + this.root.removeEventListener('click', this.handleClick); + this.host.remove(); + } +} diff --git a/apps/extension/src/dev/chrome-stubs.ts b/apps/extension/src/dev/chrome-stubs.ts index cc2b0d84..903c179f 100644 --- a/apps/extension/src/dev/chrome-stubs.ts +++ b/apps/extension/src/dev/chrome-stubs.ts @@ -55,6 +55,7 @@ const DEV_FIRST_SCAN_DONE_KEY = '__missionpulse_dev_first_scan_done'; const DEV_COPILOT_JOBS_STORAGE_KEY = '__missionpulse_dev_copilot_jobs'; const DEV_COPILOT_DOSSIERS_STORAGE_KEY = '__missionpulse_dev_copilot_dossiers'; const DEV_COPILOT_DELETION_RECEIPTS_STORAGE_KEY = '__missionpulse_dev_copilot_deletion_receipts'; +const DEV_FORM_ASSIST_STORAGE_KEY = '__missionpulse_dev_form_assist'; type RuntimeMessage = { type: string; payload?: unknown }; type RuntimeMessageListener = ( @@ -482,6 +483,12 @@ const storage: Record = { deepLinkIntent: null as import('$lib/core/deep-link/deep-link-intent').DeepLinkIntent | null, feedSortBy: 'score', profile: readDevStorage(DEV_PROFILE_STORAGE_KEY, mockProfile), + // Form Assistant — default disabled (mirrors production default in + // lib/shell/form-assistant/settings.ts). Isolated chrome.storage.local key. + formAssist: readDevStorage<{ enabled: boolean; engine: 'local' | 'remote' }>( + DEV_FORM_ASSIST_STORAGE_KEY, + { enabled: false, engine: 'local' } + ), premium_enabled: readDevStorage(DEV_PREMIUM_ENABLED_STORAGE_KEY, true), premium_feature_enabled: readDevStorage(DEV_PREMIUM_FEATURE_STORAGE_KEY, false), first_scan_done: readDevStorage(DEV_FIRST_SCAN_DONE_KEY, true), @@ -522,6 +529,46 @@ function getDevConnectorHealthSnapshots(): ConnectorHealthSnapshot[] { ); } +/** + * Dev stub — canned Form Assistant proposal mirroring what Gemini Nano would + * produce. Lets the bridge contract + widget UX be exercised without the + * built-in AI (unavailable in a normal browser tab). + */ +function devFormAssistProposal(kind: string, label: string, profile: UserProfile): string { + const name = profile.firstName?.trim() || 'Candidat'; + const job = profile.jobTitle?.trim() || 'Freelance'; + switch (kind) { + case 'first-name': + return name; + case 'last-name': + // UserProfile ne persiste pas de nom de famille (local-first). + return ''; + case 'full-name': + return name; + case 'email': + return 'exemple@domaine.fr'; + case 'phone': + return '+33 6 12 34 56 78'; + case 'linkedin': + return 'https://www.linkedin.com/in/votre-profil'; + case 'cover-letter': + return `Bonjour,\n\nFort de ${profile.seniority ?? 'plusieurs'} années en tant que ${job}, je suis intéressé par cette mission. Mon TJM se situe entre ${profile.tjmMin ?? 0}€ et ${profile.tjmMax ?? 0}€.\n\nCordialement,\n${name}`; + case 'availability': + return 'Disponible immédiatement'; + case 'tjm': + return String(profile.tjmMin ?? 0); + case 'skill': + return profile.keywords?.slice(0, 5).join(', ') || ''; + case 'address': + return profile.location || ''; + case 'job-title': + return job; + case 'free-text': + default: + return label ? `Proposition pour « ${label} »` : ''; + } +} + function createChromeStubs() { let activeDevScan: { operationId: string; @@ -658,6 +705,42 @@ function createChromeStubs() { emitRuntimeMessage({ type: 'PROFILE_UPDATED', payload: message.payload }); return { type: 'PROFILE_RESULT', payload: message.payload }; + case 'FORM_ASSIST_STATUS': { + const formAssist = storage.formAssist as { + enabled: boolean; + engine: 'local' | 'remote'; + }; + return { + type: 'FORM_ASSIST_STATUS_RESULT', + payload: { enabled: formAssist.enabled, engine: formAssist.engine }, + }; + } + case 'FORM_ASSIST_ENABLE': { + const payload = message.payload as { enabled: boolean }; + const current = storage.formAssist as { enabled: boolean; engine: 'local' | 'remote' }; + const next = { ...current, enabled: payload.enabled }; + storage.formAssist = next; + writeDevStorage(DEV_FORM_ASSIST_STORAGE_KEY, next); + return { + type: 'FORM_ASSIST_ENABLED', + payload: { enabled: next.enabled, engine: next.engine }, + }; + } + case 'FORM_ASSIST_REQUEST': { + // Dev stub : Gemini Nano n'est pas disponible dans un onglet navigateur + // normal. On renvoie une proposition câblée pour valider le contrat + // bridge et l'UX du widget (lorsque le content script est injecté). + const payload = message.payload as { + requestId: string; + field: { kind: string; label: string }; + }; + const profile = (storage.profile as UserProfile | null) ?? mockProfile; + const canned = devFormAssistProposal(payload.field.kind, payload.field.label, profile); + return { + type: 'FORM_ASSIST_PROPOSAL', + payload: { requestId: payload.requestId, text: canned, engine: 'local' }, + }; + } case 'GET_PREMIUM_STATUS': return { type: 'PREMIUM_STATUS_RESULT', diff --git a/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts b/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts new file mode 100644 index 00000000..39c87c27 --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts @@ -0,0 +1,96 @@ +import type { FieldDescriptor, FieldKind } from './types'; +import type { UserProfile } from '../types/profile'; + +/** Longueur maximale d'une liste injectée dans le prompt (garde-fou). */ +const MAX_KEYWORDS = 16; + +function joinList(items: readonly string[], max: number): string { + const slice = items.slice(0, max).filter((item) => item.trim().length > 0); + return slice.join(', '); +} + +function profileLine(profile: UserProfile): string { + const parts: string[] = []; + if (profile.firstName) { + parts.push(`Prénom : ${profile.firstName}`); + } + if (profile.jobTitle) { + parts.push(`Titre : ${profile.jobTitle}`); + } + parts.push(`Séniorité : ${profile.seniority}`); + if (profile.location) { + parts.push(`Localisation : ${profile.location}`); + } + parts.push(`Remote : ${profile.remote}`); + parts.push(`TJM min : ${profile.tjmMin}€`); + parts.push(`TJM max : ${profile.tjmMax}€`); + const keywords = joinList(profile.keywords ?? [], MAX_KEYWORDS); + if (keywords) { + parts.push(`Compétences : ${keywords}`); + } + return parts.join('\n'); +} + +function instructionsForKind(kind: FieldKind): string { + switch (kind) { + case 'first-name': + return 'Réponds uniquement par le prénom du profil. Aucune phrase.'; + case 'last-name': + return 'Réponds uniquement par le nom de famille du profil. Aucune phrase.'; + case 'full-name': + return 'Réponds uniquement par le nom complet (prénom + nom) du profil. Aucune phrase.'; + case 'email': + case 'phone': + // Le profil local ne contient jamais d'email/téléphone (local-first, + // pas de credentials). On refuse poliment plutôt que d'inventer. + return 'Ce champ nécessite une coordonnée personnelle absente du profil. Réponds par une chaîne vide : ""'; + case 'linkedin': + return 'Réponds uniquement par une URL LinkedIn neutre si le profil en indique une, sinon une chaîne vide : ""'; + case 'availability': + return 'Réponds en une phrase courte sur ta disponibilité (ex : « Disponible immédiatement » ou « Disponible sous 2 semaines »).'; + case 'tjm': + return 'Réponds uniquement par un TJM en euros cohérent avec la fourchette du profil (ex : « 550 € /jour »).'; + case 'skill': + return 'Réponds par une liste de compétences séparées par des virgules, déduites du profil et du contexte du champ.'; + case 'address': + return 'Réponds uniquement par la localisation du profil. Aucune phrase.'; + case 'job-title': + return 'Réponds uniquement par le titre de poste du profil. Aucune phrase.'; + case 'cover-letter': + return 'Rédige un court paragraphe (3 à 5 phrases) de présentation/motivation professionnel-le, ton direct et factuel, sans formules convenues, basé sur le profil.'; + case 'free-text': + default: + return 'Réponds de façon concise et pertinente au champ, en restant strictement factuel par rapport au profil.'; + } +} + +function contextLine(field: FieldDescriptor): string { + const bits: string[] = [`Champ : ${field.label || '(sans libellé)'}`]; + if (field.placeholder) { + bits.push(`Placeholder : ${field.placeholder}`); + } + bits.push(`Requis : ${field.required ? 'oui' : 'non'}`); + return bits.join('\n'); +} + +/** + * Construit le prompt envoyé au moteur local (Gemini Nano). + * Pur, déterministe : mêmes entrées ⇒ même prompt. Aucune PII coordonnée + * (email/téléphone) n'est injectée (le profil n'en contient pas). + */ +export function buildFieldPrompt(field: FieldDescriptor, profile: UserProfile): string { + return [ + 'Tu es un assistant de remplissage de formulaire pour un freelance.', + 'Tu réponds en français, uniquement avec la valeur à saisir dans le champ.', + 'N’invente jamais de coordonnée (email, téléphone) absente du profil.', + 'Si tu ne peux pas répondre factuellement, renvoie une chaîne vide.', + '', + '=== Profil ===', + profileLine(profile), + '', + '=== Champ à remplir ===', + contextLine(field), + '', + instructionsForKind(field.kind), + ].join('\n'); +} diff --git a/apps/extension/src/lib/core/form-assistant/classify-field.ts b/apps/extension/src/lib/core/form-assistant/classify-field.ts new file mode 100644 index 00000000..f922e9c7 --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/classify-field.ts @@ -0,0 +1,155 @@ +import type { FieldDescriptor, FieldKind, RawFieldInput } from './types'; + +/** + * Normalise un texte pour la classification : minuscules, sans diacritiques, + * espaces collées. Déterministe (pur). + */ +function normalize(text: string): string { + return text + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); +} + +interface KindRule { + readonly kind: FieldKind; + readonly keywords: readonly string[]; +} + +/** + * Règles ordonnées (du plus spécifique au plus générique). + * L'ordre compte : "nom de famille" doit battre "nom". + */ +const KIND_RULES: readonly KindRule[] = [ + { kind: 'first-name', keywords: ['firstname', 'prenom', 'given name', 'givenname'] }, + { + kind: 'last-name', + keywords: ['lastname', 'last name', 'surname', 'nom de famille', 'famille'], + }, + { + kind: 'full-name', + keywords: ['fullname', 'full name', 'votre nom', 'name', 'nom complet', 'complete name'], + }, + { kind: 'email', keywords: ['email', 'e-mail', 'mail', 'courriel'] }, + { + kind: 'phone', + keywords: ['telephone', 'phone', 'mobile', 'portable', 'tel', 'cell'], + }, + { + kind: 'linkedin', + keywords: ['linkedin', 'viadeo', 'lien vers votre profil'], + }, + { + kind: 'availability', + keywords: [ + 'disponibilite', + 'disponibilites', + 'disponible', + 'dispo', + 'availability', + 'available', + ], + }, + { + kind: 'tjm', + keywords: [ + 'tjm', + 'tarif journalier', + 'tarif', + 'remuneration', + 'pretention', + 'pretentions salariales', + 'salaire', + 'rate', + 'daily rate', + ], + }, + { + kind: 'skill', + keywords: ['competence', 'skills', 'stack', 'technologie', 'technologies', 'expertise'], + }, + { + kind: 'address', + keywords: ['adresse', 'address', 'ville', 'city', 'localisation', 'code postal', 'zipcode'], + }, + { + kind: 'job-title', + keywords: [ + 'poste', + 'titre du poste', + 'job title', + 'fonction', + 'profession', + 'intitule du poste', + ], + }, + { + kind: 'cover-letter', + keywords: [ + 'lettre', + 'lettre de motivation', + 'cover letter', + 'motivation', + 'message', + 'presentation', + 'a propos', + 'a propos de vous', + 'about you', + 'about', + 'commentaire', + 'comment', + 'pourquoi', + 'why', + ], + }, +]; + +/** + * Détecte la catégorie d'un champ à partir de ses métadonnées. + * L'`inputType` est prioritaire pour email/tel (signal fort). + */ +function detectKind(raw: RawFieldInput): FieldKind { + // Signal fort : type d'input DOM. + if (raw.inputType === 'email') { + return 'email'; + } + if (raw.inputType === 'tel') { + return 'phone'; + } + if (raw.inputType === 'url') { + const text = normalize(`${raw.label} ${raw.placeholder}`); + if (text.includes('linkedin')) { + return 'linkedin'; + } + } + + const text = normalize(`${raw.label} ${raw.placeholder}`); + if (text === '') { + return 'free-text'; + } + + for (const rule of KIND_RULES) { + for (const keyword of rule.keywords) { + if (text.includes(keyword)) { + return rule.kind; + } + } + } + return 'free-text'; +} + +/** + * Classifie un champ brut en FieldDescriptor canonical. + * Pur, déterministe, sans I/O. + */ +export function classifyField(raw: RawFieldInput): FieldDescriptor { + return { + kind: detectKind(raw), + label: raw.label, + placeholder: raw.placeholder, + inputType: raw.inputType, + required: raw.required, + }; +} diff --git a/apps/extension/src/lib/core/form-assistant/index.ts b/apps/extension/src/lib/core/form-assistant/index.ts new file mode 100644 index 00000000..860857f3 --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/index.ts @@ -0,0 +1,7 @@ +export * from './types'; +export { classifyField } from './classify-field'; +export { sanitizeFieldDescriptor } from './sanitize-field-descriptor'; +export { selectFormAssistEngine } from './select-engine'; +export { buildFieldPrompt } from './build-field-prompt'; +export { parseFieldProposal } from './parse-field-proposal'; +export { redactForRemote } from './redact-for-remote'; diff --git a/apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts b/apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts new file mode 100644 index 00000000..b70d062e --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts @@ -0,0 +1,37 @@ +import type { FieldProposal } from './types'; + +const MAX_PROPOSAL_LEN = 4000; + +/** + * Retire les fences markdown (```…) qu'un LLM peut ajouter malgré la consigne. + * Pur. + */ +function stripCodeFences(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed.startsWith('```')) { + return trimmed; + } + const firstNewline = trimmed.indexOf('\n'); + const body = firstNewline === -1 ? '' : trimmed.slice(firstNewline + 1); + return body.replace(/\s*```$/, '').trim(); +} + +/** + * Transforme la sortie brute d'un LLM en FieldProposal canonical, ou `null` + * si vide/invalide. + * + * Pur, déterministe, sans I/O. + */ +export function parseFieldProposal(raw: string): FieldProposal | null { + if (typeof raw !== 'string') { + return null; + } + const text = stripCodeFences(raw).trim(); + if (text.length === 0) { + return null; + } + if (text.length > MAX_PROPOSAL_LEN) { + return { text: text.slice(0, MAX_PROPOSAL_LEN) }; + } + return { text }; +} diff --git a/apps/extension/src/lib/core/form-assistant/redact-for-remote.ts b/apps/extension/src/lib/core/form-assistant/redact-for-remote.ts new file mode 100644 index 00000000..1f882788 --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/redact-for-remote.ts @@ -0,0 +1,47 @@ +import type { FieldDescriptor, RemoteFieldRequest } from './types'; +import type { UserProfile } from '../types/profile'; + +/** Garde-fou : nombre max de compétences projetées vers Eve. */ +const MAX_KEYWORDS = 16; + +/** + * Projette le profil vers un objet ne contenant QUE les champs professionnels + * allowlistés (jamais d'email/téléphone : le profil n'en contient pas, et + * `experiences.description` est exclu). Utilisé pour construire la requête + * envoyée à Eve (Phase 2). + * + * Pur, déterministe, sans I/O. + */ +export function redactForRemote(field: FieldDescriptor, profile: UserProfile): RemoteFieldRequest { + const safe: Record = {}; + + if (profile.firstName) { + safe.firstName = profile.firstName; + } + if (profile.jobTitle) { + safe.jobTitle = profile.jobTitle; + } + safe.seniority = profile.seniority; + if (profile.location) { + safe.location = profile.location; + } + safe.remote = profile.remote; + if (typeof profile.tjmMin === 'number') { + safe.tjmMin = String(profile.tjmMin); + } + if (typeof profile.tjmMax === 'number') { + safe.tjmMax = String(profile.tjmMax); + } + if (profile.keywords.length > 0) { + safe.keywords = profile.keywords.slice(0, MAX_KEYWORDS); + } + + return { + kind: field.kind, + label: field.label, + placeholder: field.placeholder, + inputType: field.inputType, + required: field.required, + profile: safe, + }; +} diff --git a/apps/extension/src/lib/core/form-assistant/sanitize-field-descriptor.ts b/apps/extension/src/lib/core/form-assistant/sanitize-field-descriptor.ts new file mode 100644 index 00000000..3d33137c --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/sanitize-field-descriptor.ts @@ -0,0 +1,44 @@ +import type { FieldDescriptor, RawFieldInput } from './types'; +import { classifyField } from './classify-field'; + +const MAX_LABEL_LEN = 120; +const MAX_PLACEHOLDER_LEN = 200; + +// Motifs retirés du texte envoyé à l'IA : URLs, emails, numéros de téléphone. +// On évite ainsi de faire transiter de la PII captée accidentellement dans un +// label ou un placeholder. +const URL_RE = /\bhttps?:\/\/\S+/gi; +const EMAIL_RE = /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/gi; +const PHONE_RE = /(?:\+?\d[\d\s().-]{7,}\d)/g; + +/** + * Nettoie un texte de métadonnée : retire PII accidentelle, normalise les + * espaces, plafonne la longueur. Pur. + */ +function sanitizeText(text: string, maxLen: number): string { + return text + .replace(URL_RE, '') + .replace(EMAIL_RE, '') + .replace(PHONE_RE, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLen); +} + +/** + * Sanitise les métadonnées brutes d'un champ puis le classifie. + * Le FieldDescriptor retourné est la seule forme autorisée à franchir le + * bridge vers le service worker / Eve. + * + * Pur, déterministe. + */ +export function sanitizeFieldDescriptor(raw: RawFieldInput): FieldDescriptor { + const label = sanitizeText(raw.label, MAX_LABEL_LEN); + const placeholder = sanitizeText(raw.placeholder, MAX_PLACEHOLDER_LEN); + return classifyField({ + label, + placeholder, + inputType: raw.inputType, + required: raw.required, + }); +} diff --git a/apps/extension/src/lib/core/form-assistant/select-engine.ts b/apps/extension/src/lib/core/form-assistant/select-engine.ts new file mode 100644 index 00000000..748fddd5 --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/select-engine.ts @@ -0,0 +1,39 @@ +import type { + AiAvailability, + ConsentState, + EnginePreference, + EngineSelection, + EntitlementState, +} from './types'; + +/** + * Table de vérité du sélecteur de moteur (Machine B, transition + * `entitlement → generating`). + * + * - `remote` n'est choisi QUE si : préférence remote + entitlement actif + + * consentement accordé. Aucun de ces trois ne peut être implicite. + * - `local` (Gemini Nano) exige `availability === 'available'`. + * `'after-download'` ⇒ local non prêt. + * - Sinon `none` (aucune proposition possible). + * + * Invariant (LLM ne décide pas) : cette fonction est pure et ne consulte + * aucune sortie d'IA. La décision est une table de vérité déterministe. + * + * Pur, déterministe, sans I/O. + */ +export function selectFormAssistEngine( + preference: EnginePreference, + availability: AiAvailability, + entitlement: EntitlementState, + consent: ConsentState +): EngineSelection { + const remoteUsable = entitlement === 'active' && consent === 'granted'; + + if (preference === 'remote' && remoteUsable) { + return { engine: 'remote' }; + } + if (availability === 'available') { + return { engine: 'local' }; + } + return { engine: 'none', reason: 'unavailable' }; +} diff --git a/apps/extension/src/lib/core/form-assistant/types.ts b/apps/extension/src/lib/core/form-assistant/types.ts new file mode 100644 index 00000000..b7a947c5 --- /dev/null +++ b/apps/extension/src/lib/core/form-assistant/types.ts @@ -0,0 +1,96 @@ +/** + * Types purs du Form Assistant (remplissage de champs type Grammarly). + * + * Règles (Core) : + * - Aucune I/O, aucun `Date`, aucun random. Tout ce qui est non-déterministe + * est injecté par le Shell. + * - FieldDescriptor ne contient JAMAIS d'identifiants DOM (id/name), la valeur + * d'autres champs, l'URL courante ou du HTML brut. Seules des métadonnées + * décrivant le champ sont conservées. + * + * Source de vérité : `src/models/form-assistant.model.md`. + */ + +/** Catégorie sémantique d'un champ de formulaire. */ +export type FieldKind = + | 'first-name' + | 'last-name' + | 'full-name' + | 'email' + | 'phone' + | 'linkedin' + | 'cover-letter' + | 'availability' + | 'tjm' + | 'skill' + | 'address' + | 'job-title' + | 'free-text'; + +/** Type d'entrée DOM dont on a la maîtrise (pas de valeurs arbitraires). */ +export type FieldInputType = + 'text' | 'textarea' | 'email' | 'tel' | 'url' | 'search' | 'contenteditable'; + +/** + * Métadonnées brutes extraites du DOM par le content script, avant + * sanitisation. Le Shell (content script) produit ces valeurs ; le Core + * les valide/sanitise/classifie. + */ +export interface RawFieldInput { + readonly label: string; + readonly placeholder: string; + readonly inputType: FieldInputType; + readonly required: boolean; +} + +/** + * Descripteur de champ canonical : métadonnées sanit-isées + catégorie. + * C'est l'unique représentation d'un champ qui franchit le bridge. + */ +export interface FieldDescriptor { + readonly kind: FieldKind; + readonly label: string; + readonly placeholder: string; + readonly inputType: FieldInputType; + readonly required: boolean; +} + +/** Proposition de valeur pour un champ. */ +export interface FieldProposal { + readonly text: string; +} + +/** Préférence utilisateur pour le moteur de génération. */ +export type EnginePreference = 'local' | 'remote'; + +/** Disponibilité du moteur local (Gemini Nano). */ +export type AiAvailability = 'available' | 'after-download' | 'no'; + +/** Droit d'accès au moteur distant (Eve). Piloté par le serveur. */ +export type EntitlementState = 'active' | 'inactive'; + +/** Consentement session pour l'appel à Eve (aucun consentement ⇒ pas d'appel). */ +export type ConsentState = 'unknown' | 'granted' | 'denied'; + +/** + * Décision du sélecteur de moteur (Machine B). Soit un moteur effectif, + * soit `none` quand aucun chemin n'est disponible (Gemini Nano absent/non + * téléchargé ET Eve non autorisé). + */ +export type EngineSelection = + | { readonly engine: 'local' } + | { readonly engine: 'remote' } + | { readonly engine: 'none'; readonly reason: 'unavailable' }; + +/** + * Requête envoyée à Eve (Phase 2). Le profil est une projection allowlistée : + * seuls les champs professionnels non-PII transitent (jamais d'email/téléphone). + */ +export interface RemoteFieldRequest { + readonly kind: FieldKind; + readonly label: string; + readonly placeholder: string; + readonly inputType: FieldInputType; + readonly required: boolean; + readonly profile: Readonly>; +} diff --git a/apps/extension/src/lib/shell/connectors/meta.ts b/apps/extension/src/lib/shell/connectors/meta.ts index 6ac29b91..30aa79e9 100644 --- a/apps/extension/src/lib/shell/connectors/meta.ts +++ b/apps/extension/src/lib/shell/connectors/meta.ts @@ -13,6 +13,12 @@ export interface ConnectorMeta { icon: string; url: string; hostPermissions: readonly string[]; + /** + * Active le Form Assistant (remplissage de champs type Grammarly) sur ce + * connecteur. Le content script est injecté uniquement sur les connecteurs + * `formAssist: true`. Source de vérité : src/models/form-assistant.model.md. + */ + formAssist?: boolean; } /** @@ -26,6 +32,8 @@ const CATALOG: readonly ConnectorMeta[] = [ icon: 'https://www.google.com/s2/favicons?domain=free-work.com&sz=32', url: 'https://www.free-work.com', hostPermissions: ['https://www.free-work.com/*'], + // Pilote Form Assistant Phase 1 (Gemini Nano local uniquement). + formAssist: true, }, { id: 'lehibou', diff --git a/apps/extension/src/lib/shell/form-assistant/local-generator.ts b/apps/extension/src/lib/shell/form-assistant/local-generator.ts new file mode 100644 index 00000000..eb3ef614 --- /dev/null +++ b/apps/extension/src/lib/shell/form-assistant/local-generator.ts @@ -0,0 +1,72 @@ +/** + * Local field-proposal generator — Gemini Nano (Chrome built-in AI). + * + * Shell module : I/O (AI API), async. Délègue la construction du prompt et le + * parsing au Core. Aucune décision d'état : produit une proposition ou null. + * + * Pattern identique à mission-generator.ts (timeout + retry + destroy). + */ +import type { FieldDescriptor, FieldProposal } from '../../core/form-assistant/types'; +import { buildFieldPrompt, parseFieldProposal } from '../../core/form-assistant'; +import type { UserProfile } from '../../core/types/profile'; +import { createPromptSession, isPromptApiAvailable } from '../ai/capabilities'; +import type { AILanguageModelSession } from '../ai/chrome-ai'; + +const TIMEOUT_MS = 8000; +const RETRY_DELAYS_MS = [500, 1000] as const; +const MAX_RETRIES = RETRY_DELAYS_MS.length; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Génère une proposition de valeur pour un champ via Gemini Nano. + * Retourne `null` si l'API est indisponible/non téléchargée, ou si la sortie + * n'est pas exploitable. + */ +export async function generateFieldProposal( + field: FieldDescriptor, + profile: UserProfile +): Promise { + const availability = await isPromptApiAvailable(); + if (availability !== 'available') { + return null; + } + + const prompt = buildFieldPrompt(field, profile); + let rawContent: string | null = null; + let session: AILanguageModelSession | null = null; + + try { + session = await createPromptSession(); + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await Promise.race([ + session.prompt(prompt), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS) + ), + ]); + rawContent = response; + break; + } catch (error) { + if (import.meta.env.DEV) { + console.warn( + `[FormAssistant] Attempt ${attempt + 1}/${MAX_RETRIES + 1} failed:`, + error instanceof Error ? error.message : error + ); + } + if (attempt < MAX_RETRIES) { + await sleep(RETRY_DELAYS_MS[attempt]); + } + } + } + } finally { + session?.destroy(); + } + + if (!rawContent) { + return null; + } + return parseFieldProposal(rawContent); +} diff --git a/apps/extension/src/lib/shell/form-assistant/settings.ts b/apps/extension/src/lib/shell/form-assistant/settings.ts new file mode 100644 index 00000000..df80964a --- /dev/null +++ b/apps/extension/src/lib/shell/form-assistant/settings.ts @@ -0,0 +1,41 @@ +/** + * Form Assistant settings — isolated persistence. + * + * Volontairement séparé du contract AppSettings (qui touche la migration + * settings-release et 7 fichiers) : le Form Assistant est une feature Phase 1 + * autonome, son réglage vit sous sa propre clé chrome.storage.local. + * + * Shell module : I/O (chrome.storage). Aucune logique métier. + * Source de vérité : src/models/form-assistant.model.md. + */ +import { z } from 'zod'; + +const STORAGE_KEY = 'formAssist'; + +const FormAssistSettingsSchema = z.object({ + enabled: z.boolean().default(false), + engine: z.enum(['local', 'remote']).default('local'), +}); + +export type FormAssistSettings = z.infer; + +export const DEFAULT_FORM_ASSIST_SETTINGS: FormAssistSettings = { + enabled: false, + engine: 'local', +}; + +export async function getFormAssistSettings(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEY); + const parsed = FormAssistSettingsSchema.safeParse(result[STORAGE_KEY]); + if (!parsed.success) { + return { ...DEFAULT_FORM_ASSIST_SETTINGS }; + } + return parsed.data; +} + +export async function setFormAssistEnabled(enabled: boolean): Promise { + const current = await getFormAssistSettings(); + const next: FormAssistSettings = { ...current, enabled }; + await chrome.storage.local.set({ [STORAGE_KEY]: next }); + return next; +} diff --git a/apps/extension/src/lib/shell/messaging/bridge.ts b/apps/extension/src/lib/shell/messaging/bridge.ts index f34e099b..e70df156 100644 --- a/apps/extension/src/lib/shell/messaging/bridge.ts +++ b/apps/extension/src/lib/shell/messaging/bridge.ts @@ -35,6 +35,7 @@ import type { CopilotJobResultPayload, CopilotLinkResultPayload, } from '../copilot/contracts'; +import type { FieldDescriptor } from '../../core/form-assistant/types'; /** * Progression d'un connecteur individuel pendant le scan @@ -330,7 +331,31 @@ export type BridgeMessage = // to re-consume a freshly-written deep-link intent. sidePanel.open() is a // no-op when the panel is already open, so without this the mount effect // would not re-fire and the intent would stay pending. - | { type: 'NOTIFICATION_CLICKED' }; + | { type: 'NOTIFICATION_CLICKED' } + // Form Assistant (Grammarly-like field fill, content script ↔ service worker). + // Source de vérité : src/models/form-assistant.model.md. + // Content → SW : la feature est-elle active pour cette origine ? + | { type: 'FORM_ASSIST_STATUS' } + | { + type: 'FORM_ASSIST_STATUS_RESULT'; + payload: { enabled: boolean; engine: 'local' | 'remote' }; + } + // Side panel → SW : activer/désactiver la feature (persisté). + | { type: 'FORM_ASSIST_ENABLE'; payload: { enabled: boolean } } + | { type: 'FORM_ASSIST_ENABLED'; payload: { enabled: boolean; engine: 'local' | 'remote' } } + // Content → SW : demander une proposition pour un champ (Machine B). + // Le field est un FieldDescriptor canonical (sanit-isé, sans PII DOM). + | { type: 'FORM_ASSIST_REQUEST'; payload: { requestId: string; field: FieldDescriptor } } + // SW → Content : proposition prête (ACCEPT explicite requis pour appliquer). + | { + type: 'FORM_ASSIST_PROPOSAL'; + payload: { requestId: string; text: string; engine: 'local' | 'remote' }; + } + // SW → Content : échec (moteur indisponible ou génération en erreur). + | { + type: 'FORM_ASSIST_ERROR'; + payload: { requestId: string; code: 'unavailable' | 'failed'; message: string }; + }; function devLog(direction: '→' | '←', type: string, payload?: unknown): void { if (import.meta.env.DEV) { diff --git a/apps/extension/src/lib/shell/messaging/schemas.ts b/apps/extension/src/lib/shell/messaging/schemas.ts index fff05e69..06a0997d 100644 --- a/apps/extension/src/lib/shell/messaging/schemas.ts +++ b/apps/extension/src/lib/shell/messaging/schemas.ts @@ -76,6 +76,42 @@ function maxBytes(maxB: number) { // Schémas par type de message // ============================================================================ +// ── Form Assistant (shared sub-schema) ─────────────────────────────────────── + +const FieldKindSchema = z.enum([ + 'first-name', + 'last-name', + 'full-name', + 'email', + 'phone', + 'linkedin', + 'cover-letter', + 'availability', + 'tjm', + 'skill', + 'address', + 'job-title', + 'free-text', +]); +const FieldInputTypeSchema = z.enum([ + 'text', + 'textarea', + 'email', + 'tel', + 'url', + 'search', + 'contenteditable', +]); +const FieldDescriptorSchema = z + .object({ + kind: FieldKindSchema, + label: z.string().max(120), + placeholder: z.string().max(200), + inputType: FieldInputTypeSchema, + required: z.boolean(), + }) + .strict(); + // ── Missions ───────────────────────────────────────────────────────────────── const MissionSchema = z @@ -1370,6 +1406,51 @@ export const MessageSchemas = { // SW → live panel broadcast: re-consume a pending deep-link intent after a // notification click on an already-open panel. No payload needed. NOTIFICATION_CLICKED: z.object({ type: z.literal('NOTIFICATION_CLICKED') }), + + // ── Form Assistant (content script ↔ service worker) ─────────────────────── + // Source de vérité : src/models/form-assistant.model.md. + FORM_ASSIST_STATUS: z.object({ type: z.literal('FORM_ASSIST_STATUS') }), + FORM_ASSIST_STATUS_RESULT: z.object({ + type: z.literal('FORM_ASSIST_STATUS_RESULT'), + payload: z.object({ + enabled: z.boolean(), + engine: z.enum(['local', 'remote']), + }), + }), + FORM_ASSIST_ENABLE: z.object({ + type: z.literal('FORM_ASSIST_ENABLE'), + payload: z.object({ enabled: z.boolean() }), + }), + FORM_ASSIST_ENABLED: z.object({ + type: z.literal('FORM_ASSIST_ENABLED'), + payload: z.object({ + enabled: z.boolean(), + engine: z.enum(['local', 'remote']), + }), + }), + FORM_ASSIST_REQUEST: z.object({ + type: z.literal('FORM_ASSIST_REQUEST'), + payload: z.object({ + requestId: z.string().min(1).max(128), + field: FieldDescriptorSchema, + }), + }), + FORM_ASSIST_PROPOSAL: z.object({ + type: z.literal('FORM_ASSIST_PROPOSAL'), + payload: z.object({ + requestId: z.string().min(1).max(128), + text: z.string().min(1).max(4000), + engine: z.enum(['local', 'remote']), + }), + }), + FORM_ASSIST_ERROR: z.object({ + type: z.literal('FORM_ASSIST_ERROR'), + payload: z.object({ + requestId: z.string().min(1).max(128), + code: z.enum(['unavailable', 'failed']), + message: z.string().min(1).max(280), + }), + }), } as const; export type MessageType = keyof typeof MessageSchemas; diff --git a/apps/extension/src/models/form-assistant.model.md b/apps/extension/src/models/form-assistant.model.md new file mode 100644 index 00000000..8c63c7c0 --- /dev/null +++ b/apps/extension/src/models/form-assistant.model.md @@ -0,0 +1,279 @@ +# Form Assistant (type Grammarly) — Modèle d'états (source of truth) + +Assistant de remplissage de formulaires de candidature sur les **plateformes +connecteurs**. Au focus sur un champ de saisie, l'extension propose une valeur +issue du profil utilisateur, générée par **Gemini Nano local (gratuit)** par +défaut, ou par **Eve Copilot distant (payant, Vercel — `copilot.missionpulse.app`)** +si l'entitlement serveur et le consentement le permettent. + +Règle courte : _« Le LLM produit des signaux ; le modèle décide. »_ Aucune +sortie IA n'est jamais injectée sans un **ACCEPT** explicite de l'utilisateur. +Le remplissage est une **proposition**, jamais une transition automatique. + +## Pourquoi un modèle + +Le feature introduit un **nouveau contexte d'exécution** (content script) sur +des pages tierces, lit le DOM de formulaire, et peut appeler une IA distante. +Cela touche à la vie privée et au modèle de consentement strict d'Eve. Tout +comportement non modélisé ici est hors périmètre jusqu'à modification de ce +fichier. + +## Autorités + +- Le **service worker** est l'**unique frontière réseau**. Le content script + n'appelle jamais `fetch`, Eve, ou `chrome.cookies`. Il ne fait que détecter + les champs, afficher le widget, et dialoguer avec le SW via le bridge typé. +- Le **profil utilisateur** reste dans le SW (IndexedDB / canonique). Il + n'est **jamais** transmis au content script. +- **Eve** réutilise l'authentification existante : bearer en + `chrome.storage.session`, origine cookieless `copilot.missionpulse.app`, + `credentials: 'omit'`, rollout `VITE_COPILOT_ROLLOUT_ENABLED`. **Aucun** + booléen Premium local ne donne accès à Eve ; seule l'entitlement serveur + fraîche y autorise. +- Le **flag premium dormant par défaut** ne clôt **pas** Eve (l'entitlement + décide). Gemini Nano local reste **toujours gratuit**. +- Le **LLM ne décide aucune transition**. Il produit un texte proposé ; le + modèle décide du routage moteur, du consentement, et de l'application. + +## Portée (décisions produit) + +1. **Sites** : plateformes connecteurs **connues** uniquement (Free-Work, + LeHibou, Malt, Cherry-Pick, Hiway, Collective.work). Pas de permission + large ; on réutilise les `host_permissions` existants + `scripting`. +2. **Données** : profil local + **métadonnées du champ ciblé** + (label/placeholder/type/`required`) uniquement. **Zéro** contenu de page + (pas de valeurs d'autres champs, pas d'URL source, pas de HTML, pas de + cookies) envoyé à l'IA. +3. **Moteurs** : Gemini Nano local (gratuit) par défaut ; Eve distant (payant) + si entitlement serveur **+** consentement explicite session. + +## Contextes + +- **Content script** (`src/content/form-assistant/` — nouveau) — monde isolé, + widget rendu en **Shadow DOM** (zéro fuite de style, comme Grammarly). + Possède : détection conservatrice des champs, rendu du widget, envoi des + requêtes au SW, application des propositions acceptées. +- **Service worker** (`src/background/`) — routage moteur, entitlement, + consentement Eve, génération locale Gemini, appel distant Eve, retour d'une + proposition. +- **Side panel** (`src/ui/`) — réglages (activation, préférence moteur, + révocation du consentement), prompt de consentement Eve. +- **Bridge** (`src/lib/shell/messaging/bridge.ts`) — messages typés ci-dessous. +- **Core** (`src/lib/core/form-assistant/` — nouveau, **pur**) — classification + de champ, assainissement, sélection du moteur, prompt, parsing, rédaction + pour l' distant. + +## Décisions pures (core, sans I/O) + +- `classifyField(rawLabel, placeholder, inputType, required): FieldDescriptor` + — mappe le champ vers un `FieldKind` (`first-name`, `last-name`, `email`, + `phone`, `linkedin`, `cover-letter`, `availability`, `tjm`, `skill`, + `address`, `free-text`…). Table de vérité testable sans mocks. +- `sanitizeFieldDescriptor(raw): FieldDescriptor` — plafonne les longueurs, + supprime URL/email/téléphone parasites du label, rédige tout identifiant. +- `selectFormAssistEngine(prefs, entitlement, availability, consent)` → + `'local' | 'remote' | 'none'`. Pure, déterministe. + + | `availability` | `entitlement` | `consent` | `prefs.engine` | sortie | + | -------------- | ------------- | --------- | -------------- | ------------------ | + | `no` | — | — | — | `none` | + | `ok` | — | — | `local` | `local` | + | `ok` | `active` | `granted` | `remote` | `remote` | + | `ok` | autre | — | `remote` | `local` (fallback) | + | `ok` | `active` | autre | `remote` | `local` (fallback) | + +- `buildFieldPrompt(descriptor, profile): string` — prompt Gemini local, pur. +- `parseFieldProposal(raw): { text: string } | null` — parsing défensif. +- `redactForRemote(descriptor, profile): RemoteFieldRequest` — ne projette que + les champs allowlistés du profil + le `FieldDescriptor` assaini. Réutilise + le principe `COPILOT_PROFILE_FIELD_ALLOWLIST`. + +### `FieldDescriptor` (type core) + +```ts +interface FieldDescriptor { + kind: FieldKind; + label: string; // assaini, ≤ 120 chars + placeholder: string; // assaini, ≤ 120 chars + inputType: 'text' | 'textarea' | 'email' | 'tel' | 'url' | 'search' | 'contenteditable'; + required: boolean; +} +// JAMAIS : id/name du champ, valeurs d'autres champs, URL, HTML voisin. +``` + +## Machine A — Widget (content script, par champ focalisé) + +```text +idle ──FOCUS(field)─────────► armed +armed ──BLUR────────────────► idle +armed ──REQUEST_FILL─────────► requesting +armed ──DISABLE──────────────► disabled +requesting ──PROPOSAL────────► ready +requesting ──ERROR(code)──────► armed (toast typé) +requesting ──CANCEL──────────► armed +ready ──ACCEPT───────────────► applying +ready ──REJECT────────────────► armed +ready ──EDIT(user)────────────► ready +applying ──APPLIED────────────► filled +applying ──ERROR──────────────► ready (toast) +filled ──CLEAR(user)──────────► armed +disabled ──ENABLE─────────────► idle +``` + +- `applying` écrit via le **setter natif** + événement `input` natif (sécurité + frameworks contrôlés React/Vue/Svelte). **Jamais** de soumission auto. +- `ACCEPT` est la **seule** transition vers `applying`. Le LLM ne l déclenche pas. + +## Machine B — Requête de génération (service worker, par `requestId`) + +```text +received ─engine=local──► generating-local +received ─engine=remote─► consent +received ─engine=none───► failed(unavailable) + +consent ─GRANTED──► entitlement +consent ─DENIED───► fallback-local (dégradation explicite vers local) + +entitlement ─ENTITLED────► generating-remote +entitlement ─NOT_ENTITLED─► fallback-local + +generating-local ─OK──► done(proposal) +generating-local ─ERR─► failed(local-error) +generating-remote ─OK──► done(proposal) +generating-remote ─ERR─► failed(remote-error) +fallback-local ─OK──► done(proposal) +fallback-local ─ERR─► failed(local-error) + +done ─► terminal (proposition renvoyée au content script) +failed ─► terminal (erreur typée renvoyée) +``` + +- `fallback-local` est **déterministe** et tracé ; il n'appelle jamais Eve en + silence. Si Gemini Nano est indisponible (`availability === 'no'`), on va + directement à `failed(unavailable)`. + +## Machine C — Consentement Eve (session-scoped) + +```text +unknown ─PROMPT──► prompting +prompting ─GRANT─► granted +prompting ─DENY──► denied +granted ─REVOKE──► denied +denied ─PROMPT───► prompting +``` + +- `granted` est **session-scoped** (`chrome.storage.session`), effacé au + redémarrage du navigateur. Révocable depuis le side panel à tout moment. +- Première demande distante en `consent` → le SW émet + `FORM_ASSIST_CONSENT_REQUIRED` vers le side panel (geste utilisateur requis + pour `chrome.permissions`/UI). La requête en cours attend ou bascule en + `fallback-local` selon préférence. + +## Messages bridge (nouveaux) + +- `FORM_ASSIST_ENABLE` (panel → SW) — `{ enabled, enginePref, perSite? }`. +- `FORM_ASSIST_STATUS` (SW → panel/content) — `{ armed, engineAvailability }`. +- `FORM_ASSIST_REQUEST` (content → SW) — `{ requestId, field: FieldDescriptor }` + (le SW relit le profil canonique lui-même ; **pas de profil côté contenu**). +- `FORM_ASSIST_PROPOSAL` (SW → content) — `{ requestId, proposal: { text, engine } }`. +- `FORM_ASSIST_ERROR` (SW → content) — `{ requestId, code, message }`. +- `FORM_ASSIST_CONSENT_REQUIRED` (SW → panel) — `{ requestId }`. +- `FORM_ASSIST_CONSENT_RESPONSE` (panel → SW) — `{ requestId, granted }`. +- `FORM_ASSIST_APPLIED` (content → SW) — télémétrie **locale** only + (`{ requestId, engine, kind }`, aucun contenu). Optionnel. + +## Eve — adaptation + +Réutilise l'**auth existante** (bearer `chrome.storage.session`, origine +cookieless, `credentials: 'omit'`). **N'utilise pas** la machine de job/dossier +`copilotDossierMachine` (trop lourde : checkpoint, idempotence, crédits Eve +0.26.2). À la place, un **endpoint léger dédié** +`POST https://copilot.missionpulse.app/api/copilot/field-fill` : + +- entrée : `RemoteFieldRequest` (FieldDescriptor assaini + champs profil + allowlistés) + `requestId`. +- sortie validée Zod `.strict()` : `{ text: string }`. +- même garde de rollout `VITE_COPILOT_ROLLOUT_ENABLED` + entitlement fraîche. +- pas de checkpoint durable (la requête est synchrone et révocable ; aucun + artefact persisté côté serveur au-delà du traitement éphémère). + +Le domaine custom doit être déployé sur le backend Vercel avant ouverture du +rollout (même exigence que le Copilot dossier). + +## Build / manifest + +- Les `matches` du `content_scripts` sont **dérivés** des connecteurs **inclus** + qui déclarent `formAssist: true` dans le catalogue (`meta.ts`). Un connecteur + exclu au build (cf. `connector-build-config.model.md`) exclut aussi + l'assistant — cohérence de moindre privilège. +- Aucune nouvelle permission large : on réutilise `host_permissions` + + `scripting` + `storage`. +- En **dev**, le content script se charge mais les `chrome.*` sont stubés + comme ailleurs ; le widget est vérifiable sur le side panel de dev via un + fixture DOM. + +## Invariants + +1. **Le LLM produit des signaux ; le modèle décide.** `ACCEPT` est l'unique + transition vers `applying`. Pas de soumission auto, pas de remplissage auto. +2. **Le service worker est l'unique frontière réseau.** Le content script ne + fait aucun `fetch`/Eve/`chrome.cookies`. +3. **Zéro fuite de contenu de page vers Eve.** Seuls `FieldDescriptor` assaini + - champs profil allowlistés quittent l'appareil. +4. **Eve** : origine cookieless, `credentials: 'omit'`, bearer en session, + rollout + entitlement + consentement. **Jamais** clôturé par un booléen + Premium local. +5. **Le profil ne quitte jamais le SW** vers le content script. +6. **Sélection du moteur pure**, jamais décidée par un LLM. +7. **Consentement** explicite, session-scoped, révocable. Un refus **dégrade** + vers local (Eve n'est jamais appelé en silence). +8. **Premium dormant par défaut** ne clôt pas Eve ; l'entitlement décide. + Gemini Nano local est toujours gratuit. +9. **Content script** : Shadow DOM, monde isolé, détection conservatrice, + jamais bloquant, jamais de soumission auto. +10. **Moindre privilège** : `matches` dérivés des connecteurs inclus opt-in ; + aucune permission large ajoutée. +11. **Aucun identifiant stocké** (credentials, cookies, tokens, sessions) dans + les commits ; aucun telemetry distant (compteurs locaux only). + +## Cas nominaux / erreurs / annulation / permissions / terminaux + +- **Nominal local** : FOCUS → REQUEST → `engine=local` → done → ACCEPT → APPLIED. +- **Nominal distant** : FOCUS → REQUEST → `consent` → GRANT → entitlement OK → + done → ACCEPT → APPLIED. +- **Erreur local** : `generating-local ERR` → `failed(local-error)` → toast ; + widget retourne à `armed`. +- **Erreur distant** : `generating-remote ERR` → `failed(remote-error)` → toast + typé ; pas de retry automatique aveugle. +- **Annulation** : `requesting CANCEL` ou fermeture widget → `armed` ; la + requête SW en cours est annulée via `AbortSignal` (cohérent avec + `semantic-scorer`). +- **Permissions** : portée limitée aux connecteurs ; pas de demande de + permission large. Eve n'exige pas de permission Chrome nouvelle (cookieless). +- **Terminaux** : `done`, `failed`, `idle`, `disabled`. Aucun job Eve persistant + ouvert par ce flux. + +## Vérification (Verify) + +- **Core pur** (sans mocks) : tables de vérité `classifyField`, + `selectFormAssistEngine`, `sanitizeFieldDescriptor` (réduction URL/email, + plafond longueur), `parseFieldProposal`, `redactForRemote` (aucune clé hors + allowlist ne sort). +- **Machine B** : transitions autorisées/interdites (interdit `generating-remote` + sans `consent=granted` **et** `entitlement=entitled` ; interdit d'appliquer + sans `ACCEPT`). +- **Machine C** : refus de consentement → `fallback-local` ou `failed` ; Eve + jamais appelé ; révocable. +- **Content script** : détection sur fixtures HTML ; widget Shadow DOM rendu ; + ACCEPT applique via événement natif ; REJECT/EDIT n'appliquent pas. +- **Bridge** : schémas Zod `.strict()` sur `FORM_ASSIST_REQUEST` et réponse ; + le profil n'apparaît jamais dans un message content→SW. + +## Hors périmètre (v1, différé) + +- Soumission auto / automatisation multi-étapes. +- Lecture du contexte de page au-delà du champ focalisé. +- Uploads de fichiers / parsing CV côté page. +- Sites hors connecteurs (pourrait être activé plus tard via + `optional_host_permissions`, comme l'import LinkedIn). +- Streaming Eve (une réponse complète par requête en v1). diff --git a/apps/extension/src/models/form-assistant.review.md b/apps/extension/src/models/form-assistant.review.md new file mode 100644 index 00000000..024f49ad --- /dev/null +++ b/apps/extension/src/models/form-assistant.review.md @@ -0,0 +1,77 @@ +# Form Assistant — Review du modèle + +Revue de `apps/extension/src/models/form-assistant.model.md` selon la boucle +Model → **Review** → Implement → Verify. Critères : couverture des cas +nominaux, erreurs, annulations, retries, permissions, états terminaux ; aucune +transition implicite ou pilotée par du texte libre ; aucun LLM ne décide une +transition. + +## Matrice de couverture + +| Axe | Couvert ? | Détail | +| --------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Nominal local | ✅ | FOCUS → REQUEST → `engine=local` → done → ACCEPT → APPLIED (Machine A + B). | +| Nominal distant | ✅ | FOCUS → REQUEST → consent → GRANT → entitlement OK → done → ACCEPT → APPLIED. | +| Erreur locale | ✅ | `generating-local ERR` → `failed(local-error)` → toast typé ; widget → `armed`. Pas de retry aveugle (cohérent `semantic-scorer` : retries bornés dans l'adapter, pas machine). | +| Erreur distante | ✅ | `generating-remote ERR` → `failed(remote-error)` ; pas de retry auto ; pas de job persistant ouvert. | +| Annulation | ✅ | `requesting CANCEL` / fermeture widget / blur → `armed` ; requête SW via `AbortSignal` annulable. | +| Retry | ✅ | Borné dans l'adapter local (cf. `mission-generator.ts`), pas dans la machine. Machine reste déterministe. | +| Permissions | ✅ | Portée = connecteurs inclus opt-in ; aucune permission large. Eve cookieless, `credentials:'omit'`, pas de `chrome.permissions` nouvelle. | +| Consentement | ✅ | Machine C : `unknown/prompting/granted/denied`, session-scoped, révocable. Refus → `fallback-local` (Eve jamais appelé en silence). | +| Terminaux | ✅ | `done`, `failed` (B) ; `idle`, `filled`, `disabled` (A). Aucun job Eve ouvert. | + +## Vérification « le LLM ne décide pas » + +- ❌ Aucune transition Machine A/B/C n'est émise depuis une sortie LLM. Le LLM + ne produit qu'un **texte proposé** (`parseFieldProposal`). +- ✅ `ACCEPT` (utilisateur) est l'unique transition vers `applying`. +- ✅ `selectFormAssistEngine` est **pure** (table de vérité), pas LLM. +- ✅ `classifyField`/`sanitizeFieldDescriptor` sont **pures**. +- Conclusion : conforme à _« Le LLM produit des signaux ; le modèle décide. »_ + +## Vérification Core/Shell + +- ✎ Core (`core/form-assistant/*`) : zéro I/O, zéro async, zéro `Date.now()` / + `Math.random()` / `console`. Tout déterministe, injectable, testable sans + mocks. ⚠ À respecter à l'implémentation (le prompt builder ne doit pas + appeler `Date.now()`). +- ✅ Shell (`content/`, `background/`, `shell/copilot/`, `shell/messaging/`) : + toute l'I/O, async, retries, `chrome.*`. Le content script ne contourne pas + le SW pour atteindre Eve. +- ✅ Règle d'import respectée : core n'importe jamais shell. + +## Risques résiduels & mitigations + +1. **Disponibilité Gemini Nano** : si `availability === 'no'` et `engine=local` + → `failed(unavailable)` ; le widget affiche un message clair (pas d'Eve + silencieux). Mitigation : toast + lien réglages. +2. **Endpoint field-fill non déployé** : rollout fermé par défaut + (`VITE_COPILOT_ROLLOUT_ENABLED`). Tant que non déployé, `entitlement` ne + peut être `active` pour ce flux → `fallback-local` ou `failed`. Le chemin + local est livrable **indépendamment et en premier**. +3. **Injection framework-controlled** : `applying` doit utiliser le setter natif + - événement `input` (React/Vue/Svelte). À valider par test E2E sur au moins + un connecteur. +4. **Shadow DOM vs styles hérités** : le widget est en Shadow DOM fermé ; + couleurs via variables CSS propres (pas de `tailwind.config.js`). +5. **Consentement session vs persistence** : `granted` est volontairement + non persistant au-delà de la session (privacy). Documenté dans le modèle. + +## Recommandation + +Modèle **approuvé pour implémentation par étapes** : + +1. **Phase 1 (local, autonome)** : core pur + tests ; content script widget ; + Machine A ; Machine B chemin local uniquement ; bridge messages ; + `content_scripts.matches` dérivés du catalogue opt-in. Aucun backend requis. +2. **Phase 2 (Eve, après backend)** : Machine C consentement ; chemin + `generating-remote` + `fallback-local` ; endpoint field-fill. + +Phase 1 est livrable et utile immédiatement (Gemini Nano gratuit). Phase 2 +dépend du déploiement Vercel. + +## À confirmer avant implémentation + +- Connecteurs opt-in pour la v1 (tous ? un sous-ensemble pilote ex: Free-Work ?). +- Libellés UX FR du widget et des toasts. +- Préférence moteur par défaut (`local`). diff --git a/apps/extension/tests/unit/form-assistant/classify-field.test.ts b/apps/extension/tests/unit/form-assistant/classify-field.test.ts new file mode 100644 index 00000000..a68f6d30 --- /dev/null +++ b/apps/extension/tests/unit/form-assistant/classify-field.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { classifyField } from '../../../src/lib/core/form-assistant/classify-field'; +import type { RawFieldInput } from '../../../src/lib/core/form-assistant/types'; + +function raw( + label: string, + placeholder = '', + inputType: RawFieldInput['inputType'] = 'text' +): RawFieldInput { + return { label, placeholder, inputType, required: false }; +} + +describe('classifyField — signal fort inputType', () => { + it('classifie email via inputType', () => { + expect(classifyField(raw('Contact', '', 'email')).kind).toBe('email'); + }); + it('classifie phone via inputType tel', () => { + expect(classifyField(raw('Contact', '', 'tel')).kind).toBe('phone'); + }); + it('classifie linkedin pour inputType url + mot-clé', () => { + expect(classifyField(raw('LinkedIn', '', 'url')).kind).toBe('linkedin'); + }); + it('laisse url générique en free-text si pas linkedin', () => { + expect(classifyField(raw('Site web', '', 'url')).kind).toBe('free-text'); + }); +}); + +describe('classifyField — insensible aux accents/casse', () => { + it('prénom avec accent → first-name', () => { + expect(classifyField(raw('Prénom')).kind).toBe('first-name'); + }); + it('PRENOM majuscule sans accent → first-name', () => { + expect(classifyField(raw('PRENOM')).kind).toBe('first-name'); + }); + it('téléphone → phone', () => { + expect(classifyField(raw('Téléphone')).kind).toBe('phone'); + }); + it('disponibilité → availability', () => { + expect(classifyField(raw('Disponibilité')).kind).toBe('availability'); + }); + it('compétences → skill', () => { + expect(classifyField(raw('Compétences')).kind).toBe('skill'); + }); +}); + +describe('classifyField — spécificité (ordre des règles)', () => { + it('"nom de famille" → last-name (pas full-name)', () => { + expect(classifyField(raw('Nom de famille')).kind).toBe('last-name'); + }); + it('"votre nom" → full-name', () => { + expect(classifyField(raw('Votre nom')).kind).toBe('full-name'); + }); + it('"lettre de motivation" → cover-letter', () => { + expect(classifyField(raw('Lettre de motivation')).kind).toBe('cover-letter'); + }); +}); + +describe('classifyField — placeholder et TJM', () => { + it('utilise le placeholder si label vide', () => { + expect(classifyField(raw('', 'Votre TJM souhaité')).kind).toBe('tjm'); + }); + it('tarif journalier → tjm', () => { + expect(classifyField(raw('Tarif journalier')).kind).toBe('tjm'); + }); +}); + +describe('classifyField — cas vides/génériques', () => { + it('label et placeholder vides → free-text', () => { + expect(classifyField(raw('', '')).kind).toBe('free-text'); + }); + it('libellé inconnu → free-text', () => { + expect(classifyField(raw('Quelque chose')).kind).toBe('free-text'); + }); +}); + +describe('classifyField — conserve les métadonnées', () => { + it('reporte label/placeholder/inputType/required', () => { + const d = classifyField({ + label: 'Email pro', + placeholder: 'vous@ex.fr', + inputType: 'email', + required: true, + }); + expect(d).toMatchObject({ + label: 'Email pro', + placeholder: 'vous@ex.fr', + inputType: 'email', + required: true, + kind: 'email', + }); + }); +}); diff --git a/apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts b/apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts new file mode 100644 index 00000000..86a39a71 --- /dev/null +++ b/apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { parseFieldProposal } from '../../../src/lib/core/form-assistant/parse-field-proposal'; + +describe('parseFieldProposal', () => { + it('retourne le texte trimé', () => { + expect(parseFieldProposal(' Disponible immédiatement ')).toEqual({ + text: 'Disponible immédiatement', + }); + }); + it('retire les fences markdown', () => { + const raw = '```\n550 € /jour\n```'; + expect(parseFieldProposal(raw)).toEqual({ text: '550 € /jour' }); + }); + it('retourne null pour une chaîne vide', () => { + expect(parseFieldProposal('')).toBeNull(); + }); + it('retourne null pour des fences vides', () => { + expect(parseFieldProposal('```\n```')).toBeNull(); + }); + it('retourne null pour du whitespace seul', () => { + expect(parseFieldProposal(' \n ')).toBeNull(); + }); + it('tronque les sorties trop longues', () => { + const huge = 'x'.repeat(5000); + const out = parseFieldProposal(huge); + expect(out?.text.length).toBe(4000); + }); + it('préserve le contenu multi-lignes (lettre de motivation)', () => { + const raw = 'Bonjour,\n\nJe suis développeur.\nCordialement.'; + expect(parseFieldProposal(raw)?.text).toBe(raw); + }); +}); diff --git a/apps/extension/tests/unit/form-assistant/redact-for-remote.test.ts b/apps/extension/tests/unit/form-assistant/redact-for-remote.test.ts new file mode 100644 index 00000000..7a216dd6 --- /dev/null +++ b/apps/extension/tests/unit/form-assistant/redact-for-remote.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { redactForRemote } from '../../../src/lib/core/form-assistant/redact-for-remote'; +import type { FieldDescriptor } from '../../../src/lib/core/form-assistant/types'; +import type { UserProfile } from '../../../src/lib/core/types/profile'; + +function makeProfile(over: Partial = {}): UserProfile { + return { + firstName: 'Ada', + keywords: ['TypeScript', 'React'], + tjmMin: 500, + tjmMax: 700, + location: 'Paris', + remote: 'hybrid', + seniority: 'senior', + jobTitle: 'Tech Lead', + experiences: [], + availability: null, + ...over, + }; +} + +const field: FieldDescriptor = { + kind: 'cover-letter', + label: 'Lettre de motivation', + placeholder: '', + inputType: 'textarea', + required: true, +}; + +describe('redactForRemote — allowlist', () => { + it('projette les champs professionnels allowlistés', () => { + const out = redactForRemote(field, makeProfile()); + expect(out.profile).toMatchObject({ + firstName: 'Ada', + jobTitle: 'Tech Lead', + seniority: 'senior', + location: 'Paris', + remote: 'hybrid', + tjmMin: '500', + tjmMax: '700', + keywords: ['TypeScript', 'React'], + }); + }); + + it('ne fait JAMAIS transiter d’email/téléphone (absents du type)', () => { + const out = redactForRemote(field, makeProfile()); + expect(out.profile).not.toHaveProperty('email'); + expect(out.profile).not.toHaveProperty('phone'); + // experiences.description est exclu : pas de clé experiences. + expect(out.profile).not.toHaveProperty('experiences'); + }); + + it('omet les champs vides', () => { + const out = redactForRemote(field, makeProfile({ firstName: '', location: '', keywords: [] })); + expect(out.profile).not.toHaveProperty('firstName'); + expect(out.profile).not.toHaveProperty('location'); + expect(out.profile).not.toHaveProperty('keywords'); + }); + + it('plafonne le nombre de keywords', () => { + const many = Array.from({ length: 50 }, (_, i) => `skill-${i}`); + const out = redactForRemote(field, makeProfile({ keywords: many })); + expect((out.profile.keywords as string[]).length).toBe(16); + }); + + it('reporte les métadonnées du champ', () => { + const out = redactForRemote(field, makeProfile()); + expect(out.kind).toBe('cover-letter'); + expect(out.label).toBe('Lettre de motivation'); + expect(out.required).toBe(true); + expect(out.inputType).toBe('textarea'); + }); +}); diff --git a/apps/extension/tests/unit/form-assistant/sanitize-field-descriptor.test.ts b/apps/extension/tests/unit/form-assistant/sanitize-field-descriptor.test.ts new file mode 100644 index 00000000..74533b3f --- /dev/null +++ b/apps/extension/tests/unit/form-assistant/sanitize-field-descriptor.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeFieldDescriptor } from '../../../src/lib/core/form-assistant/sanitize-field-descriptor'; +import type { RawFieldInput } from '../../../src/lib/core/form-assistant/types'; + +function raw(over: Partial): RawFieldInput { + return { + label: '', + placeholder: '', + inputType: 'text', + required: false, + ...over, + }; +} + +describe('sanitizeFieldDescriptor — retire la PII accidentelle', () => { + it('retire les URLs', () => { + const d = sanitizeFieldDescriptor(raw({ label: 'Voir https://evil.example.com/x Prénom' })); + expect(d.label).toBe('Voir Prénom'); + expect(d.kind).toBe('first-name'); + }); + it('retire les emails et reclassifie', () => { + const d = sanitizeFieldDescriptor(raw({ label: 'Votre nom john@doe.com' })); + expect(d.label).not.toContain('john@doe.com'); + expect(d.kind).toBe('full-name'); + }); + it('retire les numéros de téléphone', () => { + const d = sanitizeFieldDescriptor(raw({ label: 'Tel +33 6 12 34 56 78 info' })); + expect(d.label).not.toContain('+33'); + }); +}); + +describe('sanitizeFieldDescriptor — normalise et plafonne', () => { + it('colle les espaces multiples', () => { + const d = sanitizeFieldDescriptor(raw({ label: 'Prénom du candidat' })); + expect(d.label).toBe('Prénom du candidat'); + }); + it('plafonne la longueur du label', () => { + const long = 'Prénom ' + 'x'.repeat(200); + const d = sanitizeFieldDescriptor(raw({ label: long })); + expect(d.label.length).toBeLessThanOrEqual(120); + }); + it('plafonne la longueur du placeholder', () => { + const long = 'x'.repeat(300); + const d = sanitizeFieldDescriptor(raw({ placeholder: long })); + expect(d.placeholder.length).toBeLessThanOrEqual(200); + }); +}); + +describe('sanitizeFieldDescriptor — reclassifie après nettoyage', () => { + it('classifie tjm après retrait PII', () => { + const d = sanitizeFieldDescriptor(raw({ label: 'TJM https://x.com/y' })); + expect(d.kind).toBe('tjm'); + expect(d.label).not.toContain('http'); + }); +}); + +describe('sanitizeFieldDescriptor — immutabilité / pureté', () => { + it('ne modifie pas l’entrée', () => { + const input = raw({ label: 'Prénom john@doe.com', placeholder: 'p' }); + const snapshot = { ...input }; + sanitizeFieldDescriptor(input); + expect(input).toEqual(snapshot); + }); +}); diff --git a/apps/extension/tests/unit/form-assistant/select-engine.test.ts b/apps/extension/tests/unit/form-assistant/select-engine.test.ts new file mode 100644 index 00000000..1d99de41 --- /dev/null +++ b/apps/extension/tests/unit/form-assistant/select-engine.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { selectFormAssistEngine } from '../../../src/lib/core/form-assistant/select-engine'; + +describe('selectFormAssistEngine — table de vérité', () => { + describe('chemin local', () => { + it('préf local + available → local', () => { + expect(selectFormAssistEngine('local', 'available', 'inactive', 'unknown')).toEqual({ + engine: 'local', + }); + }); + it('préf local + after-download → none (local non prêt)', () => { + expect(selectFormAssistEngine('local', 'after-download', 'active', 'granted')).toEqual({ + engine: 'none', + reason: 'unavailable', + }); + }); + it('préf local + no → none', () => { + expect(selectFormAssistEngine('local', 'no', 'inactive', 'unknown')).toEqual({ + engine: 'none', + reason: 'unavailable', + }); + }); + }); + + describe('chemin remote (Eve)', () => { + it('préf remote + entitlement active + consent granted → remote', () => { + expect(selectFormAssistEngine('remote', 'no', 'active', 'granted')).toEqual({ + engine: 'remote', + }); + }); + it('préf remote mais consent denied → fallback local si available', () => { + expect(selectFormAssistEngine('remote', 'available', 'active', 'denied')).toEqual({ + engine: 'local', + }); + }); + it('préf remote mais entitlement inactif → fallback local si available', () => { + expect(selectFormAssistEngine('remote', 'available', 'inactive', 'granted')).toEqual({ + engine: 'local', + }); + }); + it('préf remote, rien de réuni, local absent → none', () => { + expect(selectFormAssistEngine('remote', 'no', 'inactive', 'denied')).toEqual({ + engine: 'none', + reason: 'unavailable', + }); + }); + }); + + describe('invariant LLM-ne-décide-pas', () => { + it('retourne toujours une décision finie (jamais d’appel IA implicite)', () => { + const matrix = (['local', 'remote'] as const).flatMap((pref) => + (['available', 'after-download', 'no'] as const).flatMap((avail) => + (['active', 'inactive'] as const).flatMap((ent) => + (['unknown', 'granted', 'denied'] as const).map( + (cons) => [pref, avail, ent, cons] as const + ) + ) + ) + ); + for (const [pref, avail, ent, cons] of matrix) { + const out = selectFormAssistEngine(pref, avail, ent, cons); + // La décision est une union finie, jamais une promesse ou un appel. + expect(['local', 'remote', 'none']).toContain(out.engine); + } + }); + }); +}); diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts index 52a7f198..90f11f30 100644 --- a/apps/extension/vite.config.ts +++ b/apps/extension/vite.config.ts @@ -58,6 +58,51 @@ function filterHostPermissions( }); } +/** + * Build the Form Assistant content_scripts entries. + * + * The content script is injected ONLY on connectors flagged `formAssist: true` + * (and included). Matches reuse the connector `hostPermissions` so the Form + * Assistant host surface stays in sync with least-privilege filtering above — + * there is no second hand-maintained match list. + * + * Returns an empty array when no Form-Assist connector ships, so the manifest + * stays minimal. Source de vérité : src/models/form-assistant.model.md. + */ +function buildFormAssistContentScripts(includedIds: readonly string[]): Array<{ + matches: string[]; + js: string[]; + run_at: 'document_start' | 'document_end' | 'document_idle'; +}> { + if (includedIds.length === 0) { + return []; + } + const catalog = getAllConnectorsMeta(); + const includedSet = new Set(includedIds); + const matches = new Set(); + for (const connector of catalog) { + if (!connector.formAssist) { + continue; + } + if (!includedSet.has(connector.id)) { + continue; + } + for (const pattern of connector.hostPermissions) { + matches.add(pattern); + } + } + if (matches.size === 0) { + return []; + } + return [ + { + matches: [...matches], + js: ['src/content/form-assistant/index.ts'], + run_at: 'document_idle', + }, + ]; +} + export default defineConfig(({ command }) => { // Only the build reads connectors.config.json. Dev and test always ship // the full catalog so vitest assertions and `pnpm dev` stay deterministic. @@ -85,6 +130,7 @@ export default defineConfig(({ command }) => { host_permissions: isBuild ? filterHostPermissions(manifest.host_permissions ?? [], INCLUDED_CONNECTOR_IDS) : manifest.host_permissions, + content_scripts: buildFormAssistContentScripts(INCLUDED_CONNECTOR_IDS), }; return { diff --git a/openspec/changes/form-assistant/proposal.md b/openspec/changes/form-assistant/proposal.md new file mode 100644 index 00000000..51faf8aa --- /dev/null +++ b/openspec/changes/form-assistant/proposal.md @@ -0,0 +1,100 @@ +# form-assistant + +## Why + +Sur les plateformes de missions, l'utilisateur doit remplir à la main des +formulaires de candidature (prénom, email, lien LinkedIn, message de motivation, +disponibilité, TJM…). On veut un assistant **type Grammarly** : au focus sur un +champ, l'extension propose une valeur issue du profil, générée par **Gemini Nano +local (gratuit)** par défaut, ou par **Eve Copilot distant (payant, Vercel)** si +l'entitlement serveur et le consentement le permettent. Le remplissage est une +**proposition** que l'utilisateur accepte explicitement — jamais une transition +automatique pilotée par le LLM. + +## What changes + +- **New core module** `core/form-assistant/` (**pur**, sans I/O) : + `classify-field.ts` (`classifyField`), `sanitize-field-descriptor.ts` + (`sanitizeFieldDescriptor`), `select-engine.ts` + (`selectFormAssistEngine` — table de vérité), `build-field-prompt.ts`, + `parse-field-proposal.ts`, `redact-for-remote.ts`. Types dans + `core/form-assistant/types.ts` (`FieldDescriptor`, `FieldKind`, + `RemoteFieldRequest`). +- **New content script** `src/content/form-assistant/` (monde isolé, widget + **Shadow DOM**) : détection conservatrice des champs focusables, rendu du + widget, envoi des requêtes au SW, application via setter natif + événement + `input` natif. Machine A (widget par champ). +- **Service worker** : handler bridge `FORM_ASSIST_*` ; orchestration Machine B + (requête par `requestId`) ; réutilise `capabilities.ts` (Gemini Nano) et + `shell/copilot/` (auth/entitlement/transport) pour un **endpoint léger dédié** + `POST https://copilot.missionpulse.app/api/copilot/field-fill`. Consentement + Machine C session-scoped dans `chrome.storage.session`. +- **Bridge messages** (+ schémas Zod `.strict()`) : `FORM_ASSIST_ENABLE`, + `FORM_ASSIST_STATUS`, `FORM_ASSIST_REQUEST`, `FORM_ASSIST_PROPOSAL`, + `FORM_ASSIST_ERROR`, `FORM_ASSIST_CONSENT_REQUIRED`, + `FORM_ASSIST_CONSENT_RESPONSE`, `FORM_ASSIST_APPLIED`. +- **Side panel** : page/section réglages (activation, préférence moteur, + révocation consentement) + prompt de consentement Eve. State module + `src/lib/state/form-assistant.svelte.ts`. +- **Catalogue connecteurs** `shell/connectors/meta.ts` : ajouter + `formAssist?: boolean` sur les connecteurs opt-in. +- **Manifest** : `content_scripts.matches` **dérivés** des connecteurs inclus + opt-in (build-time, via `vite.config.ts`, cohérent avec + `connector-build-config.model.md`). Aucune nouvelle permission large. +- **Dev stubs** `dev/chrome-stubs.ts` : widget vérifiable sur fixture DOM. +- **Tests** : core pur sans mocks ; machine B transitions autorisées/interdites ; + machine C pas d'appel Eve sans consentement ; redaction ; fixtures DOM. + +## Non-goals + +- Pas de soumission auto / automatisation multi-étapes. +- Pas de lecture du contexte de page au-delà du champ focalisé (pas de JD, pas + d'autres champs, pas d'URL source). +- Pas d'uploads / parsing CV côté page. +- Pas de sites hors connecteurs (différé ; pourra utiliser + `optional_host_permissions` comme l'import LinkedIn). +- Pas de streaming Eve en v1. +- Pas de coupling avec `copilotDossierMachine` (job/checkpoint/crédits) — flux + synchrone, éphémère, révocable, sans artefact serveur persistant. +- Pas de télémétrie distante (compteurs locaux uniquement). + +## Model + +Authoritative : `apps/extension/src/models/form-assistant.model.md`. + +Trois machines : + +- **A — Widget** (content script, par champ) : + `idle · armed · requesting · ready · applying · filled · disabled`. +- **B — Requête de génération** (SW, par `requestId`) : + `received · consent · entitlement · generating-local · generating-remote · +fallback-local · done · failed`. +- **C — Consentement Eve** (session-scoped) : + `unknown · prompting · granted · denied`. + +Invariants clés : (1) `ACCEPT` est l'unique transition vers `applying` ; le LLM +produit un signal, le modèle décide. (2) Le SW est l'unique frontière réseau ; le +content script ne fetch/cookies jamais. (3) Zéro fuite de contenu de page vers +Eve (seuls `FieldDescriptor` assaini + champs profil allowlistés sortent). (4) +Sélection du moteur **pure**. (5) Consentement explicite/révocable ; un refus +dégrade vers local, jamais d'appel Eve silencieux. (6) Premium dormant ne clôt +pas Eve ; l'entitlement décide ; Gemini Nano toujours gratuit. + +## Impact + +- **Build** : `content_scripts` injectés selon connecteurs inclus opt-in ; + `verify-manifest` étendu pour couvrir les matches. +- **Privacy/permissions** : aucune permission large ajoutée ; réutilise + `host_permissions` + `scripting` + `storage`. Eve cookieless, + `credentials: 'omit'`. +- **Backend Vercel** : doit exposer + `POST https://copilot.missionpulse.app/api/copilot/field-fill` avant ouverture + du rollout (même garde que le Copilot dossier). +- **Tests** : extension de la couverture core (portée par le gate 70/70/60/70 sur + `src/lib/core/**`). + +## Open questions + +- Format exact de l'endpoint field-fill côté serveur (Zod entrée/sortie) — à + verrouiller avec le backend Vercel avant implémentation distante. Le chemin + local (Gemini Nano) peut être livré indépendamment et en premier. From aafc73e8c7dfe6169e7165b995adea9f85196ea7 Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Wed, 29 Jul 2026 16:58:27 +0200 Subject: [PATCH 2/3] fix(form-assistant): address Copilot code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content/index.ts: fix FORM_ASSIST_STATUS response read — the SW returns a bridge message { type, payload }, so read result.payload.enabled (was result.enabled, leaving the assistant stuck disabled). - content/index.ts: fix stale model path reference in doc comment (formassistant -> form-assistant). - core/build-field-prompt.ts: drop literal '""' from email/phone/linkedin instructions to avoid the LLM returning the quotes verbatim; ask for an empty string with no characters instead. - dev/chrome-stubs.ts: return empty strings for email/phone/linkedin to mirror production (profile carries no such fields; avoids fake PII in dev UX validation). - models/form-assistant.model.md: align placeholder cap doc with the real implementation (<=200, not <=120; label stays <=120). - content/widget.ts: fix typo 'Isoled' -> 'Isolé'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/extension/src/content/form-assistant/index.ts | 12 ++++++++---- apps/extension/src/content/form-assistant/widget.ts | 2 +- apps/extension/src/dev/chrome-stubs.ts | 8 +++++--- .../lib/core/form-assistant/build-field-prompt.ts | 4 ++-- apps/extension/src/models/form-assistant.model.md | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/extension/src/content/form-assistant/index.ts b/apps/extension/src/content/form-assistant/index.ts index 2b7126d2..52e45067 100644 --- a/apps/extension/src/content/form-assistant/index.ts +++ b/apps/extension/src/content/form-assistant/index.ts @@ -1,7 +1,7 @@ /** * Content script — Form Assistant orchestrator (Machine A). * - * Source de vérité : src/models/formassistant.model.md (Machine A). + * Source de vérité : src/models/form-assistant.model.md (Machine A). * * États : disabled → idle → armed → requesting → ready → applying → filled * @@ -217,9 +217,13 @@ function init(): void { void chrome.runtime .sendMessage({ type: 'FORM_ASSIST_STATUS' }) - .then((result: { enabled: boolean; engine: 'local' | 'remote' } | undefined) => { - applyEnabledState(Boolean(result?.enabled)); - }) + .then( + ( + result: { type: 'FORM_ASSIST_STATUS_RESULT'; payload: { enabled: boolean } } | undefined + ) => { + applyEnabledState(Boolean(result?.payload.enabled)); + } + ) .catch(() => { // SW injoignable (rare) → reste désactivé par sécurité. applyEnabledState(false); diff --git a/apps/extension/src/content/form-assistant/widget.ts b/apps/extension/src/content/form-assistant/widget.ts index 6b6357e9..a23713b0 100644 --- a/apps/extension/src/content/form-assistant/widget.ts +++ b/apps/extension/src/content/form-assistant/widget.ts @@ -1,7 +1,7 @@ /** * Content script — Form Assistant widget (Shadow DOM, closed). * - * Floating UI anchored near the focused field. Isoled du CSS de la page hôte + * Floating UI anchored near the focused field. Isolé du CSS de la page hôte * via un shadow root closed. Aucune logique métier : se contente d'afficher des * états et d'émettre des callbacks (trigger / accept / dismiss). * diff --git a/apps/extension/src/dev/chrome-stubs.ts b/apps/extension/src/dev/chrome-stubs.ts index 903c179f..087c9acc 100644 --- a/apps/extension/src/dev/chrome-stubs.ts +++ b/apps/extension/src/dev/chrome-stubs.ts @@ -546,11 +546,13 @@ function devFormAssistProposal(kind: string, label: string, profile: UserProfile case 'full-name': return name; case 'email': - return 'exemple@domaine.fr'; case 'phone': - return '+33 6 12 34 56 78'; + // UserProfile ne persiste pas ces coordonnées (local-first, + // pas de credentials). Cohérent avec le prompt production. + return ''; case 'linkedin': - return 'https://www.linkedin.com/in/votre-profil'; + // Aucune URL LinkedIn dans le profil de ce modèle. + return ''; case 'cover-letter': return `Bonjour,\n\nFort de ${profile.seniority ?? 'plusieurs'} années en tant que ${job}, je suis intéressé par cette mission. Mon TJM se situe entre ${profile.tjmMin ?? 0}€ et ${profile.tjmMax ?? 0}€.\n\nCordialement,\n${name}`; case 'availability': diff --git a/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts b/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts index 39c87c27..cf00823b 100644 --- a/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts +++ b/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts @@ -43,9 +43,9 @@ function instructionsForKind(kind: FieldKind): string { case 'phone': // Le profil local ne contient jamais d'email/téléphone (local-first, // pas de credentials). On refuse poliment plutôt que d'inventer. - return 'Ce champ nécessite une coordonnée personnelle absente du profil. Réponds par une chaîne vide : ""'; + return 'Ce champ nécessite une coordonnée personnelle absente du profil. Réponds par une chaîne vide, sans aucun caractère ni guillemet.'; case 'linkedin': - return 'Réponds uniquement par une URL LinkedIn neutre si le profil en indique une, sinon une chaîne vide : ""'; + return 'Réponds uniquement par une URL LinkedIn neutre si le profil en indique une, sinon une chaîne vide, sans aucun caractère ni guillemet.'; case 'availability': return 'Réponds en une phrase courte sur ta disponibilité (ex : « Disponible immédiatement » ou « Disponible sous 2 semaines »).'; case 'tjm': diff --git a/apps/extension/src/models/form-assistant.model.md b/apps/extension/src/models/form-assistant.model.md index 8c63c7c0..91ac1758 100644 --- a/apps/extension/src/models/form-assistant.model.md +++ b/apps/extension/src/models/form-assistant.model.md @@ -94,7 +94,7 @@ fichier. interface FieldDescriptor { kind: FieldKind; label: string; // assaini, ≤ 120 chars - placeholder: string; // assaini, ≤ 120 chars + placeholder: string; // assaini, ≤ 200 chars inputType: 'text' | 'textarea' | 'email' | 'tel' | 'url' | 'search' | 'contenteditable'; required: boolean; } From c67b846768350d518d89df2a24ce078eb31e9417 Mon Sep 17 00:00:00 2001 From: Guy MANDINA Date: Wed, 29 Jul 2026 17:19:20 +0200 Subject: [PATCH 3/3] fix(form-assistant): address second round of code review feedback Widget & DOM: - Click delegation via closest('[data-action]') (fixes nested span clicks) - Right-edge viewport clamp in anchorTo (prevents overflow) - Skip widget-internal focus events (isHostElement guard) - Document mousedown listener for outside-click dismissal (shadow DOM safe) - Multi-ID aria-labelledby resolution in field-detector - Exclude readOnly/disabled/aria-readonly/aria-disabled fields Orchestration (Machine A): - applyValue returns boolean; failure shows error view instead of hiding - Track activeRequestId; cancel in-flight SW request on field switch/dismiss - Stale-response guard also checks activeRequestId match Cancellation (model-mandated via AbortSignal): - FORM_ASSIST_CANCEL message + FORM_ASSIST_CANCEL_ACK (Zod strict schemas) - SW AbortController map; abort -> 'cancelled' error code - local-generator uses promptWithCancellation/throwIfAborted/abortableDelay (mirrors semantic-scorer) Prompt & parsing: - Include profile.availability in build-field-prompt - Reject literal "" sentinel in parse-field-proposal - Strip one level of surrounding quotes from LLM output - Org/user marker guard in classify-field ("Nom de l'entreprise" -> free-text) Performance: - Bounded in-memory cache in local-generator (64 entries, FIFO) Broadcast: - FORM_ASSIST_ENABLED now also reaches content scripts via chrome.tabs.sendMessage Tests: +11 cases (org marker guard, quote stripping, availability injection, keyword cap, determinism). 56/56 form-assistant tests pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/extension/src/background/index.ts | 61 ++++++-- .../content/form-assistant/field-detector.ts | 27 +++- .../src/content/form-assistant/index.ts | 111 +++++++++++++-- .../src/content/form-assistant/widget.ts | 25 +++- .../core/form-assistant/build-field-prompt.ts | 22 +++ .../lib/core/form-assistant/classify-field.ts | 33 +++++ .../form-assistant/parse-field-proposal.ts | 34 ++++- .../shell/form-assistant/local-generator.ts | 130 ++++++++++++++++-- .../src/lib/shell/messaging/bridge.ts | 8 +- .../src/lib/shell/messaging/schemas.ts | 16 ++- .../form-assistant/build-field-prompt.test.ts | 93 +++++++++++++ .../form-assistant/classify-field.test.ts | 11 ++ .../parse-field-proposal.test.ts | 9 ++ 13 files changed, 536 insertions(+), 44 deletions(-) create mode 100644 apps/extension/tests/unit/form-assistant/build-field-prompt.test.ts diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index 3d175306..fa4a3d6f 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -1266,6 +1266,15 @@ void settingsReleaseCoordinator.boot().catch((error) => { }); // Message handler — profile management + scan orchestration + +/** + * AbortControllers actifs pour les générations Form Assistant en cours, indexés + * par requestId. Permet au content script d'annuler une génération (transition + * `requesting CANCEL → armed` du modèle) via FORM_ASSIST_CANCEL. Un Map par + * requestId évite qu'un onglet n'annule la génération d'un autre. + */ +const formAssistRequestControllers = new Map(); + chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse) => { // ── Input validation ────────────────────────────────────────────────────── const validation = validateMessage(rawMessage); @@ -2257,15 +2266,29 @@ chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse import('../lib/shell/form-assistant/settings') .then(({ setFormAssistEnabled }) => setFormAssistEnabled(enabled)) .then((settings) => { - // Broadcast l'état à tous les contextes (panel + content scripts) - // pour que l'UI et les marqueurs de champ se resynchronisent. - chrome.runtime - .sendMessage({ - type: 'FORM_ASSIST_ENABLED', - payload: { enabled: settings.enabled, engine: settings.engine }, + const enabledMessage = { + type: 'FORM_ASSIST_ENABLED', + payload: { enabled: settings.enabled, engine: settings.engine }, + }; + // Broadcast au side panel (runtime) et aux content scripts (tabs). + // `runtime.sendMessage` depuis le SW ne touche que les pages de + // l'extension ; les content scripts écoutent sur `tabs`. + chrome.runtime.sendMessage(enabledMessage).catch(() => { + /* Panel fermé — ignore */ + }); + chrome.tabs + .query({}) + .then((tabs) => { + for (const tab of tabs) { + if (typeof tab.id === 'number') { + chrome.tabs.sendMessage(tab.id, enabledMessage).catch(() => { + /* Pas de content script sur cet onglet — ignore */ + }); + } + } }) .catch(() => { - /* No listener available — ignore */ + /* tabs API indisponible — ignore */ }); sendResponse({ type: 'FORM_ASSIST_ENABLED', @@ -2284,6 +2307,8 @@ chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse if (message.type === 'FORM_ASSIST_REQUEST') { const { requestId, field } = message.payload; + const controller = new AbortController(); + formAssistRequestControllers.set(requestId, controller); (async () => { try { @@ -2320,7 +2345,7 @@ chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse // Phase 1 : selectFormAssistEngine() est la source de vérité Core pour // le choix du moteur. Ici seul le chemin local est câblé ; le chemin // remote (Eve) est Phase 2 et renverra 'unavailable' tant que non impl. - const proposal = await generateFieldProposal(field, profile); + const proposal = await generateFieldProposal(field, profile, controller.signal); if (!proposal || proposal.text.length === 0) { sendResponse({ type: 'FORM_ASSIST_ERROR', @@ -2338,16 +2363,36 @@ chrome.runtime.onMessage.addListener((rawMessage: unknown, _sender, sendResponse payload: { requestId, text: proposal.text, engine: 'local' }, }); } catch (err) { + if (controller.signal.aborted) { + sendResponse({ + type: 'FORM_ASSIST_ERROR', + payload: { requestId, code: 'cancelled', message: 'Génération annulée' }, + }); + return; + } console.warn('[MissionPulse] FORM_ASSIST_REQUEST error:', err); sendResponse({ type: 'FORM_ASSIST_ERROR', payload: { requestId, code: 'failed', message: 'Échec de génération' }, }); + } finally { + formAssistRequestControllers.delete(requestId); } })(); return true; } + if (message.type === 'FORM_ASSIST_CANCEL') { + const { requestId } = message.payload; + const controller = formAssistRequestControllers.get(requestId); + if (controller) { + controller.abort(); + formAssistRequestControllers.delete(requestId); + } + sendResponse({ type: 'FORM_ASSIST_CANCEL_ACK', payload: { requestId } }); + return false; + } + // ── Toast handler (forward to side panel) ── if (message.type === 'SHOW_TOAST') { diff --git a/apps/extension/src/content/form-assistant/field-detector.ts b/apps/extension/src/content/form-assistant/field-detector.ts index a673bcc6..3c828874 100644 --- a/apps/extension/src/content/form-assistant/field-detector.ts +++ b/apps/extension/src/content/form-assistant/field-detector.ts @@ -70,9 +70,21 @@ function resolveLabel(el: HTMLElement): string { } const labelledBy = el.getAttribute('aria-labelledby'); if (labelledBy) { - const labeller = document.getElementById(labelledBy); - if (labeller?.textContent) { - return trimText(labeller.textContent); + // `aria-labelledby` peut référencer plusieurs IDs séparés par des espaces + // (ex : "field-label field-hint"). On concatène le texte de chacun. + const ids = labelledBy.trim().split(/\s+/); + const texts: string[] = []; + for (const id of ids) { + if (!id) { + continue; + } + const labeller = document.getElementById(id); + if (labeller?.textContent) { + texts.push(trimText(labeller.textContent)); + } + } + if (texts.length > 0) { + return trimText(texts.join(' ')); } } const wrapping = el.closest('label'); @@ -100,6 +112,15 @@ export function detectFieldDescriptor(target: HTMLElement): FieldDescriptor | nu if (!inputType) { return null; } + // Champs non modifiables : rien à proposer. + if ( + (target as HTMLInputElement).readOnly || + (target as HTMLInputElement).disabled || + target.getAttribute('aria-readonly') === 'true' || + target.getAttribute('aria-disabled') === 'true' + ) { + return null; + } const raw: RawFieldInput = { label: resolveLabel(target), diff --git a/apps/extension/src/content/form-assistant/index.ts b/apps/extension/src/content/form-assistant/index.ts index 52e45067..d81754a0 100644 --- a/apps/extension/src/content/form-assistant/index.ts +++ b/apps/extension/src/content/form-assistant/index.ts @@ -22,7 +22,11 @@ type FormAssistResponse = } | { type: 'FORM_ASSIST_ERROR'; - payload: { requestId: string; code: 'unavailable' | 'failed'; message: string }; + payload: { + requestId: string; + code: 'unavailable' | 'failed' | 'cancelled'; + message: string; + }; }; let booted = false; @@ -31,41 +35,74 @@ let activeTarget: HTMLElement | null = null; let activeDescriptor: FieldDescriptor | null = null; let widget: FormAssistWidget | null = null; let requestIdCounter = 0; +let activeRequestId: string | null = null; function makeRequestId(): string { requestIdCounter += 1; return `fa-${Date.now().toString(36)}-${requestIdCounter}`; } +/** + * Annule une éventuelle requête de génération en cours côté service worker. + * Cohérent avec la transition `requesting CANCEL → armed` du modèle. + */ +function cancelInFlightRequest(): void { + const id = activeRequestId; + activeRequestId = null; + if (!id) { + return; + } + try { + void chrome.runtime + .sendMessage({ type: 'FORM_ASSIST_CANCEL', payload: { requestId: id } }) + .catch(() => { + /* SW injoignable : la garde anti-response périmée gère le cas. */ + }); + } catch { + /* no-op */ + } +} + /** * Applique une valeur à un champ en contournant les setters surchargés par les * frameworks (React/Svelte) : on appelle le setter natif du prototype puis on * émet l'événement `input` attendu par ces frameworks. + * + * Retourne `false` si l'écriture n'a pas pu être effectuée (ex : contenteditable + * avec execCommand indisponible), pour que l'orchestrateur puisse rester dans un + * état interactif plutôt que de masquer silencieusement l'échec. */ -function applyValue(element: HTMLElement, value: string): void { +function applyValue(element: HTMLElement, value: string): boolean { if (element.isContentEditable) { element.focus(); + let ok = true; try { document.execCommand('selectAll'); - document.execCommand('insertText', false, value); + ok = document.execCommand('insertText', false, value); } catch { element.textContent = value; + ok = element.textContent === value; } element.dispatchEvent(new InputEvent('input', { bubbles: true, data: value })); - return; + return ok; } const tag = element.tagName.toLowerCase(); const proto = tag === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(proto, 'value'); - if (descriptor?.set) { - descriptor.set.call(element, value); - } else { - (element as HTMLInputElement).value = value; + try { + if (descriptor?.set) { + descriptor.set.call(element, value); + } else { + (element as HTMLInputElement).value = value; + } + } catch { + return false; } element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); + return true; } function ensureWidget(): FormAssistWidget { @@ -80,9 +117,13 @@ function ensureWidget(): FormAssistWidget { } function resetToIdle(): void { + if (phase === 'requesting') { + cancelInFlightRequest(); + } phase = 'idle'; activeTarget = null; activeDescriptor = null; + activeRequestId = null; widget?.hide(); } @@ -98,13 +139,22 @@ function handleAccept(text: string): void { return; } phase = 'applying'; + let ok = true; try { - applyValue(activeTarget, text); + ok = applyValue(activeTarget, text); } catch (err) { + ok = false; if (import.meta.env.DEV) { console.warn('[MissionPulse FormAssistant] applyValue failed:', err); } } + if (!ok) { + // Rester dans un état interactif : l'utilisateur peut réessayer ou ignorer, + // plutôt que de masquer silencieusement un échec d'insertion. + phase = 'ready'; + widget?.show(activeTarget, { kind: 'error', message: "Impossible d'insérer la valeur" }); + return; + } phase = 'filled'; resetToIdle(); } @@ -119,6 +169,7 @@ async function requestProposal(target: HTMLElement, field: FieldDescriptor): Pro w.show(target, { kind: 'requesting' }); const requestId = makeRequestId(); + activeRequestId = requestId; const message = { type: 'FORM_ASSIST_REQUEST' as const, payload: { requestId, field }, @@ -133,10 +184,11 @@ async function requestProposal(target: HTMLElement, field: FieldDescriptor): Pro } } - // L'utilisateur a peut-être changé de champ entre-temps. - if (phase !== 'requesting' || activeTarget !== target) { + // L'utilisateur a peut-être changé de champ, dismissé, ou annulé entre-temps. + if (phase !== 'requesting' || activeTarget !== target || activeRequestId !== requestId) { return; } + activeRequestId = null; if (!response) { w.show(target, { kind: 'error', message: 'Service injoignable' }); @@ -152,7 +204,11 @@ async function requestProposal(target: HTMLElement, field: FieldDescriptor): Pro w.show(target, { kind: 'error', message: - response.payload.code === 'unavailable' ? 'IA locale indisponible' : 'Échec de génération', + response.payload.code === 'unavailable' + ? 'IA locale indisponible' + : response.payload.code === 'cancelled' + ? 'Génération annulée' + : 'Échec de génération', }); } @@ -161,6 +217,14 @@ function handleFocusIn(event: FocusEvent): void { if (!target || target === activeTarget) { return; } + // Ignore les focus internes au widget (clics sur ses boutons, etc.). + if (widget?.isHostElement(target)) { + return; + } + // Changement de champ : on annule une éventuelle requête en cours pour l'ancien. + if (phase === 'requesting') { + cancelInFlightRequest(); + } const descriptor = detectFieldDescriptor(target); if (!descriptor) { @@ -170,6 +234,7 @@ function handleFocusIn(event: FocusEvent): void { activeTarget = target; activeDescriptor = descriptor; + activeRequestId = null; phase = 'armed'; ensureWidget().show(target, { kind: 'armed' }); } @@ -183,6 +248,26 @@ function handleKeyDown(event: KeyboardEvent): void { } } +/** + * Ferme le widget quand l'utilisateur clique en dehors du champ actif et du + * widget (comportement type Grammarly). On utilise `mousedown` plutôt que + * `focusout`/`blur` car le target d'un mousedown observé au niveau document est + * fiable y compris avec un shadow root closed (retargeting vers le host). + */ +function handleDocumentMouseDown(event: MouseEvent): void { + if (!activeTarget || phase === 'disabled' || phase === 'idle') { + return; + } + const target = event.target as Node | null; + if (!target) { + return; + } + if (activeTarget.contains(target) || widget?.isHostElement(target)) { + return; + } + resetToIdle(); +} + function arm(): void { if (phase !== 'disabled') { return; @@ -190,11 +275,13 @@ function arm(): void { phase = 'idle'; document.addEventListener('focusin', handleFocusIn, true); document.addEventListener('keydown', handleKeyDown, true); + document.addEventListener('mousedown', handleDocumentMouseDown, true); } function disarm(): void { document.removeEventListener('focusin', handleFocusIn, true); document.removeEventListener('keydown', handleKeyDown, true); + document.removeEventListener('mousedown', handleDocumentMouseDown, true); resetToIdle(); phase = 'disabled'; widget?.destroy(); diff --git a/apps/extension/src/content/form-assistant/widget.ts b/apps/extension/src/content/form-assistant/widget.ts index a23713b0..6d3403de 100644 --- a/apps/extension/src/content/form-assistant/widget.ts +++ b/apps/extension/src/content/form-assistant/widget.ts @@ -118,12 +118,18 @@ export class FormAssistWidget { } private handleClick = (event: Event): void => { - const target = event.target as HTMLElement; - const action = target.dataset.action; + const target = event.target as HTMLElement | null; + // Les clics peuvent tomber sur un enfant (ex : ) sans + // `data-action`. On remonte jusqu'à l'ancêtre portant l'action. + const actionable = target?.closest('[data-action]') as HTMLElement | null; + if (!actionable) { + return; + } + const action = actionable.dataset.action; if (action === 'trigger') { this.callbacks.onTrigger(); } else if (action === 'accept') { - const text = target.dataset.text ?? ''; + const text = actionable.dataset.text ?? ''; this.callbacks.onAccept(text); } else if (action === 'dismiss') { this.callbacks.onDismiss(); @@ -135,10 +141,11 @@ export class FormAssistWidget { const rect = target.getBoundingClientRect(); const margin = 8; const top = rect.bottom + margin; - // Aligné à gauche du champ, replié si débordement à droite. + const rootWidth = this.root.offsetWidth || 340; + const maxLeft = Math.max(margin, window.innerWidth - rootWidth - margin); const preferredLeft = rect.left; this.root.style.top = `${Math.round(top)}px`; - this.root.style.left = `${Math.round(Math.max(margin, preferredLeft))}px`; + this.root.style.left = `${Math.round(Math.min(Math.max(margin, preferredLeft), maxLeft))}px`; // Si débordement vertical (champ en bas de page), on passe au-dessus. const rootHeight = this.root.offsetHeight || 120; if (top + rootHeight > window.innerHeight - margin) { @@ -146,6 +153,14 @@ export class FormAssistWidget { } } + /** + * Indique si un nœud DOM appartient au widget (host ou son shadow tree). + * Utilisé par l'orchestrateur pour ignorer les focus/clics internes au widget. + */ + isHostElement(node: Node | null): boolean { + return node !== null && this.host.contains(node); + } + show(target: HTMLElement, state: WidgetViewState): void { this.root.removeAttribute('hidden'); this.render(state); diff --git a/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts b/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts index cf00823b..10d9e863 100644 --- a/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts +++ b/apps/extension/src/lib/core/form-assistant/build-field-prompt.ts @@ -1,5 +1,6 @@ import type { FieldDescriptor, FieldKind } from './types'; import type { UserProfile } from '../types/profile'; +import { AVAILABILITY_STATUS_LABELS } from '../types/availability'; /** Longueur maximale d'une liste injectée dans le prompt (garde-fou). */ const MAX_KEYWORDS = 16; @@ -9,6 +10,23 @@ function joinList(items: readonly string[], max: number): string { return slice.join(', '); } +function availabilityLine(profile: UserProfile): string | null { + const availability = profile.availability; + if (!availability) { + return null; + } + const status = AVAILABILITY_STATUS_LABELS[availability.status]; + const parts: string[] = [`Statut : ${status}`]; + if (availability.date) { + parts.push(`À partir du : ${availability.date}`); + } + const note = (availability.note ?? '').trim(); + if (note) { + parts.push(`Note : ${note}`); + } + return parts.join(' — '); +} + function profileLine(profile: UserProfile): string { const parts: string[] = []; if (profile.firstName) { @@ -28,6 +46,10 @@ function profileLine(profile: UserProfile): string { if (keywords) { parts.push(`Compétences : ${keywords}`); } + const availability = availabilityLine(profile); + if (availability) { + parts.push(`Disponibilité : ${availability}`); + } return parts.join('\n'); } diff --git a/apps/extension/src/lib/core/form-assistant/classify-field.ts b/apps/extension/src/lib/core/form-assistant/classify-field.ts index f922e9c7..396c8e2f 100644 --- a/apps/extension/src/lib/core/form-assistant/classify-field.ts +++ b/apps/extension/src/lib/core/form-assistant/classify-field.ts @@ -18,6 +18,32 @@ interface KindRule { readonly keywords: readonly string[]; } +/** + * Marqueurs indiquant un champ d'organisation ou de compte, pour lesquels une + * classification "nom de personne" serait une fausse positive. Évalués sur le + * texte normalisé (label + placeholder). + */ +const ORG_USER_MARKERS: readonly string[] = [ + 'company', + 'societe', + 'entreprise', + 'organisation', + 'organization', + 'raison sociale', + 'username', + 'utilisateur', + 'login', + 'compte', + 'account', + 'user name', +]; + +const NAME_KINDS: readonly FieldKind[] = ['first-name', 'last-name', 'full-name']; + +function isNameKind(kind: FieldKind): boolean { + return NAME_KINDS.includes(kind); +} + /** * Règles ordonnées (du plus spécifique au plus générique). * L'ordre compte : "nom de famille" doit battre "nom". @@ -130,9 +156,16 @@ function detectKind(raw: RawFieldInput): FieldKind { return 'free-text'; } + const isOrgOrUserContext = ORG_USER_MARKERS.some((marker) => text.includes(marker)); + for (const rule of KIND_RULES) { for (const keyword of rule.keywords) { if (text.includes(keyword)) { + // Évite de classer un champ d'organisation/compte comme un nom de personne. + // Ex : "Nom de l'entreprise", "Username", "Raison sociale". + if (isOrgOrUserContext && isNameKind(rule.kind)) { + continue; + } return rule.kind; } } diff --git a/apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts b/apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts index b70d062e..d079f87c 100644 --- a/apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts +++ b/apps/extension/src/lib/core/form-assistant/parse-field-proposal.ts @@ -16,6 +16,34 @@ function stripCodeFences(raw: string): string { return body.replace(/\s*```$/, '').trim(); } +/** + * Détection d'un sentinelle "vide" : l'LLM peut renvoyer `""` ou `''` (avec ou + * sans espaces) pour signaler qu'il n'a pas de valeur. On rejette ces cas + * plutôt que de proposer le texte littéral `"\""`. + */ +function isEmptySentinel(text: string): boolean { + return /^["'“”‘’\s]*$/.test(text); +} + +/** + * Retire un niveau d'encadrement par des guillemets (simples, doubles, ou + * typographiques). Certains LLM entourent systématiquement la valeur proposée + * de guillemets. + */ +function stripSurroundingQuotes(text: string): string { + if (text.length < 2) { + return text; + } + const first = text[0]; + const last = text[text.length - 1]; + const isPairedQuote = + (first === '"' && last === '"') || + (first === "'" && last === "'") || + (first === '“' && last === '”') || + (first === '‘' && last === '’'); + return isPairedQuote ? text.slice(1, -1) : text; +} + /** * Transforme la sortie brute d'un LLM en FieldProposal canonical, ou `null` * si vide/invalide. @@ -26,7 +54,11 @@ export function parseFieldProposal(raw: string): FieldProposal | null { if (typeof raw !== 'string') { return null; } - const text = stripCodeFences(raw).trim(); + const stripped = stripCodeFences(raw).trim(); + if (stripped.length === 0 || isEmptySentinel(stripped)) { + return null; + } + const text = stripSurroundingQuotes(stripped).trim(); if (text.length === 0) { return null; } diff --git a/apps/extension/src/lib/shell/form-assistant/local-generator.ts b/apps/extension/src/lib/shell/form-assistant/local-generator.ts index eb3ef614..ed732769 100644 --- a/apps/extension/src/lib/shell/form-assistant/local-generator.ts +++ b/apps/extension/src/lib/shell/form-assistant/local-generator.ts @@ -4,29 +4,134 @@ * Shell module : I/O (AI API), async. Délègue la construction du prompt et le * parsing au Core. Aucune décision d'état : produit une proposition ou null. * - * Pattern identique à mission-generator.ts (timeout + retry + destroy). + * Pattern identique à semantic-scorer.ts (timeout + retry + AbortSignal + + * destroy). Un cache mémoire borné évite de ré-invoquer le modèle pour le même + * champ/profil lors d'un re-focus ou d'une nouvelle proposition. */ import type { FieldDescriptor, FieldProposal } from '../../core/form-assistant/types'; import { buildFieldPrompt, parseFieldProposal } from '../../core/form-assistant'; import type { UserProfile } from '../../core/types/profile'; import { createPromptSession, isPromptApiAvailable } from '../ai/capabilities'; import type { AILanguageModelSession } from '../ai/chrome-ai'; +import { abortableDelay } from '../utils/retry-strategy'; const TIMEOUT_MS = 8000; const RETRY_DELAYS_MS = [500, 1000] as const; const MAX_RETRIES = RETRY_DELAYS_MS.length; -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +/** Nombre maximal d'entrées du cache mémoire (garde-fou). */ +const MAX_CACHE_ENTRIES = 64; + +interface CacheKey { + readonly fingerprint: string; +} + +interface CacheEntry { + readonly value: FieldProposal | null; +} + +const cache = new Map(); + +function fieldFingerprint(field: FieldDescriptor): string { + return [ + field.kind, + field.label ?? '', + field.placeholder ?? '', + field.inputType ?? '', + field.required ? '1' : '0', + ].join('|'); +} + +function profileFingerprint(profile: UserProfile): string { + return [ + profile.firstName ?? '', + profile.jobTitle ?? '', + profile.seniority, + profile.location ?? '', + profile.remote, + String(profile.tjmMin), + String(profile.tjmMax), + (profile.keywords ?? []).join(','), + profile.availability ? JSON.stringify(profile.availability) : '', + ].join('|'); +} + +function getCache(key: CacheKey): FieldProposal | null | undefined { + const entry = cache.get(key.fingerprint); + return entry?.value; +} + +function setCache(key: CacheKey, value: FieldProposal | null): void { + cache.set(key.fingerprint, { value }); + // Éviction FIFO quand la borne est dépassée. + if (cache.size > MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next(); + if (!oldest.done && oldest.value !== key.fingerprint) { + cache.delete(oldest.value as string); + } + } +} + +/** + * Invalide tout le cache de propositions. À appeler quand le profil change. + */ +export function clearFieldProposalCache(): void { + cache.clear(); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } +} + +function promptWithCancellation( + session: AILanguageModelSession, + prompt: string, + signal?: AbortSignal +): Promise { + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timeout); + signal?.removeEventListener('abort', onAbort); + }; + const settle = (callback: () => void): void => { + cleanup(); + callback(); + }; + const onAbort = (): void => + settle(() => reject(new DOMException('The operation was aborted.', 'AbortError'))); + const timeout = setTimeout(() => settle(() => reject(new Error('timeout'))), TIMEOUT_MS); + signal?.addEventListener('abort', onAbort, { once: true }); + session.prompt(prompt).then( + (value) => settle(() => resolve(value)), + (error: unknown) => settle(() => reject(error)) + ); + }); +} /** * Génère une proposition de valeur pour un champ via Gemini Nano. * Retourne `null` si l'API est indisponible/non téléchargée, ou si la sortie - * n'est pas exploitable. + * n'est pas exploitable. Honore un `AbortSignal` (annulation cohérente avec la + * Machine A du modèle `form-assistant`). */ export async function generateFieldProposal( field: FieldDescriptor, - profile: UserProfile + profile: UserProfile, + signal?: AbortSignal ): Promise { + throwIfAborted(signal); + + const key: CacheKey = { + fingerprint: `${fieldFingerprint(field)}::${profileFingerprint(profile)}`, + }; + const cached = getCache(key); + if (cached !== undefined) { + return cached; + } + const availability = await isPromptApiAvailable(); if (availability !== 'available') { return null; @@ -41,15 +146,12 @@ export async function generateFieldProposal( for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - const response = await Promise.race([ - session.prompt(prompt), - new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS) - ), - ]); + const response = await promptWithCancellation(session, prompt, signal); + throwIfAborted(signal); rawContent = response; break; } catch (error) { + throwIfAborted(signal); if (import.meta.env.DEV) { console.warn( `[FormAssistant] Attempt ${attempt + 1}/${MAX_RETRIES + 1} failed:`, @@ -57,7 +159,7 @@ export async function generateFieldProposal( ); } if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAYS_MS[attempt]); + await abortableDelay(RETRY_DELAYS_MS[attempt], signal); } } } @@ -65,8 +167,12 @@ export async function generateFieldProposal( session?.destroy(); } + throwIfAborted(signal); if (!rawContent) { + setCache(key, null); return null; } - return parseFieldProposal(rawContent); + const proposal = parseFieldProposal(rawContent); + setCache(key, proposal); + return proposal; } diff --git a/apps/extension/src/lib/shell/messaging/bridge.ts b/apps/extension/src/lib/shell/messaging/bridge.ts index e70df156..fd6a4779 100644 --- a/apps/extension/src/lib/shell/messaging/bridge.ts +++ b/apps/extension/src/lib/shell/messaging/bridge.ts @@ -346,15 +346,19 @@ export type BridgeMessage = // Content → SW : demander une proposition pour un champ (Machine B). // Le field est un FieldDescriptor canonical (sanit-isé, sans PII DOM). | { type: 'FORM_ASSIST_REQUEST'; payload: { requestId: string; field: FieldDescriptor } } + // Content → SW : annuler une requête en vol (changement de champ, fermeture widget). + | { type: 'FORM_ASSIST_CANCEL'; payload: { requestId: string } } // SW → Content : proposition prête (ACCEPT explicite requis pour appliquer). | { type: 'FORM_ASSIST_PROPOSAL'; payload: { requestId: string; text: string; engine: 'local' | 'remote' }; } - // SW → Content : échec (moteur indisponible ou génération en erreur). + // SW → Content : annulation prise en compte. + | { type: 'FORM_ASSIST_CANCEL_ACK'; payload: { requestId: string } } + // SW → Content : échec (moteur indisponible, génération en erreur, ou annulée). | { type: 'FORM_ASSIST_ERROR'; - payload: { requestId: string; code: 'unavailable' | 'failed'; message: string }; + payload: { requestId: string; code: 'unavailable' | 'failed' | 'cancelled'; message: string }; }; function devLog(direction: '→' | '←', type: string, payload?: unknown): void { diff --git a/apps/extension/src/lib/shell/messaging/schemas.ts b/apps/extension/src/lib/shell/messaging/schemas.ts index 06a0997d..1bf680fc 100644 --- a/apps/extension/src/lib/shell/messaging/schemas.ts +++ b/apps/extension/src/lib/shell/messaging/schemas.ts @@ -1435,6 +1435,13 @@ export const MessageSchemas = { field: FieldDescriptorSchema, }), }), + // Content → SW : annule une génération en cours (transition `requesting CANCEL`). + FORM_ASSIST_CANCEL: z.object({ + type: z.literal('FORM_ASSIST_CANCEL'), + payload: z.object({ + requestId: z.string().min(1).max(128), + }), + }), FORM_ASSIST_PROPOSAL: z.object({ type: z.literal('FORM_ASSIST_PROPOSAL'), payload: z.object({ @@ -1443,11 +1450,18 @@ export const MessageSchemas = { engine: z.enum(['local', 'remote']), }), }), + // SW → Content : accuse réception de l'annulation (transition `cancelling → idle`). + FORM_ASSIST_CANCEL_ACK: z.object({ + type: z.literal('FORM_ASSIST_CANCEL_ACK'), + payload: z.object({ + requestId: z.string().min(1).max(128), + }), + }), FORM_ASSIST_ERROR: z.object({ type: z.literal('FORM_ASSIST_ERROR'), payload: z.object({ requestId: z.string().min(1).max(128), - code: z.enum(['unavailable', 'failed']), + code: z.enum(['unavailable', 'failed', 'cancelled']), message: z.string().min(1).max(280), }), }), diff --git a/apps/extension/tests/unit/form-assistant/build-field-prompt.test.ts b/apps/extension/tests/unit/form-assistant/build-field-prompt.test.ts new file mode 100644 index 00000000..3c7eba15 --- /dev/null +++ b/apps/extension/tests/unit/form-assistant/build-field-prompt.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { buildFieldPrompt } from '../../../src/lib/core/form-assistant/build-field-prompt'; +import type { FieldDescriptor } from '../../../src/lib/core/form-assistant/types'; +import type { UserProfile } from '../../../src/lib/core/types/profile'; + +function field(kind: FieldDescriptor['kind'], label = 'Champ'): FieldDescriptor { + return { + kind, + label, + placeholder: '', + inputType: 'text', + required: false, + }; +} + +function baseProfile(overrides: Partial = {}): UserProfile { + return { + firstName: 'Ada', + keywords: ['TypeScript', 'Svelte'], + tjmMin: 500, + tjmMax: 700, + location: 'Paris', + remote: 'remote', + seniority: 'senior', + jobTitle: 'Tech Lead', + experiences: [], + availability: null, + ...overrides, + }; +} + +describe('buildFieldPrompt', () => { + it('injecte le cœur du profil (prénom, titre, TJM, compétences)', () => { + const prompt = buildFieldPrompt(field('first-name'), baseProfile()); + expect(prompt).toContain('Prénom : Ada'); + expect(prompt).toContain('Titre : Tech Lead'); + expect(prompt).toContain('TJM min : 500€'); + expect(prompt).toContain('Compétences : TypeScript, Svelte'); + }); + + it('omet la ligne de disponibilité quand availability est null', () => { + const prompt = buildFieldPrompt(field('availability'), baseProfile()); + expect(prompt).not.toContain('Disponibilité :'); + }); + + it('injecte la disponibilité (statut + date + note) quand elle est renseignée', () => { + const prompt = buildFieldPrompt( + field('availability'), + baseProfile({ + availability: { + status: 'from-date', + date: '2025-02-01', + note: 'Mission actuelle en cours', + updatedAt: 1_700_000_000_000, + }, + }) + ); + expect(prompt).toContain('Disponibilité : Statut : Disponible à partir du'); + expect(prompt).toContain('À partir du : 2025-02-01'); + expect(prompt).toContain('Note : Mission actuelle en cours'); + }); + + it('omet la date et la note quand elles sont absentes (statut immediate)', () => { + const prompt = buildFieldPrompt( + field('availability'), + baseProfile({ + availability: { + status: 'immediate', + date: null, + note: '', + updatedAt: 1_700_000_000_000, + }, + }) + ); + expect(prompt).toContain('Disponibilité : Statut : Disponible immédiatement'); + expect(prompt).not.toContain('À partir du'); + expect(prompt).not.toContain('Note :'); + }); + + it('restreint à 16 mots-clés maximum', () => { + const many = Array.from({ length: 30 }, (_, i) => `skill-${i}`); + const prompt = buildFieldPrompt(field('skill'), baseProfile({ keywords: many })); + expect(prompt).toContain('skill-0'); + expect(prompt).toContain('skill-15'); + expect(prompt).not.toContain('skill-16'); + }); + + it('est déterministe : mêmes entrées ⇒ même prompt', () => { + const f = field('cover-letter'); + const p = baseProfile(); + expect(buildFieldPrompt(f, p)).toBe(buildFieldPrompt(f, p)); + }); +}); diff --git a/apps/extension/tests/unit/form-assistant/classify-field.test.ts b/apps/extension/tests/unit/form-assistant/classify-field.test.ts index a68f6d30..256ce46a 100644 --- a/apps/extension/tests/unit/form-assistant/classify-field.test.ts +++ b/apps/extension/tests/unit/form-assistant/classify-field.test.ts @@ -53,6 +53,17 @@ describe('classifyField — spécificité (ordre des règles)', () => { it('"lettre de motivation" → cover-letter', () => { expect(classifyField(raw('Lettre de motivation')).kind).toBe('cover-letter'); }); + it('"nom de l\'entreprise" → free-text (pas full-name)', () => { + // "entreprise" est un marqueur org/user : on ne doit PAS dériver un kind "name" + // pour éviter de proposer le nom du freelance. + expect(classifyField(raw("Nom de l'entreprise")).kind).toBe('free-text'); + }); + it('"company name" → free-text (pas full-name)', () => { + expect(classifyField(raw('Company name')).kind).toBe('free-text'); + }); + it('"nom d\'utilisateur" → free-text (pas full-name)', () => { + expect(classifyField(raw("Nom d'utilisateur")).kind).toBe('free-text'); + }); }); describe('classifyField — placeholder et TJM', () => { diff --git a/apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts b/apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts index 86a39a71..b395005a 100644 --- a/apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts +++ b/apps/extension/tests/unit/form-assistant/parse-field-proposal.test.ts @@ -29,4 +29,13 @@ describe('parseFieldProposal', () => { const raw = 'Bonjour,\n\nJe suis développeur.\nCordialement.'; expect(parseFieldProposal(raw)?.text).toBe(raw); }); + it('rejette une sentinel "" (quotes vides)', () => { + expect(parseFieldProposal('""')).toBeNull(); + expect(parseFieldProposal("''")).toBeNull(); + expect(parseFieldProposal('“”')).toBeNull(); + }); + it('débarrasse les quotes enveloppantes', () => { + expect(parseFieldProposal('"Disponible immédiatement"')?.text).toBe('Disponible immédiatement'); + expect(parseFieldProposal('“550 €/jour”')?.text).toBe('550 €/jour'); + }); });