diff --git a/README.md b/README.md index f145bc6..21644bd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Faire tourner llama.cpp comme un vrai service, ça veut dire d'habitude : trouve - **ROCm/HIP** (AMD), **Metal** (macOS / Apple Silicon), **Vulkan**, ou repli **CPU** - `update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre - **Intégration systemd** — `jean install` écrit l'unité, une règle sudoers `systemctl` sans mot de passe, et les dossiers de données -- **Interface web** (`jean web`) — chat, changement de modèle/preset, activation des skills & tools +- **Interface web** (`jean web`) — chat, changement de modèle/preset, activation du mode agent - **Chat terminal** (`jean chat`) — réponses en streaming - **Accès distant chiffré** (`jean link`) — connecte ce serveur à [ajean.link](https://ajean.link) par une connexion **sortante** (aucun port à ouvrir, marche même en CGNAT). Le chat est **chiffré de bout en bout** : le relais ne voit jamais tes conversations (voir [Sécurité — boîte noire](#sécurité--boîte-noire)) - **Presets** (`jean switch`) — garde plusieurs profils `config.env` et bascule entre eux @@ -119,9 +119,8 @@ Accès distant (ajean.link) : link code génère un code d'appairage (valable 10 min, usage unique) pour le portail link status | logout état du lien / oublier le token -Outils côté LLM : - skills [on|off|list] laisse le modèle lire SKILLS//SKILL.md - machine [on|off|status] active l'accès machine (le modèle dispose d'un shell complet sur le serveur) +Mode agent : + agent [on|off|status] active TOUS les outils du modèle (shell complet + skills) — un seul interrupteur Backend (llama.cpp) : llamacpp install clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), règle BIN diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..2a845cb --- /dev/null +++ b/agent.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "os" +) + +// Le « mode agent » est l'unique interrupteur qui donne à l'IA l'accès à ses +// outils. Un skill est un outil comme un autre : quand le mode agent est actif, +// l'IA dispose à la fois du shell (run_shell) et de la gestion des skills. +// Plus de drapeaux machine/skills séparés — un seul fichier .agent_enabled. + +func agentEnabled() bool { + if _, err := os.Stat(agentFlag()); err == nil { + return true + } + // Migration : si l'un des anciens drapeaux séparés traîne encore, on + // considère le mode agent comme actif (et on le matérialise au prochain set). + if _, err := os.Stat(legacyToolsFlag()); err == nil { + return true + } + if _, err := os.Stat(legacySkillsFlag()); err == nil { + return true + } + return false +} + +func setAgentEnabled(on bool) error { + _ = os.MkdirAll(JeanHome(), 0o755) + // On nettoie systématiquement les anciens drapeaux pour ne pas garder un + // état fantôme « à moitié activé » hérité de l'ancien modèle à deux toggles. + _ = os.Remove(legacyToolsFlag()) + _ = os.Remove(legacySkillsFlag()) + if on { + f, err := os.Create(agentFlag()) + if err != nil { + return err + } + return f.Close() + } + if err := os.Remove(agentFlag()); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func cmdAgent(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "on": + if err := setAgentEnabled(true); err != nil { + return err + } + fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet et de ses skills") + case "off": + if err := setAgentEnabled(false); err != nil { + return err + } + fmt.Println(green("[ok]") + " mode agent désactivé") + case "", "status", "list": + state := dim("off") + if agentEnabled() { + state = green("on") + } + fmt.Printf("%s état: %s\n", cyan("Mode agent"), state) + fmt.Printf(" outils : run_shell (timeout %ds, max %ds) + skill (read/write/append/delete)\n", toolDefaultTimeout, toolMaxTimeout) + sk := ListSkills() + if len(sk) == 0 { + fmt.Printf(" skills : aucun — crée %s//SKILL.md\n", skillsDir()) + return nil + } + fmt.Printf(" skills (%s) :\n", skillsDir()) + for _, s := range sk { + fmt.Printf(" %s %s\n", bold(s.Name), s.Desc) + } + default: + return fmt.Errorf("usage: jean agent [on|off|status]") + } + return nil +} diff --git a/llm.go b/llm.go index 25165a8..aa17833 100644 --- a/llm.go +++ b/llm.go @@ -87,14 +87,15 @@ func runShellTool() Tool { // what the model can do for this conversation, instead of always inheriting the // machine's global config. Use globalCaps() to fall back to the global config. type Caps struct { - Tools bool - Skills bool + // Agent = mode agent actif : un seul interrupteur qui débloque TOUS les + // outils de l'IA (shell + skills). Un skill est un outil comme un autre. + Agent bool } // globalCaps reads the machine-wide config — the default when a request doesn't // specify its own capabilities. func globalCaps() Caps { - return Caps{Tools: toolsEnabled(), Skills: skillsEnabled()} + return Caps{Agent: agentEnabled()} } // InjectSkills prepends context system messages to msgs: the decisive-agent @@ -130,11 +131,8 @@ func InjectSkills(msgs []Message, caps Caps) []Message { // EnabledTools returns the tools to advertise on the next inference call. func EnabledTools(caps Caps) []Tool { tools := []Tool{} - if caps.Skills { - tools = append(tools, skillTool()) - } - if caps.Tools { - tools = append(tools, runShellTool()) + if caps.Agent { + tools = append(tools, skillTool(), runShellTool()) } return tools } diff --git a/main.go b/main.go index c5881d8..5c1bee2 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ import ( "strings" ) -const Version = "0.2.9" +const Version = "0.2.10" func main() { args := os.Args[1:] @@ -47,10 +47,11 @@ func main() { mustExit(cmdWeb(args)) case "link": mustExit(cmdLink(args)) - case "skills": - mustExit(cmdSkills(args)) - case "machine", "tools": - mustExit(cmdTools(args)) + case "agent", "skills", "machine", "tools": + // « mode agent » unifie l'ancien couple machine + skills : un seul + // interrupteur active TOUS les outils de l'IA (shell + skills). Les + // anciens noms restent acceptés comme alias rétro-compatibles. + mustExit(cmdAgent(args)) case "serve": mustExit(cmdServe(args)) case "test": @@ -96,7 +97,7 @@ Presets: Interaction: chat [system-prompt] chat terminal streamé - web [PORT] UI web (défaut :8090) — chat + presets + skills + tools + web [PORT] UI web (défaut :8090) — chat + presets + mode agent Accès distant (ajean.link) : link enregistre le token et démarre le lien au relais (token = 1re fois / pour le changer) @@ -106,9 +107,8 @@ Accès distant (ajean.link) : link (sans argument) affiche l'aide des sous-commandes link link serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'jean serve') -LLM-side outils: - skills [on|off|list] active la lecture de SKILLS//SKILL.md par l'IA - machine [on|off|status] active l'accès machine (l'IA dispose d'un shell complet sur le serveur) +Mode agent: + agent [on|off|status] active TOUS les outils de l'IA (shell complet + skills) — un seul interrupteur Backend llama.cpp : llamacpp install clone + compile llama.cpp (détecte CUDA/ROCm/Metal/CPU), pointe BIN dessus @@ -176,8 +176,10 @@ func readEtcDefault() string { func confPath() string { return filepath.Join(JeanHome(), "config.env") } func presetsDir() string { return filepath.Join(JeanHome(), "configs") } func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") } -func skillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } -func toolsFlag() string { return filepath.Join(JeanHome(), ".tools_enabled") } +func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") } +// Anciens drapeaux séparés, conservés pour la migration vers le mode agent unifié. +func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") } +func legacyToolsFlag() string { return filepath.Join(JeanHome(), ".tools_enabled") } func apiKeyPath() string { return filepath.Join(JeanHome(), ".api_key") } func serviceName() string { if n := os.Getenv("JEAN_SERVICE"); n != "" { diff --git a/resource_windows_amd64.syso b/resource_windows_amd64.syso index 0aa9c33..33562c8 100644 Binary files a/resource_windows_amd64.syso and b/resource_windows_amd64.syso differ diff --git a/resource_windows_arm64.syso b/resource_windows_arm64.syso index 1aff982..9da3dc1 100644 Binary files a/resource_windows_arm64.syso and b/resource_windows_arm64.syso differ diff --git a/skills.go b/skills.go index ba0a0dd..b74d929 100644 --- a/skills.go +++ b/skills.go @@ -15,26 +15,6 @@ type Skill struct { Content string } -func skillsEnabled() bool { - _, err := os.Stat(skillsFlag()) - return err == nil -} - -func setSkillsEnabled(on bool) error { - _ = os.MkdirAll(skillsDir(), 0o755) - if on { - f, err := os.Create(skillsFlag()) - if err != nil { - return err - } - return f.Close() - } - if err := os.Remove(skillsFlag()); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - // ListSkills walks SKILLS//SKILL.md, returning name+desc+full content. func ListSkills() []Skill { entries, err := os.ReadDir(skillsDir()) @@ -157,7 +137,7 @@ func DeleteSkill(name string) error { // skillsSystemPrompt returns the lightweight skills directory message to // prepend to the conversation when skills are enabled. func skillsSystemPrompt(caps Caps) string { - if !caps.Skills { + if !caps.Agent { return "" } list := ListSkills() @@ -181,39 +161,3 @@ func skillsSystemPrompt(caps Caps) string { } return strings.TrimRight(b.String(), "\n") } - -func cmdSkills(args []string) error { - sub := "" - if len(args) > 0 { - sub = args[0] - } - switch sub { - case "on": - if err := setSkillsEnabled(true); err != nil { - return err - } - fmt.Println(green("[ok]") + " skills activés") - case "off": - if err := setSkillsEnabled(false); err != nil { - return err - } - fmt.Println(green("[ok]") + " skills désactivés") - case "", "list": - state := dim("off") - if skillsEnabled() { - state = green("on") - } - fmt.Printf("%s (%s) état: %s\n", cyan("Skills"), skillsDir(), state) - sk := ListSkills() - if len(sk) == 0 { - fmt.Printf(" (aucun — crée %s//SKILL.md)\n", skillsDir()) - return nil - } - for _, s := range sk { - fmt.Printf(" %s %s\n", bold(s.Name), s.Desc) - } - default: - return fmt.Errorf("usage: jean skills [on|off|list]") - } - return nil -} diff --git a/tools.go b/tools.go index f25d86a..b5623d3 100644 --- a/tools.go +++ b/tools.go @@ -17,26 +17,6 @@ const ( toolMaxOutput = 8000 // characters of stdout/stderr returned to the model ) -func toolsEnabled() bool { - _, err := os.Stat(toolsFlag()) - return err == nil -} - -func setToolsEnabled(on bool) error { - _ = os.MkdirAll(JeanHome(), 0o755) - if on { - f, err := os.Create(toolsFlag()) - if err != nil { - return err - } - return f.Close() - } - if err := os.Remove(toolsFlag()); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - // baseSystemPrompt is the always-on system preamble. Structured like pi's // proven prompt (identity → method → guidelines → concision → date): a concrete, // procedural prompt gives the model rails so it stops deliberating forever @@ -47,7 +27,7 @@ func baseSystemPrompt(caps Caps) string { // No tool access → no agentic preamble. A plain chat model told to "call // tools immediately" hallucinates textual tool calls (e.g. default_api:bash) // that leak into the answer. Let the user's own system prompt stand alone. - if !caps.Tools && !caps.Skills { + if !caps.Agent { return "" } var b strings.Builder @@ -72,7 +52,7 @@ func baseSystemPrompt(caps Caps) string { // run_shell acts upon (and doesn't claim it has no access to "your PC"). // Returns "" when machine access is off. func machineSystemPrompt(caps Caps) string { - if !caps.Tools { + if !caps.Agent { return "" } host, _ := os.Hostname() @@ -156,32 +136,3 @@ func tailRunes(s string, n int) string { } return string(r[len(r)-n:]) } - -func cmdTools(args []string) error { - sub := "" - if len(args) > 0 { - sub = args[0] - } - switch sub { - case "on": - if err := setToolsEnabled(true); err != nil { - return err - } - fmt.Println(green("[ok]") + " accès machine activé — l'IA dispose d'un shell complet sur le serveur") - case "off": - if err := setToolsEnabled(false); err != nil { - return err - } - fmt.Println(green("[ok]") + " accès machine désactivé") - case "", "status": - state := dim("off") - if toolsEnabled() { - state = green("on") - } - fmt.Printf("%s état: %s\n", cyan("Accès machine"), state) - fmt.Printf(" timeout défaut: %ds, max: %ds\n", toolDefaultTimeout, toolMaxTimeout) - default: - return fmt.Errorf("usage: jean machine [on|off|status]") - } - return nil -} diff --git a/ui/index.html b/ui/index.html index bcfd43c..3f7f110 100644 --- a/ui/index.html +++ b/ui/index.html @@ -30,10 +30,34 @@ body{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:9px} .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px} } -h1{margin:0 0 4px;font-size:18px;color:var(--accent)} +h1{margin:0;font-size:18px;color:var(--accent)} +#brand{display:inline-flex;align-items:baseline} +.sidehead{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:18px} +/* pastille d'état service, en haut à droite de la barre latérale */ +.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap} +.statuspill.ok{color:var(--ok);border-color:rgba(63,185,80,.45);background:rgba(63,185,80,.10)} +.statuspill.err{color:var(--err);border-color:rgba(248,81,73,.45);background:rgba(248,81,73,.10)} +.statuspill .dot{width:7px;height:7px;border-radius:50%;background:currentColor} +.statuspill .port{opacity:.6;font-weight:400} +/* en-tête + bouton d'ajout de skill (toujours visible) */ +.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px} +.skillshead>span{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600} +.addbtn{font-size:11px;padding:3px 11px 4px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;line-height:1.2} +.addbtn:hover{border-color:var(--accent);color:var(--accent)} +#brand-a{display:inline-block;line-height:1;max-width:0;opacity:0;overflow:hidden;color:#fff;transition:max-width .35s ease,opacity .35s ease} +#brand.agent #brand-a{max-width:1ch;opacity:1;margin-right:.04em} +/* interrupteur (switch) */ +.switchrow{display:flex;align-items:center;gap:9px;font-size:12px;padding:6px 0;cursor:pointer;user-select:none} +.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none} +.switch input{position:absolute;opacity:0;width:0;height:0} +.switch .slider{position:absolute;inset:0;background:var(--border);border-radius:999px;transition:background .25s ease} +.switch .slider::before{content:"";position:absolute;width:14px;height:14px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .25s ease;box-shadow:0 1px 2px rgba(0,0,0,.4)} +.switch input:checked + .slider{background:var(--accent)} +.switch input:checked + .slider::before{transform:translateX(16px)} +.switch input:focus-visible + .slider{box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)} h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600} -details{margin:8px 0;border-top:1px solid var(--border);padding-top:6px} -details>summary{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600;cursor:pointer;padding:4px 0;list-style:none;display:flex;justify-content:space-between;align-items:center;user-select:none} +details{margin:7px 0;border-top:1px solid var(--border);padding-top:4px} +details>summary{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600;cursor:pointer;padding:3px 0;list-style:none;display:flex;justify-content:space-between;align-items:center;user-select:none} details>summary::-webkit-details-marker{display:none} details>summary::after{content:"▸";transition:transform .15s;color:var(--dim);font-size:10px} details[open]>summary::after{transform:rotate(90deg)} @@ -72,6 +96,7 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.assistant,.msg.reasoning{white-space:normal} .msg .body{overflow-wrap:anywhere;word-break:break-word} .msg.user{align-self:flex-end;background:#1f6feb;color:#fff} +.msg.user .label{color:rgba(255,255,255,.8)} .msg.assistant{align-self:flex-start;background:var(--panel);border:1px solid var(--border)} .msg .body>*{margin:1em 0} .msg .body>*:first-child{margin-top:0} @@ -145,8 +170,10 @@ button:disabled{opacity:.5;cursor:not-allowed}
-

jean

-
…
+
+

ajean

+ … +
GPU
…
@@ -156,12 +183,10 @@ button:disabled{opacity:.5;cursor:not-allowed}
Presets
-
Accès machine - -
⚠️ donne à l'IA un accès shell complet au serveur — elle peut inspecter, lancer des scripts et tout faire via le terminal (timeout 30s, max 300s)
-
-
Skills - +
Mode agent + +
⚠️ active l'accès machine (shell complet sur le serveur) et les skills — l'IA peut inspecter, lancer des scripts et exécuter ses skills (timeout 30s, max 300s)
+
Skills
System prompt @@ -188,8 +213,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
- contexte 0 / 0 - + + + contexte 0 / 0 + +
@@ -298,8 +326,9 @@ async function jget(u){ const r=await jfetch(u); return r.json(); } async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); } async function loadStatus(){ const s=await jget('/api/status'); - const t=s.active?'● active':'○ '+s.state+''; - document.getElementById('status-svc').innerHTML = t + ' :'+s.port+''; + const el=document.getElementById('status-svc'); + el.className='statuspill '+(s.active?'ok':'err'); + el.innerHTML=''+(s.active?'active':s.state)+' :'+s.port+''; if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); } } // Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode @@ -338,12 +367,8 @@ async function loadPresets(){ // Build via DOM (not string concat) so preset names can contain anything — // spaces, accents, quotes, < > & — without breaking markup or handlers. const sp = document.getElementById('status-preset'); - sp.innerHTML=''; - if(act){ - const tag=document.createElement('span'); - tag.className='tag'; tag.style.borderColor='var(--accent)'; tag.style.color='var(--accent)'; - tag.textContent=act.name; sp.appendChild(tag); - } + sp.textContent = act ? act.name : ''; + sp.title = act ? 'preset actif' : ''; const cont=document.getElementById('presets'); cont.innerHTML=''; if(!p.length){ cont.innerHTML='(aucun)'; return; } @@ -383,31 +408,28 @@ async function loadPresets(){ cont.appendChild(row); }); } -async function loadSkills(){ - const s=await jget('/api/skills'); - document.getElementById('skills-toggle').checked = s.enabled; - document.getElementById('skills-badge').innerHTML = s.enabled +// « Mode agent » = accès machine + skills réunis en un seul interrupteur. +// Quand il est actif, un « a » blanc apparaît en fondu devant « jean » → « ajean ». +async function loadAgent(){ + const s=await jget('/api/agent'); + const on = s.enabled; + document.getElementById('agent-toggle').checked = on; + document.getElementById('agent-badge').innerHTML = on ? 'on' : 'off'; + document.getElementById('brand').classList.toggle('agent', on); document.getElementById('skills-list').innerHTML = (s.skills||[]).length ? s.skills.map(x=>'
'+x.name+' — '+x.desc+'
').join('') : '
(aucun skill)
'; } -async function toggleSkills(){ - const on=document.getElementById('skills-toggle').checked; - await jpost('/api/skills/toggle',{on}); loadSkills(); +// alias : plusieurs appelants rafraîchissent juste la liste des skills +const loadSkills = loadAgent; +async function toggleAgent(){ + const on=document.getElementById('agent-toggle').checked; + if(on && !confirm("Activer le mode agent ? L'IA aura un accès shell complet au serveur et pourra exécuter ses skills.")){ document.getElementById('agent-toggle').checked=false; return; } + await jpost('/api/agent/toggle',{on}); + loadAgent(); } -async function loadTools(){ - const t=await jget('/api/tools'); - document.getElementById('tools-toggle').checked = t.enabled; - document.getElementById('tools-badge').innerHTML = t.enabled - ? 'on' : 'off'; -} -async function toggleTools(){ - const on=document.getElementById('tools-toggle').checked; - if(on && !confirm("Activer l'accès machine ? L'IA aura un accès shell complet au serveur.")){ document.getElementById('tools-toggle').checked=false; return; } - await jpost('/api/tools/toggle',{on}); loadTools(); -} -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadSkills(),loadTools()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; } function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; } diff --git a/versioninfo.json b/versioninfo.json index ea102e4..af8b4f9 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 2, - "Patch": 9, + "Patch": 10, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 2, - "Patch": 9, + "Patch": 10, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.2.9", + "ProductVersion": "0.2.10", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/web.go b/web.go index 25060ba..26528c4 100644 --- a/web.go +++ b/web.go @@ -83,13 +83,18 @@ func newWebMux() *http.ServeMux { api("/api/preset", handlePreset) api("/api/preset/save", handlePresetSave) api("/api/preset/delete", handlePresetDelete) - api("/api/skills", handleSkills) - api("/api/skills/toggle", handleSkillsToggle) + api("/api/agent", handleAgent) + api("/api/agent/toggle", handleAgentToggle) + // Alias rétro-compat : l'ancien portail ajean.link (dépôt jean-relay) pilote + // encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E. + // On les mappe sur le mode agent unifié le temps que le portail soit mis à jour. + api("/api/tools", handleAgent) + api("/api/tools/toggle", handleAgentToggle) + api("/api/skills", handleAgent) + api("/api/skills/toggle", handleAgentToggle) api("/api/skill", handleSkill) api("/api/skill/save", handleSkillSave) api("/api/skill/delete", handleSkillDelete) - api("/api/tools", handleTools) - api("/api/tools/toggle", handleToolsToggle) api("/api/switch", handleSwitch) api("/api/start", svcHandler("start")) api("/api/stop", svcHandler("stop")) @@ -419,25 +424,27 @@ func handlePresetDelete(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr}) } -func handleSkills(w http.ResponseWriter, r *http.Request) { +// handleAgent renvoie l'état du mode agent ET la liste des skills (qui sont des +// outils gérés sous ce même interrupteur) — un seul aller-retour pour l'UI. +func handleAgent(w http.ResponseWriter, r *http.Request) { sk := ListSkills() out := []map[string]any{} for _, s := range sk { out = append(out, map[string]any{"name": s.Name, "desc": s.Desc}) } - sendJSON(w, 200, map[string]any{"enabled": skillsEnabled(), "skills": out}) + sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "skills": out}) } -func handleSkillsToggle(w http.ResponseWriter, r *http.Request) { +func handleAgentToggle(w http.ResponseWriter, r *http.Request) { var req struct { On bool `json:"on"` } _ = json.NewDecoder(r.Body).Decode(&req) - if err := setSkillsEnabled(req.On); err != nil { + if err := setAgentEnabled(req.On); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } - sendJSON(w, 200, map[string]any{"ok": true, "enabled": skillsEnabled()}) + sendJSON(w, 200, map[string]any{"ok": true, "enabled": agentEnabled()}) } func handleSkill(w http.ResponseWriter, r *http.Request) { @@ -480,22 +487,6 @@ func handleSkillDelete(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) } -func handleTools(w http.ResponseWriter, r *http.Request) { - sendJSON(w, 200, map[string]any{"enabled": toolsEnabled()}) -} - -func handleToolsToggle(w http.ResponseWriter, r *http.Request) { - var req struct { - On bool `json:"on"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - if err := setToolsEnabled(req.On); err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - sendJSON(w, 200, map[string]any{"ok": true, "enabled": toolsEnabled()}) -} - func handleSwitch(w http.ResponseWriter, r *http.Request) { var req struct { N int `json:"n"` @@ -576,9 +567,11 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) { type chatReq struct { Messages []Message `json:"messages"` Temperature float64 `json:"temperature"` - // Optional per-request capability overrides (used by ajean.link agents, - // which carry their own tools/skills toggles). nil = inherit the - // machine's global config. + // Optional per-request override of the agent mode (used by ajean.link + // agents, which carry their own toggle). nil = inherit the machine's + // global config. Tools/Skills sont conservés pour la rétro-compat des + // anciens clients relais : l'un OU l'autre à true active le mode agent. + Agent *bool `json:"agent"` Tools *bool `json:"tools"` Skills *bool `json:"skills"` } @@ -623,11 +616,11 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) body.Temperature = 0.7 } caps := globalCaps() - if body.Tools != nil { - caps.Tools = *body.Tools - } - if body.Skills != nil { - caps.Skills = *body.Skills + if body.Agent != nil { + caps.Agent = *body.Agent + } else if body.Tools != nil || body.Skills != nil { + // rétro-compat : anciens clients qui envoyaient deux drapeaux séparés + caps.Agent = (body.Tools != nil && *body.Tools) || (body.Skills != nil && *body.Skills) } msgs := InjectSkills(body.Messages, caps) extra, _ := runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool {