fix(agent): v4.13.1 - actions contenu document (resume/traduire/ameliorer/meeting) sans outils, apercu + appliquer/rejeter, mock ignore contexte
This commit is contained in:
@@ -1,5 +1,31 @@
|
|||||||
# Changelog — FlowDeck
|
# Changelog — FlowDeck
|
||||||
|
|
||||||
|
## v4.13.1 (2026-09-06) — Corrections actions contenu Agent & aperçu à approuver
|
||||||
|
|
||||||
|
> Corrige les actions de contenu sur document (« Résumer », « Traduire cette page », « Proposer
|
||||||
|
> des améliorations », « Créer une note de réunion ») qui partaient en recherche (`search_workspace`)
|
||||||
|
> sans retour utile, et ajoute un flux **aperçu des changements → Appliquer / Rejeter**.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- **Le contenu du document n'est plus interprété comme des intentions d'outil** : le planificateur
|
||||||
|
(mock) n'analyse que l'objectif de l'utilisateur, jamais le texte injecté dans `# Contexte`. Un
|
||||||
|
document contenant « recherche », « collection », etc. ne déclenche plus `search_workspace` /
|
||||||
|
`create_collection` intempestifs.
|
||||||
|
- **Ask AI** n'emprunte plus le canal outil : il utilise la génération de texte directe.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Endpoint `POST /api/agent/generate`** : génération de texte **sans outils** (l'IA répond à
|
||||||
|
partir du contexte du document fourni, sans chercher/créer dans le workspace).
|
||||||
|
- **Aperçu & approbation** : les actions de contenu sur document affichent la **proposition** de
|
||||||
|
l'agent dans le panneau avec boutons **« ✓ Appliquer au document » / « ✕ Rejeter »**. Appliquer
|
||||||
|
insère la proposition (mode `insert`) ou **remplace** le contenu de la page (mode `replace` pour
|
||||||
|
« Traduire cette page ») via `window.fdApplyDocument()` ; rejeter la conserve sans modification.
|
||||||
|
En mode hors-ligne, l'action explique que la modification nécessite un modèle connecté (pas
|
||||||
|
d'application accidentelle).
|
||||||
|
|
||||||
|
### Infra
|
||||||
|
- `VERSION` → 4.13.1 ; `app/main.py` version → 4.13.1.
|
||||||
|
|
||||||
## v4.13.0 (2026-09-06) — Agent : panneau « Notion AI », ouverture fiable (Ctrl+J) & contexte universel
|
## v4.13.0 (2026-09-06) — Agent : panneau « Notion AI », ouverture fiable (Ctrl+J) & contexte universel
|
||||||
|
|
||||||
> Refonte complète du panneau Agent (style Notion AI) : ouverture/fermeture **fiable** (API
|
> Refonte complète du panneau Agent (style Notion AI) : ouverture/fermeture **fiable** (API
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="4.13.0",
|
version="4.13.1",
|
||||||
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,
|
||||||
|
|||||||
@@ -310,6 +310,62 @@ async def run_conversation(request: Request, conversation_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def agent_generate(request: Request):
|
||||||
|
"""Headless text generation (NO tools) for content actions on documents.
|
||||||
|
|
||||||
|
Used by the Agent panel's contextual actions ("Résumer ce document",
|
||||||
|
"Traduire cette page", "Proposer des améliorations", "AI meeting note").
|
||||||
|
Because no tool schema is offered, the model answers with plain text based on
|
||||||
|
the provided document context instead of issuing search_workspace / tools.
|
||||||
|
"""
|
||||||
|
user_id = await _current_user_id(request)
|
||||||
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
|
prompt = (body.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
raise HTTPException(status_code=400, detail="prompt est requis")
|
||||||
|
provider = (body.get("provider") or "").strip().lower() or None
|
||||||
|
model = (body.get("model") or "").strip() or None
|
||||||
|
context = (body.get("context") or "") or ""
|
||||||
|
|
||||||
|
llm = LLMClient()
|
||||||
|
if provider:
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||||
|
user_key = get_user_llm_key(user_id, provider)
|
||||||
|
if user_key and user_key.get("api_key"):
|
||||||
|
llm = LLMClient(provider=provider, api_key=user_key["api_key"],
|
||||||
|
api_base=(user_key.get("api_base") or "").strip() or None)
|
||||||
|
else:
|
||||||
|
llm = LLMClient(provider=provider)
|
||||||
|
offline = llm.provider == "offline" or not llm._has_credentials()
|
||||||
|
|
||||||
|
system = (
|
||||||
|
"Tu es l'assistant d'édition de FlowDeck (contenu). "
|
||||||
|
"Réponds UNIQUEMENT avec le texte demandé, en Markdown léger "
|
||||||
|
"(paragraphes, listes à puces, titres si utile). "
|
||||||
|
"N'ajoute aucun préambule, aucun commentaire, aucun bloc de code autour du texte. "
|
||||||
|
"Fonde ta réponse sur le contexte du document fourni."
|
||||||
|
)
|
||||||
|
user_content = prompt
|
||||||
|
if context and context.strip():
|
||||||
|
user_content += "\n\n# Contexte du document\n" + context.strip()[:20000]
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
resp = await llm.complete(messages, model=model or None, tools=None, stream=False)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface connectivity errors
|
||||||
|
return {"ok": False, "error": str(exc), "offline": offline, "model": model or ""}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"text": (resp.text or "").strip(),
|
||||||
|
"model": resp.model or model or "",
|
||||||
|
"offline": offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Audit & rollback ──
|
# ── Audit & rollback ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ class LLMClient:
|
|||||||
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
|
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
|
||||||
user_content = self._last_user_content(messages)
|
user_content = self._last_user_content(messages)
|
||||||
sys_content = self._system_content(messages)
|
sys_content = self._system_content(messages)
|
||||||
|
# Only the user's objective drives the planner. The engine appends the
|
||||||
|
# workspace/document snapshot under "# Contexte"; that text must never
|
||||||
|
# trigger keyword heuristics (a doc mentioning "recherche"/"collection"
|
||||||
|
# used to misroute content requests into tool calls).
|
||||||
|
objective = user_content.split("\n# Contexte")[0]
|
||||||
|
|
||||||
# Once tool results are already in the conversation, we have acted:
|
# Once tool results are already in the conversation, we have acted:
|
||||||
# stop issuing new tool calls and conclude.
|
# stop issuing new tool calls and conclude.
|
||||||
@@ -200,7 +205,7 @@ class LLMClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Skill-driven: if the objective names a known skill, mirror its template.
|
# Skill-driven: if the objective names a known skill, mirror its template.
|
||||||
skill_hint = self._extract_skill_hint(user_content)
|
skill_hint = self._extract_skill_hint(objective)
|
||||||
|
|
||||||
if skill_hint == "sprint":
|
if skill_hint == "sprint":
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
@@ -218,13 +223,13 @@ class LLMClient:
|
|||||||
# before the tool-intent heuristics and scoped to the user objective
|
# before the tool-intent heuristics and scoped to the user objective
|
||||||
# only, so an injected "# Contexte" that happens to mention "collection"
|
# only, so an injected "# Contexte" that happens to mention "collection"
|
||||||
# can't misroute a writing request into a create-collection action.
|
# can't misroute a writing request into a create-collection action.
|
||||||
draft = self._draft_reply(user_content.split("\n# Contexte")[0])
|
draft = self._draft_reply(objective)
|
||||||
if draft:
|
if draft:
|
||||||
return LLMResponse(text=draft, model=model)
|
return LLMResponse(text=draft, model=model)
|
||||||
|
|
||||||
# Exact keyword → tool intent resolution.
|
# Exact keyword → tool intent resolution (objective only).
|
||||||
for regex, builder in _CREATE_PATTERNS:
|
for regex, builder in _CREATE_PATTERNS:
|
||||||
m = regex.search(user_content)
|
m = regex.search(objective)
|
||||||
if m:
|
if m:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
|
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
|
||||||
@@ -233,7 +238,7 @@ class LLMClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for regex, builder in _SEARCH_PATTERNS:
|
for regex, builder in _SEARCH_PATTERNS:
|
||||||
m = regex.search(user_content)
|
m = regex.search(objective)
|
||||||
if m:
|
if m:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
|
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
|
||||||
@@ -242,9 +247,9 @@ class LLMClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Loose "collection <X>" detection → treat as a create intent.
|
# Loose "collection <X>" detection → treat as a create intent.
|
||||||
low = user_content.lower()
|
low = objective.lower()
|
||||||
if "collection" in low:
|
if "collection" in low:
|
||||||
m = _COLLECTION_LINE.search(user_content)
|
m = _COLLECTION_LINE.search(objective)
|
||||||
if m:
|
if m:
|
||||||
name = m.group(1).strip()
|
name = m.group(1).strip()
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
@@ -256,7 +261,7 @@ class LLMClient:
|
|||||||
|
|
||||||
# Plain conversational objective → final answer (no tool).
|
# Plain conversational objective → final answer (no tool).
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
text=self._summarize(user_content),
|
text=self._summarize(objective),
|
||||||
model=model,
|
model=model,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -335,6 +340,24 @@ class LLMClient:
|
|||||||
"• Planifier le suivi et archiver ce compte-rendu.\n"
|
"• Planifier le suivi et archiver ce compte-rendu.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Traduction / analyse du document (hors-ligne) ──
|
||||||
|
if re.search(r"traduis|traduit|translate", low):
|
||||||
|
return (
|
||||||
|
"⚠️ **Traduction non disponible en mode hors-ligne** (aucun modèle d'IA "
|
||||||
|
"connecté).\n\n"
|
||||||
|
"Connectez un fournisseur dans **Paramètres → Agent & IA**, puis relancez "
|
||||||
|
"« Traduire cette page » : le document traduit apparaîtra ici, avec un aperçu "
|
||||||
|
"à approuver ou à rejeter avant application."
|
||||||
|
)
|
||||||
|
if re.search(r"r[ée]sum|am[ée]lior|sugg[èe]re des|propose des", low):
|
||||||
|
return (
|
||||||
|
"⚠️ **Cette action nécessite un modèle d'IA connecté** pour analyser le "
|
||||||
|
"document.\n\n"
|
||||||
|
"Configurez une clé API dans **Paramètres → Agent & IA**, puis relancez "
|
||||||
|
"l'action : l'agent générera la proposition ici, avec un aperçu à approuver "
|
||||||
|
"ou à rejeter avant application."
|
||||||
|
)
|
||||||
|
|
||||||
# ── Page / document draft (contextual "Ask AI") ──
|
# ── Page / document draft (contextual "Ask AI") ──
|
||||||
if title:
|
if title:
|
||||||
t = title[:80]
|
t = title[:80]
|
||||||
|
|||||||
@@ -629,8 +629,23 @@
|
|||||||
// (Notion-style inline generation). The reply is streamed in the agent
|
// (Notion-style inline generation). The reply is streamed in the agent
|
||||||
// panel and inserted into this page via window.__fdInsertAI.
|
// panel and inserted into this page via window.__fdInsertAI.
|
||||||
askAI(){
|
askAI(){
|
||||||
|
const self=this;
|
||||||
const title=(this.pageTitle||'Sans titre').trim();
|
const title=(this.pageTitle||'Sans titre').trim();
|
||||||
this.aiRun('Rédige le contenu de la page FlowDeck intitulée « '+title+' ». Fournis un texte structuré et directement utilisable : paragraphes et, si utile, des listes à puces. Sépare chaque paragraphe par une ligne vide. Ne précède pas le texte de commentaires.');
|
if(!(window.fdAgent && window.fdAgent.generate)){ self.showToast('Agent IA indisponible — configurez-le dans Paramètres → Agent & IA'); return; }
|
||||||
|
if(self.aiBusy) return;
|
||||||
|
self.aiBusy=true;
|
||||||
|
self.showToast('FlowDeck Agent : génération en cours…');
|
||||||
|
let ctx='';
|
||||||
|
try{ if(window.fdGetPageContext) ctx=String(window.fdGetPageContext()||''); }catch(e){}
|
||||||
|
const prompt='Rédige le contenu de la page FlowDeck intitulée « '+title+' ». Fournis un texte structuré et directement utilisable : paragraphes et, si utile, des listes à puces. Sépare chaque paragraphe par une ligne vide. Ne précède pas le texte de commentaires.';
|
||||||
|
window.fdAgent.generate(prompt, ctx).then(function(text){
|
||||||
|
self.aiBusy=false;
|
||||||
|
if(text && text.trim()){ self.applyAIBlocks(text); }
|
||||||
|
else { self.showToast('Aucun retour généré par l\u2019agent'); }
|
||||||
|
}).catch(function(err){
|
||||||
|
self.aiBusy=false;
|
||||||
|
self.showToast((err && err.message) ? err.message : 'Erreur de génération');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
meetingNote(){
|
meetingNote(){
|
||||||
// v4.12.0: crée une vraie « boîte de réunion » (bloc 'meeting') au lieu
|
// v4.12.0: crée une vraie « boîte de réunion » (bloc 'meeting') au lieu
|
||||||
|
|||||||
@@ -55,6 +55,13 @@
|
|||||||
.fd-agent-msg-meta{font-size:10px;color:var(--text-dim,#888);margin-bottom:2px;text-transform:uppercase;letter-spacing:.5px}
|
.fd-agent-msg-meta{font-size:10px;color:var(--text-dim,#888);margin-bottom:2px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
.fd-agent-msg-content{font-size:13.5px;color:var(--text,#ececec);white-space:pre-wrap;word-break:break-word;line-height:1.55}
|
.fd-agent-msg-content{font-size:13.5px;color:var(--text,#ececec);white-space:pre-wrap;word-break:break-word;line-height:1.55}
|
||||||
.fd-agent-msg-content a{color:var(--accent)}
|
.fd-agent-msg-content a{color:var(--accent)}
|
||||||
|
.fd-agent-msg-proposal .fd-agent-msg-content{background:rgba(35,131,226,.07);border:1px solid rgba(35,131,226,.22);border-radius:10px;padding:10px 12px}
|
||||||
|
.fd-proposal-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
|
||||||
|
.fd-proposal-btn{background:var(--bg-tertiary,#242424);border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text,#e6e6e6);border-radius:8px;padding:6px 12px;font-size:12.5px;cursor:pointer}
|
||||||
|
.fd-proposal-btn:hover{background:var(--bg-hover,#2e2e2e)}
|
||||||
|
.fd-proposal-btn.primary{background:var(--accent,#2383E2);border-color:transparent;color:#fff;font-weight:600}
|
||||||
|
.fd-proposal-btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.fd-proposal-note{font-size:11px;color:var(--text-dim,#999)}
|
||||||
.fd-agent-thinking{font-size:12.5px;color:var(--text-dim,#999);padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.04);margin:4px 0 8px;font-style:italic}
|
.fd-agent-thinking{font-size:12.5px;color:var(--text-dim,#999);padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.04);margin:4px 0 8px;font-style:italic}
|
||||||
.fd-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px}
|
.fd-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px}
|
||||||
|
|
||||||
@@ -175,11 +182,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-for="m in messages" :key="m.id">
|
<template x-for="m in messages" :key="m.id">
|
||||||
<div class="fd-agent-msg" :class="'fd-agent-msg-'+(m.role||'user')">
|
<div class="fd-agent-msg" :class="'fd-agent-msg-'+(m.role||'user') + (m.proposal ? ' fd-agent-msg-proposal' : '')">
|
||||||
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
|
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
|
||||||
<div class="fd-agent-msg-body">
|
<div class="fd-agent-msg-body">
|
||||||
<div class="fd-agent-msg-meta" x-text="(m.role==='assistant'?'Agent':'Vous')+(m.model?' · '+m.model:'')"></div>
|
<div class="fd-agent-msg-meta" x-text="(m.role==='assistant'?'Agent':'Vous')+(m.model?' · '+m.model:'')"></div>
|
||||||
<div class="fd-agent-msg-content" x-text="m.content"></div>
|
<div class="fd-agent-msg-content" x-text="m.generating ? 'Génération…' : m.content"></div>
|
||||||
|
<template x-if="m.proposal">
|
||||||
|
<div class="fd-proposal-bar">
|
||||||
|
<button class="fd-proposal-btn primary" type="button" @click="approveProposal(m)" :disabled="m.applied || m.offline">
|
||||||
|
<span x-text="m.applied ? (m.rejected ? 'Rejetée' : '✓ Appliquée') : '✓ Appliquer au document'"></span>
|
||||||
|
</button>
|
||||||
|
<button class="fd-proposal-btn" type="button" @click="rejectProposal(m)" :disabled="m.applied">✕ Rejeter</button>
|
||||||
|
<span class="fd-proposal-note" x-show="m.offline" x-cloak>Hors-ligne — configurez une clé API pour appliquer la modification.</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -376,10 +392,10 @@
|
|||||||
var c = this.activeContext || {};
|
var c = this.activeContext || {};
|
||||||
var out = [];
|
var out = [];
|
||||||
if(c.kind === 'page'){
|
if(c.kind === 'page'){
|
||||||
out.push({icon:'📄', label:'Résumer ce document', prompt:'Résume ce document de façon structurée et concise.'});
|
out.push({icon:'📄', label:'Résumer ce document', prompt:'Résume ce document de façon structurée et concise.', docRun:true, mode:'insert'});
|
||||||
out.push({icon:'🌐', label:'Traduire cette page', prompt:'Traduis le contenu de ce document en anglais.', new:false});
|
out.push({icon:'🌐', label:'Traduire cette page', prompt:'Traduis le contenu de ce document en anglais, en conservant sa structure (titres, listes, paragraphes).', docRun:true, mode:'replace'});
|
||||||
out.push({icon:'✍️', label:'Proposer des améliorations', prompt:'Analyse ce document et propose des améliorations concrètes de structure et de contenu.'});
|
out.push({icon:'✍️', label:'Proposer des améliorations', prompt:'Analyse ce document et propose une version améliorée (structure, clarté, complétude) sans en perdre le sens.', docRun:true, mode:'insert'});
|
||||||
out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Crée un squelette d\u2019AI meeting note structuré à partir de ce document.'});
|
out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Rédige une « AI Meeting Note » structurée à partir de ce document : en-tête avec date, sections Résumé, Décisions, Action items et Prochaines étapes.', docRun:true, mode:'insert'});
|
||||||
} else if(c.kind === 'db'){
|
} else if(c.kind === 'db'){
|
||||||
out.push({icon:'🗄️', label:'Analyser cette base', prompt:'Analyse cette base de données et résume sa structure, ses propriétés et ses données.'});
|
out.push({icon:'🗄️', label:'Analyser cette base', prompt:'Analyse cette base de données et résume sa structure, ses propriétés et ses données.'});
|
||||||
out.push({icon:'📊', label:'Créer une vue', prompt:'Propose une vue adaptée (board, tableau, calendrier…) pour cette base et explique pourquoi.'});
|
out.push({icon:'📊', label:'Créer une vue', prompt:'Propose une vue adaptée (board, tableau, calendrier…) pour cette base et explique pourquoi.'});
|
||||||
@@ -399,6 +415,7 @@
|
|||||||
},
|
},
|
||||||
runQuick(q){
|
runQuick(q){
|
||||||
if(!q || !q.prompt) return;
|
if(!q || !q.prompt) return;
|
||||||
|
if(q.docRun){ this.runDocAction(q); return; }
|
||||||
var self = this;
|
var self = this;
|
||||||
this.ensureConversation().then(function(){
|
this.ensureConversation().then(function(){
|
||||||
self.input = q.prompt;
|
self.input = q.prompt;
|
||||||
@@ -406,6 +423,63 @@
|
|||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Document actions (no tools) → proposition à approuver / rejeter ──
|
||||||
|
runDocAction(q){
|
||||||
|
var self = this;
|
||||||
|
this.ensureConversation().then(function(){
|
||||||
|
self.errorMsg = '';
|
||||||
|
self.messages.push({id:'udoc-'+Date.now(), role:'user', content: q.label});
|
||||||
|
self.refreshContext();
|
||||||
|
var ctxText = (self.activeContext && self.activeContext.kind === 'page' && self.activeContext.text) ? self.activeContext.text : '';
|
||||||
|
var thinkId = 'g-'+Date.now()+Math.random();
|
||||||
|
self.messages.push({id: thinkId, role:'assistant', generating: true});
|
||||||
|
self.scrollBottom();
|
||||||
|
|
||||||
|
var payload = {prompt: q.prompt};
|
||||||
|
if(ctxText) payload.context = ctxText;
|
||||||
|
if(self.llmProvider) payload.provider = self.llmProvider;
|
||||||
|
if(self.llmModel) payload.model = self.llmModel;
|
||||||
|
|
||||||
|
fetch('/api/agent/generate', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
|
||||||
|
if(d && d.ok && d.text){
|
||||||
|
self.messages.push({
|
||||||
|
id:'prop-'+Date.now()+Math.random(), role:'assistant',
|
||||||
|
content: d.text, proposal: true, mode: q.mode || 'insert',
|
||||||
|
offline: !!d.offline, model: d.model || '', applied: false, rejected: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.messages.push({id:'err-'+Date.now()+Math.random(), role:'assistant',
|
||||||
|
content: '⚠️ ' + ((d && d.error) || 'Échec de la génération'), model:''});
|
||||||
|
}
|
||||||
|
self.scrollBottom();
|
||||||
|
}).catch(function(err){
|
||||||
|
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
|
||||||
|
self.messages.push({id:'err-'+Date.now()+Math.random(), role:'assistant',
|
||||||
|
content: '⚠️ ' + ((err && err.message) || 'Erreur réseau'), model:''});
|
||||||
|
self.scrollBottom();
|
||||||
|
});
|
||||||
|
}).catch(function(){});
|
||||||
|
},
|
||||||
|
approveProposal(m){
|
||||||
|
if(!m || m.applied || m.offline) return;
|
||||||
|
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
|
||||||
|
m.applied = true;
|
||||||
|
m.rejected = false;
|
||||||
|
if(!ok){
|
||||||
|
m.content = '⚠️ Aucun document éditable n\u2019est ouvert pour appliquer cette proposition.';
|
||||||
|
}
|
||||||
|
this.$nextTick(function(){ this.scrollBottom(); });
|
||||||
|
},
|
||||||
|
rejectProposal(m){
|
||||||
|
if(!m || m.applied) return;
|
||||||
|
m.applied = true;
|
||||||
|
m.rejected = true;
|
||||||
|
this.$nextTick(function(){ this.scrollBottom(); });
|
||||||
|
},
|
||||||
|
|
||||||
// ── Providers / models ──
|
// ── Providers / models ──
|
||||||
fetchProviders(){
|
fetchProviders(){
|
||||||
var self = this;
|
var self = this;
|
||||||
@@ -832,6 +906,36 @@
|
|||||||
})();
|
})();
|
||||||
window.FlowDeckCtx = FlowDeckCtx;
|
window.FlowDeckCtx = FlowDeckCtx;
|
||||||
|
|
||||||
|
// Apply an AI-produced document change to the open page editor.
|
||||||
|
// mode 'replace' → remplace tout le contenu de la page ;
|
||||||
|
// mode 'insert' → insère la proposition en fin de page (comme Ask AI).
|
||||||
|
window.fdApplyDocument = function(text, mode){
|
||||||
|
var E = window.E;
|
||||||
|
if(!E || !E.blocks || typeof E.mkB !== 'function') return false;
|
||||||
|
text = (text || '').trim();
|
||||||
|
if(!text) return false;
|
||||||
|
try{
|
||||||
|
E.sync();
|
||||||
|
if(mode === 'replace'){
|
||||||
|
var nb = (typeof E.md2b === 'function') ? E.md2b(text) : null;
|
||||||
|
if(!nb || !nb.length){ E.blocks = [E.mkB('paragraph', text)]; }
|
||||||
|
else { E.blocks = nb; }
|
||||||
|
} else {
|
||||||
|
if(typeof E.applyAIBlocks === 'function'){ E.applyAIBlocks(text); }
|
||||||
|
else {
|
||||||
|
var lines = text.split(/\r?\n/).map(function(s){ return s.trim(); }).filter(Boolean);
|
||||||
|
var startEmpty = E.blocks.length === 1 && E.blocks[0].type === 'paragraph' && !(E.blocks[0].content || '').trim();
|
||||||
|
var nb2 = lines.map(function(l){ return E.mkB('paragraph', l); });
|
||||||
|
E.blocks = startEmpty ? nb2 : E.blocks.concat(nb2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
E.dirty = true;
|
||||||
|
if(E.autoSave) E.autoSave();
|
||||||
|
if(E.render) E.render();
|
||||||
|
return true;
|
||||||
|
}catch(e){ return false; }
|
||||||
|
};
|
||||||
|
|
||||||
// Backward-compatible detection hooks (banner + Ask AI)
|
// Backward-compatible detection hooks (banner + Ask AI)
|
||||||
window.fdPageContextProvider = function(){
|
window.fdPageContextProvider = function(){
|
||||||
var c = FlowDeckCtx.current();
|
var c = FlowDeckCtx.current();
|
||||||
|
|||||||
@@ -662,3 +662,52 @@ def test_router_user_key_test_failure(client, monkeypatch):
|
|||||||
prov = client.get("/api/agent/providers").json()
|
prov = client.get("/api/agent/providers").json()
|
||||||
p = [x for x in prov["providers"] if x["id"] == "openai"][0]
|
p = [x for x in prov["providers"] if x["id"] == "openai"][0]
|
||||||
assert p["functional"] is False
|
assert p["functional"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_endpoint_returns_text(client, monkeypatch):
|
||||||
|
"""/api/agent/generate performs headless text generation (no tools)."""
|
||||||
|
from app.routers import agent as agent_router
|
||||||
|
from app.services.llm_client import LLMResponse
|
||||||
|
|
||||||
|
class FakeLLM(agent_router.LLMClient):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
||||||
|
# The tool schema must never be offered for content generation.
|
||||||
|
assert tools is None
|
||||||
|
return LLMResponse(text="Voici le document traduit en anglais.", model="gpt-4o-mini")
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_router, "LLMClient", FakeLLM)
|
||||||
|
|
||||||
|
r = client.post("/api/agent/generate", json={
|
||||||
|
"prompt": "Traduis ce document en anglais.",
|
||||||
|
"context": "Titre du document : Doc\nContenu en français.",
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert "traduit en anglais" in body["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_endpoint_requires_prompt(client):
|
||||||
|
r = client.post("/api/agent/generate", json={"prompt": " "})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_planner_ignores_context_keywords(client):
|
||||||
|
"""Document context words (ex: 'cherche', 'collection') must not trigger
|
||||||
|
tool intents — the planner only inspects the user objective."""
|
||||||
|
import asyncio
|
||||||
|
from app.services.llm_client import LLMClient
|
||||||
|
|
||||||
|
llm = LLMClient(provider="offline")
|
||||||
|
resp = asyncio.run(llm.complete(
|
||||||
|
[{"role": "system", "content": "x"},
|
||||||
|
{"role": "user", "content": "Résume ce document.\n\n# Contexte\n"
|
||||||
|
"Le rapport mentionne une recherche sur la collection Z."}],
|
||||||
|
model="gpt-4o", tools=[{"type": "function"}],
|
||||||
|
))
|
||||||
|
assert resp.tool_calls == []
|
||||||
|
assert resp.text
|
||||||
Reference in New Issue
Block a user