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.
1567 lines
66 KiB
JavaScript
1567 lines
66 KiB
JavaScript
/* 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
function attr(v) { return esc(v).replace(/"/g, '"'); }
|
||
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;
|
||
})();
|