feat(agent): v4.15.2 - skills multi-epingles en bulles + chips de contexte cliquables
- 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:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,6 +114,9 @@ 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']}")
|
||||||
|
# Plusieurs skills peuvent être appliqués au même post : chacun injecte
|
||||||
|
# son prompt dans les instructions système.
|
||||||
|
for skill in skills:
|
||||||
if skill:
|
if skill:
|
||||||
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}")
|
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 ;
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user