feat(agent): v4.15.2 - skills multi-epingles en bulles + chips de contexte cliquables
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped

- Les skills (menu /, integres et enregistres) s'epinglent comme des bulles dans le composer, au meme titre que les mentions @; plusieurs peuvent coexister sur un meme post
- L'API /run accepte skill_ids (tableau) et l'engine injecte tous leurs prompts dans le system prompt (skill_id conserve pour retro-compat)
- Clic sur une bulle de contexte (@ document/page/base) ouvre l'element comme document courant (/pages/<id> ou /db/<id>); le bouton x retire sans ouvrir
- Envoi possible avec des skills seuls sans texte
- test_engine_multiple_skills_applied
This commit is contained in:
2026-09-06 13:27:38 -04:00
parent 113f880863
commit 917a04d543
7 changed files with 119 additions and 19 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog — FlowDeck # Changelog — FlowDeck
## 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
> l'éditeur du chat), on peut en associer **plusieurs** à un même post, et chaque bulle de contexte
> référencée s'ouvre d'un clic comme document courant.
### Améliorations
- **Skills en bulles** : sélectionner un skill (menu `/`, intégré *ou* enregistré) ajoute une vraie
bulle dans le composer, comportement identique aux mentions `@`. La bulle d'un skill intégré greffe
son modèle de demande dans le texte ; plusieurs skills peuvent coexister.
- **Plusieurs skills par post** : l'API `/run` accepte `skill_ids` (tableau) et l'engine injecte tous
leurs prompts dans les instructions système (`skill_id` reste supporté pour la rétro-compatibilité).
- **Chips de contexte cliquables** : cliquer sur une bulle de contexte (`@ document / page / base`)
ouvre l'élément comme document courant — `document:<id>`/`page:<id>` → `/pages/<id>`,
`collection:<id>` → `/db/<id>`. Le bouton « ✕ » retire toujours la bulle (sans ouvrir).
- Envoi possible avec uniquement des skills (sans texte) : « Applique le(s) skill(s) sélectionné(s)… ».
### Correctifs
- L'écriture du chat (`send`) renvoie désormais tous les `skill_id` épinglés via `skill_ids`.
## v4.15.1 (2026-09-06) — Agent : réparation des conversations réelles (protocole tool_calls) ## v4.15.1 (2026-09-06) — Agent : réparation des conversations réelles (protocole tool_calls)
> Le chatbot ne « répondait » plus : avec un fournisseur réel, dès qu'un **outil** était appelé la passe > Le chatbot ne « répondait » plus : avec un fournisseur réel, dès qu'un **outil** était appelé la passe
+1 -1
View File
@@ -1 +1 @@
4.15.1 4.15.2
+1 -1
View File
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="4.15.1", version="4.15.2",
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,
+1
View File
@@ -299,6 +299,7 @@ async def run_conversation(request: Request, conversation_id: int):
mentions=body.get("mentions"), mentions=body.get("mentions"),
files=body.get("files"), files=body.get("files"),
skill_id=body.get("skill_id"), skill_id=body.get("skill_id"),
skill_ids=body.get("skill_ids"),
extra_context=body.get("context"), extra_context=body.get("context"),
): ):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
+17 -6
View File
@@ -100,7 +100,11 @@ class AgentEngine:
"scope_json": "{}", "approval_mode": "auto"} "scope_json": "{}", "approval_mode": "auto"}
return dict(row) return dict(row)
def _build_system_prompt(self, agent: dict, skill: dict | None = None) -> str: def _build_system_prompt(self, agent: dict, skills=None) -> str:
if skills is None:
skills = []
if isinstance(skills, dict):
skills = [skills]
lines = [ lines = [
"Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.", "Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.",
"Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.", "Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.",
@@ -110,8 +114,11 @@ class AgentEngine:
] ]
if agent.get("system_instructions"): if agent.get("system_instructions"):
lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}") lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}")
if skill: # Plusieurs skills peuvent être appliqués au même post : chacun injecte
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}") # son prompt dans les instructions système.
for skill in skills:
if skill:
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}")
# L'utilisateur peut poser des questions d'aide sans contexte de document ; # L'utilisateur peut poser des questions d'aide sans contexte de document ;
# le guide intégré permet d'y répondre (aucun outil requis). # le guide intégré permet d'y répondre (aucun outil requis).
lines.append("\n" + APP_GUIDE) lines.append("\n" + APP_GUIDE)
@@ -121,14 +128,18 @@ class AgentEngine:
async def run(self, conversation_id: int, objective: str, *, model: str | None = None, async def run(self, conversation_id: int, objective: str, *, model: str | None = None,
mentions: list[str] | None = None, files: list[dict] | None = None, mentions: list[str] | None = None, files: list[dict] | None = None,
skill_id: int | None = None, extra_context: str | None = None): skill_id: int | None = None, skill_ids: list[int] | None = None,
extra_context: str | None = None):
agent = self._load_agent(conversation_id) agent = self._load_agent(conversation_id)
scope = json.loads(agent.get("scope_json") or "{}") scope = json.loads(agent.get("scope_json") or "{}")
approval_mode = agent.get("approval_mode") or "auto" approval_mode = agent.get("approval_mode") or "auto"
model = model or agent.get("model") or settings.llm_model model = model or agent.get("model") or settings.llm_model
skill = self._load_skill(skill_id, scope) if skill_id else None ids = list(skill_ids or [])
system = self._build_system_prompt(agent, skill) if skill_id and skill_id not in ids:
ids.append(skill_id)
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
system = self._build_system_prompt(agent, skills)
context = self.ctx.build(mentions=mentions, files=files) context = self.ctx.build(mentions=mentions, files=files)
if extra_context and extra_context.strip(): if extra_context and extra_context.strip():
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip() context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
+47 -11
View File
@@ -115,6 +115,8 @@
.fd-chip-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap} .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{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.skill{background:rgba(168,85,247,.12);border-color:rgba(168,85,247,.38)}
.fd-chip.chip-open{cursor:pointer}
.fd-chip.chip-open:hover{background:rgba(35,131,226,.20);border-color:rgba(35,131,226,.55)}
.fd-chip-ico{flex-shrink:0;font-size:12px} .fd-chip-ico{flex-shrink:0;font-size:12px}
.fd-chip-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fd-chip-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-chip-spacer{flex-basis:100%;height:0} .fd-chip-spacer{flex-basis:100%;height:0}
@@ -289,10 +291,10 @@
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak> <div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length"> <div class="fd-chip-row" x-show="mentionChips.length">
<template x-for="chip in mentionChips" :key="chip.key"> <template x-for="chip in mentionChips" :key="chip.key">
<span class="fd-chip"> <span class="fd-chip chip-open" :title="chipAction(chip)" @click="openChip(chip)">
<span class="fd-chip-ico" x-text="chip.icon"></span> <span class="fd-chip-ico" x-text="chip.icon"></span>
<span class="fd-chip-lbl" x-text="chip.label"></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> <button class="fd-chip-x" type="button" @click.stop="removeChip(chip.token)" title="Retirer du contexte">✕</button>
</span> </span>
</template> </template>
</div> </div>
@@ -523,7 +525,7 @@
get composerHint(){ get composerHint(){
var chips = this.contextChips.length; var chips = this.contextChips.length;
var base = 'Demandez, tapez / pour un skill, @ pour référencer un élément'; var base = 'Demandez, tapez / pour un skill, @ pour référencer un élément';
var extra = chips ? ' · ' + chips + ' réf. épinglée(s)' : ''; var extra = chips ? ' · ' + chips + ' réf./skill épinglé(s)' : '';
return base + extra + ' · Entrée ↵ = envoyer · Maj+Entrée = ligne'; return base + extra + ' · Entrée ↵ = envoyer · Maj+Entrée = ligne';
}, },
get canInsert(){ return !!(this.activeContext && this.activeContext.kind === 'page'); }, get canInsert(){ return !!(this.activeContext && this.activeContext.kind === 'page'); },
@@ -1075,16 +1077,24 @@
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; }); 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.'); } if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
else { else {
var hasSkill = this.contextChips.some(function(ch){ return ch.kind === 'skill'; }); var pin = {key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨',
this.contextChips.push({key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨', label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId};
label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId}); this.contextChips.push(pin);
this.clearComposer(); this.clearComposer();
this._insertToken({token:'skill:'+c.skillId, label:String(c.cmd).replace(/^\//, ''), icon:c.icon||'✨', kind:'skill'}); this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.'); this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
} }
} else { } else {
this.setComposerText(c.template || ''); var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-');
this.toast('Complétez la demande puis envoyez (Entrée).'); var builtinExists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.token === 'builtin:' + slug; });
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.');
}
} }
this.cmdFocus = -1; this.slashDismiss = false; this.cmdFocus = -1; this.slashDismiss = false;
this._focusComposer(); this._focusComposer();
@@ -1218,6 +1228,27 @@
this._removeTokenSpan(chip.token); this._removeTokenSpan(chip.token);
this._emptyCheck(); this._emptyCheck();
}, },
chipHref(chip){
if(!chip || chip.kind !== 'mention') return null;
var id;
if(chip.refId != null) id = Number(chip.refId);
else {
var tk = String(chip.token || '').split(':');
if(tk.length === 2 && /^\d+$/.test(tk[1])) id = Number(tk[1]);
}
if(id == null || isNaN(id)) return null;
if(chip.type === 'collection') return '/db/' + id;
return '/pages/' + id; // document & pages de collection s'ouvrent dans l'éditeur
},
chipAction(chip){
if(!chip || chip.kind !== 'mention') return '';
return chip.type === 'collection' ? 'Ouvrir la base de données' : 'Ouvrir le document';
},
openChip(chip){
var href = this.chipHref(chip);
if(!href){ this.toast('Impossible d\u2019ouvrir cet élément.', true); return; }
window.location.assign(href);
},
onInput(){ onInput(){
this.resizeInput(); this.resizeInput();
this.slashDismiss = false; this.slashDismiss = false;
@@ -1399,7 +1430,9 @@
if(/^\/+$/.test(text)){ self.clearComposer(); return; } if(/^\/+$/.test(text)){ self.clearComposer(); return; }
var ment = self.composerMentions(); var ment = self.composerMentions();
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 { return; } else { return; }
} }
try { await self.ensureConversation(); } catch(e){ return; } try { await self.ensureConversation(); } catch(e){ return; }
@@ -1427,8 +1460,11 @@
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 skillChip = (self.contextChips || []).find(function(ch){ return ch.kind === 'skill'; }); var skillIds = [];
if(skillChip && skillChip.skillId) payload.skill_id = skillChip.skillId; (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', { return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST', method:'POST',
+32
View File
@@ -399,6 +399,38 @@ def test_engine_tool_protocol_messages(client):
assert payload["status"] == "ok" assert payload["status"] == "ok"
def test_engine_multiple_skills_applied(client):
"""Plusieurs skills (skill_ids) sont injectés ensemble dans les instructions."""
from app.services.agent_engine import AgentEngine
from app.db import get_conn
with get_conn() as conn:
c1 = conn.execute(
"INSERT INTO agent_skills (workspace_id, name, description, prompt_template, allowed_tools_json, created_by) "
"VALUES (NULL, 'Skill A', '', 'Template A', '[]', ?)", (_admin_id(),))
c2 = conn.execute(
"INSERT INTO agent_skills (workspace_id, name, description, prompt_template, allowed_tools_json, created_by) "
"VALUES (NULL, 'Skill B', '', 'Template B', '[]', ?)", (_admin_id(),))
conn.commit()
id1, id2 = c1.lastrowid, c2.lastrowid
class FakeLLM:
def __init__(self):
self.system = ""
async def complete(self, messages, *, model=None, tools=None, stream=False):
self.system = next((m["content"] for m in messages if m["role"] == "system"), "")
return _Resp("ok", [])
fake = FakeLLM()
engine = AgentEngine(_admin_id(), llm=fake)
conv = client.post("/api/agent/conversations", json={"title": "multi-skill"}).json()["id"]
asyncio.run(_run_engine(engine, conv, "bonjour", skill_ids=[id1, id2]))
assert "Skill A" in fake.system and "Template A" in fake.system
assert "Skill B" in fake.system and "Template B" in fake.system
def _make_conversation(client) -> int: def _make_conversation(client) -> int:
resp = client.post("/api/agent/conversations", json={"title": "test"}) resp = client.post("/api/agent/conversations", json={"title": "test"})
return resp.json()["id"] return resp.json()["id"]