Files
flowdeck/app/templates/agent_panel.html
T
bruno 2391de418d
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped
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
2026-09-06 13:57:03 -04:00

1764 lines
90 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# ════════════ FlowDeck Agent panel v4.13.0 — Notion-AI-style sidebar ════════════ #}
<style>
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--accent,#2383E2);color:#fff;border:none;font-size:24px;cursor:pointer;box-shadow:0 8px 26px rgba(35,131,226,.5);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(35,131,226,.62)}
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:470px;max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
#fd-agent-panel.open{transform:translateX(0)}
#fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px}
#fd-agent-panel ::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35);border-radius:4px}
#fd-agent-panel ::-webkit-scrollbar-track{background:transparent}
/* Header */
.fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px}
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--accent,#2383E2),#5b8def);color:#fff;display:flex;align-items:center;justify-content:center;font-size:19px;flex-shrink:0;box-shadow:0 4px 12px rgba(35,131,226,.35)}
.fd-ap-head-main{flex:1;min-width:0}
.fd-ap-title{font-size:15px;font-weight:600;color:var(--text,#f0f0f0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fd-ap-sub{font-size:11.5px;color:var(--text-dim,#9aa);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fd-ap-headbtn{background:none;border:none;color:var(--text-dim,#999);width:30px;height:30px;border-radius:8px;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.fd-ap-headbtn:hover{background:var(--bg-hover,#2b2b2b);color:var(--text,#fff)}
/* Context bar */
.fd-ap-ctx{display:flex;align-items:center;gap:8px;margin:0 16px 8px;padding:7px 11px;border-radius:9px;background:rgba(35,131,226,.09);border:1px solid rgba(35,131,226,.16);font-size:12px;color:var(--text-secondary,#ddd);min-height:18px}
.fd-ap-ctx .fd-ctx-ico{font-size:13px}
.fd-ap-ctx .fd-ctx-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* No-config banner */
.fd-ap-noconfig{padding:8px 14px;font-size:12px;color:#e6b4b4;background:rgba(227,98,98,.1);border:1px solid rgba(227,98,98,.18);margin:0 16px 10px;border-radius:9px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.fd-ap-noconfig a{color:#ff9d9d;font-weight:600;text-decoration:none}
.fd-ap-noconfig .fd-k-sub{width:100%;color:var(--text-dim,#aaa)}
/* Tabs */
.fd-ap-tabs{display:flex;gap:2px;padding:0 16px 8px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
.fd-ap-tab{flex:1;text-align:center;padding:7px;background:none;border:none;border-radius:8px;color:var(--text-dim,#999);font-size:12.5px;cursor:pointer}
.fd-ap-tab.active{background:var(--bg-tertiary,#242424);color:var(--text,#fff);font-weight:600}
/* Body */
.fd-agent-body{flex:1;overflow-y:auto;padding:12px 16px}
.fd-ap-hist-item{padding:8px 11px;border-radius:9px;cursor:pointer;color:var(--text,#e0e0e0);font-size:13px;border:1px solid transparent;margin-bottom:2px}
.fd-ap-hist-item:hover,.fd-ap-hist-item.active{background:var(--bg-hover,#262626);border-color:var(--border,rgba(255,255,255,.08))}
/* Empty / welcome state */
.fd-ap-empty{padding:6px 2px 4px}
.fd-ap-empty-title{font-size:14px;font-weight:600;color:var(--text,#eee);margin-bottom:2px}
.fd-ap-empty-desc{font-size:12px;color:var(--text-dim,#999);margin-bottom:12px}
.fd-ap-qa{display:flex;flex-direction:column;gap:6px}
.fd-ap-qa-item{display:flex;align-items:center;gap:10px;text-align:left;background:var(--bg-tertiary,#202020);border:1px solid var(--border,rgba(255,255,255,.07));border-radius:10px;padding:9px 12px;color:var(--text,#e6e6e6);font-size:13px;cursor:pointer;transition:background .12s,border-color .12s}
.fd-ap-qa-item:hover{background:var(--bg-hover,#2a2a2a);border-color:rgba(35,131,226,.4)}
.fd-ap-qa-item .fd-qa-ico{font-size:16px;flex-shrink:0}
.fd-ap-qa-item .fd-qa-new{margin-left:auto;font-size:9px;font-weight:700;color:#fff;background:var(--accent);border-radius:99px;padding:2px 8px;letter-spacing:.3px}
/* Messages */
.fd-agent-msg{display:flex;gap:9px;margin-bottom:14px}
.fd-agent-msg-avatar{width:28px;height:28px;border-radius:50%;background:var(--bg-tertiary,#2b2b2b);display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
.fd-agent-msg-assistant .fd-agent-msg-avatar{background:rgba(35,131,226,.18)}
.fd-agent-msg-body{flex:1;min-width:0}
.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 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-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px}
/* Composer */
.fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative}
.fd-ap-box{background:var(--bg-tertiary,#1f1f1f);border:1px solid var(--border,rgba(255,255,255,.13));border-radius:16px;padding:10px 12px 8px;transition:border-color .12s,box-shadow .12s}
.fd-ap-box:focus-within{border-color:rgba(35,131,226,.65);box-shadow:0 0 0 3px rgba(35,131,226,.12)}
.fd-ap-input{width:100%;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:14px;resize:none;font-family:inherit;line-height:1.6;max-height:220px;box-sizing:border-box;overflow-y:auto;white-space:pre-wrap;word-break:break-word;min-height:26px;position:relative}
.fd-ap-input.empty::before{content:attr(data-placeholder);color:var(--text-dim,#8a8a8a);position:absolute;left:0;top:0;pointer-events:none;white-space:pre-wrap;max-width:100%}
.ment-token{display:inline-flex;align-items:center;gap:4px;vertical-align:middle;background:rgba(35,131,226,.14);border:1px solid rgba(35,131,226,.35);color:var(--text,#eaf2ff);border-radius:6px;padding:0 3px 0 4px;font-size:13px;line-height:1.5;margin:0 1px;max-width:200px;overflow:hidden;white-space:nowrap}
.ment-token .tk-lbl{overflow:hidden;text-overflow:ellipsis}
.ment-token.skill{background:rgba(168,85,247,.16);border-color:rgba(168,85,247,.5);color:#f3e8ff}
.ment-token .tk-ico{flex-shrink:0;opacity:.9}
.fd-ap-toolbar{display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap}
.fd-ap-tip{font-size:11px;color:var(--text-dim,#8f8f8f);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fd-ap-tools{display:flex;align-items:center;gap:6px;margin-left:auto}
.fd-ap-model{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.05);border:1px solid var(--border);border-radius:99px;padding:4px 10px;font-size:11.5px;color:var(--text-secondary,#ddd);cursor:pointer;white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
.fd-ap-model:hover{background:var(--bg-hover,#2c2c2c)}
.fd-ap-model.off{opacity:.75}
.fd-ap-send{width:34px;height:34px;border-radius:50%;border:none;background:var(--accent,#2383E2);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:15px;transition:background .12s,transform .12s;flex-shrink:0}
.fd-ap-send:hover{background:#2c8ceb}
.fd-ap-send:disabled{opacity:.4;cursor:not-allowed;transform:none}
.fd-ap-send:not(:disabled):active{transform:scale(.94)}
/* Model picker popover */
.fd-ap-pop{position:absolute;right:14px;bottom:calc(100% + 6px);width:270px;background:var(--bg-modal,#1c1c1c);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.5);padding:12px;z-index:60}
.fd-ap-pop label{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:8px 0 4px}
.fd-ap-pop select{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border);border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:7px 8px;outline:none}
.fd-ap-pop a{display:inline-block;margin-top:10px;font-size:12px;color:var(--accent);text-decoration:none}
/* Slash command menu */
.fd-agent-cmd-menu{position:absolute;bottom:calc(100% + 4px);left:0;right:0;background:var(--bg-modal,#1e1e1e);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:11px;max-height:190px;overflow-y:auto;box-shadow:0 -8px 26px rgba(0,0,0,.4);z-index:70;display:none}
.fd-agent-cmd-menu.show{display:block}
.fd-agent-cmd-item{padding:8px 12px;font-size:12px;color:var(--text,#eee);cursor:pointer;border-radius:7px;margin:3px;display:flex;gap:8px;align-items:baseline}
.fd-agent-cmd-item:hover{background:var(--bg-hover,#2b2b2b)}
.fd-agent-cmd-item b{color:var(--accent,#2383E2);font-family:monospace;white-space:nowrap}
.fd-agent-cmd-item b.k-cmd{color:var(--accent,#2383E2)}
.fd-agent-cmd-item b.k-skill{color:#c084fc}
.fd-agent-cmd-item span{color:var(--text-dim,#999);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fd-agent-cmd-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
/* Action bar under each assistant post */
.fd-agent-msg-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px;align-items:center}
.fd-act-btn{display:inline-flex;align-items:center;justify-content:center;gap:0;background:transparent;border:1px solid var(--border,rgba(255,255,255,.09));color:var(--text-dim,#9aa);border-radius:7px;width:30px;height:26px;font-size:13px;cursor:pointer;transition:background .12s,color .12s,border-color .12s;line-height:1}
.fd-act-btn:hover{background:var(--bg-hover,#262626);color:var(--text,#fff);border-color:rgba(255,255,255,.25)}
.fd-act-btn.on{background:rgba(35,131,226,.18);border-color:rgba(35,131,226,.55);color:#fff}
.fd-act-btn:disabled,.fd-act-btn.disabled{opacity:.4;cursor:not-allowed}
/* Chips (contexte épinglé @ / + / skills) */
.fd-ap-chips{display:flex;flex-direction:column;gap:5px;padding:8px 4px 4px;max-height:92px;overflow-y:auto}
.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}
.fd-chip-x{background:none;border:none;color:var(--text-dim,#aaa);cursor:pointer;font-size:13px;line-height:1;padding:0 4px;border-radius:50%;flex-shrink:0}
.fd-chip-x:hover{color:#fff;background:rgba(255,255,255,.14)}
/* Add button (+ contexte) */
.fd-ap-add{width:30px;height:30px;border-radius:50%;background:var(--bg-tertiary,#262626);border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);cursor:pointer;font-size:17px;display:flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1}
.fd-ap-add:hover{background:var(--bg-hover,#303030);color:#fff}
/* Mention menu (@ / +) */
.fd-ap-mention-menu{position:absolute;bottom:calc(100% + 4px);left:0;right:0;background:var(--bg-modal,#1e1e1e);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:11px;max-height:220px;overflow-y:auto;box-shadow:0 -8px 26px rgba(0,0,0,.4);z-index:70;display:none}
.fd-ap-mention-menu.show{display:block}
.fd-mention-item{display:flex;align-items:center;gap:9px;padding:7px 12px;font-size:12.5px;color:var(--text,#eee);cursor:pointer;border-radius:7px;margin:2px 4px}
.fd-mention-item:hover,.fd-mention-item.focus{background:var(--bg-hover,#2b2b2b)}
.fd-mention-ico{width:24px;text-align:center;flex-shrink:0;font-size:14px}
.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-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}
/* Toast */
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
.fd-ap-toast button{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:13px;padding:0}
[x-cloak]{display:none!important}
</style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script>
(function(){
var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
close: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { close: true } })); },
toggle: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
ask: function(){}, generate: function(){ return Promise.resolve(''); }
};
window.fdAgent = window.fdAgent || defaultAPI;
// Ctrl/Cmd+J — open / close the Agent panel from anywhere.
document.addEventListener('keydown', function(e){
if((e.ctrlKey || e.metaKey) && (e.key === 'j' || e.key === 'J')){
e.preventDefault();
if(window.fdAgent && window.fdAgent.toggle) window.fdAgent.toggle();
}
});
})();
</script>
{# ── Floating button ── #}
<button id="fd-agent-fab" title="FlowDeck Agent (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.toggle()">🤖</button>
{# ── Panel ── #}
<div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck">
<div class="fd-ap-head">
<span class="fd-ap-logo">🤖</span>
<div class="fd-ap-head-main">
<div class="fd-ap-title">How can I help you today?</div>
<div class="fd-ap-sub" x-text="providerModelLabel"></div>
</div>
<button class="fd-ap-headbtn" title="Nouvelle conversation" @click="newConversation()">+</button>
<button class="fd-ap-headbtn" title="Fermer (Ctrl+J)" @click="close()">✕</button>
</div>
<div class="fd-ap-noconfig" x-show="!hasActiveProvider">
<span>⚠️ Aucun fournisseur activé et fonctionnel.</span>
<a href="/settings" target="_blank" @click.stop>Configurer → Agent &amp; IA</a>
<template x-if="unverifiedCount">
<span class="fd-k-sub">🔑 <span x-text="unverifiedCount"></span> clé(s) à tester : Paramètres puis « Tester ».</span>
</template>
</div>
<div class="fd-ap-tabs">
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
</div>
<div class="fd-agent-body">
<template x-if="tab==='history'">
<div>
<template x-for="c in conversations" :key="c.id">
<div class="fd-ap-hist-item" :class="{active: currentConv && currentConv.id===c.id}" @click="loadConversation(c.id)">
<div x-text="c.title || 'New conversation'"></div>
<div style="font-size:11px;color:var(--text-dim)" x-text="c.updated_at"></div>
</div>
</template>
<div x-show="!conversations.length" style="font-size:13px;color:var(--text-dim);padding:10px">Aucune conversation. Démarrez-en une !</div>
</div>
</template>
<template x-if="tab==='chat'">
<div>
<!-- Welcome / quick actions -->
<div class="fd-ap-empty" x-show="!messages.length && !streaming" x-cloak>
<div class="fd-ap-empty-desc" x-text="quickIntro"></div>
<div class="fd-ap-qa">
<template x-for="q in quickActions" :key="q.label">
<button class="fd-ap-qa-item" type="button" @click="runQuick(q)">
<span class="fd-qa-ico" x-text="q.icon"></span>
<span x-text="q.label"></span>
<span class="fd-qa-new" x-show="q.new" x-cloak>New</span>
</button>
</template>
</div>
</div>
<template x-for="m in messages" :key="m.id">
<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-body">
<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.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>
<template x-if="m.role==='assistant' && !m.generating && m.content && !m.act">
<div class="fd-agent-msg-actions">
<button class="fd-act-btn" type="button" @click="copyResponse(m)" title="Copier la réponse">⧉</button>
<button class="fd-act-btn" type="button" @click="insertIntoPage(m)" :disabled="!canInsert" title="Insérer dans la page ouverte">➕</button>
<button class="fd-act-btn" :class="{on: m.fb==='up'}" type="button" @click="feedback(m,'up')" :disabled="!!m.fb && m.fb!=='up'" title="Feedback positif">👍</button>
<button class="fd-act-btn" :class="{on: m.fb==='down'}" type="button" @click="feedback(m,'down')" :disabled="!!m.fb && m.fb!=='down'" title="Feedback négatif">👎</button>
</div>
</template>
</div>
</div>
</template>
<div class="fd-agent-thinking" x-show="streaming">L'agent réfléchit et agit… <span x-text="liveReasoning"></span></div>
<div class="fd-ap-err" x-show="errorMsg" x-text="errorMsg"></div>
</div>
</template>
</div>
<div class="fd-ap-composer">
<div style="position:relative;">
<div class="fd-ap-toast" x-show="toastMsg" x-cloak :class="{err: toastErr}">
<span x-text="toastMsg"></span>
<button type="button" @click="toastMsg=''">✕</button>
</div>
<div class="fd-agent-cmd-menu" x-ref="cmdMenu" :class="{show: commandOpen && commandSuggestions.length && !slashDismiss}">
<template x-for="c in commandSuggestions" :key="c.key">
<div>
<div class="fd-agent-cmd-sep" x-show="c._sep" x-text="c._sep"></div>
<div class="fd-agent-cmd-item" x-show="!c._sep" :class="{focus: c._i===cmdFocus}" @click="useSuggestion(c)" @mouseenter="cmdFocus=c._i">
<b :class="c._admin ? 'k-cmd' : 'k-skill'" x-text="(c._admin ? c.cmd : c.cmd)"></b>
<span x-text="c.desc"></span>
</div>
</div>
</template>
</div>
<div class="fd-ap-mention-menu" :class="{show: mentionOpen}" x-ref="mentionMenu">
<div class="fd-mention-load" x-show="mentionLoad" x-cloak>Recherche…</div>
<template x-if="!mentionLoad && !hasMentionItems">
<div class="fd-mention-empty">Aucun élément. Tapez un nom de document, page ou base.</div>
</template>
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token || it.key">
<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>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
</div>
</div>
</template>
</div>
</div>
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
<div class="fd-chip-row" x-show="mentionChips.length">
<template x-for="chip in mentionChips" :key="chip.key">
<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-lbl" x-text="chip.label"></span>
<button class="fd-chip-x" type="button" @click.stop="removeChip(chip.token)" title="Retirer du contexte">✕</button>
</span>
</template>
</div>
<div class="fd-chip-row" x-show="skillChips.length">
<template x-for="chip in skillChips" :key="chip.key">
<span class="fd-chip skill">
<span class="fd-chip-ico" x-text="chip.icon"></span>
<span class="fd-chip-lbl" x-text="chip.label"></span>
<button class="fd-chip-x" type="button" @click="removeChip(chip.token)" title="Retirer le skill">✕</button>
</span>
</template>
</div>
</div>
<div class="fd-ap-box">
<div class="fd-ap-input empty" x-ref="cmdInput" contenteditable="true" spellcheck="false" role="textbox" aria-multiline="true"
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
<div class="fd-ap-toolbar">
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
<span class="fd-ap-tip" x-text="composerHint"></span>
<div class="fd-ap-tools">
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
<span x-text="autoLabel"></span> <span style="opacity:.6">▾</span>
</button>
<button class="fd-ap-send" @click="send()" :disabled="streaming || !canSend" title="Envoyer">▲</button>
</div>
</div>
</div>
<div class="fd-ap-pop" x-show="modelOpen" @click.outside="modelOpen=false" x-cloak>
<label>Fournisseur</label>
<select x-model="llmProvider" @change="onProviderChange()">
<template x-for="p in providers" :key="p.id">
<option :value="p.id" x-text="p.id==='offline' ? 'Hors-ligne (mock)' : p.name"></option>
</template>
</select>
<label>Modèle</label>
<select x-model="llmModel" @change="patchConversation()" x-show="llmProvider!=='offline'">
<template x-for="m in providerModels" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
<a href="/settings" target="_blank" @click="modelOpen=false">⚙️ Configurer (clés API) →</a>
</div>
</div>
</div>
<script>
(function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [
{cmd:'/help', desc:'liste des commandes'},
{cmd:'/providers', desc:'liste les fournisseurs actifs'},
{cmd:'/provider <nom>', desc:'changer de fournisseur LLM'},
{cmd:'/model <nom>', desc:'changer de modèle LLM'},
{cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'}
];
var FD_SKILLS = [
{cmd:'/research', icon:'🔎', desc:'recherche structurée + recommandation',
template:'Fais un état des lieux de [sujet] : contexte, avantages/inconvénients, options, puis une recommandation finale. Indique tes sources si tu en utilises.'},
{cmd:'/create-new-skill', icon:'🛠️', desc:'crée un workflow réutilisable (skill)',
template:'Je veux créer un skill réutilisable qui [décris le workflow]. Produis : nom, description courte, étapes et format de sortie attendu.'},
{cmd:'/resume', icon:'📄', desc:'résumé / synthèse structurée',
template:'Résume ce contenu de façon structurée et concise : points clés, décisions, TL;DR.'},
{cmd:'/actions', icon:'✅', desc:'extraire les actions & to-dos',
template:'À partir de ce contenu, liste les actions à mener avec responsable et échéance quand c\u2019est indiqué.'},
{cmd:'/reformuler', icon:'✍️', desc:'réécriture clarté / ton',
template:'Reformule ce contenu pour le rendre plus clair et plus concis en conservant le sens. Précise le ton souhaité (interne, client, formel…).'},
{cmd:'/correction', icon:'🔤', desc:'correction grammaire / orthographe / style',
template:'Corrige la grammaire, l\u2019orthographe et le style de ce contenu sans en changer le sens.'},
{cmd:'/brainstorm', icon:'💡', desc:'générer des idées, angles, variantes',
template:'Propose des idées variées sur [sujet] : angles, exemples concrets, variantes de messages (annonce, email, post).'},
{cmd:'/plan', icon:'🧭', desc:'planifier / structurer un document',
template:'Construis un plan structuré pour [sujet] : sections logiques, sous-points et ordre de rédaction.'},
{cmd:'/ask', icon:'💬', desc:'Q&A sur un contenu référencé',
template:'À partir du contenu référencé, réponds précisément à [ta question].'},
{cmd:'/meeting-note', icon:'📝', desc:'compte-rendu / note de réunion',
template:'Rédige une AI Meeting Note : Résumé, Décisions, Action items (avec responsables et échéances), Prochaines étapes.'},
{cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack',
template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'}
];
function agentPanel(){
return {
open: false, modelOpen: false, tab: 'chat',
agents: [], conversations: [], currentConv: null,
messages: [], input: '', streaming: false, liveReasoning: '',
provider: 'offline', providerAvailable: false, errorMsg: '',
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
llmModel: '', llmKeys: [], defaultProvider: 'offline',
activeContext: null, ctxPinnedKey: null,
contextChips: [], skills: [],
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_slashSeq: 0,
init(){
var self = this;
var el = document.getElementById('fd-agent-panel');
function applyState(){
if(el){ if(self.open){ el.classList.add('open'); el.setAttribute('aria-hidden','false'); } else { el.classList.remove('open'); el.setAttribute('aria-hidden','true'); } }
}
window.addEventListener('fd-agent-toggle', function(e){
if(e && e.detail){
if(e.detail.close){ self.close(); return; }
if(e.detail.agentId){ self.agentId = e.detail.agentId; }
self.openAgent(); return;
}
// legacy toggle semantics
if(self.open){ self.close(); } else { self.openAgent(); }
});
this._applyState = applyState;
this.fetchProviders();
this.fetchAgents();
this.fetchSkills();
// Exposed imperative API (used by the FAB, the sidebar, Ask AI…)
window.fdAgent.open = function(){ self.openAgent(); };
window.fdAgent.close = function(){ self.close(); };
window.fdAgent.toggle = function(){ self.open ? self.close() : self.openAgent(); };
window.fdAgent.ask = function(text){
var t = (text || '').trim();
if(!t) return;
self.openAgent();
self.setComposerText(t);
setTimeout(function(){ self.send(); }, 60);
};
window.fdAgent.generate = function(message, context){
message = (message || '').trim();
if(!message) return Promise.resolve('');
var payload = {message: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/conversations', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({title:'Agent'})
}).then(function(r){ return r.json(); }).then(function(conv){
var cid = conv && conv.id;
if(!cid) throw new Error('Impossible de créer une conversation agent');
return fetch('/api/agent/conversations/' + cid + '/run', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP ' + resp.status)); }); }
var reader = resp.body.getReader();
var dec = new TextDecoder();
var buf = '', finalText = '', lastError = '';
function pump(){
return reader.read().then(function(res){
if(res.done){ if(lastError) throw new Error(lastError); return finalText; }
buf += dec.decode(res.value, {stream:true});
var parts = buf.split('\n\n');
buf = parts.pop();
for(var i=0;i<parts.length;i++){
var line = parts[i].replace(/^data:\s*/, '').trim();
if(!line) continue;
try{
var ev = JSON.parse(line);
if(ev.type === 'final') finalText = ev.content || '';
else if(ev.type === 'error') lastError = ev.message || 'Erreur agent';
}catch(e){}
}
return pump();
});
}
return pump();
});
});
};
},
// ── Open / close (robust) ──
openAgent(){
if(this.open) { this.refreshContext(); return; }
this.open = true;
this.refreshContext(true);
if(this._applyState) this._applyState();
this.loadConversations();
var self = this;
this.$nextTick(function(){
var t = self.$refs && self.$refs.cmdInput;
if(t){ t.focus(); self.resizeInput(); }
});
},
close(){
if(!this.open) return;
this.open = false;
this.modelOpen = false;
if(this._applyState) this._applyState();
},
toggle(){ this.open ? this.close() : this.openAgent(); },
// ── Context (reflected as an @-style mention chip in the composer) ──
refreshContext(force){
var ctx = FlowDeckCtx.current();
if(!ctx){ this.activeContext = null; return; }
if(!this.activeContext) { this.activeContext = ctx; this._syncContextChip(); return; }
// Always follow a currently-open document so follow-up messages use it.
if(ctx.kind === 'page' || force || ctx.key !== this.activeContext.key){
this.activeContext = ctx;
this._syncContextChip();
}
},
_syncContextChip(){
var c = this.activeContext;
if(!c || !c.mentionToken) return;
var exists = this.contextChips.some(function(ch){ return ch.token === c.mentionToken; });
if(exists) return;
this.contextChips.push({
key: c.mentionToken, kind: 'mention', type: c.refType || 'document',
icon: c.icon || '📎', label: c.title || c.label, token: c.mentionToken, refId: c.refId
});
},
get chatContextActive(){ return !!(this.activeContext && this.activeContext.kind === 'page'); },
contextPayload(){
this.refreshContext();
var c = this.activeContext;
if(!c) return '';
// Si le contexte est déjà épinglé comme mention (@), le backend résout son
// contenu via context_builder — on évite de dupliquer le texte du document.
if(c.mentionToken && this.contextChips.some(function(ch){ return ch.token === c.mentionToken; })) return '';
if(c.text && c.text.trim()) return c.text.trim().slice(0, 9000);
return c.title ? ('Contexte : ' + c.title + '.').slice(0, 2000) : '';
},
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./skill épinglé(s)' : '';
return base + extra + ' · Entrée ↵ = envoyer · Maj+Entrée = ligne';
},
get canInsert(){ return !!(this.activeContext && this.activeContext.kind === 'page'); },
// ── Quick actions (contextual) ──
get quickIntro(){
var c = this.activeContext;
if(c && c.kind === 'page') return 'Comment puis-je vous aider sur ce document ?';
return 'Que voulez-vous faire ?';
},
get quickActions(){
var c = this.activeContext || {};
var out = [];
if(c.kind === 'page'){
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, 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 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:'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'){
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:'Synthèse des données', prompt:'Produis une synthèse des données présentes dans cette base.'});
out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Crée un squelette d\u2019AI meeting note structuré.'});
} else if(c.kind === 'tasks'){
out.push({icon:'✅', label:'Mes tâches', prompt:'Liste mes tâches et suggère les priorités de la journée.'});
out.push({icon:'🗓️', label:'Planifier ma journée', prompt:'Aide-moi à organiser ma journée à partir de mes tâches.'});
out.push({icon:'🤖', label:'Question générale', prompt:'Explique-moi comment fonctionne My Tasks dans FlowDeck.'});
} else {
out.push({icon:'🗄️', label:'Créer une collection', prompt:'Crée une collection « Projets » avec les propriétés Status et Priorité, puis une vue board.', new:true});
out.push({icon:'📋', label:'Créer un board', prompt:'Crée un board pour suivre le travail de l\u2019équipe.'});
out.push({icon:'❓', label:'Comment utiliser FlowDeck ?', prompt:'Guide-moi sur les fonctionnalités principales de FlowDeck et comment les utiliser.'});
out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Crée un squelette d\u2019AI meeting note structuré.'});
}
return out;
},
runQuick(q){
if(!q || !q.prompt) return;
if(q.docRun){ this.runDocAction(q); return; }
var self = this;
this.ensureConversation().then(function(){
self.setComposerText(q.prompt);
self.send();
}).catch(function(){});
},
// ── Document actions (no tools) → proposition à approuver / rejeter ──
_isContentRequest(t){
var low = (t || '').toLowerCase();
var kws = ['écris','écrire','écrit un','rédige','rédiger','génère','générer','produis',
'développe','texte sur','paragraphe','article sur','rédaction','résume','résumer',
'résumé de','traduis','traduire','améliore','améliorer','note de réunion',
'série tv','série','contenu de la page','poème','histoire sur','sujet de','courriel','email sur'];
for(var i=0;i<kws.length;i++){ if(low.indexOf(kws[i]) !== -1) return true; }
return false;
},
_docModeFor(t){
var low = (t || '').toLowerCase();
if(low.indexOf('traduis') !== -1 || low.indexOf('traduire') !== -1 || low.indexOf('translate') !== -1) return 'replace';
return 'insert';
},
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 ──
fetchProviders(){
var self = this;
fetch('/api/agent/providers').then(function(r){return r.json()}).then(function(d){
self.provider = d.provider || 'offline';
self.providerAvailable = !!d.available;
self.allProviders = d.providers || [];
self.llmKeys = d.keys || [];
self.defaultProvider = d.default_provider || d.provider || 'offline';
self.providers = self.allProviders.filter(function(p){ return p.functional; });
if(!self.providers.some(function(x){ return x.id === 'offline'; })){
self.providers.push({id:'offline', name:'Hors-ligne', functional:true, verified:true, models: [], default_model:'', requires_key:false});
}
var want = self.defaultProvider;
if(!self.providers.some(function(x){ return x.id === want; })) want = 'offline';
if(want === 'offline'){
var firstReal = self.providers.find(function(x){ return x.id !== 'offline'; });
if(firstReal) want = firstReal.id;
}
if(!self.providers.some(function(x){ return x.id === self.llmProvider; })) self.llmProvider = want;
if(!self.llmModel) self.llmModel = d.model || '';
self.applyModels();
}).catch(function(){});
},
applyModels(){
var self = this;
var p = self.providers.find(function(x){ return x.id === self.llmProvider; });
self.providerModels = (p && p.models) || [];
if(self.providerModels.length && self.providerModels.indexOf(self.llmModel) === -1){
self.llmModel = (p && p.default_model) || self.providerModels[0];
}
},
onProviderChange(){
var self = this;
var p = self.providers.find(function(x){ return x.id === self.llmProvider; });
self.providerModels = (p && p.models) || [];
self.llmModel = (p && p.default_model) || ((p && p.models && p.models[0]) || '');
self.patchConversation();
},
get hasActiveProvider(){ return (this.providers || []).some(function(p){ return p.id !== 'offline'; }); },
get unverifiedCount(){
return (this.allProviders || []).filter(function(p){ return p.configured && !p.verified && p.id !== 'offline'; }).length;
},
providerName(id){
var p = (this.allProviders || []).find(function(x){ return x.id === id; });
return (p && p.name) || id;
},
get providerModelLabel(){
var real = this.llmProvider && this.llmProvider !== 'offline';
if(!real) return this.hasActiveProvider ? 'Choisissez un fournisseur' : 'Mode hors-ligne (mock)';
var label = this.providerName(this.llmProvider);
return this.llmModel ? (label + ' · ' + this.llmModel) : label;
},
get autoLabel(){
if(!this.llmProvider || this.llmProvider === 'offline'){
return this.hasActiveProvider ? 'Choisir un modèle' : 'Hors-ligne (mock)';
}
var base = this.llmModel || this.providerName(this.llmProvider);
return 'Auto · ' + base;
},
// ── Conversations ──
fetchAgents(){
var self = this;
fetch('/api/agent').then(function(r){return r.json()}).then(function(d){ self.agents = d.agents || []; }).catch(function(){});
},
fetchSkills(){
var self = this;
fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){
self.skills = d.skills || [];
}).catch(function(){ self.skills = []; });
},
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
self.conversations = d.conversations || [];
if(self.conversations.length && !self.currentConv){
self.currentConv = self.conversations[0];
self.loadConversation(self.currentConv.id);
}
}).catch(function(){});
},
loadConversation(id){
var self = this;
this.tab='chat';
this.errorMsg = '';
fetch('/api/agent/conversations/'+id).then(function(r){return r.json()}).then(function(d){
self.currentConv = d.conversation;
self.messages = (d.messages||[]).map(function(m){ return {id:m.id, role:m.role, content:m.content, model:m.model, tool_calls:m.tool_calls_json}; });
var prov = d.conversation && d.conversation.provider;
if(prov && (!self.providers.length || self.providers.some(function(x){ return x.id === prov; }))){ self.llmProvider = prov; }
self.applyModels();
if(d.conversation && d.conversation.model && self.providerModels.indexOf(d.conversation.model) !== -1){ self.llmModel = d.conversation.model; }
}).catch(function(){});
},
newConversation(){
var self = this;
var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self.$nextTick(function(){ self.resizeInput(); });
}).catch(function(){});
},
ensureConversation(){
var self = this;
if(self.currentConv && self.currentConv.id) return Promise.resolve(self.currentConv);
if(self._ensuring) return self._ensuring;
var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self._ensuring = null;
return conv;
}).catch(function(err){
self._ensuring = null;
self.errorMsg = (err && err.message) ? err.message : 'Impossible de créer la conversation';
throw err;
});
return self._ensuring;
},
patchConversation(){
var self = this;
if(!self.currentConv) return;
var body = {};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations/'+self.currentConv.id, {
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
}).catch(function(){});
},
// ── Composer (contenteditable) helpers ──
_composerEl(){ return this.$refs && this.$refs.cmdInput; },
composerText(){
var el = this._composerEl();
if(!el) return (this.input || '');
var parts = [];
var nodes = el.childNodes;
for(var i=0;i<nodes.length;i++){
var n = nodes[i];
if(n.nodeType===3) parts.push(n.nodeValue);
else if(n.nodeType===1){
if(n.tagName === 'BR') parts.push('\n');
else if(n.hasAttribute && n.hasAttribute('data-ctoken')) parts.push(n.getAttribute('data-label')||'');
else parts.push(n.textContent||'');
}
}
return parts.join('').replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').trim();
},
composerMentions(){
var el = this._composerEl();
var out = [], seen = {};
if(el){
var spans = el.querySelectorAll('[data-ctoken]');
for(var i=0;i<spans.length;i++){
if(spans[i].getAttribute('data-kind') === 'skill') continue;
var t = spans[i].getAttribute('data-token');
if(t && !seen[t]){ seen[t]=1; out.push(t); }
}
}
for(i=0;i<this.contextChips.length;i++){
var ch = this.contextChips[i];
if(ch.kind==='mention' && !seen[ch.token]){ seen[ch.token]=1; out.push(ch.token); }
}
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(){
var t = this._composerEl();
if(!t) return;
t.style.height = 'auto';
var h = Math.min(t.scrollHeight, 220);
t.style.height = h + 'px';
t.style.overflowY = (t.scrollHeight > 220) ? 'auto' : 'hidden';
},
_emptyCheck(){
var el = this._composerEl();
if(!el) return;
var empty = !el.textContent.trim() && !el.querySelector('[data-ctoken]');
el.classList.toggle('empty', !!empty);
},
setComposerText(str){
var el = this._composerEl();
if(!el){ this.input = str || ''; return; }
el.textContent = str || '';
this.input = str || '';
this.resizeInput();
this._emptyCheck();
},
clearComposer(){
var el = this._composerEl();
if(el){ el.textContent = ''; }
this.input = '';
this.resizeInput();
this._emptyCheck();
},
_focusComposer(){
var t = this._composerEl();
if(t) t.focus();
},
insertNewline(){
var el = this._composerEl();
if(!el) return;
try{
var sel = window.getSelection();
if(!sel.rangeCount) return;
var r = sel.getRangeAt(0);
r.deleteContents();
var br = document.createElement('br');
r.insertNode(br);
r.setStartAfter(br); r.collapse(true);
this.input = this.composerText();
}catch(e){}
this.resizeInput();
},
_caretQuery(){
try{
var sel = window.getSelection();
if(!sel || !sel.rangeCount || !sel.isCollapsed) return null;
var r = sel.getRangeAt(0);
var node = r.startContainer;
if(node.nodeType !== 3) return null;
var s = node.nodeValue, off = r.startOffset, i = off;
while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--;
if(i > 0 && s[i-1] === '@' && (i === 1 || /\s/.test(s[i-2]))){
return {node: node, at: i-1, raw: s.slice(i, off)};
}
}catch(e){}
return null;
},
_expandToAt(range){
var node = range.startContainer;
if(node.nodeType !== 3) return;
var s = node.nodeValue, off = range.startOffset, i = off;
while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--;
if(i > 0 && s[i-1] === '@') range.setStart(node, i-1);
},
_insertToken(item, range){
var el = this._composerEl();
if(!el) return;
try{
var span = document.createElement('span');
span.setAttribute('contenteditable','false');
span.setAttribute('data-ctoken','1');
span.setAttribute('data-token', item.token);
span.setAttribute('data-label', item.label);
span.setAttribute('data-kind', item.kind || 'mention');
span.className = 'ment-token' + (item.kind === 'skill' ? ' skill' : '');
var ico = document.createElement('span'); ico.className='tk-ico'; ico.textContent = ' ' + (item.icon || '');
var lbl = document.createElement('span'); lbl.className='tk-lbl'; lbl.textContent = ' ' + item.label + ' ';
span.appendChild(ico); span.appendChild(lbl);
var useRange = (range && range.startContainer) ? range : null;
if(!useRange){
var sel = window.getSelection();
if(sel && sel.rangeCount){
var cand = sel.getRangeAt(0);
if(cand && el.contains(cand.startContainer)) useRange = cand;
}
}
if(useRange){
if(this._mentionFrom !== '+' && !range) this._expandToAt(useRange);
useRange.deleteContents();
useRange.insertNode(span);
var sp = document.createTextNode(' ');
useRange.setStartAfter(span); useRange.collapse(true);
useRange.insertNode(sp);
useRange.setStart(sp, 1); useRange.setEnd(sp, 1);
try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch(e2){}
} else {
if(el.textContent) el.appendChild(document.createTextNode(' '));
el.appendChild(span);
el.appendChild(document.createTextNode(' '));
try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch(e3){}
}
}catch(e){}
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
_insertMentionAt(item){
var node = this._atNode;
if(node && node.nodeType === 3 && node.parentNode){
try{
var s = node.nodeValue;
var idx = -1;
for(var k=0;k<s.length;k++){ if(s[k] === '@'){ idx = k; break; } }
if(idx >= 0){
var r = document.createRange();
r.setStart(node, idx);
r.setEnd(node, s.length);
this._insertToken(item, r);
return;
}
}catch(e){}
}
this._insertToken(item);
},
_removeTokenSpan(token){
var el = this._composerEl();
if(!el) return;
var spans = el.querySelectorAll('[data-ctoken][data-token="'+token+'"]');
for(var i=0;i<spans.length;i++){
var p = spans[i];
if(p.parentNode){
if(p.nextSibling && p.nextSibling.nodeType===3 && /^\s*$/.test(p.nextSibling.nodeValue)) p.parentNode.removeChild(p.nextSibling);
p.parentNode.removeChild(p);
}
}
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
_prevToken(node, off){
if(node.nodeType === 3 && off === 0){
var p = node.previousSibling;
while(p && p.nodeType===3 && !p.nodeValue.trim()) p = p.previousSibling;
if(p && p.nodeType===1 && p.hasAttribute && p.hasAttribute('data-ctoken')) return p;
}
return null;
},
_nextToken(node, off){
if(node.nodeType === 3 && off === node.nodeValue.length){
var p = node.nextSibling;
while(p && p.nodeType===3 && !p.nodeValue.trim()) p = p.nextSibling;
if(p && p.nodeType===1 && p.hasAttribute && p.hasAttribute('data-ctoken')) return p;
}
return null;
},
_removeTokenDom(span){
var token = span.getAttribute('data-token');
if(span.parentNode){
if(span.previousSibling && span.previousSibling.nodeType===3 && /^\s*$/.test(span.previousSibling.nodeValue)) span.parentNode.removeChild(span.previousSibling);
if(span.nextSibling && span.nextSibling.nodeType===3 && /^\s*$/.test(span.nextSibling.nodeValue)) span.parentNode.removeChild(span.nextSibling);
span.parentNode.removeChild(span);
}
for(var i=0;i<this.contextChips.length;i++){ if(this.contextChips[i].token === token){ this.contextChips.splice(i,1); break; } }
this.input = this.composerText();
this.resizeInput();
this._emptyCheck();
},
onPaste(e){
e.preventDefault();
var text = '';
try{ if(e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; }catch(err){ }
if(!text) return;
try{
var sel = window.getSelection();
if(sel && sel.rangeCount){ sel.getRangeAt(0).deleteContents(); document.execCommand('insertText', false, text); }
}catch(err){}
this.input = this.composerText();
this.resizeInput();
},
get canSend(){ return !!((this.input || '').trim() || this.contextChips.length); },
get mentionChips(){ return (this.contextChips || []).filter(function(c){ return c.kind === 'mention'; }); },
get skillChips(){ return (this.contextChips || []).filter(function(c){ return c.kind === 'skill'; }); },
get commandOpen(){ return /^\s*\//.test(this.input) && !this.streaming; },
get commandSuggestions(){
var raw = (this.input||'').trim();
if(!/^\//.test(raw)) return [];
var q = raw.replace(/^\/+/, '').toLowerCase();
var first = (q.split(/\s+/).filter(Boolean)[0] || '').replace(/[^a-z0-9-]/g, '');
function qmatch(name){
var slug = String(name).toLowerCase().split(/[\s<]/)[0].replace(/^\/+/, '');
if(!first) return true;
return slug.indexOf(first) === 0 || slug.replace(/-/g, '').indexOf(first) === 0;
}
var out = [];
var seq = 0;
function pushItems(items){
items.forEach(function(o){ o._i = seq++; out.push(o); });
}
var skillsPresent = false, admPresent = false;
var built = [], dbs = [], adm = [];
for(var i=0;i<FD_SKILLS.length;i++){
if(qmatch(FD_SKILLS[i].cmd)){
var s = FD_SKILLS[i];
built.push({key:'bi-'+s.cmd, cmd:s.cmd, desc:s.desc, icon:s.icon, template:s.template, _type:'builtin'});
}
}
for(i=0;i<(this.skills||[]).length;i++){
var sk = this.skills[i];
if(qmatch(sk.name)){
dbs.push({key:'db-'+sk.id, cmd:'/'+sk.name, desc:(sk.description || 'Skill enregistré'), icon:'✨', _type:'db', skillId:sk.id});
}
}
for(i=0;i<FD_ADMIN_CMDS.length;i++){
if(qmatch(FD_ADMIN_CMDS[i].cmd)) adm.push({key:'adm-'+FD_ADMIN_CMDS[i].cmd, cmd:FD_ADMIN_CMDS[i].cmd, desc:FD_ADMIN_CMDS[i].desc});
}
skillsPresent = built.length || dbs.length;
admPresent = adm.length;
if(skillsPresent){
out.push({_sep:'Skills', key:'sep-skills'});
pushItems(built.concat(dbs));
}
if(skillsPresent && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
if(admPresent) pushItems(adm);
this._slashSeq = seq;
return out;
},
// Items slash « exécutables » (skills) — non séparateurs
_slashItems(){
return this.commandSuggestions.filter(function(o){ return !o._sep; });
},
_slugSkill(slug){
slug = String(slug || '').toLowerCase().replace(/^\/+/, '');
for(var i=0;i<FD_SKILLS.length;i++){
var c = FD_SKILLS[i].cmd.replace(/^\/+/, '').toLowerCase();
if(c === slug || c.replace(/-/g, '') === slug) return FD_SKILLS[i];
}
return null;
},
useSuggestion(c){
var self = this;
if(!c || c._sep) return;
if(c._admin){
this.setComposerText(c.cmd.replace(/\s*<[^>]*>\s*$/, ' '));
this.cmdFocus = -1;
this._focusComposer();
return;
}
if(c._type === 'db'){
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 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:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
}
} else {
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, template:c.template||''});
// On n\u2019injecte PAS le texte du skill dans la bo\u00eete d\u2019\u00e9dition :
// la requ\u00eate est compos\u00e9e en arri\u00e8re-plan \u00e0 l\u2019envoi.
this._focusComposer();
this.toast('Skill épinglé — votre demande sera composée avec lui à l\u2019envoi.');
}
}
this.cmdFocus = -1; this.slashDismiss = false;
this._focusComposer();
},
echo(msg){
this.messages.push({id:'cmd-'+Date.now()+Math.random(), role:'assistant', content: msg, model:''});
this.scrollBottom();
},
handleCommand(text){
var self = this;
var parts = text.replace(/^\/+/, '').trim().split(/\s+/);
var cmd = (parts.shift() || '').toLowerCase();
var arg = parts.join(' ').trim();
if(cmd === 'help' || cmd === 'aide' || cmd === '?'){
this.echo('Tapez / pour choisir un skill (research, resume, brainstorm, plan, meeting-note…). Commandes : /providers · /keys · /provider <nom> · /model <nom>');
return true;
}
if(cmd === 'providers'){
var list = this.providers.filter(function(x){ return x.id !== 'offline'; })
.map(function(x){ return x.id + (x.verified ? ' ✓' : ''); }).join(', ');
this.echo('Fournisseurs actifs : ' + (list || 'aucun — configurez-en un dans Paramètres → Agent & IA'));
return true;
}
if(cmd === 'keys'){
var saved = this.llmKeys.map(function(k){ return k.provider + (k.verified ? ' ✓' : ' (à tester)'); }).join(', ');
this.echo('Clés enregistrées : ' + (saved || 'aucune — configurez-les dans Paramètres → Agent & IA'));
return true;
}
if(cmd === 'provider'){
var avail = this.providers.map(function(x){ return x.id; });
if(!arg){
this.echo('Utilisez : /provider <nom>. Fournisseurs : ' + avail.join(', '));
return true;
}
var p = this.providers.find(function(x){ return x.id === arg.toLowerCase(); });
if(!p){
this.echo('Fournisseur inconnu : ' + arg + ' (disponibles : ' + avail.join(', ') + ')');
return true;
}
this.llmProvider = p.id;
this.onProviderChange();
if(p.models && p.models.length && this.providerModels.indexOf(this.llmModel) === -1){
this.llmModel = (p.default_model) || p.models[0];
}
this.patchConversation();
this.echo('Provider activé : ' + p.id + ' · modèle : ' + (this.llmModel || 'défaut'));
return true;
}
if(cmd === 'model'){
if(!arg){
this.echo('Utilisez : /model <nom>. Modèles pour ' + this.llmProvider + ' : ' + this.providerModels.join(', '));
return true;
}
this.llmModel = arg;
this.patchConversation();
this.echo('Modèle activé : ' + arg + ' (' + this.llmProvider + ')');
return true;
}
return false;
},
// ── Toast ──
toast(msg, err){
var self = this;
this.toastMsg = msg || '';
this.toastErr = !!err;
if(this._toastTimer) clearTimeout(this._toastTimer);
if(!msg){ return; }
this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200);
},
// ── Mentions & contexte épinglé (@ / +) ──
toggleAddPicker(){
if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; }
this.openMentionPicker('+');
},
openMentionPicker(from){
this.mentionOpen = true;
this._mentionFrom = from || '+';
this.mentionQuery = '';
this.mentionFocus = -1;
this.searchMentions('');
},
searchMentions(q){
var self = this;
q = (q || '').trim();
this.mentionQuery = q;
this.mentionLoad = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer);
this._mentionTimer = setTimeout(function(){
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(d){
var flat = [], gi = 0;
(d.sections || []).forEach(function(grp){
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;
var sel = self._selectableMentionItems();
if(sel.length) self.mentionFocus = sel[0]._i;
self.$nextTick(function(){ self._scrollMentionItem(); });
})
.catch(function(){ self.mentionItems = []; self.mentionLoad = false; });
}, 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){
if(!item || !item.token) return;
var dup = this.contextChips.some(function(ch){ return ch.token === item.token; });
if(dup){ this.toast('Déjà dans le contexte'); return; }
this.contextChips.push({key: item.token, kind: 'mention', icon: item.icon || '📎',
label: item.label, token: item.token, type: item.type, refId: item.id});
},
pickMention(item){
if(!item) return;
this.addChip(item);
if(this._mentionFrom === '@'){
this._insertMentionAt(item);
}
this.mentionOpen = false;
this.mentionQuery = '';
this._mentionFrom = null;
this._atNode = null;
this._focusComposer();
},
removeChip(token){
var idx = -1;
for(var i=0;i<this.contextChips.length;i++){
if(this.contextChips[i].token === token){ idx = i; break; }
}
if(idx < 0) return;
var chip = this.contextChips[idx];
this.contextChips.splice(idx, 1);
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;
var text = this.composerText();
this.input = text;
var cmdNow = /^\s*\//.test(text);
if(cmdNow){
if(!this._cmdWasOpen){
var firstSkill = (this.commandSuggestions || []).filter(function(o){ return !o._sep && !o._admin; })[0];
this.cmdFocus = firstSkill ? firstSkill._i : -1;
} else if(this.cmdFocus !== -1){
var present = (this.commandSuggestions || []).some(function(o){ return !o._sep && o._i === this.cmdFocus; }.bind(this));
if(!present){
var fs = (this.commandSuggestions || []).filter(function(o){ return !o._sep && !o._admin; })[0];
this.cmdFocus = fs ? fs._i : -1;
}
}
} else {
this.cmdFocus = -1;
}
this._cmdWasOpen = cmdNow;
// @ mention inline
var cq = this._caretQuery();
if(cq){
if(this._mentionFrom !== '@'){ this._mentionFrom = '@'; this._atNode = cq.node; }
this.mentionOpen = true;
if(this._mentionTimer) clearTimeout(this._mentionTimer);
this.mentionLoad = true;
this.searchMentions(cq.raw);
} else if(this._mentionFrom === '@'){
this.mentionOpen = false;
this.mentionQuery = '';
}
this._emptyCheck();
},
moveMentionFocus(delta){
var sel = this._selectableMentionItems();
if(!sel.length) return;
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;
idx = (idx + delta + n) % n;
this.mentionFocus = sel[idx]._i;
this._scrollMentionItem();
},
moveSlashFocus(delta){
var items = this._slashItems().filter(function(o){ return !o._admin; });
if(!items.length) return;
var idx = items.findIndex(function(o){ return o._i === this.cmdFocus; }.bind(this));
if(idx < 0) idx = delta > 0 ? -1 : 0;
idx = (idx + delta + items.length) % items.length;
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){
var key = e.key;
if(key === 'Backspace' || key === 'Delete'){
try{
var sel = window.getSelection();
if(sel && sel.rangeCount){
var r = sel.getRangeAt(0);
if(r.collapsed){
var tok = key === 'Backspace'
? this._prevToken(r.startContainer, r.startOffset)
: this._nextToken(r.startContainer, r.startOffset);
if(tok){ e.preventDefault(); this._removeTokenDom(tok); return; }
}
}
}catch(err){}
}
if(key === 'Escape'){
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; }
return; // laisse fermer le panneau
}
if(key === 'ArrowDown' || key === 'ArrowUp'){
var delta = key === 'ArrowDown' ? 1 : -1;
if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; }
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; }
return;
}
// Entrée / Tab avec un menu ouvert (skills / mentions)
if(this.mentionOpen){
var mSel = this._selectableMentionItems();
if((key === 'Enter' || key === 'Tab') && mSel.length){
e.preventDefault();
var mi = mSel.find(function(it){ return it._i === this.mentionFocus; }.bind(this)) || mSel[0];
this.pickMention(mi);
return;
}
}
if(this.commandOpen && !this.slashDismiss && this._slashItems().length){
var items = this._slashItems();
if(key === 'Enter' || key === 'Tab'){
var focusItem = items.find(function(o){ return o._i === this.cmdFocus; }.bind(this));
if(focusItem){
if(!focusItem._admin){ e.preventDefault(); this.useSuggestion(focusItem); return; }
e.preventDefault(); this.send(); return;
}
var queryTyped = (this.composerText() || '').replace(/^\/+/, '').trim().length > 0;
var onlyAdmin = items.every(function(o){ return o._admin; });
var onlySkill = !onlyAdmin && items.every(function(o){ return !o._admin; });
if(onlySkill && queryTyped){
e.preventDefault(); this.useSuggestion(items[0]); return;
}
if(onlyAdmin){
e.preventDefault(); this.send(); return;
}
e.preventDefault(); return;
}
}
// Entrée : envoyer (Maj+Entrée = nouvelle ligne)
if(key === 'Enter'){
if(e.shiftKey){ e.preventDefault(); this.insertNewline(); }
else { e.preventDefault(); this.send(); }
return;
}
},
// ── Actions sous chaque réponse agent ──
copyResponse(m){
var self = this;
var text = (m && m.content) || '';
if(!text) return;
function fallback(){
try{
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
self.toast(ok ? 'Réponse copiée.' : 'Copie impossible.', !ok);
}catch(err){ self.toast('Copie impossible.', true); }
}
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(function(){ self.toast('Réponse copiée.'); }).catch(function(){ fallback(); });
} else { fallback(); }
},
insertIntoPage(m){
var c = FlowDeckCtx.current();
if(!c || c.kind !== 'page'){ this.toast('Aucune page ouverte pour insérer cette réponse.', true); return; }
if(!window.fdApplyDocument){ this.toast('Insertion indisponible ici.', true); return; }
var text = (m && m.content) || '';
if(!text){ return; }
var ok = window.fdApplyDocument(text, m && m.mode && !m.applied ? m.mode : 'insert');
this.toast(ok ? 'Réponse insérée dans la page.' : 'Insertion impossible (page non éditable).', !ok);
},
feedback(m, rating){
var self = this;
if(!m || m.fb) return;
var payload = {rating: rating, content: ((m.content) || '').slice(0, 1000)};
if(this.currentConv && this.currentConv.id) payload.conversation_id = this.currentConv.id;
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
self.toast(rating === 'up' ? 'Merci pour votre retour 👍' : 'Retour enregistré 👎', false);
}).catch(function(){ self.toast('Retour non envoyé.', true); });
},
// ── Send / SSE ──
async send(){
var self = this;
if(self.streaming) return;
var text = (self.input || '').trim();
if(!text && !self.contextChips.length) return;
if(self.commandOpen && self.handleCommand(text)){ self.clearComposer(); return; }
// skill intégré tapé « en dur » (« /research », « /meeting-note … ») → applique son modèle
if(self.commandOpen){
var firstTok = (text.split(/\s+/)[0] || '');
var sk = self._slugSkill(firstTok);
if(sk){
var rest = text.slice(String(firstTok).length).trim();
text = sk.template + (rest ? '\n\n' + rest : '');
}
}
// barre oblique seule (après Escape du menu /) → on ne l'envoie pas
if(/^\/+$/.test(text)){ self.clearComposer(); return; }
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(ment.length){ text = 'Analyse le contexte référencé.'; }
else if(!hasSkills){ return; }
}
try { await self.ensureConversation(); } catch(e){ return; }
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.errorMsg = '';
self.mentionOpen = false;
// Une demande d'écriture avec document ouvert et SANS skill/contexte épinglé →
// génération headless avec Appliquer/Rejeter. Dès qu'on épingle un skill ou un
// contexte, on passe par l'agent (outils + skills + mentions).
if(rawText && self.activeContext && self.activeContext.kind === 'page'
&& self._isContentRequest(rawText) && !self.contextChips.length){
self.refreshContext();
return self.runDocAction({prompt: rawText, label: rawText, mode: self._docModeFor(rawText)});
}
self.streaming = true;
self.liveReasoning = '…';
self.messages.push({id:'u-'+Date.now(), role:'user', content: displayText});
self.scrollBottom();
var payload = {message: composed};
if(self.llmProvider) payload.provider = self.llmProvider;
if(self.llmModel) payload.model = self.llmModel;
var ctxText = self.contextPayload();
if(ctxText) payload.context = ctxText;
if(ment && ment.length) payload.mentions = ment;
if(skillIds.length) payload.skill_ids = skillIds;
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
var reader = resp.body.getReader();
var dec = new TextDecoder();
var respBuf = '';
function read(){
return reader.read().then(function(res){
if(res.done){ return; }
respBuf += dec.decode(res.value);
var parts = respBuf.split('\n\n');
respBuf = parts.pop();
parts.forEach(function(p){
var line = p.replace(/^data:\s*/, '').trim();
if(!line) return;
try{
var ev = JSON.parse(line);
if(ev.type==='reasoning'){ self.liveReasoning = ev.content; }
else if(ev.type==='action'){
var msg = ev.status==='success'
? (ev.tool+' ✓ '+(ev.message||''))
: (ev.tool+' ✗ '+(ev.detail||'erreur'));
self.messages.push({id:'a-'+Date.now()+Math.random(), role:'assistant', content: msg, act:true});
}
else if(ev.type==='final'){
self.messages.push({id:'f-'+Date.now(), role:'assistant', content: ev.content});
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} }
}
else if(ev.type==='error'){ self.errorMsg = ev.message; }
}catch(e){}
});
self.scrollBottom();
return read();
});
}
return read();
}).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; })
.finally(function(){
self.streaming = false; self.liveReasoning='';
self.fetchConversationsRefresh();
});
},
fetchConversationsRefresh(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){ self.conversations = d.conversations || []; }).catch(function(){});
},
scrollBottom(){
this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; });
}
};
}
document.addEventListener('alpine:init', function(){
Alpine.data('agentPanel', agentPanel);
});
window.agentPanel = agentPanel;
// ═══════════ Context reader — document (page editor) ═══════════
window.fdReadPageContext = function(){
try{
var tEl = document.getElementById('_titleEl');
if(!tEl) return '';
var title = (tEl.textContent || '').trim();
var ct = document.getElementById('_blocksCt');
var text = '';
if(ct){
var kids = ct.children || [];
for(var i = 0; i < kids.length; i++){
var el = kids[i];
if(!el || !el.textContent) continue;
var t = el.textContent.replace(/\s+/g, ' ').trim();
if(t) text += t + '\n';
}
text = text.trim();
}
if(!title && !text) return '';
var out = title ? ('Titre du document : ' + title) : 'Document ouvert';
if(text) out += '\n\n' + text;
return out.slice(0, 9000);
}catch(e){ return ''; }
};
if(!window.fdGetPageContext) window.fdGetPageContext = window.fdReadPageContext;
// ═══════════ FlowDeckCtx — universal context detection ═══════════
// Priority: (1) an explicit marker set by a screen (window.fdViewContext /
// data-fd-context), (2) the currently open document (page editor),
// (3) the route the user is on. Every screen of the app yields a context.
var FlowDeckCtx = (function(){
function idFromPath(prefix){
try{
var m = (window.location.pathname || '').match(new RegExp('^\\/?' + prefix + '\\/(\\d+)'));
return m ? m[1] : null;
}catch(e){ return null; }
}
function labelFromTitle(){
try{
var t = document.title || '';
t = t.replace(/^\s*FlowDeck\s*-\s*/i, '');
t = t.replace(/\s*[-|]\s*FlowDeck\s*$/i, '');
if(t && t.toLowerCase() !== 'home' && t.toLowerCase() !== 'flowdeck') return t;
}catch(e){}
return '';
}
function docContext(){
try{
if(window.fdGetPageContext){
var raw = String(window.fdGetPageContext() || '').trim();
if(!raw) return null;
var firstLine = raw.split('\n')[0] || 'Document';
var title = firstLine.replace(/^Titre du document\s*:\s*/i, '').trim() || 'Document en cours';
var pid = idFromPath('pages');
return {
kind:'page', icon:'📄', label:'Document « ' + title + ' »', text: raw, key:'page',
title: title,
mentionToken: pid ? ('document:' + pid) : null,
refId: pid ? Number(pid) : null,
refType: pid ? 'document' : null
};
}
}catch(e){}
return null;
}
function marker(){
try{
var m = window.fdViewContext;
if(m && (m.label || m.detail)){
var label = m.label || m.detail;
return {kind: m.kind || 'section', icon: m.icon || '📍', label: String(label), text: m.text || '', key: 'marker'};
}
var b = document.body;
if(b && b.getAttribute && b.getAttribute('data-fd-context')){
var lbl = b.getAttribute('data-fd-context');
return {kind:'section', icon:'📍', label:lbl, text:'', key:'marker'};
}
}catch(e){}
return null;
}
function byPath(){
var p = window.location.pathname || '/';
var icon = '📍';
var label = '';
var kind = 'section';
var t;
if(p === '/' || p === '/dashboard'){ icon='🏠'; label='Accueil'; kind='home'; }
else if(/^\/pages\/\d+/.test(p)){
icon='📄'; kind='page'; label=labelFromTitle() || 'Page en cours';
var pid = idFromPath('pages');
t = pid ? ('document:' + pid) : null;
}
else if(/^\/library/.test(p)){
icon='📚'; kind='library';
var tab = (window.location.search.match(/tab=([^&]+)/)||[])[1] || '';
label = tab ? ('Bibliothèque — ' + tab.charAt(0).toUpperCase() + tab.slice(1)) : 'Bibliothèque';
}
else if(/^\/trash/.test(p)){ icon='🗑️'; label='Corbeille'; kind='trash'; }
else if(/^\/my-tasks/.test(p)){ icon='✅'; label='My Tasks'; kind='tasks'; }
else if(/^\/workspaces/.test(p)){ icon='🗂️'; label='Espaces de travail'; kind='workspaces'; }
else if(/^\/local-workspace/.test(p)){ icon='🗂️'; label='Espace local'; kind='workspace'; }
else if(/^\/workspace($|\?)/.test(p)){ icon='🗂️'; label='Espace de travail'; kind='workspace'; }
else if(/^\/gitea-workspace/.test(p)){ icon='🔗'; label='Workspace Gitea'; kind='workspace'; }
else if(/^\/db\//.test(p)){
icon='🗄️'; kind='db';
label = labelFromTitle() || 'Base de données';
var cid = idFromPath('db');
t = cid ? ('collection:' + cid) : null;
}
else if(/^\/board\//.test(p)){
icon='📋'; kind='board';
if(/^\/board\/library/.test(p)){ icon='📚'; label='Bibliothèque'; kind='library'; }
else if(/^\/board\/trash/.test(p)){ icon='🗑️'; label='Corbeille'; kind='trash'; }
else { label='Board'; t = labelFromTitle(); if(t) label = t; }
}
else if(/^\/notes\//.test(p)){ icon='📝'; label='Notes'; kind='notes'; }
else if(/^\/settings/.test(p) || /^\/accounts\/settings/.test(p)){ icon='⚙️'; label='Paramètres'; kind='settings'; }
else if(/^\/accounts/.test(p)){ icon='👤'; label='Compte'; kind='account'; }
else if(/^\/help/.test(p)){ icon='❓'; label='Aide'; kind='help'; }
else {
label = labelFromTitle() || 'FlowDeck';
kind='section';
}
var mentionToken = (kind === 'page' && pid) ? ('document:' + pid) : ((kind === 'db' && cid) ? ('collection:' + cid) : null);
return {kind: kind, icon: icon, label: label, text: '', key: p,
title: label, mentionToken: mentionToken,
refId: mentionToken ? Number(String(mentionToken).split(':')[1]) : null,
refType: mentionToken ? String(mentionToken).split(':')[0] : null};
}
return {
current: function(){
var m = marker();
if(m) return m;
var d = docContext();
if(d) return d;
return byPath();
}
};
})();
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)
window.fdPageContextProvider = function(){
var c = FlowDeckCtx.current();
return c && c.kind === 'page' ? c.text || '' : '';
};
})();
</script>