From 917a04d54307bcebccaed7fd1aba137cb69ca112 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 6 Sep 2026 13:27:38 -0400 Subject: [PATCH] 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/ ou /db/); le bouton x retire sans ouvrir - Envoi possible avec des skills seuls sans texte - test_engine_multiple_skills_applied --- CHANGELOG.md | 20 ++++++++++++ VERSION | 2 +- app/main.py | 2 +- app/routers/agent.py | 1 + app/services/agent_engine.py | 23 ++++++++++---- app/templates/agent_panel.html | 58 +++++++++++++++++++++++++++------- tests/test_agent.py | 32 +++++++++++++++++++ 7 files changed, 119 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a24e88b..7008f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # 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:`/`page:` → `/pages/`, + `collection:` → `/db/`. 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) > Le chatbot ne « répondait » plus : avec un fournisseur réel, dès qu'un **outil** était appelé la passe diff --git a/VERSION b/VERSION index fb05571..3e2accd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.15.1 +4.15.2 diff --git a/app/main.py b/app/main.py index 2ba3290..0083fee 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.15.1", + version="4.15.2", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/agent.py b/app/routers/agent.py index b2ba9de..033529b 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -299,6 +299,7 @@ async def run_conversation(request: Request, conversation_id: int): mentions=body.get("mentions"), files=body.get("files"), skill_id=body.get("skill_id"), + skill_ids=body.get("skill_ids"), extra_context=body.get("context"), ): yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index cc6f8e2..d92452e 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -100,7 +100,11 @@ class AgentEngine: "scope_json": "{}", "approval_mode": "auto"} 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 = [ "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.", @@ -110,8 +114,11 @@ class AgentEngine: ] if agent.get("system_instructions"): lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}") - if skill: - lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}") + # 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: + 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 ; # le guide intégré permet d'y répondre (aucun outil requis). lines.append("\n" + APP_GUIDE) @@ -121,14 +128,18 @@ class AgentEngine: async def run(self, conversation_id: int, objective: str, *, model: str | 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) scope = json.loads(agent.get("scope_json") or "{}") approval_mode = agent.get("approval_mode") or "auto" model = model or agent.get("model") or settings.llm_model - skill = self._load_skill(skill_id, scope) if skill_id else None - system = self._build_system_prompt(agent, skill) + ids = list(skill_ids or []) + 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) if extra_context and extra_context.strip(): context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip() diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 1c1fb47..e8341b0 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -115,6 +115,8 @@ .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.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-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fd-chip-spacer{flex-basis:100%;height:0} @@ -289,10 +291,10 @@
@@ -523,7 +525,7 @@ get composerHint(){ var chips = this.contextChips.length; 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'; }, 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; }); if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); } else { - var hasSkill = this.contextChips.some(function(ch){ return ch.kind === 'skill'; }); - this.contextChips.push({key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨', - label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId}); + var pin = {key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨', + label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId}; + this.contextChips.push(pin); 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.'); } } else { - this.setComposerText(c.template || ''); - this.toast('Complétez la demande puis envoyez (Entrée).'); + var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-'); + 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._focusComposer(); @@ -1218,6 +1228,27 @@ this._removeTokenSpan(chip.token); 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(){ this.resizeInput(); this.slashDismiss = false; @@ -1399,7 +1430,9 @@ if(/^\/+$/.test(text)){ self.clearComposer(); return; } var ment = self.composerMentions(); 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; } } try { await self.ensureConversation(); } catch(e){ return; } @@ -1427,8 +1460,11 @@ var ctxText = self.contextPayload(); if(ctxText) payload.context = ctxText; if(ment && ment.length) payload.mentions = ment; - var skillChip = (self.contextChips || []).find(function(ch){ return ch.kind === 'skill'; }); - if(skillChip && skillChip.skillId) payload.skill_id = skillChip.skillId; + 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', { method:'POST', diff --git a/tests/test_agent.py b/tests/test_agent.py index 3aea591..2dc1a48 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -399,6 +399,38 @@ def test_engine_tool_protocol_messages(client): 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: resp = client.post("/api/agent/conversations", json={"title": "test"}) return resp.json()["id"]