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
+20
View File
@@ -1,5 +1,25 @@
# Changelog — FlowDeck # Changelog — FlowDeck
## v4.15.3 (2026-09-06) — Agent : mentions @ par espace courant + skills composés en arrière-plan
> La commande « @ » propose en priorité les **fichiers de l'espace de travail ouvert**, puis les
> autres contextes (collections, pages de collections, autres documents). La navigation clavier
> (↑/↓) positionne maintenant visuellement la sélection dans la fenêtre (défilement), et les
> fichiers identiques ne sont plus affichés plusieurs fois (déduplication par titre).
### Améliorations
- **Mentions @ contextuelles** : nouvelle API `/api/agent/mentions` par **sections** :
*Fichiers de l'espace courant* (« `workspace_id` transmis par le frontend »), *Collections / bases*,
*Pages de collections*, *Autres documents*.
- **Navigation clavier corrigée** : ↑/↓ font défiler la liste ouverte pour garder la sélection
visible à l'écran (aussi appliqué au menu `/`). Entrée/Tab sélectionne toujours l'élément focalisé.
- **Déduplication** : deux fichiers du même type et même titre (ex. « Sans titre », « Aussi Vide »)
n'apparaissent qu'une seule fois dans les propositions.
- **Skills composés en arrière-plan** : sélectionner un skill n'injecte plus son texte dans la boîte
d'édition. À l'envoi la requête est assemblée : `contexte1 + contexte2 + skill-1 + skill-2 + texte`.
Les skills intégrés (templace) et enregistrés (skill_ids) sont tous deux pris en compte ; les
contextes @ continuent d'être résolus en contenu côté serveur.
## v4.15.2 (2026-09-06) — Agent : skills multi-épinglés en bulles + chips de contexte cliquables ## v4.15.2 (2026-09-06) — Agent : skills multi-épinglés en bulles + chips de contexte cliquables
> Les skills s'épinglent désormais au contexte **comme les mentions @** (bulles « / » purples dans > Les skills s'épinglent désormais au contexte **comme les mentions @** (bulles « / » purples dans
+1 -1
View File
@@ -1 +1 @@
4.15.2 4.15.3
+1 -1
View File
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="4.15.2", version="4.15.3",
docs_url="/docs" if settings.log_level == "DEBUG" else None, docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None, redoc_url=None,
lifespan=lifespan, lifespan=lifespan,
+105 -35
View File
@@ -450,58 +450,128 @@ async def apply_skill(request: Request, skill_id: int):
async def list_mentions(request: Request, q: str = ""): async def list_mentions(request: Request, q: str = ""):
"""Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »). """Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »).
Retourne un flux unique d'objets FlowDeck (documents éditeur, collections, Retourne des sections d'objets FlowDeck que l'utilisateur peut épingler au
pages de collection) que l'utilisateur peut épingler au contexte du chat. contexte du chat. La première section privilégie les **fichiers de l'espace de
Chaque entrée porte un jeton ``token`` (``document:<id>``, ``collection:<id>``, travail courant ouvert** (`workspace_id`), puis viennent les collections, les
``page:<id>``) que ContextBuilder résout en contenu réel pour le LLM. pages de collection et les autres documents. Chaque élément porte un jeton
``token`` (``document:<id>``, ``collection:<id>``, ``page:<id>``) que
ContextBuilder résout en contenu réel pour le LLM.
Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans
titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois.
""" """
query = (q or "").strip()[:60] query = (q or "").strip()[:60]
like = f"%{query}%" like = f"%{query}%"
limit = 8 limit = 8
items: list[dict] = [] # L'espace courant ouvert est fourni par le frontend (localWsId) et doit primer
# sur l'espace par défaut de l'utilisateur pour cette liste contextuelle.
try:
ws = int(request.query_params.get("workspace_id", 0) or 0) or None
except (TypeError, ValueError):
ws = None
if not ws:
ws = await _workspace_id(request)
def dedupe(items: list[dict]) -> list[dict]:
seen: set = set()
out: list[dict] = []
for it in items:
key = (it["type"], (it.get("label") or "").strip().lower())
if key in seen:
continue
seen.add(key)
out.append(it)
return out
sections: list[dict] = []
ws_name = None
with get_conn() as conn: with get_conn() as conn:
# 1) Documents (pages éditeur) — token document:<id> if ws:
sql_docs = ( row = conn.execute("SELECT name FROM workspaces WHERE id=?", (ws,)).fetchone()
"SELECT p.id, p.title, p.workspace_id, w.name AS ws_name " ws_name = row["name"] if row else None
"FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) " # 1) Fichiers de l'espace de travail courant ouvert — token document:<id>
"ORDER BY p.updated_at DESC LIMIT ?" if ws:
) sql_ws = (
for r in conn.execute(sql_docs, (query, like, limit)).fetchall(): "SELECT p.id, p.title FROM pages p "
items.append({ "WHERE p.workspace_id=? AND p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) "
"type": "document", "icon": "📄", "ORDER BY p.updated_at DESC LIMIT ?"
"id": r["id"], "label": r["title"] or "Sans titre", )
"sub": r["ws_name"] or "", "token": f"document:{r['id']}", files = [
}) {"type": "document", "icon": "📄", "id": r["id"], "label": r["title"] or "Sans titre",
"sub": ws_name or "Espace courant", "token": f"document:{r['id']}"}
for r in conn.execute(sql_ws, (ws, query, like, limit)).fetchall()
]
sections.append({"key": "files", "label": (ws_name or "Fichiers de l'espace"),
"items": dedupe(files)})
# 2) Collections (bases de données) — token collection:<id> # 2) Collections (bases de données) — token collection:<id>
sql_colls = ( colls = []
"SELECT id, name, icon FROM collections " for r in conn.execute(
"WHERE (? = '' OR name LIKE ?) ORDER BY updated_at DESC LIMIT ?" "SELECT id, name, icon FROM collections WHERE (? = '' OR name LIKE ?) "
) "ORDER BY updated_at DESC LIMIT ?", (query, like, limit)
for r in conn.execute(sql_colls, (query, like, limit)).fetchall(): ).fetchall():
items.append({ colls.append({"type": "collection", "icon": r["icon"] or "🗄️", "id": r["id"],
"type": "collection", "icon": r["icon"] or "🗄️", "label": r["name"], "sub": "Base de données",
"id": r["id"], "label": r["name"], "token": f"collection:{r['id']}"})
"sub": "Base de données", "token": f"collection:{r['id']}", if colls:
}) sections.append({"key": "collections", "label": "Collections / bases",
"items": dedupe(colls)})
# 3) Pages de collection — token page:<id> # 3) Pages de collection — token page:<id>
sql_pages = ( pages = []
for r in conn.execute(
"SELECT cp.id, cp.title, cp.icon, c.name AS coll_name " "SELECT cp.id, cp.title, cp.icon, c.name AS coll_name "
"FROM collection_pages cp LEFT JOIN collections c ON c.id=cp.collection_id " "FROM collection_pages cp LEFT JOIN collections c ON c.id=cp.collection_id "
"WHERE (? = '' OR cp.title LIKE ?) ORDER BY cp.updated_at DESC LIMIT ?" "WHERE (? = '' OR cp.title LIKE ?) ORDER BY cp.updated_at DESC LIMIT ?",
) (query, like, limit)
for r in conn.execute(sql_pages, (query, like, limit)).fetchall(): ).fetchall():
items.append({ pages.append({
"type": "page", "icon": r["icon"] if str(r["icon"]).startswith(("📄", "📑", "🗂️", "📁")) else "📑", "type": "page",
"icon": r["icon"] if str(r["icon"]).startswith(("📄", "📑", "🗂️", "📁")) else "📑",
"id": r["id"], "label": r["title"] or "Sans titre", "id": r["id"], "label": r["title"] or "Sans titre",
"sub": f"Page · {r['coll_name']}" if r["coll_name"] else "Page", "sub": f"Page · {r['coll_name']}" if r["coll_name"] else "Page",
"token": f"page:{r['id']}", "token": f"page:{r['id']}",
}) })
if pages:
sections.append({"key": "pages", "label": "Pages de collections",
"items": dedupe(pages)})
return {"mentions": items} # 4) Autres documents éditeur hors de l'espace courant — token document:<id>
if ws:
other = []
for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name "
"FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (p.workspace_id IS ? OR p.workspace_id IS NULL) "
"AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?",
(ws, query, like, limit)
).fetchall():
other.append({"type": "document", "icon": "📄", "id": r["id"],
"label": r["title"] or "Sans titre", "sub": r["ws_name"] or "",
"token": f"document:{r['id']}"})
dedup_other = dedupe(other)
if dedup_other:
sections.append({"key": "other", "label": "Autres documents",
"items": dedup_other})
else:
# Pas de workspace courant → liste les documents éditeur (comme avant)
docs = []
for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name FROM pages p "
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit)
).fetchall():
docs.append({"type": "document", "icon": "📄", "id": r["id"],
"label": r["title"] or "Sans titre", "sub": r["ws_name"] or "",
"token": f"document:{r['id']}"})
if docs:
sections.insert(0, {"key": "files", "label": "Documents",
"items": dedupe(docs)})
return {"workspace_id": ws, "sections": sections}
@router.post("/feedback") @router.post("/feedback")
+91 -34
View File
@@ -136,6 +136,7 @@
.fd-mention-main{flex:1;min-width:0;display:flex;flex-direction:column} .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-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-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} .fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
/* Toast */ /* Toast */
@@ -261,7 +262,7 @@
<span x-text="toastMsg"></span> <span x-text="toastMsg"></span>
<button type="button" @click="toastMsg=''">✕</button> <button type="button" @click="toastMsg=''">✕</button>
</div> </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"> <template x-for="c in commandSuggestions" :key="c.key">
<div> <div>
<div class="fd-agent-cmd-sep" x-show="c._sep" x-text="c._sep"></div> <div class="fd-agent-cmd-sep" x-show="c._sep" x-text="c._sep"></div>
@@ -272,13 +273,15 @@
</div> </div>
</template> </template>
</div> </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> <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> <div class="fd-mention-empty">Aucun élément. Tapez un nom de document, page ou base.</div>
</template> </template>
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token"> <template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token || it.key">
<div class="fd-mention-item" :class="{focus: it._i===mentionFocus}" @click="pickMention(it)" @mouseenter="mentionFocus=it._i"> <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> <span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main"> <div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div> <div class="fd-mention-lbl" x-text="it.label"></div>
@@ -820,6 +823,25 @@
} }
return out; 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(){ resizeInput(){
var t = this._composerEl(); var t = this._composerEl();
if(!t) return; if(!t) return;
@@ -1090,10 +1112,11 @@
if(builtinExists){ this.toast('Ce skill est déjà épinglé au contexte.'); } if(builtinExists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
else { else {
this.contextChips.push({key:'bi-'+slug, kind:'skill', icon:c.icon||'✨', this.contextChips.push({key:'bi-'+slug, kind:'skill', icon:c.icon||'✨',
label:slug, token:'builtin:'+slug, skillId:null, builtin:true}); label:slug, token:'builtin:'+slug, skillId:null, builtin:true, template:c.template||''});
var cur = this.composerText(); // On n\u2019injecte PAS le texte du skill dans la bo\u00eete d\u2019\u00e9dition :
this.setComposerText(cur ? (cur + '\n\n' + (c.template || '')) : (c.template || '')); // la requ\u00eate est compos\u00e9e en arri\u00e8re-plan \u00e0 l\u2019envoi.
this.toast('Skill épinglé — complétez la demande puis envoyez.'); this._focusComposer();
this.toast('Skill épinglé — votre demande sera composée avec lui à l\u2019envoi.');
} }
} }
this.cmdFocus = -1; this.slashDismiss = false; this.cmdFocus = -1; this.slashDismiss = false;
@@ -1185,19 +1208,40 @@
this.mentionLoad = true; this.mentionLoad = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer); if(this._mentionTimer) clearTimeout(this._mentionTimer);
this._mentionTimer = setTimeout(function(){ 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(r){ return r.json(); })
.then(function(d){ .then(function(d){
self.mentionItems = (d.mentions || []).map(function(it, idx){ var flat = [], gi = 0;
it._i = idx; (d.sections || []).forEach(function(grp){
return it; 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; 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; }); .catch(function(){ self.mentionItems = []; self.mentionLoad = false; });
}, q ? 180 : 0); }, 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){ addChip(item){
if(!item || !item.token) return; if(!item || !item.token) return;
var dup = this.contextChips.some(function(ch){ return ch.token === item.token; }); var dup = this.contextChips.some(function(ch){ return ch.token === item.token; });
@@ -1285,12 +1329,14 @@
this._emptyCheck(); this._emptyCheck();
}, },
moveMentionFocus(delta){ moveMentionFocus(delta){
if(!this.mentionItems.length) return; var sel = this._selectableMentionItems();
var n = this.mentionItems.length; if(!sel.length) return;
var idx = this.mentionItems.findIndex(function(it){ return it._i === this.mentionFocus; }.bind(this)); 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; if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + n) % n; idx = (idx + delta + n) % n;
this.mentionFocus = this.mentionItems[idx]._i; this.mentionFocus = sel[idx]._i;
this._scrollMentionItem();
}, },
moveSlashFocus(delta){ moveSlashFocus(delta){
var items = this._slashItems().filter(function(o){ return !o._admin; }); var items = this._slashItems().filter(function(o){ return !o._admin; });
@@ -1299,6 +1345,11 @@
if(idx < 0) idx = delta > 0 ? -1 : 0; if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + items.length) % items.length; idx = (idx + delta + items.length) % items.length;
this.cmdFocus = items[idx]._i; 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){ onComposerKeydown(e){
var key = e.key; var key = e.key;
@@ -1328,10 +1379,11 @@
return; return;
} }
// Entrée / Tab avec un menu ouvert (skills / mentions) // Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen && this.mentionItems.length){ if(this.mentionOpen){
if(key === 'Enter' || key === 'Tab'){ var mSel = this._selectableMentionItems();
if((key === 'Enter' || key === 'Tab') && mSel.length){
e.preventDefault(); 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); this.pickMention(mi);
return; return;
} }
@@ -1429,41 +1481,46 @@
// barre oblique seule (après Escape du menu /) → on ne l'envoie pas // barre oblique seule (après Escape du menu /) → on ne l'envoie pas
if(/^\/+$/.test(text)){ self.clearComposer(); return; } if(/^\/+$/.test(text)){ self.clearComposer(); return; }
var ment = self.composerMentions(); 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){ if(!text){
var hasSkills = (self.contextChips || []).some(function(ch){ return ch.kind === 'skill'; });
if(ment.length){ text = 'Analyse le contexte référencé.'; } 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 if(!hasSkills){ return; }
else { return; }
} }
try { await self.ensureConversation(); } catch(e){ return; } try { await self.ensureConversation(); } catch(e){ return; }
var rawText = text; 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.clearComposer();
self.errorMsg = ''; self.errorMsg = '';
self.mentionOpen = false; self.mentionOpen = false;
// A writing/content request typed while a document is open → route it to // Une demande d'écriture avec document ouvert et SANS skill/contexte épinglé →
// headless text generation and offer the result with Apply / Reject, // génération headless avec Appliquer/Rejeter. Dès qu'on épingle un skill ou un
// instead of sending the agent into tool calls (search/create). // contexte, on passe par l'agent (outils + skills + mentions).
if(rawText && self.activeContext && self.activeContext.kind === 'page' && self._isContentRequest(rawText)){ if(rawText && self.activeContext && self.activeContext.kind === 'page'
&& self._isContentRequest(rawText) && !self.contextChips.length){
self.refreshContext(); self.refreshContext();
return self.runDocAction({prompt: rawText, label: rawText, mode: self._docModeFor(rawText)}); return self.runDocAction({prompt: rawText, label: rawText, mode: self._docModeFor(rawText)});
} }
self.streaming = true; self.streaming = true;
self.liveReasoning = '…'; 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(); self.scrollBottom();
var payload = {message: rawText}; var payload = {message: composed};
if(self.llmProvider) payload.provider = self.llmProvider; if(self.llmProvider) payload.provider = self.llmProvider;
if(self.llmModel) payload.model = self.llmModel; if(self.llmModel) payload.model = self.llmModel;
var ctxText = self.contextPayload(); var ctxText = self.contextPayload();
if(ctxText) payload.context = ctxText; if(ctxText) payload.context = ctxText;
if(ment && ment.length) payload.mentions = ment; 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; if(skillIds.length) payload.skill_ids = skillIds;
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', { return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
+30 -3
View File
@@ -526,17 +526,44 @@ def test_router_mentions(client):
r = client.get("/api/agent/mentions") r = client.get("/api/agent/mentions")
assert r.status_code == 200 assert r.status_code == 200
items = r.json()["mentions"] data = r.json()
tokens = {it["token"] for it in items} sections = data["sections"]
assert isinstance(sections, list) and sections
tokens = {it["token"] for grp in sections for it in grp["items"]}
assert f"document:{doc_id}" in tokens assert f"document:{doc_id}" in tokens
assert f"collection:{coll_id}" in tokens assert f"collection:{coll_id}" in tokens
assert f"page:{page_id}" in tokens assert f"page:{page_id}" in tokens
# search filter # search filter
r2 = client.get("/api/agent/mentions", params={"q": "Compte rendu"}) r2 = client.get("/api/agent/mentions", params={"q": "Compte rendu"})
found = r2.json()["mentions"] found = [it for grp in r2.json()["sections"] for it in grp["items"]]
assert found and all(it["type"] == "document" and it["label"] == "Compte rendu projet" for it in found) assert found and all(it["type"] == "document" and it["label"] == "Compte rendu projet" for it in found)
# déduplication : deux fichiers avec le même titre n'apparaissent qu'une fois
with get_conn() as conn:
conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) "
"VALUES ('', 'Compte rendu projet', 'Bis', 'markdown')")
conn.commit()
r3 = client.get("/api/agent/mentions")
dup_docs = [it for grp in r3.json()["sections"] for it in grp["items"]
if it["type"] == "document" and it["label"] == "Compte rendu projet"]
assert len(dup_docs) == 1, dup_docs
# scoping : les fichiers de l'espace courant ouvert sont proposés en section dédiée
with get_conn() as conn:
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('Espace Scope', ?)", (_admin_id(),))
ws_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format) "
"VALUES ('Espace Scope', ?, 'Fichier du ws', '', 'markdown')", (ws_id,))
conn.commit()
r4 = client.get("/api/agent/mentions", params={"workspace_id": ws_id})
secs = r4.json()["sections"]
assert secs[0]["key"] == "files" and secs[0]["label"] == "Espace Scope"
file_tokens = {it["token"] for it in secs[0]["items"]}
assert any(t.startswith("document:") and (r4.json()["workspace_id"] == ws_id) for t in file_tokens)
def test_router_feedback(client): def test_router_feedback(client):
cid = _make_conversation(client) cid = _make_conversation(client)