Files
flowdeck/static/js/meeting_block.js
T
bruno 0218d8f5e5
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped
fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
2026-10-09 16:58:04 -04:00

1567 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* FlowDeck — AI Meeting Notes, bloc façon Notion (v7.1.1).
*
* Refondte alignée sur docs/architecture-meeting-notion-flowdeck.md (§3A/3B) :
* - un seul bloc à états (idle/recording/paused/processing/done/failed, §6.5),
* jamais d'écrans multiples ;
* - révélation progressive des onglets : [Notes] → [Notes, Transcript] →
* [Summary, Notes, Transcript], Summary actif par défaut après l'arrêt ;
* - coquille fixe : en-tête (calendrier + titre généré), rangée
* onglets + contrôles d'état, corps selon (état × onglet), pied
* (instructions + attestation de consentement à l'état initial,
* bandeau d'info ensuite) ;
* - capture Web 2 modes : A micro (getUserMedia), B onglet + micro
* (getDisplayMedia + micro, canaux séparés non mixés, §6.2) ;
* - forme d'onde en direct (AnalyserNode, ~10 img/s), chronomètre de session,
* alerte « je n'entends que vous » si le canal distant reste muet ;
* - tampon local IndexedDB : les segments non envoyés survivent au
* rechargement ; un rechargement restaure l'état exact (recording →
* paused avec reprise proposée, processing → reprise du suivi) ;
* - le titre généré par l'IA ne touche JAMAIS le titre de la page hôte.
*
* Surcharge window.FDMeeting (défini par page_editor_scripts.js) : le
* placeholder .block-meeting et l'appel window.FDMeeting.mount() existants
* sont réutilisés tels quels. Les anciens blocs (v1, champs started/lines/
* lastTab) sont migrés à la volée par _ensure().
*/
(function () {
'use strict';
/* ── utilitaires ─────────────────────────────────────────────── */
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function attr(v) { return esc(v).replace(/"/g, '&quot;'); }
function findBlock(bid) {
var E = window.E;
if (E && E.blocks) for (var i = 0; i < E.blocks.length; i++)
if (E.blocks[i].id === bid) return E.blocks[i];
return null;
}
function save() {
var E = window.E;
if (E) { E.dirty = true; if (E.autoSave) E.autoSave(); }
}
function csrf() {
try { if (typeof getCsrf === 'function') return getCsrf(); } catch (e) {}
var m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : '';
}
function api(path, opts) {
opts = opts || {};
var headers = { 'X-CSRF-Token': csrf() };
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
return fetch(path, {
method: opts.method || 'GET', credentials: 'same-origin',
headers: headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
}).then(function (r) {
if (!r.ok) return r.text().then(function (t) {
var err = new Error('HTTP ' + r.status + ' ' + (t || '').slice(0, 200));
err.status = r.status; throw err;
});
return r.json();
});
}
function toast(msg) {
try {
if (window.E && window.E.showToast) { window.E.showToast(msg); return; }
} catch (e) {}
try { console.log('[meeting]', msg); } catch (e2) {}
}
function fmtClock(ms) {
var s = Math.max(0, Math.floor(ms / 1000));
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
function p(n) { return (n < 10 ? '0' : '') + n; }
return (h ? h + ':' + p(m) : p(m)) + ':' + p(ss);
}
function fmtTs(ms) {
var s = Math.floor(Math.max(0, ms || 0) / 1000);
return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60);
}
var CONSENT_TEXT = 'This meeting is being transcribed by FlowDeck AI Meeting Notes. ' +
'By staying, you agree to the transcription.';
var INSTRUCTIONS = [
['auto', 'Auto'], ['standup', 'Standup'], ['team', 'Team meeting'],
['sales', 'Sales call'], ['one_to_one', '1:1'], ['interview', 'Interview']
];
var THINKING_STEPS = [
['reading_transcript', 'Reading transcript…'],
['analyzing_transcript', 'Analyzing the transcript'],
['resolving_references', 'Thinking about how to refer to people'],
['understanding_content', 'Understanding the content'],
['classifying_completeness', 'Checking completeness'],
['generating_summary', 'Writing the summary'],
['generating_title', 'Choosing a title'],
['validating_citations', 'Checking citations']
];
/* ── état du bloc ────────────────────────────────────────────── */
function defState() {
return {
v: 2, title: 'Meeting', date: new Date().toISOString().slice(0, 10),
state: 'idle', mode: 'mic_only', instruction: 'auto', lang: 'fr',
consent: null, consentAudio: false, tab: 'notes',
channels: [], segments: [], segSeq: 0,
notes: '', manual: '', interim: '',
startedAt: 0, elapsedBase: 0, pausedMs: 0,
steps: [], summaryMd: '', completeness: '', genTitle: '',
titleEdited: false, shareHidden: false, bannerHidden: false,
transcriptId: null, error: '', remoteSilentWarned: false,
menuOpen: false, settingsOpen: false
};
}
function ensure(bid) {
var b = findBlock(bid);
if (!b) return null;
if (!b.meeting || !b.meeting.v) {
var old = b.meeting || {}, m = defState();
if (old.title) m.title = old.title;
if (old.date) m.date = old.date;
if (old.notes) m.notes = old.notes;
if (old.manualTranscript) m.manual = old.manualTranscript;
if (old.summary) m.summaryMd = old.summary;
if (old.summaryError) m.error = old.summaryError;
var st = old.status || 'idle';
if (st === 'running') st = 'recording';
else if (st === 'manual') st = 'idle';
m.state = (st === 'recording' || st === 'paused' || st === 'done') ? st : 'idle';
m.tab = old.lastTab === 'summary' ? 'summary'
: old.lastTab === 'notes' ? 'notes' : (m.state === 'idle' ? 'notes' : 'transcript');
if (m.state === 'done' && !m.summaryMd) m.state = 'idle';
m.segments = (old.lines || []).map(function (l, i) {
return {
seq: i + 1, channel: 'self', source: '', speaker: l.who || '',
startMs: 0, endMs: 0, text: l.text || '', mid: false
};
});
m.segSeq = m.segments.length;
b.meeting = m;
}
var m2 = b.meeting;
['segments', 'channels', 'steps'].forEach(function (k) {
if (!Array.isArray(m2[k])) m2[k] = [];
});
return b;
}
function displayTitle(m) {
if (m.genTitle && !m.titleEdited) return m.genTitle;
return m.title || 'Meeting';
}
/* ── tampon local IndexedDB (segments non envoyés) ───────────── */
var idb = null;
function idbOpen() {
if (idb) return Promise.resolve(idb);
return new Promise(function (resolve) {
try {
var req = indexedDB.open('fd-meet', 1);
req.onupgradeneeded = function () {
req.result.createObjectStore('outbox');
};
req.onsuccess = function () { idb = req.result; resolve(idb); };
req.onerror = function () { resolve(null); };
} catch (e) { resolve(null); }
});
}
function idbPut(bid, segs) {
idbOpen().then(function (db) {
if (!db) return;
try {
var tx = db.transaction('outbox', 'readwrite');
if (segs && segs.length) tx.objectStore('outbox').put(segs, 'out-' + bid);
else tx.objectStore('outbox').delete('out-' + bid);
} catch (e) {}
});
}
function idbGet(bid) {
return idbOpen().then(function (db) {
if (!db) return [];
return new Promise(function (resolve) {
try {
var tx = db.transaction('outbox', 'readonly');
var rq = tx.objectStore('outbox').get('out-' + bid);
rq.onsuccess = function () { resolve(rq.result || []); };
rq.onerror = function () { resolve([]); };
} catch (e) { resolve([]); }
});
});
}
/* ── rendu : coquille fixe (en-tête / rangée / corps / pied) ─── */
function tabDefs(state) {
if (state === 'idle') return [['notes', '✎', 'Notes']];
if (state === 'recording' || state === 'paused')
return [['notes', '✎', 'Notes'], ['transcript', 'mic', 'Transcript']];
return [['summary', '☰', 'Summary'], ['notes', '✎', 'Notes'],
['transcript', 'mic', 'Transcript']];
}
function defaultTab(state) {
return (state === 'processing' || state === 'done' || state === 'failed')
? 'summary' : state === 'idle' ? 'notes' : 'transcript';
}
function renderHead(m) {
return '<div class="fm-head">' +
'<span class="fm-cal" title="Meeting">▤</span>' +
'<span class="fm-chev">▾</span>' +
'<input class="fm-title" data-fm="title" value="' + attr(displayTitle(m)) + '"' +
' placeholder="Meeting title" spellcheck="false" aria-label="Meeting title">' +
'</div><div class="fm-rule"></div>';
}
function renderRow(bid, m) {
var tabs = tabDefs(m.state).map(function (t) {
var active = (m.tab === t[0]) || (!m.tab && defaultTab(m.state) === t[0]);
return '<button type="button" class="fm-tab' + (active ? ' active' : '') + '"' +
' data-act="tab" data-tab="' + t[0] + '">' +
(t[0] === 'transcript'
? '<span class="fm-mic">mic</span> '
: t[0] === 'notes' ? '<span>✎</span> ' : '<span>☰</span> ') +
esc(t[2]) + '</button>';
}).join('');
var ctrl = '';
if (m.state === 'idle') {
ctrl = '<div class="fm-split">' +
'<button type="button" class="fm-btn fm-btn-primary fm-start" data-act="start">Start transcribing</button>' +
'<button type="button" class="fm-btn fm-btn-primary fm-chevron" data-act="menu" aria-label="Start options">▾</button>' +
'</div>' + (m.menuOpen ? renderStartMenu(m) : '');
} else if (m.state === 'recording') {
ctrl = '<canvas class="fm-wave" width="140" height="30" data-fm-wave aria-label="Live audio level"></canvas>' +
'<span class="fm-timer" data-fm-timer>' + esc(fmtClock(elapsed(m))) + '</span>' +
'<button type="button" class="fm-btn" data-act="pause">Pause</button>' +
'<button type="button" class="fm-btn fm-btn-stop" data-act="stop">Stop</button>';
} else if (m.state === 'paused') {
ctrl = '<span class="fm-paused-badge">Paused</span>' +
'<span class="fm-timer" data-fm-timer>' + esc(fmtClock(elapsed(m))) + '</span>' +
'<button type="button" class="fm-btn fm-btn-primary" data-act="resume">Resume</button>' +
'<button type="button" class="fm-btn fm-btn-stop" data-act="stop">Stop</button>';
} else if (m.state === 'processing') {
ctrl = '<span class="fm-thinking">Thinking</span>';
}
return '<div class="fm-row"><div class="fm-tabs">' + tabs + '</div>' +
'<div class="fm-controls">' +
'<button type="button" class="fm-icon" data-act="suggest" title="AI suggestions">☼</button>' +
'<button type="button" class="fm-icon" data-act="settings" title="Meeting settings">-o-</button>' +
ctrl + '</div></div>' +
(m.settingsOpen ? renderSettings(m) : '');
}
function renderStartMenu(m) {
var stt = hasSTT();
var tabCap = hasDisplayMedia();
return '<div class="fm-menu" role="menu">' +
'<div class="fm-menu-label">Audio source</div>' +
'<button type="button" class="fm-menu-item' + (m.mode === 'mic_only' ? ' sel' : '') +
'" data-act="mode" data-v="mic_only">🎤 Microphone <span class="fm-dim">— in person, speakerphone</span></button>' +
'<button type="button" class="fm-menu-item' + (m.mode === 'tab_plus_mic' ? ' sel' : '') +
'" data-act="mode" data-v="tab_plus_mic">🪟 Tab + microphone <span class="fm-dim">— call in another tab</span></button>' +
'<div class="fm-menu-label">Language</div>' +
'<div class="fm-menu-row">' +
'<button type="button" class="fm-menu-item' + (m.lang === 'fr' ? ' sel' : '') +
'" data-act="lang" data-v="fr">Français</button>' +
'<button type="button" class="fm-menu-item' + (m.lang === 'en' ? ' sel' : '') +
'" data-act="lang" data-v="en">English</button></div>' +
'<label class="fm-menu-check"><input type="checkbox" data-fm="consentAudio"' +
(m.consentAudio ? ' checked' : '') + '> Play spoken consent message at start</label>' +
'<div class="fm-menu-label">Setup check — avoid losing a take</div>' +
'<div class="fm-setup">' +
'<div>' + (stt ? '✓' : '⚠') + ' Live transcription' +
(stt ? '' : ' <span class="fm-dim">— unavailable here, manual transcript fallback</span>') + '</div>' +
'<div>' + (tabCap ? '✓' : '○') + ' Tab + microphone mode' +
(tabCap ? '' : ' <span class="fm-dim">— unsupported in this browser</span>') + '</div>' +
'<div>✓ Consent message ready <span class="fm-dim">— copy or play it from the footer</span></div>' +
'</div>' +
'<button type="button" class="fm-menu-item" data-act="testmic">🎤 Test microphone' +
' <span class="fm-dim">— 4 s level check</span></button>' +
'</div>';
}
function renderSettings(m) {
var inner;
if (m.state === 'idle') {
inner = '<div class="fm-set-row"><span>Audio source</span><div>' +
'<button type="button" class="fm-chip' + (m.mode === 'mic_only' ? ' active' : '') +
'" data-act="mode" data-v="mic_only">Microphone</button> ' +
'<button type="button" class="fm-chip' + (m.mode === 'tab_plus_mic' ? ' active' : '') +
'" data-act="mode" data-v="tab_plus_mic">Tab + mic</button></div></div>' +
'<div class="fm-set-row"><span>Language</span><div>' +
'<button type="button" class="fm-chip' + (m.lang === 'fr' ? ' active' : '') +
'" data-act="lang" data-v="fr">FR</button> ' +
'<button type="button" class="fm-chip' + (m.lang === 'en' ? ' active' : '') +
'" data-act="lang" data-v="en">EN</button></div></div>' +
'<div class="fm-set-row"><span>Summary instructions</span>' +
'<select data-fm="instruction">' +
INSTRUCTIONS.map(function (o) {
return '<option value="' + o[0] + '"' +
(m.instruction === o[0] ? ' selected' : '') + '>' + esc(o[1]) + '</option>';
}).join('') + '</select></div>';
} else {
inner = '<div class="fm-set-row"><span>Mode</span><span class="fm-dim">' +
esc(m.mode === 'tab_plus_mic' ? 'Tab + microphone' : 'Microphone') +
' · ' + esc(m.lang.toUpperCase()) + ' · instructions: ' +
esc(m.instruction) + '</span></div>' +
((m.state === 'done' || m.state === 'failed')
? '<div class="fm-set-row"><button type="button" class="fm-btn" data-act="reset">🔄 New transcription</button></div>'
: '');
}
return '<div class="fm-settings">' + inner + '</div>';
}
function renderBody(bid, m) {
var tab = m.tab || defaultTab(m.state);
if (m.state === 'idle') return renderIdleBody(m);
if (m.state === 'processing')
return tab === 'summary' ? renderSteps(m) : renderBodyTab(bid, m, tab);
if (m.state === 'done' || m.state === 'failed')
return tab === 'summary' ? renderSummary(bid, m) : renderBodyTab(bid, m, tab);
return renderBodyTab(bid, m, tab); // recording / paused
}
function renderIdleBody(m) {
var stt = hasSTT(), tab = hasDisplayMedia();
return '<div class="fm-body"><div class="fm-promise">' +
'FlowDeck AI will summarize the notes and transcript</div>' +
'<div class="fm-setupstrip" aria-label="Setup check">' +
'<span data-fm-micperm>○ Microphone…</span>' +
'<span>' + (stt ? '✓ Live transcription' : '⚠ Live transcription unavailable — manual fallback') + '</span>' +
'<span>' + (tab ? '✓ Tab + mic' : '○ Tab + mic unsupported here') + '</span>' +
'<button type="button" class="fm-btn fm-chipbtn" data-act="testmic">🎤 Test microphone</button>' +
'</div>' +
'<textarea class="fm-area fm-notes" data-fm="notes" rows="3"' +
' placeholder="Add an agenda or context here — the AI uses it in the summary…">' +
esc(m.notes) + '</textarea>' +
'<div class="fm-hint">Press \'space\' for AI or \'/\' for commands</div></div>';
}
// État de la permission micro (async, sans repaint : pas de perte de focus).
function refreshMicPerm(bid) {
var rt = FDMeeting._rt[bid];
if (!rt || !rt.root) return;
if (!rt.root.querySelector('[data-fm-micperm]')) return;
function set(txt) {
var r2 = FDMeeting._rt[bid];
if (!r2 || !r2.root) return;
var el = r2.root.querySelector('[data-fm-micperm]');
if (el) el.textContent = txt;
}
try {
if (!navigator.permissions || !navigator.permissions.query) {
set('○ Microphone — permission unknown'); return;
}
navigator.permissions.query({ name: 'microphone' }).then(function (st) {
var label = st.state === 'granted' ? '✓ Microphone allowed'
: st.state === 'denied'
? '✕ Microphone blocked — allow it in the address bar, or paste a transcript'
: '○ Microphone — requested at Start (or Test now)';
set(label);
try { st.onchange = function () { refreshMicPerm(bid); }; } catch (e) {}
}).catch(function () { set('○ Microphone — permission unknown'); });
} catch (e) { set('○ Microphone — permission unknown'); }
}
function renderBodyTab(bid, m, tab) {
if (tab === 'notes') {
return '<div class="fm-body"><textarea class="fm-area fm-notes" data-fm="notes" rows="6"' +
' placeholder="Take live notes — transcription keeps running in the background…">' +
esc(m.notes) + '</textarea>' +
'<div class="fm-hint">Press \'space\' for AI or \'/\' for commands</div></div>';
}
return renderTranscript(bid, m);
}
function groupSegments(m) {
var groups = [], byKey = {};
(m.segments || []).forEach(function (s) {
var key = s.source || (s.channel === 'remote' ? 'Tab audio' : 'Microphone');
if (!byKey[key]) { byKey[key] = []; groups.push({ source: key, items: byKey[key] }); }
byKey[key].push(s);
});
return groups;
}
function renderTranscript(bid, m) {
var groups = groupSegments(m);
var live = (m.state === 'recording' || m.state === 'paused');
var h = '<div class="fm-body"><div class="fm-transcript" data-fm-transcript>';
if (!groups.length && !m.interim && !m.manual) {
h += '<div class="fm-empty">The transcription will appear here live. ' +
'Record the meeting to generate a transcript.</div>';
}
groups.forEach(function (g) {
h += '<div class="fm-src">◎ ' + esc(g.source) + '</div>';
g.items.forEach(function (s) {
h += '<div class="fm-seg" data-seq="' + s.seq + '">' +
'<span class="fm-ts">' + esc(fmtTs(s.startMs)) + '</span>' +
'<span class="fm-rail"></span>' +
'<span class="fm-seg-body"><button type="button" class="fm-who" data-act="speaker"' +
' data-seq="' + s.seq + '" title="Correct speaker">' +
esc(s.speaker || (s.channel === 'remote' ? 'Other' : 'You')) + '</button>' +
'<span class="fm-txt">' + esc(s.text) + '</span></span></div>';
});
});
if (m.interim)
h += '<div class="fm-seg fm-interim"><span class="fm-ts">·</span>' +
'<span class="fm-rail"></span><span class="fm-txt">' + esc(m.interim) + '…</span></div>';
h += '</div>';
var stt = hasSTT();
h += '<details class="fm-manual"' + (m.manual || !stt ? ' open' : '') + '>' +
'<summary>Manual transcript (paste, or import audio below)</summary>' +
'<textarea class="fm-area" data-fm="manual" rows="4"' +
' placeholder="Paste the meeting transcript here…">' + esc(m.manual) + '</textarea>' +
'<div class="fm-manual-row"><label class="fm-btn">🎧 Import audio' +
'<input type="file" accept="audio/*" data-fm="importAudio" hidden></label>' +
'<span class="fm-dim">MP3 · WAV · M4A · OGG · FLAC · AAC (100 MB max)</span></div>' +
'</details>';
h += '</div>';
return h;
}
function renderSteps(m) {
var steps = m.steps && m.steps.length ? m.steps : THINKING_STEPS.map(function (s) {
return { key: s[0], label: s[1], state: 'pending' };
});
var h = '<div class="fm-body"><div class="fm-steps">';
steps.forEach(function (s) {
var icon = s.state === 'done' ? '✓' : s.state === 'running' ? '⊙'
: s.state === 'failed' ? '✕' : '○';
h += '<div class="fm-step ' + esc(s.state) + '"><span class="fm-step-ico">' +
icon + '</span><span>' + esc(s.label) + '</span></div>';
});
h += '</div></div>';
return h;
}
function renderSummaryMd(md) {
// Sections ## + puces • + marqueurs ¹ [seg N] → HTML sûr.
var h = esc(md || '');
h = h.replace(/^## (.+)$/gm, '</div><div class="fm-sum-sec"><h4>$1</h4>');
h = '<div class="fm-sum-sec">' + h + '</div>';
h = h.replace(/^•\s?(.*)$/gm, '<div class="fm-bullet">•<span>$1</span></div>');
h = h.replace(/ ⁠¹ \[seg (\d+)\]/g,
' <button type="button" class="fm-cite" data-act="cite" data-seq="$1" title="Show in transcript">¹</button>');
h = h.replace(/ ⁠¹/g,
' <span class="fm-cite-static" title="Sourced from the transcript">¹</span>');
h = h.replace(/^_([^_]+)_$/gm, '<div class="fm-sum-note">$1</div>');
h = h.replace(/\n/g, '<br>');
return '<div class="fm-summary">' + h + '</div>';
}
function renderSummary(bid, m) {
var h = '<div class="fm-body">';
if (m.state === 'failed') {
h += '<div class="fm-error">' + esc(m.error || 'Processing failed.') +
' The transcript is kept — retry when ready.</div>' +
'<div class="fm-actions"><button type="button" class="fm-btn fm-btn-primary"' +
' data-act="retry">Retry processing</button></div>';
if (m.segments.length) h += renderTranscript(bid, m).replace(
'<div class="fm-body">', '<div>');
return h + '</div>';
}
if (!m.shareHidden && m.summaryMd) {
h += '<div class="fm-sharebar"><span>Share this summary</span><span class="fm-share-btns">' +
'<button type="button" class="fm-btn fm-chipbtn" data-act="share" data-v="copy_link">⧉ Copy link</button>' +
'<button type="button" class="fm-btn fm-chipbtn" data-act="share" data-v="email">✉ Email</button>' +
'<button type="button" class="fm-btn fm-chipbtn" data-act="share" data-v="slack">Slack</button>' +
'<button type="button" class="fm-icon" data-act="sharehide" title="Hide">✕</button>' +
'</span></div>';
}
if (m.completeness && m.completeness !== 'complete')
h += '<div class="fm-class">Summary class: ' + esc(m.completeness) +
' — partial recording, verify against the transcript.</div>';
h += renderSummaryMd(m.summaryMd);
h += '<div class="fm-actions"><button type="button" class="fm-btn" data-act="regen">↻ Regenerate</button></div>';
return h + '</div>';
}
function renderFoot(bid, m) {
if (m.state === 'idle') {
return '<div class="fm-foot"><span class="fm-instr">Instructions:' +
' <select data-fm="instruction" aria-label="Summary instructions">' +
INSTRUCTIONS.map(function (o) {
return '<option value="' + o[0] + '"' +
(m.instruction === o[0] ? ' selected' : '') + '>' + esc(o[1]) + '</option>';
}).join('') + '</select></span>' +
'<span class="fm-sep">│</span>' +
'<span class="fm-consent">By starting, you confirm everyone being transcribed has given consent.</span>' +
'<span class="fm-foot-icons"><button type="button" class="fm-icon" data-act="consentAudio" title="Play spoken consent message">♪</button>' +
'<button type="button" class="fm-icon" data-act="consentCopy" title="Copy consent message">⧉</button></span></div>';
}
if (m.bannerHidden) return '';
return '<div class="fm-banner"><span>✨ <b>AI Meeting Notes</b> — live transcription' +
' in your browser · audio is ephemeral, transcript is kept.</span>' +
'<button type="button" class="fm-icon" data-act="bannerhide" title="Dismiss">✕</button></div>';
}
function html(bid) {
var b = ensure(bid);
if (!b) return '';
var m = b.meeting;
var warn = '';
if ((m.state === 'recording') && m.remoteSilentWarn && !m.remoteSilentWarned)
warn = '<div class="fm-warn">🔇 I can only hear you. If the call is in another' +
' tab, check its audio is shared (Tab + microphone mode).</div>';
else if ((m.state === 'recording') && m.micLowWarn)
warn = '<div class="fm-warn">🎤 Microphone level is very low — move closer or check input.</div>';
return '<div class="fm" data-state="' + esc(m.state) + '">' +
renderHead(m) + renderRow(bid, m) + warn + renderBody(bid, m) + renderFoot(bid, m) + '</div>';
}
/* ── elapsed ─────────────────────────────────────────────────── */
function elapsed(m) {
if (m.state === 'recording') return (m.elapsedBase || 0) + (Date.now() - (m.startedAt || Date.now()));
if (m.state === 'paused' || m.state === 'processing' ||
m.state === 'done' || m.state === 'failed')
return m.elapsedBase || 0;
return 0;
}
/* ── moteur de capture (modes A/B, §6.2) ─────────────────────── */
function hasSTT() {
return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
}
function hasDisplayMedia() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
}
function stopTracks(rt) {
['micStream', 'tabStream'].forEach(function (k) {
var s = rt[k];
if (s) try { s.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
rt[k] = null;
});
if (rt.audioCtx) try { rt.audioCtx.close(); } catch (e) {}
rt.audioCtx = null; rt.analyserSelf = null; rt.analyserRemote = null;
if (rt.waveRaf) try { cancelAnimationFrame(rt.waveRaf); } catch (e) {}
rt.waveRaf = null;
}
function stopRecog(rt) {
if (rt.recog) { rt.recogStopping = true; try { rt.recog.stop(); } catch (e) {} }
rt.recog = null;
}
function teardownCapture(bid) {
var rt = FDMeeting._rt[bid] || {};
stopRecog(rt); stopTracks(rt);
if (rt.timer) { try { clearInterval(rt.timer); } catch (e) {} rt.timer = null; }
if (rt.poller) { try { clearInterval(rt.poller); } catch (e) {} rt.poller = null; }
}
function rms(analyser, buf) {
if (!analyser) return 0;
analyser.getByteTimeDomainData(buf);
var sum = 0;
for (var i = 0; i < buf.length; i++) {
var v = (buf[i] - 128) / 128; sum += v * v;
}
return Math.sqrt(sum / buf.length);
}
function attachAnalyser(rt, stream) {
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
if (!rt.audioCtx) { try { rt.audioCtx = new Ctx(); } catch (e) { return null; } }
try {
var src = rt.audioCtx.createMediaStreamSource(stream);
var an = rt.audioCtx.createAnalyser();
an.fftSize = 512;
src.connect(an);
return an;
} catch (e) { return null; }
}
function startWave(bid, rt) {
var bars = rt.waveBars || (rt.waveBars = new Array(28).fill(0.05));
var bufS = new Uint8Array(512), bufR = new Uint8Array(512);
var last = 0, remoteQuietSince = 0;
function frame(t) {
var b = findBlock(bid);
if (!b || b.meeting.state !== 'recording') return;
if (t - last > 100) {
last = t;
var lvS = rms(rt.analyserSelf, bufS);
var lvR = rt.analyserRemote ? rms(rt.analyserRemote, bufR) : 0;
var lv = Math.max(lvS, lvR);
bars.push(Math.min(1, lv * 6 + 0.04)); bars.shift();
if (rt.analyserRemote) {
if (lvR < 0.015 && lvS > 0.02) {
if (!remoteQuietSince) remoteQuietSince = Date.now();
else if (Date.now() - remoteQuietSince > 20000 && !b.meeting.remoteSilentWarn) {
b.meeting.remoteSilentWarn = true; save(); FDMeeting.paint(bid);
}
} else { remoteQuietSince = 0; }
}
if (lvS < 0.008) {
rt.lowSince = rt.lowSince || Date.now();
if (Date.now() - rt.lowSince > 20000 && !b.meeting.micLowWarn) {
b.meeting.micLowWarn = true; save(); FDMeeting.paint(bid);
}
} else { rt.lowSince = 0; }
drawWave(bid, bars);
}
rt.waveRaf = requestAnimationFrame(frame);
}
rt.waveRaf = requestAnimationFrame(frame);
}
function drawWave(bid, bars) {
var rt = FDMeeting._rt[bid];
if (!rt || !rt.root) return;
var cv = rt.root.querySelector('[data-fm-wave]');
if (!cv) return;
var ctx = cv.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, cv.width, cv.height);
var n = bars.length, w = cv.width / n;
ctx.fillStyle = '#2383E2';
for (var i = 0; i < n; i++) {
var h = Math.max(2, bars[i] * cv.height);
var x = i * w + w * 0.25;
ctx.beginPath();
if (bars[i] < 0.12) {
ctx.arc(x + w * 0.25, cv.height / 2, 1.4, 0, 7);
ctx.fill();
} else {
ctx.fillRect(x, (cv.height - h) / 2, w * 0.5, h);
}
}
}
function startTimer(bid) {
var rt = FDMeeting._rt[bid] || {};
if (rt.timer) try { clearInterval(rt.timer); } catch (e) {}
rt.timer = setInterval(function () {
var b = findBlock(bid);
if (!b || (b.meeting.state !== 'recording' && b.meeting.state !== 'paused')) {
try { clearInterval(rt.timer); } catch (e2) {}
return;
}
var el = rt.root && rt.root.querySelector('[data-fm-timer]');
if (el) el.textContent = fmtClock(elapsed(b.meeting));
}, 1000);
}
function pushSegment(bid, seg) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
m.segSeq = (m.segSeq || 0) + 1;
var item = {
seq: m.segSeq, channel: seg.channel || 'self',
source: seg.source || '', speaker: seg.speaker || '',
startMs: seg.startMs != null ? seg.startMs : elapsed(m),
endMs: seg.endMs != null ? seg.endMs : elapsed(m),
text: (seg.mid ? '--' : '') + (seg.text || ''), mid: !!seg.mid
};
m.segments.push(item);
save();
// tampon local + envoi serveur (reprise sur rechargement).
idbGet(bid).then(function (pending) {
pending.push({
channel: item.channel, source_label: item.source, speaker: item.speaker,
text: (seg.text || ''), start_ms: item.startMs, end_ms: item.endMs,
starts_mid: item.mid
});
idbPut(bid, pending);
flushSegments(bid);
});
// rafraîchir seulement la zone transcript (pas de repaint complet).
var rt = FDMeeting._rt[bid];
if (rt && rt.root && (m.tab === 'transcript' || !m.tab)) {
var body = rt.root.querySelector('[data-fm-transcript]');
if (body) {
var tmp = document.createElement('div');
tmp.innerHTML = renderTranscript(bid, m);
var fresh = tmp.querySelector('[data-fm-transcript]');
if (fresh) body.innerHTML = fresh.innerHTML;
}
}
}
var flushBusy = {};
function flushSegments(bid) {
var b = findBlock(bid);
if (!b || !b.meeting.transcriptId || flushBusy[bid]) return;
flushBusy[bid] = true;
idbGet(bid).then(function (pending) {
if (!pending.length) { flushBusy[bid] = false; return; }
var batch = pending.slice(0, 50);
api('/api/v2/meetings/transcripts/' + b.meeting.transcriptId + '/segments', {
method: 'POST', body: { segments: batch }
}).then(function () {
idbPut(bid, pending.slice(batch.length));
flushBusy[bid] = false;
if (pending.length > batch.length) flushSegments(bid);
}).catch(function () { flushBusy[bid] = false; });
});
}
// Vide le tampon local vers le serveur avant toute analyse : le manuel
// collé après un échec (cas du 400 en boucle) part lui aussi — c'était
// le chaînon manquant. Résout true si le serveur a tout, false sinon.
function drainOutbox(bid, timeoutMs) {
var b = findBlock(bid);
if (b && (b.meeting.manual || '').trim() && b.meeting.transcriptId) {
var pasted = b.meeting.manual.trim();
b.meeting.manual = '';
save();
FDMeeting._pushSegment(bid, {
channel: 'self', source: 'Pasted transcript', speaker: '',
text: pasted, startMs: 0, endMs: elapsed(b.meeting), mid: false
});
} else {
flushSegments(bid);
}
var bb = findBlock(bid);
if (!bb || !bb.meeting.transcriptId) return Promise.resolve(true);
var waited = 0, step = 400, max = timeoutMs || 5000;
return new Promise(function (resolve) {
var iv = setInterval(function () {
idbGet(bid).then(function (pending) {
if (!pending.length || waited >= max) {
clearInterval(iv);
resolve(!pending.length);
}
waited += step;
});
}, step);
});
}
// Réponse d'analyse (/stop ou /retry) → état done, dans tous les cas.
function applyDone(bid, out) {
var bb = findBlock(bid);
if (!bb) return;
var rtx = FDMeeting._rt[bid];
if (rtx) rtx.allowEmpty = false;
bb.meeting.state = 'done';
bb.meeting.summaryMd = (out && out.summary) || '';
bb.meeting.completeness = (out && out.completeness) || '';
bb.meeting.genTitle = (out && out.generated_title) || '';
bb.meeting.error = '';
save(); FDMeeting.paint(bid);
idbPut(bid, []);
}
function applyFailed(bid, err, defaultMsg) {
var bb = findBlock(bid);
if (!bb) return;
var rtx = FDMeeting._rt[bid];
if (rtx) rtx.allowEmpty = false;
bb.meeting.state = 'failed';
bb.meeting.error = err ? friendlyMeetingError(err)
: (defaultMsg || 'Processing failed — the transcript is kept, Retry when ready.');
save(); FDMeeting.paint(bid);
}
function startRecognition(bid, rt, m) {
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return false;
stopRecog(rt);
var rec;
try { rec = new SR(); } catch (e) { return false; }
rt.recog = rec; rt.recogStopping = false;
rec.lang = m.lang === 'en' ? 'en-US' : 'fr-FR';
rec.continuous = true; rec.interimResults = true; rec.maxAlternatives = 1;
var base = 0;
rec.onresult = function (ev) {
var b = findBlock(bid);
if (!b) return;
var finalText = '', interim = '';
for (var i = base; i < ev.results.length; i++) {
var r = ev.results[i];
var t = (r[0] && r[0].transcript) || '';
if (r.isFinal) finalText += t; else interim += t;
}
base = ev.results.length;
if (finalText.trim()) {
var firstEver = b.meeting.segments.length === 0;
FDMeeting._pushSegment(bid, {
channel: 'self',
source: m.mode === 'tab_plus_mic' ? 'My microphone' : "My audio",
speaker: '',
text: finalText.trim(),
// Heuristique §3A.7 : si le premier segment arrive alors que la
// session tourne depuis un moment, on a pris en pleine phrase.
mid: firstEver && elapsed(b.meeting) > 5000
});
b.meeting.interim = '';
} else {
b.meeting.interim = interim.trim();
var el = rt.root && rt.root.querySelector('.fm-interim .fm-txt');
if (el) el.textContent = (b.meeting.interim || '') + '…';
}
};
rec.onend = function () {
var b2 = findBlock(bid);
if (!rt.recogStopping && b2 && b2.meeting.state === 'recording') {
try { rec.start(); } catch (e) {}
}
};
rec.onerror = function (ev) {
if (ev && ev.error === 'not-allowed') {
var b3 = findBlock(bid);
if (b3) {
toast('Microphone refused — allow the mic or use the manual transcript.');
paintBody(bid);
}
}
};
try { rec.start(); return true; }
catch (e) { return false; }
}
function paintBody(bid) { FDMeeting.paint(bid); }
function elapsedOf(m) { return elapsed(m); }
/* ── objet public (surcharge) ────────────────────────────────── */
var FDMeeting = {
_rt: {},
mount: function (bid, root) {
var rt = this._rt[bid] || (this._rt[bid] = {});
rt.root = root;
if (!rt.wired) {
rt.wired = true;
var self = this;
root.addEventListener('click', function (ev) {
var t = ev.target && ev.target.closest
? ev.target.closest('[data-act]') : null;
if (t) self.act(bid, t.getAttribute('data-act'), t, ev);
});
root.addEventListener('input', function (ev) { self.onInput(bid, ev.target, ev); });
root.addEventListener('change', function (ev) { self.onInput(bid, ev.target, ev); });
}
var b = ensure(bid);
if (b) {
var m = b.meeting;
// Rechargement en pleine session : restaurer l'état exact (§6.5).
if (m.state === 'recording' || m.state === 'paused') {
if (m.startedAt && m.state === 'recording') {
m.elapsedBase = elapsed(m);
m.state = 'paused';
toast('Capture interrupted by reload — press Resume to continue.');
save();
}
} else if (m.state === 'processing') {
this.followProcessing(bid);
}
// Rejouer le tampon local vers le serveur.
if (m.transcriptId) flushSegments(bid);
if (m.state === 'recording') startTimer(bid);
}
this.paint(bid);
},
focus: function () {},
paint: function (bid) {
var rt = this._rt[bid], b = ensure(bid);
if (!b || !rt || !rt.root) return;
rt.root.innerHTML = html(bid);
if (b.meeting.state === 'idle') refreshMicPerm(bid);
if (b.meeting.state === 'recording') {
startTimer(bid);
if (!rt.waveRaf && (rt.analyserSelf || !hasSTT())) startWave(bid, rt);
}
},
onInput: function (bid, el) {
var b = ensure(bid);
if (!b || !el) return;
var m = b.meeting, k = el.getAttribute && el.getAttribute('data-fm');
if (k === 'title') {
var v = el.value || '';
if (m.genTitle && !m.titleEdited && v !== m.genTitle && v !== m.title) {
m.titleEdited = true; m.title = v;
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/generated-title',
{ method: 'PATCH', body: { title: v } }).catch(function () {});
} else if (!m.genTitle || m.titleEdited) { m.title = v; }
else if (v !== m.genTitle) { m.titleEdited = true; m.title = v; }
save(); return; // pas de repaint (garder le focus)
}
if (k === 'notes') {
m.notes = el.value;
save();
if (m.transcriptId) {
clearTimeout(this._notesT);
this._notesT = setTimeout(function () {
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/notes',
{ method: 'PATCH', body: { notes: m.notes } }).catch(function () {});
}, 1200);
}
return;
}
if (k === 'manual') { m.manual = el.value; save(); return; }
if (k === 'instruction') { m.instruction = el.value; save(); this.paint(bid); return; }
if (k === 'consentAudio') { m.consentAudio = !!el.checked; save(); return; }
if (el.getAttribute && el.getAttribute('data-fm') === 'importAudio' && el.files) {
this.importAudio(bid, el.files[0]);
return;
}
// input file import audio (attribut data-fm="importAudio")
if (el.type === 'file' && el.files) { this.importAudio(bid, el.files[0]); }
},
act: function (bid, act, el, ev) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (ev && (act === 'mode' || act === 'lang')) ev.stopPropagation();
switch (act) {
case 'tab':
m.tab = (el && el.getAttribute('data-tab')) || defaultTab(m.state);
save(); this.paint(bid); break;
case 'menu':
m.menuOpen = !m.menuOpen; save(); this.paint(bid); break;
case 'settings':
m.settingsOpen = !m.settingsOpen; save(); this.paint(bid); break;
case 'testmic': this.testMic(bid); break;
case 'mode':
m.mode = el.getAttribute('data-v'); m.menuOpen = false;
save(); this.paint(bid); break;
case 'lang':
m.lang = el.getAttribute('data-v'); m.menuOpen = false;
save(); this.paint(bid); break;
case 'suggest': this.suggest(bid); break;
case 'consentAudio': this.playConsent(); break;
case 'consentCopy':
copyText(CONSENT_TEXT);
toast('Consent message copied — paste it in the call chat.'); break;
case 'bannerhide':
m.bannerHidden = true; save(); this.paint(bid); break;
case 'sharehide':
m.shareHidden = true; save(); this.paint(bid); break;
case 'start': this.start(bid); break;
case 'pause': this.pause(bid); break;
case 'resume': this.resume(bid); break;
case 'stop': this.stop(bid); break;
case 'retry': this.retry(bid); break;
case 'regen': this.regen(bid); break;
case 'reset': this.reset(bid); break;
case 'cite': {
var seq = parseInt(el.getAttribute('data-seq'), 10);
m.tab = 'transcript'; save(); this.paint(bid);
setTimeout(function () {
var rt = FDMeeting._rt[bid];
var target = rt && rt.root &&
rt.root.querySelector('.fm-seg[data-seq="' + seq + '"]');
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('fm-flash');
setTimeout(function () { target.classList.remove('fm-flash'); }, 2200);
}
}, 60);
break;
}
case 'speaker': {
var sq = parseInt(el.getAttribute('data-seq'), 10);
var cur = '';
m.segments.forEach(function (s) { if (s.seq === sq) cur = s.speaker || ''; });
var name = window.prompt('Speaker name for segment ' + sq + ':', cur || 'You');
if (name === null) break;
m.segments.forEach(function (s) { if (s.seq === sq) s.speaker = name.trim(); });
save();
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId +
'/segments/' + sq + '/speaker',
{ method: 'PATCH', body: { speaker: name.trim() } }).catch(function () {});
this.paint(bid);
break;
}
case 'share': this.share(bid, el.getAttribute('data-v')); break;
}
},
suggest: function (bid) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (!m.notes.trim()) {
m.notes = 'Agenda\n• …\n\nContext\n• …\n';
save(); this.paint(bid);
toast('Agenda template added — the AI uses it in the summary.');
} else {
toast('Tip: write decisions and names in Notes — they take priority in the summary.');
}
},
playConsent: function () {
try {
var u = new SpeechSynthesisUtterance(CONSENT_TEXT);
u.lang = 'en-US';
window.speechSynthesis.speak(u);
} catch (e) { toast(CONSENT_TEXT); }
},
/* ── cycle de vie ─────────────────────────────────────────── */
ensureServerRow: function (bid, m) {
if (m.transcriptId) return Promise.resolve(m.transcriptId);
var pid = (window.E && window.E.pid) || 0;
if (!pid) return Promise.reject(new Error('Open the page first (no page id).'));
return api('/api/v2/meetings/transcripts', {
method: 'POST',
body: {
page_id: pid, language: m.lang, mode: m.mode,
instruction_id: m.instruction
}
}).then(function (row) {
m.transcriptId = row.id;
save();
return row.id;
});
},
start: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
m.menuOpen = false;
if (m.state === 'recording' || m.state === 'processing') return;
// Porte de consentement : le clic sur Start vaut attestation (§3A.2).
this.ensureServerRow(bid, m).then(function (tid) {
return api('/api/v2/meetings/transcripts/' + tid + '/consents', {
method: 'POST',
body: { method: 'start_attestation', participants: [] }
}).then(function () { return tid; });
}).then(function (tid) {
m.consent = { method: 'start_attestation', at: Date.now() };
return api('/api/v2/meetings/transcripts/' + tid + '/sessions', {
method: 'POST',
body: {
mode: m.mode, language: m.lang, instruction_id: m.instruction,
channels: m.mode === 'tab_plus_mic'
? [{ kind: 'self', label: 'My microphone' }, { kind: 'remote', label: 'Tab audio' }]
: [{ kind: 'self', label: 'My audio' }]
}
});
}).then(function () {
m.state = 'recording';
m.tab = m.tab === 'summary' ? 'transcript' : (m.tab || 'transcript');
m.startedAt = Date.now();
m.remoteSilentWarn = false; m.micLowWarn = false; m.remoteSilentWarned = false;
m.steps = []; m.summaryMd = ''; m.error = '';
m.channels = m.mode === 'tab_plus_mic'
? [{ kind: 'self', label: 'My microphone' }, { kind: 'remote', label: 'Tab audio' }]
: [{ kind: 'self', label: 'My audio' }];
if (m.consentAudio) self.playConsent();
save(); self.paint(bid);
self.acquireAudio(bid);
}).catch(function (err) {
m.error = friendlyMeetingError(err);
save(); self.paint(bid);
toast(m.error);
});
this.paint(bid);
},
acquireAudio: function (bid) {
var self = this, b = findBlock(bid);
if (!b) return;
var m = b.meeting, rt = this._rt[bid] || (this._rt[bid] = {});
teardownCaptureKeepTimer(rt);
var wantTab = m.mode === 'tab_plus_mic';
function afterStreams() {
if (rt.micStream) rt.analyserSelf = attachAnalyser(rt, rt.micStream);
if (rt.tabStream) rt.analyserRemote = attachAnalyser(rt, rt.tabStream);
startWave(bid, rt);
var ok = startRecognition(bid, rt, m);
if (!ok && !rt.micStream && !rt.tabStream) {
toast('No audio capture available — use the manual transcript.');
}
self.paint(bid);
}
function micOnly() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
afterStreams(); return;
}
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) {
rt.micStream = s; afterStreams();
}).catch(function () { afterStreams(); });
}
if (wantTab) {
if (!hasDisplayMedia()) {
toast('Tab capture unsupported here — microphone mode used instead.');
m.mode = 'mic_only'; save(); micOnly(); return;
}
toast('Choose the call tab and tick “Share audio”.');
navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })
.then(function (tab) {
rt.tabStream = tab;
// Couper la vidéo : seul l'audio nous intéresse.
try {
tab.getVideoTracks().forEach(function (t) { t.stop(); });
} catch (e) {}
if (!navigator.mediaDevices.getUserMedia) { afterStreams(); return; }
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) {
rt.micStream = s; afterStreams();
}).catch(function () { afterStreams(); });
})
.catch(function () {
toast('Tab share cancelled — microphone mode used instead.');
m.mode = 'mic_only'; save(); micOnly();
});
} else { micOnly(); }
},
pause: function (bid) {
var self = this, b = ensure(bid);
if (!b || b.meeting.state !== 'recording') return;
var m = b.meeting, rt = this._rt[bid] || {};
m.elapsedBase = elapsed(m);
var delta = 0;
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/pause',
{ method: 'POST', body: {} }).catch(function () {});
stopRecog(rt);
if (rt.waveRaf) try { cancelAnimationFrame(rt.waveRaf); } catch (e) {}
rt.waveRaf = null;
m.state = 'paused';
save(); self.paint(bid);
},
resume: function (bid) {
var self = this, b = ensure(bid);
if (!b || b.meeting.state !== 'paused') return;
var m = b.meeting, rt = this._rt[bid] || (this._rt[bid] = {});
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/resume',
{ method: 'POST' }).catch(function () {});
m.startedAt = Date.now();
m.state = 'recording';
save(); self.paint(bid);
// Reprendre la reconnaissance vocale (les flux coupés sont réacquis).
if (!rt.micStream && !rt.tabStream) self.acquireAudio(bid);
else {
startWave(bid, rt);
startRecognition(bid, rt, m);
startTimer(bid);
}
},
stop: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (m.state !== 'recording' && m.state !== 'paused') return;
var rt = this._rt[bid] || (this._rt[bid] = {});
// Garde-fou : arrêter à vide effacerait la prise — proposer d'abord
// de vérifier le micro ou de coller un transcript (§ point 5).
if (!hasCaptured(m) && !rt.allowEmpty) {
if (!confirmStopEmpty()) return;
rt.allowEmpty = true;
}
var allowEmpty = !!rt.allowEmpty;
m.elapsedBase = elapsed(m);
teardownCapture(bid);
// Verser le manuel éventuel dans les segments avant l'analyse
// (via pushSegment : tampon local + envoi serveur).
if (m.manual.trim()) {
var pasted = m.manual.trim();
m.manual = '';
pushSegment(bid, {
channel: 'self', source: 'Pasted transcript', speaker: '',
text: pasted, startMs: 0, endMs: m.elapsedBase, mid: false
});
}
self.flushManualNotes(bid);
m.state = 'processing';
m.tab = 'summary';
m.steps = THINKING_STEPS.map(function (s, i) {
return { key: s[0], label: s[1], state: i === 0 ? 'running' : 'pending' };
});
save(); self.paint(bid);
function finishWith(out) {
applyDone(bid, out);
var bb = findBlock(bid);
if (!bb) return;
var mm = bb.meeting;
// Étapes finales depuis le serveur (source de vérité).
if (mm.transcriptId)
api('/api/v2/meetings/transcripts/' + mm.transcriptId + '/processing-steps', {})
.then(function (st) {
var bbb = findBlock(bid);
if (bbb && st.steps && st.steps.length) {
bbb.meeting.steps = st.steps;
save(); self.paint(bid);
}
}).catch(function () {});
}
if (!m.transcriptId) {
// 100 % local (page non sauvegardée) : résumé via l'agent.
this.summarizeLocal(bid);
return;
}
// Drainer d'abord : appeler /stop avec un tampon non parti condamnait
// à un 400 alors même que les segments existaient en local.
drainOutbox(bid, 5000).then(function (drained) {
if (!drained) {
// Session serveur conservée (toujours live) : Retry renverra.
applyFailed(bid, null,
'Could not upload the transcript — check your connection, then Retry. Nothing was lost.');
return;
}
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/stop', {
method: 'POST', body: { duration_ms: m.elapsedBase, allow_empty: allowEmpty }
}).then(finishWith).catch(function (err) {
applyFailed(bid, err);
});
});
this.followProcessing(bid);
},
followProcessing: function (bid) {
var self = this, rt = this._rt[bid] || (this._rt[bid] = {});
if (rt.poller) try { clearInterval(rt.poller); } catch (e) {}
var b = findBlock(bid);
if (!b || !b.meeting.transcriptId) return;
var tid = b.meeting.transcriptId;
rt.poller = setInterval(function () {
var bb = findBlock(bid);
if (!bb || bb.meeting.state !== 'processing') {
try { clearInterval(rt.poller); } catch (e2) {}
return;
}
api('/api/v2/meetings/transcripts/' + tid + '/processing-steps', {})
.then(function (st) {
var bbb = findBlock(bid);
if (!bbb || bbb.meeting.state !== 'processing') return;
if (st.steps && st.steps.length) {
// Ne pas écraser par une liste vide ; animer running→done.
var cur = {};
bbb.meeting.steps.forEach(function (s) { cur[s.key] = s.state; });
var changed = false;
st.steps.forEach(function (s) {
if (cur[s.key] !== s.state) changed = true;
});
if (changed || !bbb.meeting.steps.length) {
bbb.meeting.steps = st.steps;
save(); self.paint(bid);
}
}
if (st.status === 'done') {
try { clearInterval(rt.poller); } catch (e3) {}
api('/api/v2/meetings/transcripts/' + tid, {}).then(function (row) {
var b4 = findBlock(bid);
if (!b4) return;
b4.meeting.state = 'done';
b4.meeting.summaryMd = row.summary || b4.meeting.summaryMd;
b4.meeting.completeness = row.completeness_class || '';
b4.meeting.genTitle = row.generated_title || '';
save(); self.paint(bid);
idbPut(bid, []);
}).catch(function () {});
} else if (st.status === 'failed') {
try { clearInterval(rt.poller); } catch (e4) {}
}
}).catch(function () {});
}, 1500);
},
// Retry intelligent : verse d'abord le local (le manuel collé après un
// échec part lui aussi — fin du 400 en boucle), puis termine par /stop
// si la session serveur est encore en vie, sinon /processing:retry.
retry: function (bid) {
var self = this, b = ensure(bid);
if (!b || !b.meeting.transcriptId) { this.summarizeLocal(bid); return; }
var m = b.meeting;
var rt = this._rt[bid] || (this._rt[bid] = {});
if (!hasCaptured(m) && !(m.manual || '').trim() && !rt.allowEmpty) {
if (!confirmStopEmpty()) return;
rt.allowEmpty = true;
}
var allowEmpty = !!rt.allowEmpty;
var tid = m.transcriptId;
m.state = 'processing'; m.tab = 'summary'; m.error = '';
save(); self.paint(bid);
drainOutbox(bid, 5000).then(function (drained) {
if (!drained) {
applyFailed(bid, null,
'Could not upload the transcript — check your connection, then Retry. Nothing was lost.');
return;
}
api('/api/v2/meetings/transcripts/' + tid + '/processing-steps', {})
.then(function (st) {
if (st.status === 'recording' || st.status === 'paused') {
var bb = findBlock(bid);
var dur = bb ? (bb.meeting.elapsedBase || 0) : 0;
return api('/api/v2/meetings/transcripts/' + tid + '/stop', {
method: 'POST', body: { duration_ms: dur, allow_empty: allowEmpty }
});
}
return api('/api/v2/meetings/transcripts/' + tid + '/processing:retry',
{ method: 'POST', body: { allow_empty: allowEmpty } });
}).then(function (out) {
applyDone(bid, out);
}).catch(function (err) {
applyFailed(bid, err);
});
});
this.followProcessing(bid);
},
regen: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
if (!m.transcriptId) { this.summarizeLocal(bid); return; }
var rt = this._rt[bid] || (this._rt[bid] = {});
if (!hasCaptured(m) && !(m.manual || '').trim() && !rt.allowEmpty) {
if (!confirmStopEmpty()) return;
rt.allowEmpty = true;
}
var allowEmpty = !!rt.allowEmpty;
var tid = m.transcriptId;
m.state = 'processing'; m.tab = 'summary';
m.steps = THINKING_STEPS.map(function (s) {
return { key: s[0], label: s[1], state: 'pending' };
});
save(); self.paint(bid);
// Le manuel collé entre-temps part d'abord, sinon le serveur
// résume un transcript vide et répond 400.
drainOutbox(bid, 5000).then(function (drained) {
if (!drained) {
applyFailed(bid, null,
'Could not upload the transcript — check your connection, then Regenerate. Nothing was lost.');
return;
}
api('/api/v2/meetings/transcripts/' + tid + '/summarize',
{ method: 'POST', body: { allow_empty: allowEmpty } }).then(function (out) {
var bb = findBlock(bid);
if (!bb) return;
var rtx = FDMeeting._rt[bid];
if (rtx) rtx.allowEmpty = false;
bb.meeting.state = 'done';
bb.meeting.summaryMd = out.summary || '';
bb.meeting.completeness = out.completeness || bb.meeting.completeness;
bb.meeting.genTitle = out.generated_title || bb.meeting.genTitle;
save(); self.paint(bid);
}).catch(function (err) {
applyFailed(bid, err);
});
});
this.followProcessing(bid);
},
/* Pre-recording microphone check : 4 s live level, then guidance. */
testMic: function (bid) {
var self = this;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
toast('Microphone API unavailable in this browser — use the manual transcript.');
return;
}
toast('Microphone test — speak for 4 seconds…');
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) {
function done(msg) {
try { s.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
toast(msg);
self.paint(bid);
}
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) { done('Microphone granted (no level analysis available). You can Start.'); return; }
var ctx, an;
try {
ctx = new Ctx();
an = ctx.createAnalyser(); an.fftSize = 512;
ctx.createMediaStreamSource(s).connect(an);
} catch (e) { done('Microphone granted. You can Start.'); return; }
var buf = new Uint8Array(512), peak = 0, n = 0;
var iv = setInterval(function () {
try { an.getByteTimeDomainData(buf); } catch (e2) {}
var sum = 0, i;
for (i = 0; i < buf.length; i++) {
var v = (buf[i] - 128) / 128; sum += v * v;
}
var lv = Math.sqrt(sum / buf.length);
if (lv > peak) peak = lv;
if (++n >= 40) {
clearInterval(iv);
try { ctx.close(); } catch (e3) {}
if (peak > 0.02) done('Microphone OK — good level detected. You can Start.');
else done('Microphone granted but silent — check the input device, move closer and speak up.');
}
}, 100);
}).catch(function () {
toast('Microphone refused — allow access in the browser address bar, or use the manual transcript.');
});
},
/* Résumé 100 % local (aucune ligne serveur) via l'agent IA. */ summarizeLocal: function (bid) {
var self = this, b = ensure(bid);
if (!b) return;
var m = b.meeting;
var content = transcriptText(m);
if (!content.trim()) {
m.state = 'failed'; m.error = 'No meeting content to summarize.';
save(); self.paint(bid); return;
}
if (!(window.fdAgent && window.fdAgent.generate)) {
m.state = 'failed';
m.error = 'AI agent unavailable — configure it in Settings → Agent & AI.';
save(); self.paint(bid); return;
}
window.fdAgent.generate(
'Write the structured summary of this meeting (AI Meeting Note): one line' +
' with the date of ' + m.date + ', then “Overview”, “Decisions” and' +
' “Action Items” sections with concrete bullets. If the content is too' +
' short or fragmentary, say “No meaningful content could be extracted”.' +
' No preamble.',
content
).then(function (text) {
var bb = findBlock(bid);
if (!bb) return;
bb.meeting.state = 'done';
bb.meeting.summaryMd = (text || '').trim();
bb.meeting.completeness = 'complete';
save(); self.paint(bid);
}).catch(function (err) {
var bb2 = findBlock(bid);
if (!bb2) return;
bb2.meeting.state = 'failed';
bb2.meeting.error = (err && err.message) || 'Summary failed.';
save(); self.paint(bid);
});
},
flushManualNotes: function (bid) {
var b = findBlock(bid);
if (!b || !b.meeting.transcriptId) return;
var m = b.meeting;
if (m.notes.trim())
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/notes',
{ method: 'PATCH', body: { notes: m.notes } }).catch(function () {});
flushSegments(bid);
},
reset: function (bid) {
var b = ensure(bid);
if (!b) return;
teardownCapture(bid);
var keepTitle = b.meeting.title, keepDate = b.meeting.date;
b.meeting = defState();
b.meeting.title = keepTitle; b.meeting.date = keepDate;
save(); this.paint(bid);
},
share: function (bid, channel) {
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
function record(status) {
if (m.transcriptId)
api('/api/v2/meetings/transcripts/' + m.transcriptId + '/distributions', {
method: 'POST',
body: { channel: channel, recipients: '', status: status || 'created' }
}).catch(function () {});
}
var url = location.href.split('#')[0] + '#meeting-' + bid;
var title = displayTitle(m);
if (channel === 'copy_link') {
copyText(url); record('link_copied');
toast('Summary link copied.');
} else if (channel === 'email') {
record('created');
var body = (m.summaryMd || '').replace(/ ⁠¹( \[seg \d+\])?/g, '').slice(0, 3000);
location.href = 'mailto:?subject=' +
encodeURIComponent('Meeting summary — ' + title) +
'&body=' + encodeURIComponent(body + '\n\n' + url);
} else if (channel === 'slack') {
var txt = '*' + title + '*\n' +
(m.summaryMd || '').replace(/ ⁠¹( \[seg \d+\])?/g, '').slice(0, 2500) +
'\n' + url;
copyText(txt); record('created');
toast('Summary formatted for Slack — copied, paste it in your channel.');
}
},
importAudio: function (bid, file) {
var self = this;
if (!file) return;
var b = ensure(bid);
if (!b) return;
var m = b.meeting;
var pid = (window.E && window.E.pid) || 0;
if (!pid) { toast('Save the page first, then import audio.'); return; }
toast('Uploading audio for transcription…');
var fd = new FormData();
fd.append('audio', file);
fd.append('page_id', String(pid));
fd.append('language', m.lang || 'fr');
fetch('/api/v2/meetings/transcribe', {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRF-Token': csrf() }, body: fd
}).then(function (r) {
if (!r.ok) throw new Error('upload rejected (' + r.status + ')');
return r.json();
}).then(function (row) {
var bb = ensure(bid);
if (!bb) return;
if (row.transcript) {
bb.meeting.manual = row.transcript;
bb.meeting.state = bb.meeting.state === 'idle' ? 'idle' : bb.meeting.state;
save(); self.paint(bid);
toast('Audio transcribed — review the manual transcript, then Start → Stop to summarize.');
} else {
toast('Audio stored — no server transcription backend; paste the transcript manually.');
}
}).catch(function (err) {
toast((err && err.message) || 'Import failed.');
});
},
_pushSegment: pushSegment
};
function teardownCaptureKeepTimer(rt) {
stopRecog(rt); stopTracks(rt);
}
// Raw API failures (ex. "HTTP 400 {...}") → actionable guidance, because a
// lost take is the worst possible outcome of this block.
function friendlyMeetingError(err) {
var raw = (err && err.message) || '';
if (/transcript is empty/i.test(raw))
return 'Nothing was captured — check the microphone (Start ▾ → Test microphone), ' +
'or paste a transcript in Transcript → Manual transcript, then Retry. Your notes are kept.';
if (/consent/i.test(raw))
return 'Consent is required before analysis — Start again to record it, then Retry.';
if (/^HTTP 5/i.test(raw))
return 'The AI service is unavailable — your transcript is kept, please Retry in a moment.';
var short = raw.replace(/^HTTP \d+ /, '').replace(/\{.*\}$/, '').trim();
return short || 'Processing failed — the transcript is kept, Retry when ready.';
}
// True when the session holds nothing analyzable (local view).
function hasCaptured(m) {
return (m.segments && m.segments.length > 0) ||
((m.manual || '').trim().length > 0);
}
function confirmStopEmpty() {
return window.confirm(
'No speech has been captured yet.\n\n' +
'Before closing this take:\n' +
'• check the microphone is allowed (Start ▾ → Test microphone),\n' +
'• or paste a transcript in Transcript → Manual transcript.\n\n' +
'Stop anyway and file an empty diagnostic?');
}
function copyText(t) {
try {
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(t).catch(function () {});
else {
var ta = document.createElement('textarea');
ta.value = t; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
}
} catch (e) {}
}
function transcriptText(m) {
var out = [];
(m.segments || []).forEach(function (s) {
out.push('[' + fmtTs(s.startMs) + '] ' + (s.speaker || s.channel) + ' : ' + s.text);
});
if ((m.manual || '').trim()) out.push('(pasted transcript) ' + m.manual.trim());
if ((m.notes || '').trim()) out.push('(notes) ' + m.notes.trim());
return out.join('\n');
}
window.FDMeeting = FDMeeting;
})();