feat(agent): v4.15.3 - mentions @ par espace courant + skills composes en arriere-plan
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped

- /api/agent/mentions : reponse en sections (fichiers de l'espace courant ouvert, collections, pages de collections, autres documents), workspace_id transmis par le frontend (localWsId) et prioritaire
- Navigation clavier up/down : la liste defile pour garder la selection visible (menus @ et /)
- Deduplication des propositions (meme type + meme titre affiches une seule fois)
- Skills : plus d'injection du texte du skill dans la boite d'edition; requete composee en arriere-plan (contexte + skills + texte) a l'envoi; les skills integres injectent leur template, les enregistres partent via skill_ids
- Tests : shape des mentions, dedup, scoping workspace
This commit is contained in:
2026-09-06 13:57:03 -04:00
parent 917a04d543
commit 2391de418d
6 changed files with 248 additions and 74 deletions
+91 -34
View File
@@ -136,6 +136,7 @@
.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-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
/* Toast */
@@ -261,7 +262,7 @@
<span x-text="toastMsg"></span>
<button type="button" @click="toastMsg=''">✕</button>
</div>
<div class="fd-agent-cmd-menu" :class="{show: commandOpen && commandSuggestions.length && !slashDismiss}">
<div class="fd-agent-cmd-menu" x-ref="cmdMenu" :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>
@@ -272,13 +273,15 @@
</div>
</template>
</div>
<div class="fd-ap-mention-menu" :class="{show: mentionOpen}">
<div class="fd-ap-mention-menu" :class="{show: mentionOpen}" x-ref="mentionMenu">
<div class="fd-mention-load" x-show="mentionLoad" x-cloak>Recherche…</div>
<template x-if="!mentionLoad && !mentionItems.length">
<template x-if="!mentionLoad && !hasMentionItems">
<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">
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token || it.key">
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: it._i===mentionFocus}"
@click="pickMention(it)" @mouseenter="setMentionFocus(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>
@@ -820,6 +823,25 @@
}
return out;
},
_composePrompt(text){
var chips = this.contextChips || [];
var segs = [];
// contextes épinglés (@ documents / bases / pages)
chips.forEach(function(ch){
if(ch.kind === 'mention'){ segs.push('Contexte « ' + (ch.label || '') + ' »'); }
});
// skills épinglés : template intégré, ou libellé pour un skill enregistré
// (son prompt complet est injecté via skill_ids côté serveur)
chips.forEach(function(ch){
if(ch.kind === 'skill'){
if(ch.builtin && ch.template){ segs.push(ch.template); }
else { segs.push('Skill « ' + (ch.label || '') + ' »'); }
}
});
var t = (text || '').trim();
if(t){ segs.push(t); }
return segs.join('\n\n');
},
resizeInput(){
var t = this._composerEl();
if(!t) return;
@@ -1090,10 +1112,11 @@
if(builtinExists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
else {
this.contextChips.push({key:'bi-'+slug, kind:'skill', icon:c.icon||'✨',
label:slug, token:'builtin:'+slug, skillId:null, builtin:true});
var cur = this.composerText();
this.setComposerText(cur ? (cur + '\n\n' + (c.template || '')) : (c.template || ''));
this.toast('Skill épinglé — complétez la demande puis envoyez.');
label:slug, token:'builtin:'+slug, skillId:null, builtin:true, template:c.template||''});
// On n\u2019injecte PAS le texte du skill dans la bo\u00eete d\u2019\u00e9dition :
// la requ\u00eate est compos\u00e9e en arri\u00e8re-plan \u00e0 l\u2019envoi.
this._focusComposer();
this.toast('Skill épinglé — votre demande sera composée avec lui à l\u2019envoi.');
}
}
this.cmdFocus = -1; this.slashDismiss = false;
@@ -1185,19 +1208,40 @@
this.mentionLoad = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer);
this._mentionTimer = setTimeout(function(){
fetch('/api/agent/mentions?q=' + encodeURIComponent(q))
var ws = (window.appState && window.appState.localWsId) || 0;
fetch('/api/agent/mentions?q=' + encodeURIComponent(q) + '&workspace_id=' + ws)
.then(function(r){ return r.json(); })
.then(function(d){
self.mentionItems = (d.mentions || []).map(function(it, idx){
it._i = idx;
return it;
var flat = [], gi = 0;
(d.sections || []).forEach(function(grp){
var items = grp.items || [];
if(!items.length) return;
flat.push({_sep: grp.label || '', key: 'sep-' + (grp.key || gi), _kind: 'sep'});
items.forEach(function(it){ it._i = gi++; flat.push(it); });
});
self.mentionItems = flat;
self.mentionLoad = false;
if(self.mentionItems.length && self.mentionFocus < 0) self.mentionFocus = 0;
var sel = self._selectableMentionItems();
if(sel.length) self.mentionFocus = sel[0]._i;
self.$nextTick(function(){ self._scrollMentionItem(); });
})
.catch(function(){ self.mentionItems = []; self.mentionLoad = false; });
}, q ? 180 : 0);
},
get hasMentionItems(){ return (this.mentionItems || []).some(function(it){ return !it._sep; }); },
_selectableMentionItems(){
return (this.mentionItems || []).filter(function(it){ return it && it._sep !== true && it._kind !== 'sep'; });
},
setMentionFocus(i){
this.mentionFocus = i;
this._scrollMentionItem();
},
_scrollMentionItem(){
var menu = this.$refs && this.$refs.mentionMenu;
if(!menu) return;
var el = menu.querySelector('.fd-mention-item.focus');
if(el && el.scrollIntoView){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; }
},
addChip(item){
if(!item || !item.token) return;
var dup = this.contextChips.some(function(ch){ return ch.token === item.token; });
@@ -1285,12 +1329,14 @@
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));
var sel = this._selectableMentionItems();
if(!sel.length) return;
var n = sel.length;
var idx = sel.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;
this.mentionFocus = sel[idx]._i;
this._scrollMentionItem();
},
moveSlashFocus(delta){
var items = this._slashItems().filter(function(o){ return !o._admin; });
@@ -1299,6 +1345,11 @@
if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + items.length) % items.length;
this.cmdFocus = items[idx]._i;
var menu = this.$refs && this.$refs.cmdMenu;
if(menu){
var el = menu.querySelector('.fd-agent-cmd-item.focus');
if(el && el.offsetTop != null){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; }
}
},
onComposerKeydown(e){
var key = e.key;
@@ -1328,10 +1379,11 @@
return;
}
// Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen && this.mentionItems.length){
if(key === 'Enter' || key === 'Tab'){
if(this.mentionOpen){
var mSel = this._selectableMentionItems();
if((key === 'Enter' || key === 'Tab') && mSel.length){
e.preventDefault();
var mi = this.mentionItems.find(function(it){ return it._i === this.mentionFocus; }.bind(this)) || this.mentionItems[0];
var mi = mSel.find(function(it){ return it._i === this.mentionFocus; }.bind(this)) || mSel[0];
this.pickMention(mi);
return;
}
@@ -1429,41 +1481,46 @@
// barre oblique seule (après Escape du menu /) → on ne l'envoie pas
if(/^\/+$/.test(text)){ self.clearComposer(); return; }
var ment = self.composerMentions();
var skillIds = [];
(self.contextChips || []).forEach(function(ch){ if(ch.kind==='skill' && ch.skillId) skillIds.push(ch.skillId); });
var hasSkills = skillIds.length > 0 || (self.contextChips || []).some(function(ch){ return ch.kind==='skill'; });
if(!text){
var hasSkills = (self.contextChips || []).some(function(ch){ return ch.kind === 'skill'; });
if(ment.length){ text = 'Analyse le contexte référencé.'; }
else if(hasSkills){ text = 'Applique le(s) skill(s) sélectionné(s) à ma demande.'; }
else { return; }
else if(!hasSkills){ return; }
}
try { await self.ensureConversation(); } catch(e){ return; }
var rawText = text;
// La requête est composée EN ARRIÈRE-PLAN (hors de la boîte d'édition) :
// contexte1 + contexte2 + skill-1 + skill-2 + texte.
var composed = this._composePrompt(rawText);
var skillLabels = (self.contextChips || []).filter(function(ch){ return ch.kind==='skill'; })
.map(function(ch){ return ch.label; });
var displayText = rawText || (ment.length ? 'Analyse le contexte référencé.'
: ('Skills : ' + skillLabels.join(', ')));
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(rawText && self.activeContext && self.activeContext.kind === 'page' && self._isContentRequest(rawText)){
// Une demande d'écriture avec document ouvert et SANS skill/contexte épinglé →
// génération headless avec Appliquer/Rejeter. Dès qu'on épingle un skill ou un
// contexte, on passe par l'agent (outils + skills + mentions).
if(rawText && self.activeContext && self.activeContext.kind === 'page'
&& self._isContentRequest(rawText) && !self.contextChips.length){
self.refreshContext();
return self.runDocAction({prompt: rawText, label: rawText, mode: self._docModeFor(rawText)});
}
self.streaming = true;
self.liveReasoning = '…';
if(rawText) self.messages.push({id:'u-'+Date.now(), role:'user', content: rawText});
self.messages.push({id:'u-'+Date.now(), role:'user', content: displayText});
self.scrollBottom();
var payload = {message: rawText};
var payload = {message: composed};
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 skillIds = [];
(self.contextChips || []).forEach(function(ch){
if(ch.kind === 'skill' && ch.skillId) skillIds.push(ch.skillId);
});
if(skillIds.length) payload.skill_ids = skillIds;
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {