fix(agent): v4.12.1 - champs cle API/URL API dans la config, AI Meeting Note en bloc de reunion interactif (transcription live + resume)
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 23:26:18 -04:00
parent c322f30801
commit b594458b6e
8 changed files with 449 additions and 15 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog — FlowDeck
## v4.12.1 (2026-09-05) — Corrections configuration Agent & IA + AI Meeting Note
> Correctifs UI sur la section **Settings → Agent & IA** et remplacement de l'ancien bouton
> « AI meeting note » (qui insérait du texte) par une vraie **boîte de réunion interactive**.
### Fix
- **Champs « Clé API »** : le `x-text` embarquait le SVG de l'icône œil dans l'attribut HTML, ce qui
cassait l'attribut et laissait du texte résiduel (`sk-…': '👁'" style="top:50%;">`). Les boutons
d'affichage/masquage utilisent désormais deux `<span x-show>` avec l'icône en contenu.
- **Champs « URL API »** : pré-remplis avec l'URL réellement utilisée par l'application (base par
défaut du fournisseur, ex. `https://api.openai.com/v1`) quand aucune URL personnalisée n'est
stockée — exposée via `base_url` dans `/api/agent/providers` (fournisseur par défaut + au
changement de provider).
### Changed
- **AI Meeting Note** : le bouton « Get started » ne génère plus du texte via l'agent. Il crée un
bloc **`meeting`** interactif dans la page (page renommée « Meeting @date » si vide) :
- Étape 1 — consentement (moi-même / message audio auto) + « Start transcribing » ;
- Étape 2 — transcription **live** via Web Speech API (fr-FR, horodatée), boutons Pause / Stop,
fallback « sans audio » (transcript à saisir/coller) si le navigateur ne supporte pas ;
- Étape 3 — onglets Notes / Transcript / Summary, bouton « Générer le résumé » (helper headless
`window.fdAgent.generate` sur le FlowDeck Agent) produisant Résumé / Décisions / Action items,
copie presse-papiers et « Nouvelle transcription ».
- Persistance du bloc et de son état dans la page (champ `meeting` ajouté aux blocs sauvegardés).
### Infra
- `VERSION` → 4.12.1 ; `app/main.py` version → 4.12.1.
## v4.12.0 (2026-09-05) — Agent & IA : refonte des réglages, fournisseurs activés/fonctionnels dans le panneau, contexte document & guide
> Refonte de la section **Settings → Agent & IA** pour la rendre plus claire, et aligne le panneau
+1 -1
View File
@@ -1 +1 @@
4.12.0
4.12.1
+1 -1
View File
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="4.12.0",
version="4.12.1",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+7 -2
View File
@@ -126,9 +126,13 @@ def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> N
def provider_info() -> list[dict]:
"""Providers list for the UI: known models + whether an API key is required."""
"""Providers list for the UI: known models + whether an API key is required.
``base_url`` is the endpoint the application uses by default for this
provider's chat requests (shown in the Settings "URL API" fields).
"""
out: list[dict] = []
for name, (_base, default_model) in PROVIDERS.items():
for name, (base, default_model) in PROVIDERS.items():
models = list(PROVIDER_MODELS.get(name) or ())
if default_model and default_model not in models:
models.insert(0, default_model)
@@ -137,6 +141,7 @@ def provider_info() -> list[dict]:
"name": name.capitalize(),
"default_model": default_model,
"models": models,
"base_url": (base or ""),
"requires_key": name not in ("offline", "ollama"),
})
return out
+271 -4
View File
@@ -204,6 +204,7 @@
get isOpen(){return SM._o;}};
function renderBlock(b,idx){
if(b.type==='meeting')return renderMeetingBlock(b,idx);
if(b.type==='table')return renderTableBlock(b,idx);
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
@@ -282,6 +283,235 @@
return `<div class="block-wrapper nested-block"><div class="block-content block-nested" data-bid="${c.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(c.content)}</div></div>`;
}
// ═══════════ AI Meeting Note — widget de réunion (v4.12.0) ═══════════
// Bloc interactif « Notion-like » : consentement, transcription live (Web
// Speech API quand disponible), transcript horodaté + notes, puis résumé
// généré par le FlowDeck Agent.
function renderMeetingBlock(b, idx){
var actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
return `<div class="block-wrapper">
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
${actions}
<div class="block-content block-meeting" data-meeting-bid="${b.id}"><div class="fm-loading">Préparation de la réunion…</div></div>
</div>`;
}
function _fmFind(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 _fmDef(){return {title:'Meeting',date:new Date().toISOString().slice(0,10),consent:'self',started:false,status:'idle',lines:[],interim:'',notes:'',manualTranscript:'',summary:'',summaryError:'',summaryLoading:false,lastTab:'transcript'};}
function _fmEnsure(bid){var b=_fmFind(bid);if(!b)return null;if(!b.meeting)b.meeting=_fmDef();if(!b.meeting.lines)b.meeting.lines=[];if(b.meeting.summaryLoading===undefined)b.meeting.summaryLoading=false;if(b.meeting.manualTranscript===undefined)b.meeting.manualTranscript='';return b;}
function _fmTime(){var d=new Date();var p=function(n){return (n<10?'0':'')+n;};return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());}
function _fmEsc(v){return esc(v==null?'':String(v));}
function _fmAttr(v){return _fmEsc(v).replace(/"/g,'&quot;');}
function _fmSave(){var e=window.E;if(e){e.dirty=true;if(e.autoSave)e.autoSave();}}
function _fmTranscriptText(b){var out=[];(b.meeting.lines||[]).forEach(function(l){out.push('['+l.t+'] '+(l.who||'Vous')+' : '+l.text);});if((b.meeting.manualTranscript||'').trim())out.push('(transcription saisie/collée) '+(b.meeting.manualTranscript||'').trim());if((b.meeting.notes||'').trim())out.push('(notes) '+(b.meeting.notes||'').trim());return out.join('\n');}
var FDMeeting = {
_rt:{},
_audio(){ return !!(window.SpeechRecognition||window.webkitSpeechRecognition); },
mount(bid,root){
var s=this._rt[bid]||(this._rt[bid]={});s.root=root;
if(!s.wired){
s.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);});
root.addEventListener('input',function(ev){self.onInput(bid,ev.target);});
root.addEventListener('change',function(ev){self.onInput(bid,ev.target);});
}
this.paint(bid);
},
focus(bid){},
paint(bid){
var s=this._rt[bid];var b=_fmEnsure(bid);if(!b||!s||!s.root)return;
s.root.innerHTML=this.html(bid);
},
_statusLabel(m){
if(m.status==='running')return '● Enregistrement';
if(m.status==='paused')return '⏸ En pause';
if(m.status==='done')return '✓ Terminée';
if(m.status==='manual')return '📝 Sans audio';
return 'Prête';
},
html(bid){
var b=_fmEnsure(bid);if(!b)return '';
var m=b.meeting;var s=this._rt[bid]||{};
var audioOk=this._audio();
var st=this._statusLabel(m);
var pill=m.status==='running'||m.status==='paused'||m.status==='done'||m.status==='manual'
? `<span class="fm-pill ${m.status==='done'?'fm-pill-ok':''}">${_fmEsc(st)}</span>`:'';
var head=`<div class="fm-head">
<span class="fm-ico">🎙️</span>
<input class="fm-title" data-fm="title" value="${_fmAttr(m.title)}" placeholder="Titre de la réunion" spellcheck="false">
<span class="fm-date">📅 ${_fmEsc(m.date)}</span>
${pill}
</div>`;
var body='';
if(!m.started){
body+=`<div class="fm-step">
<div class="fm-step-title">1 · Consentement des participants</div>
<label class="fm-radio"><input type="radio" name="fm-consent-${bid}" data-fm="consent" value="self" ${m.consent==='auto'?'':'checked'}> <span>🔘 Obtenir le consentement moi-même</span></label>
<label class="fm-radio"><input type="radio" name="fm-consent-${bid}" data-fm="consent" value="auto" ${m.consent==='auto'?'checked':''}> <span>🔘 Lire automatiquement un message audio</span></label>
<div class="fm-step-desc">${audioOk?'Notion AI est prêt : la réunion sera transcrite en direct. Autorisez le micro lorsque le navigateur le demande.':'Ce navigateur ne supporte pas la transcription audio : vous pourrez saisir ou coller la transcription manuellement.'}</div>
<button class="fm-btn fm-btn-primary" data-act="start" type="button">${audioOk?'🎙 Start transcribing':'Ouvrir la boîte de réunion (sans audio)'}</button>
</div>`;
}else{
var tabs=`<div class="fm-tabs">
<button class="fm-tab ${m.lastTab==='notes'?'active':''}" data-act="tab" data-tab="notes" type="button">Notes</button>
<button class="fm-tab ${m.lastTab==='transcript'?'active':''}" data-act="tab" data-tab="transcript" type="button">Transcript</button>
<button class="fm-tab ${m.lastTab==='summary'?'active':''}" data-act="tab" data-tab="summary" type="button">Summary</button>
</div>`;
var ctrl='';
if(m.status==='running'){
ctrl=`<div class="fm-wave">•••|ı|ı|ı|ı•••</div>
<div class="fm-actions">
<button class="fm-btn" data-act="pause" type="button">⏸ Pause</button>
<button class="fm-btn fm-btn-primary" data-act="stop" type="button">⏹ Stop</button>
</div>`;
}else if(m.status==='paused'){
ctrl=`<div class="fm-actions">
<button class="fm-btn fm-btn-primary" data-act="start" type="button">▶ Reprendre</button>
<button class="fm-btn" data-act="stop" type="button">⏹ Stop</button>
</div>`;
}else if(m.status==='manual'){
ctrl=`<div class="fm-actions"><button class="fm-btn" data-act="stop" type="button">⏹ Terminer la réunion</button></div>`;
}else if(m.status==='done'){
ctrl=`<div class="fm-actions"><button class="fm-btn" data-act="new" type="button">🔄 Nouvelle transcription</button></div>`;
}
var panel='';
if(m.lastTab==='notes'){
panel=`<textarea class="fm-area" data-fm="notes" rows="6" placeholder="Prenez des notes manuelles pendant la réunion…">${_fmEsc(m.notes)}</textarea>`;
}else if(m.lastTab==='transcript'){
var lines=(m.lines||[]).map(function(l){return `<div class="fm-line"><span class="fm-ts">${_fmEsc(l.t||'')}</span><span class="fm-who">${_fmEsc(l.who||'Vous')}</span><span class="fm-txt">${_fmEsc(l.text)}</span></div>`;}).join('');
if(m.status==='running'){lines+=`<div class="fm-line fm-interim"><span class="fm-ts">·</span><span class="fm-who">Vous</span><span class="fm-txt">${_fmEsc(m.interim||'')}${(m.interim||'')?'':'…'}</span></div>`;}
var manual=m.status!=='running'?`<div class="fm-manual"><div class="fm-manual-label">Transcript saisi ou collé (si aucune capture audio)</div><textarea class="fm-area" data-fm="manual" rows="4" placeholder="Collez ici la transcription de la réunion (optionnel)…">${_fmEsc(m.manualTranscript)}</textarea></div>`:'';
panel=`<div class="fm-transcript"><div data-fm-transcript-body>${lines||'<div class="fm-empty">La transcription apparaîtra ici en direct. Enregistrez la réunion pour générer un transcript.</div>'}</div>${manual}</div>`;
}else{
var summaryHtml='';
if(m.summaryLoading){
summaryHtml=`<div class="fm-loading">Génération du résumé…</div>`;
}else if(m.summary){
summaryHtml=`<textarea class="fm-area" data-fm="summary" rows="14">${_fmEsc(m.summary)}</textarea>
<div class="fm-actions"><button class="fm-btn" data-act="copy" type="button">📋 Copier le résumé</button></div>`;
}else if(m.summaryError){
summaryHtml=`<div class="fm-error">${_fmEsc(m.summaryError)}</div>
<button class="fm-btn fm-btn-primary" data-act="generate" type="button">Réessayer</button>`;
}else{
var has=!!(_fmTranscriptText(b).trim());
if(m.status==='done'&&has){
summaryHtml=`<button class="fm-btn fm-btn-primary" data-act="generate" type="button">✨ Générer le résumé</button>
<div class="fm-step-desc">L'agent analyse le transcript et produit un aperçu structuré (sujets, décisions, actions).</div>`;
}else{
summaryHtml=`<div class="fm-empty">Arrêtez l'enregistrement (ou ajoutez un transcript) puis générez le résumé ici.</div>`;
}
}
panel=summaryHtml;
}
body=`<div class="fm-step">${ctrl}${tabs}${panel}</div>`;
}
return `<div class="fm">${head}${body}</div>`;
},
onInput(bid,el){
var b=_fmEnsure(bid);if(!b||!el)return;var m=b.meeting;var k=el.getAttribute&&el.getAttribute('data-fm');
if(!k)return;
if(k==='title'){m.title=el.value||'';var tEl=document.getElementById('_titleEl');if(tEl&&!((tEl.textContent||'').trim())){tEl.textContent=m.title;if(window.E)window.E.pageTitle=m.title;}}
else if(k==='notes'){m.notes=el.value;}
else if(k==='manual'){m.manualTranscript=el.value;}
else if(k==='summary'){m.summary=el.value;}
else if(k==='consent'){m.consent=el.value;}
else return;
_fmSave();
},
act(bid,act,el){
var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;var self=this;
if(act==='start'){
if(m.status==='done'||m.status==='idle'||m.status==='paused'){
if(m.status==='done'){m.lines=[];m.summary='';m.summaryError='';m.manualTranscript='';}
m.started=true;m.lastTab='transcript';
if(!self._audio()){m.status='manual';m.interim='';_fmSave();self.paint(bid);return;}
if(m.consent==='auto'&&window.speechSynthesis){
var u=new SpeechSynthesisUtterance('Cette réunion est enregistrée et transcrite par FlowDeck. Merci de votre attention.');
try{window.speechSynthesis.speak(u);}catch(e){}
}
setTimeout(function(){self._startRec(bid);},m.consent==='auto'?1300:0);
}
}else if(act==='pause'){
this._stopRec(bid,false);m.status='paused';m.interim='';_fmSave();this.paint(bid);
}else if(act==='stop'){
this._stopRec(bid,true);m.status='done';m.lastTab='transcript';m.interim='';_fmSave();this.paint(bid);
}else if(act==='new'){
m.lines=[];m.summary='';m.summaryError='';m.status='idle';m.started=false;m.lastTab='transcript';_fmSave();this.paint(bid);
}else if(act==='tab'){
m.lastTab=(el&&el.getAttribute&&el.getAttribute('data-tab'))||'transcript';_fmSave();this.paint(bid);
}else if(act==='generate'){
this._generate(bid);
}else if(act==='copy'){
var txt=(m.summary||'').trim();
if(!txt)return;
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt).catch(function(){});}
}
},
_startRec(bid){
var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;var s=this._rt[bid]||(this._rt[bid]={});
var SR=window.SpeechRecognition||window.webkitSpeechRecognition;
if(!SR){m.status='manual';_fmSave();this.paint(bid);return;}
if(s.rec){try{s.rec.stop();}catch(e){}}
var rec=new SR();s.rec=rec;s.offset=0;s.stopped=false;
rec.lang='fr-FR';rec.continuous=true;rec.interimResults=true;rec.maxAlternatives=1;
var self=this;
rec.onresult=function(ev){
var final='',interim='';
for(var i=(s.offset||0);i<ev.results.length;i++){
var r=ev.results[i];
if(r.isFinal)final+=(r[0]&&r[0].transcript||'');
else interim+=(r[0]&&r[0].transcript||'');
}
s.offset=ev.results.length;
if(final&&final.trim()){
m.lines.push({t:_fmTime(),who:'Vous',text:final.trim()});
_fmSave();
self._updateTranscriptOnly(bid);
}
m.interim=interim.trim();
self._updateInterim(bid);
};
rec.onend=function(){
if(!s.stopped&&m.status==='running'&&self._audio()){try{rec.start();}catch(e){}}
};
rec.onerror=function(ev){
if(ev&&ev.error==='not-allowed'){m.summaryError='Microphone refusé : autorisez le micro ou utilisez la saisie manuelle du transcript.';m.status='manual';s.stopped=true;_fmSave();self.paint(bid);}
};
m.status='running';m.interim='';_fmSave();
this.paint(bid);
try{rec.start();}catch(e){m.status='manual';s.stopped=true;_fmSave();this.paint(bid);}
},
_stopRec(bid,stop){
var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;var s=this._rt[bid];
if(s&&s.rec){s.stopped=true;try{s.rec.stop();}catch(e){}}
if(stop&&m.interim&&m.interim.trim()){m.lines.push({t:_fmTime(),who:'Vous',text:m.interim.trim()});}
m.interim='';
},
_updateTranscriptOnly(bid){var s=this._rt[bid];var b=_fmEnsure(bid);if(!b||!s||!s.root)return;var el=s.root.querySelector('[data-fm-transcript-body]');if(el)el.innerHTML=this._transcriptLines(b);},
_updateInterim(bid){var s=this._rt[bid];var b=_fmEnsure(bid);if(!b||!s||!s.root)return;var el=s.root.querySelector('.fm-interim .fm-txt');if(el&&el.textContent!==(b.meeting.interim||'')){el.textContent=(b.meeting.interim||'')+'…';}},
_transcriptLines(b){var m=b.meeting;var lines=(m.lines||[]).map(function(l){return `<div class="fm-line"><span class="fm-ts">${_fmEsc(l.t||'')}</span><span class="fm-who">${_fmEsc(l.who||'Vous')}</span><span class="fm-txt">${_fmEsc(l.text)}</span></div>`;}).join('');return lines||'<div class="fm-empty">La transcription apparaîtra ici en direct…</div>';},
_generate(bid){
var b=_fmEnsure(bid);if(!b)return;var m=b.meeting;
var content=_fmTranscriptText(b);
if(!content.trim()){m.summaryError='Aucun contenu de réunion à résumer.';_fmSave();this.paint(bid);return;}
m.summaryLoading=true;m.summaryError='';_fmSave();this.paint(bid);
var self=this;
var prompt='Rédige le résumé structuré de cette réunion (format AI Meeting Note) : une ligne d\'en-tête avec la date du '+_fmEsc(m.date)+', puis les sections « Résumé » (Overview), « Décisions » et « Action items » avec des puces concrètes. Si le contenu est trop court ou fragmentaire, indique « No meaningful content could be extracted ». Ne précède pas la réponse de commentaires.';
if(!(window.fdAgent&&window.fdAgent.generate)){
m.summaryLoading=false;m.summaryError='Agent IA indisponible — configurez-le dans Paramètres → Agent & IA.';_fmSave();this.paint(bid);return;
}
window.fdAgent.generate(prompt, content).then(function(text){
m.summary=(text||'').trim();m.summaryLoading=false;m.summaryError='';_fmSave();self.paint(bid);
}).catch(function(err){
m.summaryLoading=false;m.summaryError=(err&&err.message)?err.message:'Échec de génération du résumé';_fmSave();self.paint(bid);
});
}
};
window.FDMeeting=FDMeeting;
window.E=null;
function editorState(){
return {
@@ -377,8 +607,10 @@
var self=this;
ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');});
ct.querySelectorAll('.math-editor').forEach(function(ta){var disp=ct.querySelector(`.math-display[data-bid="${ta.dataset.mathBid}"]`);ta.addEventListener('input',function(){self._renderKatex(disp,ta.value);self.dirty=true;self.autoSave();});});
// Initialize inline database blocks (scripts in innerHTML don't execute)
setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
// Initialize inline database blocks (scripts in innerHTML don't execute)
setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
// Initialize AI Meeting Note widgets (block type 'meeting')
setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
},
@@ -401,7 +633,42 @@
this.aiRun('Rédige le contenu de la page FlowDeck intitulée « '+title+' ». Fournis un texte structuré et directement utilisable : paragraphes et, si utile, des listes à puces. Sépare chaque paragraphe par une ligne vide. Ne précède pas le texte de commentaires.');
},
meetingNote(){
this.aiRun('Crée le squelette d\'une « AI meeting note » FlowDeck : une ligne d\'en-tête avec la date et les participants, puis les sections « Résumé », « Décisions » et « Action items » avec des exemples réalistes. Sépare chaque bloc/paragraphe par une ligne vide.');
// v4.12.0: crée une vraie « boîte de réunion » (bloc 'meeting') au lieu
// d'insérer du texte. La page est renommée « Meeting @date » si elle
// est vide, puis le widget est ajouté (ou remplace le bloc vide initial).
var self=this;
this.gsMoreOpen=false;
var today=new Date();
var iso=today.toISOString().slice(0,10);
var title='Meeting @'+iso;
var tEl=document.getElementById('_titleEl');
var curTitle=tEl?(tEl.textContent||'').trim():'';
var blank=!curTitle||curTitle==='Untitled'||curTitle==='New page';
if(blank){
this.pageTitle=title;
if(tEl)tEl.textContent=title;
}
var mb=this.mkB('meeting','');
mb.meeting={
title:title,
date:iso,
consent:'self',
started:false,
status:'idle',
lines:[],
interim:'',
notes:'',
summary:'',
summaryError:'',
lastTab:'transcript'
};
this.sync();
var emptyFirst=this.blocks.length===1 && this.blocks[0].type==='paragraph' && !(this.blocks[0].content||'').trim();
if(emptyFirst){ this.blocks=[mb]; } else { this.blocks.push(mb); }
this.dirty=true; this.autoSave(); this.render();
this.showToast('AI Meeting Note : bloc de réunion ajouté. Cliquez sur « Start transcribing ».');
var bid=mb.id;
setTimeout(function(){ var el=document.querySelector('[data-meeting-bid="'+bid+'"]'); if(el&&el.scrollIntoView)el.scrollIntoView({behavior:'smooth',block:'center'}); if(el&&window.FDMeeting)window.FDMeeting.focus(bid); },120);
},
aiRun(prompt){
const self=this;
@@ -828,7 +1095,7 @@
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
this.saving=true;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
if(cb)cb();
+48
View File
@@ -163,6 +163,54 @@
self.input = t;
setTimeout(function(){ self.send(); }, 60);
};
// Headless generation (no panel): used by the AI Meeting Note block to
// build its Summary. Returns a Promise of the final assistant text.
window.fdAgent.generate = function(message, context){
message = (message || '').trim();
if(!message) return Promise.resolve('');
var payload = {message: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/conversations', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({title:'AI Meeting Note'})
}).then(function(r){ return r.json(); }).then(function(conv){
var cid = conv && conv.id;
if(!cid) throw new Error('Impossible de créer une conversation agent');
return fetch('/api/agent/conversations/' + cid + '/run', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){
return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP ' + resp.status)); });
}
var reader = resp.body.getReader();
var dec = new TextDecoder();
var buf = '', finalText = '', lastError = '';
function pump(){
return reader.read().then(function(res){
if(res.done){
if(lastError) throw new Error(lastError);
return finalText;
}
buf += dec.decode(res.value, {stream:true});
var parts = buf.split('\n\n');
buf = parts.pop();
for(var i=0;i<parts.length;i++){
var line = parts[i].replace(/^data:\s*/, '').trim();
if(!line) continue;
try{
var ev = JSON.parse(line);
if(ev.type === 'final') finalText = ev.content || '';
else if(ev.type === 'error') lastError = ev.message || 'Erreur agent';
}catch(e){}
}
return pump();
});
}
return pump();
});
});
};
},
// Only providers *configured and functional* (verified) are selectable,
+25 -7
View File
@@ -193,7 +193,10 @@
<div class="setting-label">New password</div>
<div class="pw-wrapper" style="max-width:300px;">
<input :type="pwVisible ? 'text' : 'password'" class="settings-input" x-model="accountForm.password" placeholder="Leave blank to keep current" style="padding-right:36px;">
<button type="button" class="pw-toggle" @click="pwVisible=!pwVisible" x-text="pwVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
<button type="button" class="pw-toggle" @click="pwVisible=!pwVisible" style="top:50%;">
<span x-show="pwVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
<span x-show="!pwVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
</button>
</div>
</div>
<div style="margin-top:12px;">
@@ -578,7 +581,10 @@
<div class="prov-field-label">Clé API <span class="muted" x-text="llmHasKey ? '(déjà configurée — laisser vide pour conserver)' : '(requise pour ce fournisseur)'"></span></div>
<div class="pw-wrapper" style="max-width:320px;">
<input :type="llmKeyVisible ? 'text' : 'password'" class="settings-input" x-model="llmForm.api_key" placeholder="sk-…" style="padding-right:36px;">
<button type="button" class="pw-toggle" @click="llmKeyVisible=!llmKeyVisible" x-text="llmKeyVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
<button type="button" class="pw-toggle" @click="llmKeyVisible=!llmKeyVisible" style="top:50%;">
<span x-show="llmKeyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
<span x-show="!llmKeyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
</button>
</div>
</div>
<div class="prov-field-row">
@@ -620,8 +626,10 @@
<div class="pw-wrapper" style="max-width:100%;">
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible"
x-text="keyForm(p.id).keyVisible ? '{{ fd_icon('eye-off',14) }}' : '{{ fd_icon('eye',14) }}'" style="top:50%;"></button>
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible" style="top:50%;">
<span x-show="keyForm(p.id).keyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
<span x-show="!keyForm(p.id).keyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
</button>
</div>
</div>
<div class="prov-field-row">
@@ -996,7 +1004,9 @@ document.addEventListener('alpine:init', function() {
this.llmProviders.forEach(p => {
var k = keys.find(x => x.provider === p.id);
var f = this.keyForm(p.id);
f.api_base = (k && k.api_base) || '';
// Pré-remplit l'URL avec celle réellement utilisée par l'application
// (base par défaut du fournisseur) tant qu'aucune URL personnalisée n'est stockée.
f.api_base = (k && k.api_base) || p.base_url || '';
f.model = (k && k.default_model) || (p.default_model) || '';
f.models = (k && k.models) || [];
f.has_key = !!(k && k.has_key);
@@ -1122,7 +1132,11 @@ document.addEventListener('alpine:init', function() {
this.llmProviders = d.providers || [];
this.llmDefaultProvider = d.default_provider || d.provider || 'offline';
this.llmHasKey = !!d.has_api_key;
this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '', api_base: d.api_base || ''};
var defp = this.providerById(d.provider || 'offline');
// Affiche l'URL réellement utilisée par l'application (base par défaut
// du fournisseur) quand aucune URL personnalisée n'est encore stockée.
this.llmForm = {provider: d.provider || 'offline', model: d.model || '', api_key: '',
api_base: d.api_base || (defp && defp.base_url) || ''};
this.applyLlmModels();
this.syncLlmStatus();
} catch(e) {}
@@ -1137,7 +1151,11 @@ document.addEventListener('alpine:init', function() {
onLlmProviderChange() {
this.applyLlmModels();
var cur = this.providerById(this.llmForm.provider);
if (cur) this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || '');
if (cur) {
this.llmForm.model = (cur.default_model || this.llmProviderModels[0] || '');
// Affiche l'URL utilisée par l'application pour ce fournisseur.
this.llmForm.api_base = cur.base_url || '';
}
},
async saveLlmConfig() {
this.llmSaving = true;
+68
View File
@@ -2966,6 +2966,74 @@ button.breadcrumb-current { max-width: 260px; }
.gs-more-item:hover { background: var(--bg-hover); }
.gs-more-sep { height: 1px; background: var(--border); margin: 4px 0; }
/* ═══════════ AI Meeting Note — widget réunion (v4.12.0) ═══════════ */
.block-meeting { cursor: default; }
.fm {
background: var(--bg-secondary, #1f1f1f); border: 1px solid var(--border, #333);
border-radius: 12px; padding: 12px 14px; font-size: 13px;
color: var(--text-primary, #eee); display: flex; flex-direction: column; gap: 10px;
}
.fm-loading { color: var(--text-dim); font-size: 13px; padding: 8px 2px; }
.fm-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.fm-ico { font-size: 20px; }
.fm-title {
flex: 1; min-width: 160px; background: transparent; border: 1px solid transparent;
color: var(--text-primary); font-size: 14px; font-weight: 600; outline: none;
border-radius: 6px; padding: 3px 6px; font-family: inherit;
}
.fm-title:focus { border-color: var(--accent); background: var(--bg-tertiary); }
.fm-date { font-size: 12px; color: var(--text-dim); white-space: nowrap; }
.fm-pill {
font-size: 11px; padding: 3px 10px; border-radius: 99px;
background: var(--bg-tertiary); color: var(--text-secondary); white-space: nowrap;
}
.fm-pill-ok { color: #2ea043; }
.fm-step-title {
font-size: 12px; font-weight: 600; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px;
}
.fm-step-desc { font-size: 12px; color: var(--text-dim); line-height: 1.45; }
.fm-radio {
display: flex; gap: 8px; align-items: center; font-size: 13px;
color: var(--text-primary); padding: 4px 2px; cursor: pointer;
}
.fm-radio input { accent-color: var(--accent); }
.fm-btn {
background: var(--bg-tertiary); border: 1px solid var(--border);
color: var(--text-primary); border-radius: 8px; padding: 7px 14px;
font-size: 13px; cursor: pointer; font-family: inherit;
}
.fm-btn:hover { background: var(--bg-hover); }
.fm-btn-primary { background: var(--accent, #2383E2); border-color: transparent; color: #fff; font-weight: 600; }
.fm-btn-primary:hover { background: #2C8CEB; }
.fm-btn:disabled { opacity: .55; cursor: not-allowed; }
.fm-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.fm-wave {
font-family: monospace; letter-spacing: 2px; color: var(--accent);
font-size: 14px; padding: 2px 0;
}
.fm-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-top: 2px; }
.fm-tab {
background: none; border: none; color: var(--text-dim); font-size: 12.5px;
cursor: pointer; padding: 6px 12px; border-radius: 7px; font-weight: 500;
}
.fm-tab.active { background: var(--bg-tertiary); color: var(--text-primary); }
.fm-empty { color: var(--text-dim); font-size: 12.5px; padding: 8px 0; }
.fm-error { color: #e5484d; font-size: 12.5px; padding: 8px 0; }
.fm-line { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; color: var(--text-primary); }
.fm-line .fm-ts { color: var(--text-dim); font-family: monospace; font-size: 11px; flex-shrink: 0; padding-top: 2px; }
.fm-line .fm-who { color: var(--accent); font-weight: 600; flex-shrink: 0; }
.fm-interim { opacity: .55; font-style: italic; }
.fm-area {
width: 100%; box-sizing: border-box; background: var(--bg-tertiary);
border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary);
font-size: 13px; padding: 8px 10px; outline: none; font-family: inherit; resize: vertical;
}
.fm-area:focus { border-color: var(--accent); }
.fm-manual { margin-top: 8px; }
.fm-manual-label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
.fm-transcript { max-height: 40vh; overflow-y: auto; }
/* ═══════════ Page Editor — Top Bar ═══════════ */
.page-topbar {
display: flex; align-items: center; justify-content: flex-end;