feat(agent): v4.15.0 - panneau Agent style Notion AI (mentions @ inline + skills / + actions sous chaque réponse)
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped

- Composeur contenteditable : mentions inline (pastilles icône+nom), barre de contexte groupée documents/skills, sélecteur @ (icône+titre+chemin), skills intégrés + agent_skills + commandes admin via '/'
- Actions sous chaque réponse agent : copier, insérer dans la page, feedback 👍/👎
- Backend : table agent_feedback, GET /api/agent/mentions, POST /api/agent/feedback, contexte document:<id>/page:<id>/collection:<id> résolu en contenu réel
- Inclut le travail v4.14.0 (documents/espace de travail + outils + auto-titre de conversation)
This commit is contained in:
2026-09-06 12:17:40 -04:00
parent 3025a7a44e
commit 27c9eb98db
12 changed files with 1633 additions and 86 deletions
+719 -49
View File
@@ -69,8 +69,12 @@
.fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative}
.fd-ap-box{background:var(--bg-tertiary,#1f1f1f);border:1px solid var(--border,rgba(255,255,255,.13));border-radius:16px;padding:10px 12px 8px;transition:border-color .12s,box-shadow .12s}
.fd-ap-box:focus-within{border-color:rgba(35,131,226,.65);box-shadow:0 0 0 3px rgba(35,131,226,.12)}
.fd-ap-input{width:100%;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:14px;resize:none;font-family:inherit;line-height:1.5;max-height:220px;box-sizing:border-box}
.fd-ap-input::placeholder{color:var(--text-dim,#8a8a8a)}
.fd-ap-input{width:100%;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:14px;resize:none;font-family:inherit;line-height:1.6;max-height:220px;box-sizing:border-box;overflow-y:auto;white-space:pre-wrap;word-break:break-word;min-height:26px;position:relative}
.fd-ap-input.empty::before{content:attr(data-placeholder);color:var(--text-dim,#8a8a8a);position:absolute;left:0;top:0;pointer-events:none;white-space:pre-wrap;max-width:100%}
.ment-token{display:inline-flex;align-items:center;gap:4px;vertical-align:middle;background:rgba(35,131,226,.14);border:1px solid rgba(35,131,226,.35);color:var(--text,#eaf2ff);border-radius:6px;padding:0 3px 0 4px;font-size:13px;line-height:1.5;margin:0 1px;max-width:200px;overflow:hidden;white-space:nowrap}
.ment-token .tk-lbl{overflow:hidden;text-overflow:ellipsis}
.ment-token.skill{background:rgba(168,85,247,.16);border-color:rgba(168,85,247,.5);color:#f3e8ff}
.ment-token .tk-ico{flex-shrink:0;opacity:.9}
.fd-ap-toolbar{display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap}
.fd-ap-tip{font-size:11px;color:var(--text-dim,#8f8f8f);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-ap-tools{display:flex;align-items:center;gap:6px;margin-left:auto}
@@ -94,7 +98,48 @@
.fd-agent-cmd-item{padding:8px 12px;font-size:12px;color:var(--text,#eee);cursor:pointer;border-radius:7px;margin:3px;display:flex;gap:8px;align-items:baseline}
.fd-agent-cmd-item:hover{background:var(--bg-hover,#2b2b2b)}
.fd-agent-cmd-item b{color:var(--accent,#2383E2);font-family:monospace;white-space:nowrap}
.fd-agent-cmd-item b.k-cmd{color:var(--accent,#2383E2)}
.fd-agent-cmd-item b.k-skill{color:#c084fc}
.fd-agent-cmd-item span{color:var(--text-dim,#999);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fd-agent-cmd-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
/* Action bar under each assistant post */
.fd-agent-msg-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px;align-items:center}
.fd-act-btn{display:inline-flex;align-items:center;gap:4px;background:transparent;border:1px solid var(--border,rgba(255,255,255,.09));color:var(--text-dim,#9aa);border-radius:7px;padding:3px 8px;font-size:11px;cursor:pointer;transition:background .12s,color .12s,border-color .12s;line-height:1.5}
.fd-act-btn:hover{background:var(--bg-hover,#262626);color:var(--text,#fff);border-color:rgba(255,255,255,.25)}
.fd-act-btn.on{background:rgba(35,131,226,.18);border-color:rgba(35,131,226,.55);color:#fff}
.fd-act-btn:disabled,.fd-act-btn.disabled{opacity:.4;cursor:not-allowed}
/* Chips (contexte épinglé @ / + / skills) */
.fd-ap-chips{display:flex;flex-direction:column;gap:5px;padding:8px 4px 4px;max-height:92px;overflow-y:auto}
.fd-chip-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.fd-chip{display:inline-flex;align-items:center;gap:6px;background:rgba(35,131,226,.10);border:1px solid rgba(35,131,226,.30);color:var(--text,#e8e8e8);border-radius:99px;padding:3px 5px 3px 9px;font-size:11.5px;max-width:220px}
.fd-chip.skill{background:rgba(168,85,247,.12);border-color:rgba(168,85,247,.38)}
.fd-chip-ico{flex-shrink:0;font-size:12px}
.fd-chip-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-chip-spacer{flex-basis:100%;height:0}
.fd-chip-x{background:none;border:none;color:var(--text-dim,#aaa);cursor:pointer;font-size:13px;line-height:1;padding:0 4px;border-radius:50%;flex-shrink:0}
.fd-chip-x:hover{color:#fff;background:rgba(255,255,255,.14)}
/* Add button (+ contexte) */
.fd-ap-add{width:30px;height:30px;border-radius:50%;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);cursor:pointer;font-size:17px;display:flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1}
.fd-ap-add:hover{background:var(--bg-hover,#303030);color:#fff}
/* Mention menu (@ / +) */
.fd-ap-mention-menu{position:absolute;bottom:calc(100% + 4px);left:0;right:0;background:var(--bg-modal,#1e1e1e);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:11px;max-height:220px;overflow-y:auto;box-shadow:0 -8px 26px rgba(0,0,0,.4);z-index:70;display:none}
.fd-ap-mention-menu.show{display:block}
.fd-mention-item{display:flex;align-items:center;gap:9px;padding:7px 12px;font-size:12.5px;color:var(--text,#eee);cursor:pointer;border-radius:7px;margin:2px 4px}
.fd-mention-item:hover,.fd-mention-item.focus{background:var(--bg-hover,#2b2b2b)}
.fd-mention-ico{width:24px;text-align:center;flex-shrink:0;font-size:14px}
.fd-mention-main{flex:1;min-width:0;display:flex;flex-direction:column}
.fd-mention-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-mention-sub{font-size:10.5px;color:var(--text-dim,#999);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
/* Toast */
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
.fd-ap-toast button{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:13px;padding:0}
[x-cloak]{display:none!important}
</style>
@@ -196,6 +241,14 @@
<span class="fd-proposal-note" x-show="m.offline" x-cloak>Hors-ligne — configurez une clé API pour appliquer la modification.</span>
</div>
</template>
<template x-if="m.role==='assistant' && !m.generating && m.content && !m.act">
<div class="fd-agent-msg-actions">
<button class="fd-act-btn" type="button" @click="copyResponse(m)" title="Copier la réponse">⧉ copy response</button>
<button class="fd-act-btn" type="button" @click="insertIntoPage(m)" :disabled="!canInsert" title="Insérer dans la page ouverte">➕ insert into this page</button>
<button class="fd-act-btn" :class="{on: m.fb==='up'}" type="button" @click="feedback(m,'up')" :disabled="!!m.fb && m.fb!=='up'">👍 Share feedback</button>
<button class="fd-act-btn" :class="{on: m.fb==='down'}" type="button" @click="feedback(m,'down')" :disabled="!!m.fb && m.fb!=='down'">👎 Share feedback</button>
</div>
</template>
</div>
</div>
</template>
@@ -207,26 +260,69 @@
<div class="fd-ap-composer">
<div style="position:relative;">
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length}">
<template x-for="c in commandSuggestions" :key="c.cmd">
<div class="fd-agent-cmd-item" @click="useSuggestion(c)">
<b x-text="c.cmd"></b><span x-text="c.desc"></span>
<div class="fd-ap-toast" x-show="toastMsg" x-cloak :class="{err: toastErr}">
<span x-text="toastMsg"></span>
<button type="button" @click="toastMsg=''">✕</button>
</div>
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length && !slashDismiss}">
<template x-for="c in commandSuggestions" :key="c.key">
<div>
<div class="fd-agent-cmd-sep" x-show="c._sep" x-text="c._sep"></div>
<div class="fd-agent-cmd-item" x-show="!c._sep" :class="{focus: c._i===cmdFocus}" @click="useSuggestion(c)" @mouseenter="cmdFocus=c._i">
<b :class="c._admin ? 'k-cmd' : 'k-skill'" x-text="(c._admin ? c.cmd : c.cmd)"></b>
<span x-text="c.desc"></span>
</div>
</div>
</template>
</div>
<div class="fd-ap-mention-menu" :class="{show: mentionOpen}">
<div class="fd-mention-load" x-show="mentionLoad" x-cloak>Recherche…</div>
<template x-if="!mentionLoad && !mentionItems.length">
<div class="fd-mention-empty">Aucun élément. Tapez un nom de document, page ou base.</div>
</template>
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token">
<div class="fd-mention-item" :class="{focus: it._i===mentionFocus}" @click="pickMention(it)" @mouseenter="mentionFocus=it._i">
<span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
</div>
</div>
</template>
</div>
</div>
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length">
<template x-for="chip in mentionChips" :key="chip.key">
<span class="fd-chip">
<span class="fd-chip-ico" x-text="chip.icon"></span>
<span class="fd-chip-lbl" x-text="chip.label"></span>
<button class="fd-chip-x" type="button" @click="removeChip(chip.token)" title="Retirer du contexte">✕</button>
</span>
</template>
</div>
<div class="fd-chip-row" x-show="skillChips.length">
<template x-for="chip in skillChips" :key="chip.key">
<span class="fd-chip skill">
<span class="fd-chip-ico" x-text="chip.icon"></span>
<span class="fd-chip-lbl" x-text="chip.label"></span>
<button class="fd-chip-x" type="button" @click="removeChip(chip.token)" title="Retirer le skill">✕</button>
</span>
</template>
</div>
</div>
<div class="fd-ap-box">
<textarea class="fd-ap-input" x-ref="cmdInput" rows="1"
placeholder="Demandez à l'agent (ex : crée une collection Projets)… Tapez / pour les commandes"
x-model="input" @input="resizeInput()" @keydown.enter.exact.prevent="send()"
@keydown.enter.shift.prevent="insertNewline()"></textarea>
<div class="fd-ap-input empty" x-ref="cmdInput" contenteditable="true" spellcheck="false" role="textbox" aria-multiline="true"
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
<div class="fd-ap-toolbar">
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
<span class="fd-ap-tip" x-text="composerHint"></span>
<div class="fd-ap-tools">
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
<span x-text="autoLabel"></span> <span style="opacity:.6">▾</span>
</button>
<button class="fd-ap-send" @click="send()" :disabled="streaming || !input.trim()" title="Envoyer">▲</button>
<button class="fd-ap-send" @click="send()" :disabled="streaming || !canSend" title="Envoyer">▲</button>
</div>
</div>
</div>
@@ -251,6 +347,39 @@
<script>
(function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [
{cmd:'/help', desc:'liste des commandes'},
{cmd:'/providers', desc:'liste les fournisseurs actifs'},
{cmd:'/provider <nom>', desc:'changer de fournisseur LLM'},
{cmd:'/model <nom>', desc:'changer de modèle LLM'},
{cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'}
];
var FD_SKILLS = [
{cmd:'/research', icon:'🔎', desc:'recherche structurée + recommandation',
template:'Fais un état des lieux de [sujet] : contexte, avantages/inconvénients, options, puis une recommandation finale. Indique tes sources si tu en utilises.'},
{cmd:'/create-new-skill', icon:'🛠️', desc:'crée un workflow réutilisable (skill)',
template:'Je veux créer un skill réutilisable qui [décris le workflow]. Produis : nom, description courte, étapes et format de sortie attendu.'},
{cmd:'/resume', icon:'📄', desc:'résumé / synthèse structurée',
template:'Résume ce contenu de façon structurée et concise : points clés, décisions, TL;DR.'},
{cmd:'/actions', icon:'✅', desc:'extraire les actions & to-dos',
template:'À partir de ce contenu, liste les actions à mener avec responsable et échéance quand c\u2019est indiqué.'},
{cmd:'/reformuler', icon:'✍️', desc:'réécriture clarté / ton',
template:'Reformule ce contenu pour le rendre plus clair et plus concis en conservant le sens. Précise le ton souhaité (interne, client, formel…).'},
{cmd:'/correction', icon:'🔤', desc:'correction grammaire / orthographe / style',
template:'Corrige la grammaire, l\u2019orthographe et le style de ce contenu sans en changer le sens.'},
{cmd:'/brainstorm', icon:'💡', desc:'générer des idées, angles, variantes',
template:'Propose des idées variées sur [sujet] : angles, exemples concrets, variantes de messages (annonce, email, post).'},
{cmd:'/plan', icon:'🧭', desc:'planifier / structurer un document',
template:'Construis un plan structuré pour [sujet] : sections logiques, sous-points et ordre de rédaction.'},
{cmd:'/ask', icon:'💬', desc:'Q&A sur un contenu référencé',
template:'À partir du contenu référencé, réponds précisément à [ta question].'},
{cmd:'/meeting-note', icon:'📝', desc:'compte-rendu / note de réunion',
template:'Rédige une AI Meeting Note : Résumé, Décisions, Action items (avec responsables et échéances), Prochaines étapes.'},
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
];
function agentPanel(){
return {
open: false, modelOpen: false, tab: 'chat',
@@ -260,6 +389,12 @@
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
llmModel: '', llmKeys: [], defaultProvider: 'offline',
activeContext: null, ctxPinnedKey: null,
contextChips: [], skills: [],
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_slashSeq: 0,
init(){
var self = this;
@@ -282,6 +417,7 @@
this._applyState = applyState;
this.fetchProviders();
this.fetchAgents();
this.fetchSkills();
// Exposed imperative API (used by the FAB, the sidebar, Ask AI…)
window.fdAgent.open = function(){ self.openAgent(); };
@@ -291,7 +427,7 @@
var t = (text || '').trim();
if(!t) return;
self.openAgent();
self.input = t;
self.setComposerText(t);
setTimeout(function(){ self.send(); }, 60);
};
window.fdAgent.generate = function(message, context){
@@ -377,10 +513,14 @@
},
get composerHint(){
var c = this.activeContext;
if(!c) return 'Demandez ou tapez / pour les commandes';
if(c.kind === 'page') return 'Contexte : le document est pris en compte par l\u2019agent.';
return 'Contexte : ' + c.label;
var ctx;
if(!c) ctx = 'Demandez, tapez / pour un skill, @ pour référencer un élément';
else if(c.kind === 'page') ctx = 'Contexte : le document ouvert est pris en compte par l\u2019agent.';
else ctx = 'Contexte : ' + c.label;
var extra = this.contextChips.length ? ' · ' + this.contextChips.length + ' réf. épinglée(s)' : '';
return ctx + extra + ' · Entrée ↵ = envoyer · Maj+Entrée = ligne';
},
get canInsert(){ return !!(this.activeContext && this.activeContext.kind === 'page'); },
// ── Quick actions (contextual) ──
get quickIntro(){
@@ -418,7 +558,7 @@
if(q.docRun){ this.runDocAction(q); return; }
var self = this;
this.ensureConversation().then(function(){
self.input = q.prompt;
self.setComposerText(q.prompt);
self.send();
}).catch(function(){});
},
@@ -560,6 +700,12 @@
var self = this;
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){ self.agents = d.agents || []; }).catch(function(){});
},
fetchSkills(){
var self = this;
fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){
self.skills = d.skills || [];
}).catch(function(){ self.skills = []; });
},
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
@@ -593,6 +739,7 @@
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self.$nextTick(function(){ self.resizeInput(); });
}).catch(function(){});
},
@@ -609,6 +756,7 @@
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self._ensuring = null;
return conv;
}).catch(function(err){
@@ -629,42 +777,311 @@
}).catch(function(){});
},
// ── Input helpers ──
// ── Composer (contenteditable) helpers ──
_composerEl(){ return this.$refs && this.$refs.cmdInput; },
composerText(){
var el = this._composerEl();
if(!el) return (this.input || '');
var parts = [];
var nodes = el.childNodes;
for(var i=0;i<nodes.length;i++){
var n = nodes[i];
if(n.nodeType===3) parts.push(n.nodeValue);
else if(n.nodeType===1){
if(n.tagName === 'BR') parts.push('\n');
else if(n.hasAttribute && n.hasAttribute('data-ctoken')) parts.push(n.getAttribute('data-label')||'');
else parts.push(n.textContent||'');
}
}
return parts.join('').replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').trim();
},
composerMentions(){
var el = this._composerEl();
var out = [], seen = {};
if(el){
var spans = el.querySelectorAll('[data-ctoken]');
for(var i=0;i<spans.length;i++){
if(spans[i].getAttribute('data-kind') === 'skill') continue;
var t = spans[i].getAttribute('data-token');
if(t && !seen[t]){ seen[t]=1; out.push(t); }
}
}
for(i=0;i<this.contextChips.length;i++){
var ch = this.contextChips[i];
if(ch.kind==='mention' && !seen[ch.token]){ seen[ch.token]=1; out.push(ch.token); }
}
return out;
},
resizeInput(){
var t = this.$refs && this.$refs.cmdInput;
var t = this._composerEl();
if(!t) return;
t.style.height = 'auto';
t.style.height = Math.min(t.scrollHeight, 220) + 'px';
var h = Math.min(t.scrollHeight, 220);
t.style.height = h + 'px';
t.style.overflowY = (t.scrollHeight > 220) ? 'auto' : 'hidden';
},
_emptyCheck(){
var el = this._composerEl();
if(!el) return;
var empty = !el.textContent.trim() && !el.querySelector('[data-ctoken]');
el.classList.toggle('empty', !!empty);
},
setComposerText(str){
var el = this._composerEl();
if(!el){ this.input = str || ''; return; }
el.textContent = str || '';
this.input = str || '';
this.resizeInput();
this._emptyCheck();
},
clearComposer(){
var el = this._composerEl();
if(el){ el.textContent = ''; }
this.input = '';
this.resizeInput();
this._emptyCheck();
},
_focusComposer(){
var t = this._composerEl();
if(t) t.focus();
},
insertNewline(){
var t = this.$refs && this.$refs.cmdInput;
if(!t) return;
var s = t.selectionStart, e = t.selectionEnd;
var v = this.input || '';
this.input = v.slice(0, s) + '\n' + v.slice(e);
this.$nextTick(function(){ t.selectionStart = t.selectionEnd = s + 1; });
var el = this._composerEl();
if(!el) return;
try{
var sel = window.getSelection();
if(!sel.rangeCount) return;
var r = sel.getRangeAt(0);
r.deleteContents();
var br = document.createElement('br');
r.insertNode(br);
r.setStartAfter(br); r.collapse(true);
this.input = this.composerText();
}catch(e){}
this.resizeInput();
},
_caretQuery(){
try{
var sel = window.getSelection();
if(!sel || !sel.rangeCount || !sel.isCollapsed) return null;
var r = sel.getRangeAt(0);
var node = r.startContainer;
if(node.nodeType !== 3) return null;
var s = node.nodeValue, off = r.startOffset, i = off;
while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--;
if(i > 0 && s[i-1] === '@' && (i === 1 || /\s/.test(s[i-2]))){
return {node: node, at: i-1, raw: s.slice(i, off)};
}
}catch(e){}
return null;
},
_expandToAt(range){
var node = range.startContainer;
if(node.nodeType !== 3) return;
var s = node.nodeValue, off = range.startOffset, i = off;
while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--;
if(i > 0 && s[i-1] === '@') range.setStart(node, i-1);
},
_insertToken(item, range){
var el = this._composerEl();
if(!el) return;
try{
var span = document.createElement('span');
span.setAttribute('contenteditable','false');
span.setAttribute('data-ctoken','1');
span.setAttribute('data-token', item.token);
span.setAttribute('data-label', item.label);
span.setAttribute('data-kind', item.kind || 'mention');
span.className = 'ment-token' + (item.kind === 'skill' ? ' skill' : '');
var ico = document.createElement('span'); ico.className='tk-ico'; ico.textContent = ' ' + (item.icon || '');
var lbl = document.createElement('span'); lbl.className='tk-lbl'; lbl.textContent = ' ' + item.label + ' ';
span.appendChild(ico); span.appendChild(lbl);
var useRange = (range && range.startContainer) ? range : null;
if(!useRange){
var sel = window.getSelection();
if(sel && sel.rangeCount){
var cand = sel.getRangeAt(0);
if(cand && el.contains(cand.startContainer)) useRange = cand;
}
}
if(useRange){
if(this._mentionFrom !== '+' && !range) this._expandToAt(useRange);
useRange.deleteContents();
useRange.insertNode(span);
var sp = document.createTextNode(' ');
useRange.setStartAfter(span); useRange.collapse(true);
useRange.insertNode(sp);
useRange.setStart(sp, 1); useRange.setEnd(sp, 1);
try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch(e2){}
} else {
if(el.textContent) el.appendChild(document.createTextNode(' '));
el.appendChild(span);
el.appendChild(document.createTextNode(' '));
try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch(e3){}
}
}catch(e){}
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
_insertMentionAt(item){
var node = this._atNode;
if(node && node.nodeType === 3 && node.parentNode){
try{
var s = node.nodeValue;
var idx = -1;
for(var k=0;k<s.length;k++){ if(s[k] === '@'){ idx = k; break; } }
if(idx >= 0){
var r = document.createRange();
r.setStart(node, idx);
r.setEnd(node, s.length);
this._insertToken(item, r);
return;
}
}catch(e){}
}
this._insertToken(item);
},
_removeTokenSpan(token){
var el = this._composerEl();
if(!el) return;
var spans = el.querySelectorAll('[data-ctoken][data-token="'+token+'"]');
for(var i=0;i<spans.length;i++){
var p = spans[i];
if(p.parentNode){
if(p.nextSibling && p.nextSibling.nodeType===3 && /^\s*$/.test(p.nextSibling.nodeValue)) p.parentNode.removeChild(p.nextSibling);
p.parentNode.removeChild(p);
}
}
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
_prevToken(node, off){
if(node.nodeType === 3 && off === 0){
var p = node.previousSibling;
while(p && p.nodeType===3 && !p.nodeValue.trim()) p = p.previousSibling;
if(p && p.nodeType===1 && p.hasAttribute && p.hasAttribute('data-ctoken')) return p;
}
return null;
},
_nextToken(node, off){
if(node.nodeType === 3 && off === node.nodeValue.length){
var p = node.nextSibling;
while(p && p.nodeType===3 && !p.nodeValue.trim()) p = p.nextSibling;
if(p && p.nodeType===1 && p.hasAttribute && p.hasAttribute('data-ctoken')) return p;
}
return null;
},
_removeTokenDom(span){
var token = span.getAttribute('data-token');
if(span.parentNode){
if(span.previousSibling && span.previousSibling.nodeType===3 && /^\s*$/.test(span.previousSibling.nodeValue)) span.parentNode.removeChild(span.previousSibling);
if(span.nextSibling && span.nextSibling.nodeType===3 && /^\s*$/.test(span.nextSibling.nodeValue)) span.parentNode.removeChild(span.nextSibling);
span.parentNode.removeChild(span);
}
for(var i=0;i<this.contextChips.length;i++){ if(this.contextChips[i].token === token){ this.contextChips.splice(i,1); break; } }
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
onPaste(e){
e.preventDefault();
var text = '';
try{ if(e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; }catch(err){ }
if(!text) return;
try{
var sel = window.getSelection();
if(sel && sel.rangeCount){ sel.getRangeAt(0).deleteContents(); document.execCommand('insertText', false, text); }
}catch(err){}
this.input = this.composerText();
this.resizeInput();
},
get canSend(){ return !!((this.input || '').trim() || this.contextChips.length); },
get mentionChips(){ return (this.contextChips || []).filter(function(c){ return c.kind === 'mention'; }); },
get skillChips(){ return (this.contextChips || []).filter(function(c){ return c.kind === 'skill'; }); },
get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; },
get commandSuggestions(){
var raw = (this.input||'').trim();
if(!/^\//.test(raw)) return [];
var q = raw.replace(/^\/+/, '').toLowerCase();
var first = (q.split(/\s+/).filter(Boolean)[0] || '');
var all = [
{cmd:'/help', desc:'liste des commandes'},
{cmd:'/providers', desc:'liste les fournisseurs actifs'},
{cmd:'/provider <nom>', desc:'changer de fournisseur LLM'},
{cmd:'/model <nom>', desc:'changer de modèle LLM'},
{cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'}
];
if(!first) return all;
return all.filter(function(c){ return c.cmd.toLowerCase().replace(/^\/+/, '').indexOf(first) === 0; });
var first = (q.split(/\s+/).filter(Boolean)[0] || '').replace(/[^a-z0-9-]/g, '');
function qmatch(name){
var slug = String(name).toLowerCase().split(/[\s<]/)[0].replace(/^\/+/, '');
if(!first) return true;
return slug.indexOf(first) === 0 || slug.replace(/-/g, '').indexOf(first) === 0;
}
var out = [];
var seq = 0;
function pushItems(items){
items.forEach(function(o){ o._i = seq++; out.push(o); });
}
var skillsPresent = false, admPresent = false;
var built = [], dbs = [], adm = [];
for(var i=0;i<FD_SKILLS.length;i++){
if(qmatch(FD_SKILLS[i].cmd)){
var s = FD_SKILLS[i];
built.push({key:'bi-'+s.cmd, cmd:s.cmd, desc:s.desc, icon:s.icon, template:s.template, _type:'builtin'});
}
}
for(i=0;i<(this.skills||[]).length;i++){
var sk = this.skills[i];
if(qmatch(sk.name)){
dbs.push({key:'db-'+sk.id, cmd:'/'+sk.name, desc:(sk.description || 'Skill enregistré'), icon:'✨', _type:'db', skillId:sk.id});
}
}
for(i=0;i<FD_ADMIN_CMDS.length;i++){
if(qmatch(FD_ADMIN_CMDS[i].cmd)) adm.push({key:'adm-'+FD_ADMIN_CMDS[i].cmd, cmd:FD_ADMIN_CMDS[i].cmd, desc:FD_ADMIN_CMDS[i].desc});
}
skillsPresent = built.length || dbs.length;
admPresent = adm.length;
if(skillsPresent){
out.push({_sep:'Skills', key:'sep-skills'});
pushItems(built.concat(dbs));
}
if(skillsPresent && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
if(admPresent) pushItems(adm);
this._slashSeq = seq;
return out;
},
// Items slash « exécutables » (skills) — non séparateurs
_slashItems(){
return this.commandSuggestions.filter(function(o){ return !o._sep; });
},
_slugSkill(slug){
slug = String(slug || '').toLowerCase().replace(/^\/+/, '');
for(var i=0;i<FD_SKILLS.length;i++){
var c = FD_SKILLS[i].cmd.replace(/^\/+/, '').toLowerCase();
if(c === slug || c.replace(/-/g, '') === slug) return FD_SKILLS[i];
}
return null;
},
useSuggestion(c){
var v = c.cmd.replace(/\s*<[^>]*>\s*$/, ' ');
this.input = v;
var t = this.$refs && this.$refs.cmdInput;
if(t){ t.focus(); this.resizeInput(); }
var self = this;
if(!c || c._sep) return;
if(c._admin){
this.setComposerText(c.cmd.replace(/\s*<[^>]*>\s*$/, ' '));
this.cmdFocus = -1;
this._focusComposer();
return;
}
if(c._type === 'db'){
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; });
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
else {
var hasSkill = this.contextChips.some(function(ch){ return ch.kind === 'skill'; });
this.contextChips.push({key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨',
label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId});
this.clearComposer();
this._insertToken({token:'skill:'+c.skillId, label:String(c.cmd).replace(/^\//, ''), icon:c.icon||'✨', kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
}
} else {
this.setComposerText(c.template || '');
this.toast('Complétez la demande puis envoyez (Entrée).');
}
this.cmdFocus = -1; this.slashDismiss = false;
this._focusComposer();
},
echo(msg){
this.messages.push({id:'cmd-'+Date.now()+Math.random(), role:'assistant', content: msg, model:''});
@@ -676,7 +1093,7 @@
var cmd = (parts.shift() || '').toLowerCase();
var arg = parts.join(' ').trim();
if(cmd === 'help' || cmd === 'aide' || cmd === '?'){
this.echo('Commandes : /providers · /keys · /provider <nom> · /model <nom> · /help');
this.echo('Tapez / pour choisir un skill (research, resume, brainstorm, plan, meeting-note…). Commandes : /providers · /keys · /provider <nom> · /model <nom>');
return true;
}
if(cmd === 'providers'){
@@ -723,36 +1140,289 @@
return false;
},
// ── Toast ──
toast(msg, err){
var self = this;
this.toastMsg = msg || '';
this.toastErr = !!err;
if(this._toastTimer) clearTimeout(this._toastTimer);
if(!msg){ return; }
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
},
// ── Mentions & contexte épinglé (@ / +) ──
toggleAddPicker(){
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; }
this.openMentionPicker('+');
},
openMentionPicker(from){
this.mentionOpen = true;
this._mentionFrom = from || '+';
this.mentionQuery = '';
this.mentionFocus = -1;
this.searchMentions('');
},
searchMentions(q){
var self = this;
q = (q || '').trim();
this.mentionQuery = q;
this.mentionLoad = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer);
this._mentionTimer = setTimeout(function(){
fetch('/api/agent/mentions?q=' + encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(d){
self.mentionItems = (d.mentions || []).map(function(it, idx){
it._i = idx;
return it;
});
self.mentionLoad = false;
if(self.mentionItems.length && self.mentionFocus < 0) self.mentionFocus = 0;
})
.catch(function(){ self.mentionItems = []; self.mentionLoad = false; });
}, q ? 180 : 0);
},
addChip(item){
if(!item || !item.token) return;
var dup = this.contextChips.some(function(ch){ return ch.token === item.token; });
if(dup){ this.toast('Déjà dans le contexte'); return; }
this.contextChips.push({key: item.token, kind: 'mention', icon: item.icon || '📎',
label: item.label, token: item.token, type: item.type, refId: item.id});
},
pickMention(item){
if(!item) return;
this.addChip(item);
if(this._mentionFrom === '@'){
this._insertMentionAt(item);
}
this.mentionOpen = false;
this.mentionQuery = '';
this._mentionFrom = null;
this._atNode = null;
this._focusComposer();
},
removeChip(token){
var idx = -1;
for(var i=0;i<this.contextChips.length;i++){
if(this.contextChips[i].token === token){ idx = i; break; }
}
if(idx < 0) return;
var chip = this.contextChips[idx];
this.contextChips.splice(idx, 1);
this._removeTokenSpan(chip.token);
this._emptyCheck();
},
onInput(){
this.resizeInput();
this.slashDismiss = false;
var text = this.composerText();
this.input = text;
var cmdNow = /^\s*\//.test(text);
if(cmdNow){
if(!this._cmdWasOpen){
var firstSkill = (this.commandSuggestions || []).filter(function(o){ return !o._sep && !o._admin; })[0];
this.cmdFocus = firstSkill ? firstSkill._i : -1;
} else if(this.cmdFocus !== -1){
var present = (this.commandSuggestions || []).some(function(o){ return !o._sep && o._i === this.cmdFocus; }.bind(this));
if(!present){
var fs = (this.commandSuggestions || []).filter(function(o){ return !o._sep && !o._admin; })[0];
this.cmdFocus = fs ? fs._i : -1;
}
}
} else {
this.cmdFocus = -1;
}
this._cmdWasOpen = cmdNow;
// @ mention inline
var cq = this._caretQuery();
if(cq){
if(this._mentionFrom !== '@'){ this._mentionFrom = '@'; this._atNode = cq.node; }
this.mentionOpen = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer);
this.mentionLoad = true;
this.searchMentions(cq.raw);
} else if(this._mentionFrom === '@'){
this.mentionOpen = false;
this.mentionQuery = '';
}
this._emptyCheck();
},
moveMentionFocus(delta){
if(!this.mentionItems.length) return;
var n = this.mentionItems.length;
var idx = this.mentionItems.findIndex(function(it){ return it._i === this.mentionFocus; }.bind(this));
if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + n) % n;
this.mentionFocus = this.mentionItems[idx]._i;
},
moveSlashFocus(delta){
var items = this._slashItems().filter(function(o){ return !o._admin; });
if(!items.length) return;
var idx = items.findIndex(function(o){ return o._i === this.cmdFocus; }.bind(this));
if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + items.length) % items.length;
this.cmdFocus = items[idx]._i;
},
onComposerKeydown(e){
var key = e.key;
if(key === 'Backspace' || key === 'Delete'){
try{
var sel = window.getSelection();
if(sel && sel.rangeCount){
var r = sel.getRangeAt(0);
if(r.collapsed){
var tok = key === 'Backspace'
? this._prevToken(r.startContainer, r.startOffset)
: this._nextToken(r.startContainer, r.startOffset);
if(tok){ e.preventDefault(); this._removeTokenDom(tok); return; }
}
}
}catch(err){}
}
if(key === 'Escape'){
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
return; // laisse fermer le panneau
}
if(key === 'ArrowDown' || key === 'ArrowUp'){
var delta = key === 'ArrowDown' ? 1 : -1;
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
return;
}
// Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen && this.mentionItems.length){
if(key === 'Enter' || key === 'Tab'){
e.preventDefault();
var mi = this.mentionItems.find(function(it){ return it._i === this.mentionFocus; }.bind(this)) || this.mentionItems[0];
this.pickMention(mi);
return;
}
}
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){
var items = this._slashItems();
if(key === 'Enter' || key === 'Tab'){
var focusItem = items.find(function(o){ return o._i === this.cmdFocus; }.bind(this));
if(focusItem){
if(!focusItem._admin){ e.preventDefault(); this.useSuggestion(focusItem); return; }
e.preventDefault(); this.send(); return;
}
var queryTyped = (this.composerText() || '').replace(/^\/+/, '').trim().length > 0;
var onlyAdmin = items.every(function(o){ return o._admin; });
var onlySkill = !onlyAdmin && items.every(function(o){ return !o._admin; });
if(onlySkill && queryTyped){
e.preventDefault(); this.useSuggestion(items[0]); return;
}
if(onlyAdmin){
e.preventDefault(); this.send(); return;
}
e.preventDefault(); return;
}
}
// Entrée : envoyer (Maj+Entrée = nouvelle ligne)
if(key === 'Enter'){
if(e.shiftKey){ e.preventDefault(); this.insertNewline(); }
else { e.preventDefault(); this.send(); }
return;
}
},
// ── Actions sous chaque réponse agent ──
copyResponse(m){
var self = this;
var text = (m && m.content) || '';
if(!text) return;
function fallback(){
try{
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
self.toast(ok ? 'Réponse copiée.' : 'Copie impossible.', !ok);
}catch(err){ self.toast('Copie impossible.', true); }
}
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(function(){ self.toast('Réponse copiée.'); }).catch(function(){ fallback(); });
} else { fallback(); }
},
insertIntoPage(m){
var c = FlowDeckCtx.current();
if(!c || c.kind !== 'page'){ this.toast('Aucune page ouverte pour insérer cette réponse.', true); return; }
if(!window.fdApplyDocument){ this.toast('Insertion indisponible ici.', true); return; }
var text = (m && m.content) || '';
if(!text){ return; }
var ok = window.fdApplyDocument(text, m && m.mode && !m.applied ? m.mode : 'insert');
this.toast(ok ? 'Réponse insérée dans la page.' : 'Insertion impossible (page non éditable).', !ok);
},
feedback(m, rating){
var self = this;
if(!m || m.fb) return;
var payload = {rating: rating, content: ((m.content) || '').slice(0, 1000)};
if(this.currentConv && this.currentConv.id) payload.conversation_id = this.currentConv.id;
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
self.toast(rating === 'up' ? 'Merci pour votre retour 👍' : 'Retour enregistré 👎', false);
}).catch(function(){ self.toast('Retour non envoyé.', true); });
},
// ── Send / SSE ──
async send(){
var self = this;
if(self.streaming) return;
var text = (self.input || '').trim();
if(!text) return;
if(self.commandOpen && self.handleCommand(text)){ self.input=''; this.resizeInput(); return; }
if(!text && !self.contextChips.length) return;
if(self.commandOpen && self.handleCommand(text)){ self.clearComposer(); return; }
// skill intégré tapé « en dur » (« /research », « /meeting-note … ») → applique son modèle
if(self.commandOpen){
var firstTok = (text.split(/\s+/)[0] || '');
var sk = self._slugSkill(firstTok);
if(sk){
var rest = text.slice(String(firstTok).length).trim();
text = sk.template + (rest ? '\n\n' + rest : '');
}
}
// barre oblique seule (après Escape du menu /) → on ne l'envoie pas
if(/^\/+$/.test(text)){ self.clearComposer(); return; }
var ment = self.composerMentions();
if(!text){
if(ment.length){ text = 'Analyse le contexte référencé.'; }
else { return; }
}
try { await self.ensureConversation(); } catch(e){ return; }
self.input = '';
this.resizeInput();
var rawText = text;
self.clearComposer();
self.errorMsg = '';
self.mentionOpen = false;
// A writing/content request typed while a document is open → route it to
// headless text generation and offer the result with Apply / Reject,
// instead of sending the agent into tool calls (search/create).
if(self.activeContext && self.activeContext.kind === 'page' && self._isContentRequest(text)){
if(rawText && self.activeContext && self.activeContext.kind === 'page' && self._isContentRequest(rawText)){
self.refreshContext();
return self.runDocAction({prompt: text, label: text, mode: self._docModeFor(text)});
return self.runDocAction({prompt: rawText, label: rawText, mode: self._docModeFor(rawText)});
}
self.streaming = true;
self.liveReasoning = '…';
self.messages.push({id:'u-'+Date.now(), role:'user', content: text});
if(rawText) self.messages.push({id:'u-'+Date.now(), role:'user', content: rawText});
self.scrollBottom();
var payload = {message: text};
var payload = {message: rawText};
if(self.llmProvider) payload.provider = self.llmProvider;
if(self.llmModel) payload.model = self.llmModel;
var ctxText = self.contextPayload();
if(ctxText) payload.context = ctxText;
if(ment && ment.length) payload.mentions = ment;
var skillChip = (self.contextChips || []).find(function(ch){ return ch.kind === 'skill'; });
if(skillChip && skillChip.skillId) payload.skill_id = skillChip.skillId;
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
@@ -779,7 +1449,7 @@
var msg = ev.status==='success'
? (ev.tool+' ✓ '+(ev.message||''))
: (ev.tool+' ✗ '+(ev.detail||'erreur'));
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg});
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg, act:true});
}
else if(ev.type==='final'){
self.messages.push({id:'f-'+Date.now(), role:'assistant', content: ev.content});