fix(agent): v4.12.1 - champs cle API/URL API dans la config, AI Meeting Note en bloc de reunion interactif (transcription live + resume)
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,'"');}
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user