feat(agent): v4.15.3 - mentions @ par espace courant + skills composes en arriere-plan
- /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:
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user