mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
v0.2.17 : accès internet (Crawl4AI) + 3 modes de mémoire
- accès web de l'IA via un serveur Crawl4AI configurable (outils
web_search / web_open / web_read / web_grep), réglage CRAWL4AI_URL +
toggle, actifs seulement si le mode agent et le serveur sont joignables.
CLI `jean internet [on|off|status|url <url>]`.
- mémoire réglable en 3 modes indépendants du mode agent : off / sur
demande / auto (proactive). CLI `jean memory [off|ondemand|always|status]`.
- fix : prompt et outils gouvernés par la même condition (plus de
web_search tapé en bash quand le crawl est injoignable) ; consigne
année de recherche durcie ; icône 🌐 des outils web dans l'UI.
This commit is contained in:
1 parent
fbfb3effd7
commit
c398cb85e6
11 files changed
+1179
-55
No files matched your search
@@ -93,6 +93,9 @@ func cmdChat(args []string) error {
|
||||
case "edit":
|
||||
icon = "✏️"
|
||||
verb = "édition"
|
||||
case "web_search", "web_open", "web_read", "web_grep":
|
||||
icon = "🌐"
|
||||
verb = "web"
|
||||
}
|
||||
if inReason {
|
||||
fmt.Print("\n")
|
||||
|
||||
+841
@@ -0,0 +1,841 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Accès internet de l'IA — port Go de l'extension pi ~/.pi/agent/extensions/web.ts.
|
||||
//
|
||||
// jean parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL
|
||||
// est configurée dans config.env (CRAWL4AI_URL). Quand le mode agent ET l'accès
|
||||
// internet sont actifs ET que le serveur répond, l'IA dispose de 4 outils :
|
||||
//
|
||||
// - web_search : recherche DuckDuckGo (via crawl), liste {title, url, snippet}.
|
||||
// - web_open : récupère une URL (cache 10 min), renvoie SEULEMENT les métadonnées
|
||||
// (nb de lignes, taille, plan des titres) — pas le contenu.
|
||||
// - web_read : lit une plage de lignes d'une URL déjà ouverte (offset + limit).
|
||||
// - web_grep : recherche regex dans une URL déjà ouverte, lignes + contexte.
|
||||
//
|
||||
// Workflow attendu : web_open(url) → web_read/web_grep. Même logique que pi.
|
||||
|
||||
// ─── configuration & état ───────────────────────────────────────────────────
|
||||
|
||||
// crawl4aiURL renvoie l'URL du serveur Crawl4AI (config.env CRAWL4AI_URL), sans
|
||||
// slash final. Vide si non configuré.
|
||||
func crawl4aiURL() string {
|
||||
u := strings.TrimSpace(ReadConfig()["CRAWL4AI_URL"])
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
|
||||
// internetEnabled : accès internet actif = drapeau .internet_enabled présent ET
|
||||
// une URL Crawl4AI configurée. Même modèle que agentEnabled() (agent.go).
|
||||
func internetEnabled() bool {
|
||||
if crawl4aiURL() == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(internetFlag())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func setInternetEnabled(on bool) error {
|
||||
_ = os.MkdirAll(JeanHome(), 0o755)
|
||||
if on {
|
||||
f, err := os.Create(internetFlag())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
if err := os.Remove(internetFlag()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crawlReachable teste que le serveur Crawl4AI répond, avec un cache court (~30 s)
|
||||
// pour ne pas ralentir chaque tour de chat. Un serveur configuré mais injoignable
|
||||
// => les outils web ne sont pas proposés (« actif ET fonctionnel »).
|
||||
var (
|
||||
reachMu sync.Mutex
|
||||
reachOK bool
|
||||
reachURL string
|
||||
reachWhen time.Time
|
||||
)
|
||||
|
||||
const reachTTL = 30 * time.Second
|
||||
|
||||
func crawlReachable() bool {
|
||||
base := crawl4aiURL()
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
reachMu.Lock()
|
||||
defer reachMu.Unlock()
|
||||
if base == reachURL && time.Since(reachWhen) < reachTTL {
|
||||
return reachOK
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
ok := false
|
||||
// Crawl4AI expose /health ; on tolère aussi une simple réponse HTTP sur la racine.
|
||||
for _, path := range []string{"/health", "/"} {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 500 {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
reachOK, reachURL, reachWhen = ok, base, time.Now()
|
||||
return ok
|
||||
}
|
||||
|
||||
// ─── invocation crwl ────────────────────────────────────────────────────────
|
||||
|
||||
// autoDismissJS : dismisser conservateur de bandeaux cookies/consentement, exécuté
|
||||
// dans la page. N'agit que sur des éléments qui RESSEMBLENT à une UI cookie
|
||||
// (position fixed/sticky ou id/class cookie/consent/gdpr). Port direct de web.ts.
|
||||
const autoDismissJS = `(() => { try {
|
||||
const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;
|
||||
const isOverlayish = (el) => {
|
||||
try {
|
||||
let cur = el;
|
||||
for (let i = 0; i < 6 && cur; i++) {
|
||||
const s = getComputedStyle(cur);
|
||||
if (s.position === 'fixed' || s.position === 'sticky') return true;
|
||||
const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();
|
||||
if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
};
|
||||
const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]');
|
||||
let clicked = 0;
|
||||
for (const b of candidates) {
|
||||
if (clicked >= 2) break;
|
||||
const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();
|
||||
if (!t || !TEXT.test(t)) continue;
|
||||
if (b.offsetParent === null) continue;
|
||||
if (!isOverlayish(b)) continue;
|
||||
try { b.click(); clicked++; } catch {}
|
||||
}
|
||||
document.querySelectorAll('[id*="cookie" i],[id*="consent" i],[class*="cookie" i],[class*="consent" i],[id*="gdpr" i],[class*="gdpr" i],[id*="cmp" i],[class*="cmp" i]')
|
||||
.forEach(el => { try {
|
||||
const s = getComputedStyle(el);
|
||||
if (s.position === 'fixed' || s.position === 'sticky') el.remove();
|
||||
} catch {} });
|
||||
document.documentElement.style.overflow = 'auto';
|
||||
if (document.body) document.body.style.overflow = 'auto';
|
||||
} catch {} })();`
|
||||
|
||||
type crwlOptions struct {
|
||||
jsCode []string
|
||||
waitFor string
|
||||
pageTimeoutMs int
|
||||
rawMarkdown bool // true => préfère raw_markdown (recherche) ; false => fit_markdown
|
||||
}
|
||||
|
||||
// runCrwl appelle POST {base}/crawl et renvoie le markdown extrait. Port de web.ts.
|
||||
func runCrwl(target string, opts crwlOptions) (string, error) {
|
||||
base := crawl4aiURL()
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("aucun serveur Crawl4AI configuré (CRAWL4AI_URL)")
|
||||
}
|
||||
params := map[string]any{}
|
||||
if len(opts.jsCode) > 0 {
|
||||
params["js_code"] = opts.jsCode
|
||||
}
|
||||
if opts.waitFor != "" {
|
||||
params["wait_for"] = opts.waitFor
|
||||
}
|
||||
if opts.pageTimeoutMs > 0 {
|
||||
params["page_timeout"] = opts.pageTimeoutMs
|
||||
}
|
||||
body := map[string]any{
|
||||
"urls": []string{target},
|
||||
"browser_config": map[string]any{
|
||||
"type": "BrowserConfig",
|
||||
"params": map[string]any{"headless": true},
|
||||
},
|
||||
"crawler_config": map[string]any{
|
||||
"type": "CrawlerRunConfig",
|
||||
"params": params,
|
||||
},
|
||||
}
|
||||
buf, _ := json.Marshal(body)
|
||||
|
||||
timeout := 60 * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/crawl", bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Crawl4AI injoignable (%s): %v", base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("Crawl4AI HTTP %d: %s", resp.StatusCode, tailRunes(string(b), 300))
|
||||
}
|
||||
var data struct {
|
||||
Results []crwlResult `json:"results"`
|
||||
}
|
||||
// La réponse peut être soit {results:[...]}, soit un objet unique. On décode
|
||||
// d'abord la forme {results}, sinon on retombe sur un résultat unique.
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if jerr := json.Unmarshal(raw, &data); jerr != nil || len(data.Results) == 0 {
|
||||
var single crwlResult
|
||||
if json.Unmarshal(raw, &single) == nil {
|
||||
data.Results = []crwlResult{single}
|
||||
}
|
||||
}
|
||||
if len(data.Results) == 0 {
|
||||
return "", fmt.Errorf("Crawl4AI : réponse vide")
|
||||
}
|
||||
r := data.Results[0]
|
||||
if !r.Success {
|
||||
msg := r.ErrorMessage
|
||||
if msg == "" {
|
||||
msg = "échec du crawl"
|
||||
}
|
||||
return "", fmt.Errorf("Crawl4AI : %s", tailRunes(msg, 300))
|
||||
}
|
||||
return r.markdown(opts.rawMarkdown), nil
|
||||
}
|
||||
|
||||
// crwlResult modélise un résultat Crawl4AI. markdown peut être une string ou un
|
||||
// objet {raw_markdown, fit_markdown} — on gère les deux via json.RawMessage.
|
||||
type crwlResult struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Markdown json.RawMessage `json:"markdown"`
|
||||
}
|
||||
|
||||
func (r crwlResult) markdown(preferRaw bool) string {
|
||||
if len(r.Markdown) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(r.Markdown, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var obj struct {
|
||||
Raw string `json:"raw_markdown"`
|
||||
Fit string `json:"fit_markdown"`
|
||||
}
|
||||
if json.Unmarshal(r.Markdown, &obj) == nil {
|
||||
if preferRaw {
|
||||
if obj.Raw != "" {
|
||||
return obj.Raw
|
||||
}
|
||||
return obj.Fit
|
||||
}
|
||||
if obj.Fit != "" {
|
||||
return obj.Fit
|
||||
}
|
||||
return obj.Raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── URL & normalisation ────────────────────────────────────────────────────
|
||||
|
||||
// normalizeCrawlURL : github.com/owner/repo → README brut. Port de web.ts.
|
||||
func normalizeCrawlURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if u.Host == "github.com" {
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) == 2 {
|
||||
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/README.md", parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// normalizeLines : \r\n → \n, trim trailing, réduit les runs de lignes vides et
|
||||
// les doublons consécutifs, retire les vides en tête/queue. Port de web.ts.
|
||||
func normalizeLines(raw string) []string {
|
||||
src := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
||||
out := []string{}
|
||||
prev := ""
|
||||
blankRun := 0
|
||||
trailSpace := regexp.MustCompile(`[ \t]+$`)
|
||||
for _, l := range src {
|
||||
l = trailSpace.ReplaceAllString(l, "")
|
||||
if strings.TrimSpace(l) == "" {
|
||||
blankRun++
|
||||
if blankRun > 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, "")
|
||||
prev = ""
|
||||
continue
|
||||
}
|
||||
blankRun = 0
|
||||
if l == prev {
|
||||
continue
|
||||
}
|
||||
out = append(out, l)
|
||||
prev = l
|
||||
}
|
||||
for len(out) > 0 && out[0] == "" {
|
||||
out = out[1:]
|
||||
}
|
||||
for len(out) > 0 && out[len(out)-1] == "" {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── cache mémoire (TTL 10 min) ─────────────────────────────────────────────
|
||||
|
||||
type cacheEntry struct {
|
||||
url string
|
||||
lines []string
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
pageCacheMu sync.Mutex
|
||||
pageCache = map[string]*cacheEntry{}
|
||||
)
|
||||
|
||||
const pageCacheTTL = 10 * time.Minute
|
||||
|
||||
type fetchOptions struct {
|
||||
force bool
|
||||
actions []string
|
||||
dismissPopups bool
|
||||
waitFor string
|
||||
}
|
||||
|
||||
func cacheKeyFor(u string, opts fetchOptions) string {
|
||||
fp, _ := json.Marshal(map[string]any{"a": opts.actions, "d": opts.dismissPopups, "w": opts.waitFor})
|
||||
if string(fp) == `{"a":null,"d":true,"w":""}` || string(fp) == `{"a":[],"d":true,"w":""}` {
|
||||
return u
|
||||
}
|
||||
h := md5.Sum(fp)
|
||||
return u + "#" + hex.EncodeToString(h[:])[:8]
|
||||
}
|
||||
|
||||
// findCached : entrée de cache la plus récente pour une URL (toutes options).
|
||||
func findCached(rawURL string) *cacheEntry {
|
||||
u := normalizeCrawlURL(rawURL)
|
||||
pageCacheMu.Lock()
|
||||
defer pageCacheMu.Unlock()
|
||||
var best *cacheEntry
|
||||
for k, v := range pageCache {
|
||||
if k == u || strings.HasPrefix(k, u+"#") {
|
||||
if time.Since(v.fetchedAt) > pageCacheTTL {
|
||||
continue
|
||||
}
|
||||
if best == nil || v.fetchedAt.After(best.fetchedAt) {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
||||
u := normalizeCrawlURL(rawURL)
|
||||
key := cacheKeyFor(u, opts)
|
||||
pageCacheMu.Lock()
|
||||
cached := pageCache[key]
|
||||
pageCacheMu.Unlock()
|
||||
if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
jsCode := []string{}
|
||||
if opts.dismissPopups {
|
||||
jsCode = append(jsCode, autoDismissJS)
|
||||
}
|
||||
jsCode = append(jsCode, opts.actions...)
|
||||
pageTimeout := 0
|
||||
if opts.waitFor != "" || len(opts.actions) > 0 {
|
||||
pageTimeout = 45000
|
||||
}
|
||||
md, err := runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(md) == "" {
|
||||
return nil, fmt.Errorf("page vide")
|
||||
}
|
||||
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
|
||||
pageCacheMu.Lock()
|
||||
pageCache[key] = entry
|
||||
pageCacheMu.Unlock()
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// ─── helpers de formatage ───────────────────────────────────────────────────
|
||||
|
||||
var headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*$`)
|
||||
|
||||
func extractOutline(lines []string) string {
|
||||
var out []string
|
||||
for i, l := range lines {
|
||||
if m := headingRe.FindStringSubmatch(l); m != nil {
|
||||
indent := strings.Repeat(" ", len(m[1])-1)
|
||||
out = append(out, fmt.Sprintf("%5d | %s%s", i+1, indent, m[2]))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "(aucun titre markdown trouvé)"
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func formatLines(lines []string, startLine int) string {
|
||||
var b strings.Builder
|
||||
for i, l := range lines {
|
||||
fmt.Fprintf(&b, "%5d | %s\n", startLine+i, l)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func formatBytes(n int) string {
|
||||
switch {
|
||||
case n < 1024:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
case n < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%.2f MB", float64(n)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── recherche DuckDuckGo ───────────────────────────────────────────────────
|
||||
|
||||
type searchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
var (
|
||||
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
wsRe = regexp.MustCompile(`\s+`)
|
||||
numEntityRe = regexp.MustCompile(`&#(\d+);`)
|
||||
ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`)
|
||||
ddgLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
|
||||
urlishRe = regexp.MustCompile(`^[\w.-]+\.[a-z]{2,}`)
|
||||
)
|
||||
|
||||
func decodeHTMLEntities(s string) string {
|
||||
s = strings.NewReplacer(
|
||||
"&", "&", "<", "<", ">", ">", """, `"`,
|
||||
"'", "'", "/", "/", " ", " ",
|
||||
).Replace(s)
|
||||
return numEntityRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
var n int
|
||||
fmt.Sscanf(m, "&#%d;", &n)
|
||||
if n > 0 {
|
||||
return string(rune(n))
|
||||
}
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
|
||||
func decodeUddg(raw string) string {
|
||||
s := raw
|
||||
if strings.HasPrefix(s, "//") {
|
||||
s = "https:" + s
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
if q := u.Query().Get("uddg"); q != "" {
|
||||
if dec, e := url.QueryUnescape(q); e == nil {
|
||||
return dec
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func duckduckgoSearch(query string, limit int) ([]searchResult, error) {
|
||||
searchURL := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
|
||||
md, err := runCrwl(searchURL, crwlOptions{rawMarkdown: true, pageTimeoutMs: 30000})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.Contains(md, "anomaly-modal") || strings.Contains(md, "anomaly.js") {
|
||||
return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot")
|
||||
}
|
||||
var results []searchResult
|
||||
lines := strings.Split(md, "\n")
|
||||
for i := 0; i < len(lines) && len(results) < limit; i++ {
|
||||
h := ddgHeadRe.FindStringSubmatch(lines[i])
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(h[1]), "**", ""))
|
||||
u := decodeUddg(h[2])
|
||||
if title == "" || u == "" || strings.Contains(u, "duckduckgo.com") {
|
||||
continue
|
||||
}
|
||||
snippet := ""
|
||||
for j := i + 1; j < i+6 && j < len(lines); j++ {
|
||||
ln := strings.TrimSpace(lines[j])
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
for _, lm := range ddgLinkRe.FindAllStringSubmatch(ln, -1) {
|
||||
text := strings.TrimSpace(lm[1])
|
||||
if text == "" || strings.HasPrefix(text, "!") || urlishRe.MatchString(text) {
|
||||
continue
|
||||
}
|
||||
snippet = strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(text), "**", ""))
|
||||
break
|
||||
}
|
||||
if snippet != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
dup := false
|
||||
for _, r := range results {
|
||||
if r.URL == u {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dup {
|
||||
continue
|
||||
}
|
||||
results = append(results, searchResult{Title: title, URL: u, Snippet: snippet})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ─── définitions d'outils (schémas OpenAI, comme llm.go) ────────────────────
|
||||
|
||||
func webSearchTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_search",
|
||||
Description: "Recherche sur le web via DuckDuckGo. Renvoie une liste classée de {title, url, snippet}. " +
|
||||
"À utiliser quand l'utilisateur pose une question sans URL, cherche un outil/une bibliothèque, " +
|
||||
"ou demande une information récente. À enchaîner avec web_open + web_read sur le meilleur résultat.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Requête (langage naturel ou mots-clés)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "Nb max de résultats (défaut 8, max 20)"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func webOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_open",
|
||||
Description: "Récupère une URL et renvoie SEULEMENT les métadonnées (taille, nb de lignes, plan des titres). " +
|
||||
"Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " +
|
||||
"— les web_read / web_grep suivants le réutilisent.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL complète à récupérer"},
|
||||
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."},
|
||||
"actions": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."},
|
||||
"dismiss_popups": map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."},
|
||||
"wait_for": map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func webReadTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_read",
|
||||
Description: "Lit une plage de lignes d'une URL déjà ouverte avec web_open. Coût en tokens prévisible. " +
|
||||
"Lignes 1-indexées, préfixées par leur numéro.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
|
||||
"offset": map[string]any{"type": "integer", "description": "Ligne de départ (1-indexée, défaut 1)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "Nb de lignes (défaut 80, max 500)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func webGrepTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "web_grep",
|
||||
Description: "Recherche regex dans une URL déjà ouverte avec web_open. Renvoie les lignes correspondantes " +
|
||||
"avec contexte et numéros. Idéal quand la page est longue et qu'on connaît un mot-clé.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
|
||||
"pattern": map[string]any{"type": "string", "description": "Motif regex (insensible à la casse)"},
|
||||
"context": map[string]any{"type": "integer", "description": "Lignes de contexte autour de chaque match. Défaut 2."},
|
||||
"max_matches": map[string]any{"type": "integer", "description": "Plafond de matches renvoyés. Défaut 30."},
|
||||
},
|
||||
"required": []string{"url", "pattern"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ─── exécution des outils (appelée par le dispatch de llm.go) ───────────────
|
||||
|
||||
func toolWebSearch(args map[string]any) string {
|
||||
query, _ := args["query"].(string)
|
||||
limit := 8
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
results, err := duckduckgoSearch(query, limit)
|
||||
if err != nil {
|
||||
return "❌ Recherche échouée : " + err.Error()
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("Aucun résultat pour « %s »", query)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Recherche : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
|
||||
for i, r := range results {
|
||||
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func toolWebOpen(args map[string]any) string {
|
||||
u, _ := args["url"].(string)
|
||||
opts := fetchOptions{dismissPopups: true}
|
||||
if v, ok := args["refresh"].(bool); ok {
|
||||
opts.force = v
|
||||
}
|
||||
if v, ok := args["dismiss_popups"].(bool); ok {
|
||||
opts.dismissPopups = v
|
||||
}
|
||||
if v, ok := args["wait_for"].(string); ok {
|
||||
opts.waitFor = v
|
||||
}
|
||||
if arr, ok := args["actions"].([]any); ok {
|
||||
for _, a := range arr {
|
||||
if s, ok := a.(string); ok {
|
||||
opts.actions = append(opts.actions, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry, err := getPage(u, opts)
|
||||
if err != nil {
|
||||
return "❌ " + err.Error()
|
||||
}
|
||||
total := len(entry.lines)
|
||||
chars := total
|
||||
for _, l := range entry.lines {
|
||||
chars += len(l)
|
||||
}
|
||||
return fmt.Sprintf("# Ouvert : %s\nTotal : %d lignes, %s (%d caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n%s\n```",
|
||||
entry.url, total, formatBytes(chars), chars, extractOutline(entry.lines))
|
||||
}
|
||||
|
||||
func toolWebRead(args map[string]any) string {
|
||||
u, _ := args["url"].(string)
|
||||
entry := findCached(u)
|
||||
if entry == nil {
|
||||
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
|
||||
}
|
||||
total := len(entry.lines)
|
||||
offset := 1
|
||||
if v, ok := args["offset"].(float64); ok {
|
||||
offset = int(v)
|
||||
}
|
||||
if offset < 1 {
|
||||
offset = 1
|
||||
}
|
||||
limit := 80
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
start := offset - 1
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
slice := entry.lines[start:end]
|
||||
remaining := total - end
|
||||
tail := " (fin de page)"
|
||||
if remaining > 0 {
|
||||
tail = fmt.Sprintf(" (%d de plus en dessous)", remaining)
|
||||
}
|
||||
return fmt.Sprintf("# %s\nLignes %d–%d sur %d%s\n\n```\n%s\n```",
|
||||
entry.url, offset, end, total, tail, formatLines(slice, offset))
|
||||
}
|
||||
|
||||
func toolWebGrep(args map[string]any) string {
|
||||
u, _ := args["url"].(string)
|
||||
pattern, _ := args["pattern"].(string)
|
||||
entry := findCached(u)
|
||||
if entry == nil {
|
||||
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
|
||||
}
|
||||
re, err := regexp.Compile("(?i)" + pattern)
|
||||
if err != nil {
|
||||
return "❌ Regex invalide : " + err.Error()
|
||||
}
|
||||
ctx := 2
|
||||
if v, ok := args["context"].(float64); ok {
|
||||
ctx = int(v)
|
||||
}
|
||||
if ctx < 0 {
|
||||
ctx = 0
|
||||
}
|
||||
maxMatches := 30
|
||||
if v, ok := args["max_matches"].(float64); ok {
|
||||
maxMatches = int(v)
|
||||
}
|
||||
if maxMatches < 1 {
|
||||
maxMatches = 1
|
||||
}
|
||||
lines := entry.lines
|
||||
var matchIdx []int
|
||||
for i := 0; i < len(lines) && len(matchIdx) < maxMatches; i++ {
|
||||
if re.MatchString(lines[i]) {
|
||||
matchIdx = append(matchIdx, i)
|
||||
}
|
||||
}
|
||||
if len(matchIdx) == 0 {
|
||||
return fmt.Sprintf("# %s\nAucun match pour /%s/i", entry.url, pattern)
|
||||
}
|
||||
// Fusionne les fenêtres de contexte qui se chevauchent.
|
||||
type rng struct{ s, e int }
|
||||
var ranges []rng
|
||||
for _, i := range matchIdx {
|
||||
s := i - ctx
|
||||
if s < 0 {
|
||||
s = 0
|
||||
}
|
||||
e := i + ctx
|
||||
if e > len(lines)-1 {
|
||||
e = len(lines) - 1
|
||||
}
|
||||
if n := len(ranges); n > 0 && s <= ranges[n-1].e+1 {
|
||||
if e > ranges[n-1].e {
|
||||
ranges[n-1].e = e
|
||||
}
|
||||
} else {
|
||||
ranges = append(ranges, rng{s, e})
|
||||
}
|
||||
}
|
||||
var blocks []string
|
||||
for _, r := range ranges {
|
||||
blocks = append(blocks, "```\n"+formatLines(lines[r.s:r.e+1], r.s+1)+"\n```")
|
||||
}
|
||||
capped := ""
|
||||
if len(matchIdx) == maxMatches {
|
||||
capped = fmt.Sprintf(" (plafonné à %d)", maxMatches)
|
||||
}
|
||||
return fmt.Sprintf("# %s\n%d match(es) pour /%s/i%s\n\n%s",
|
||||
entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
|
||||
}
|
||||
|
||||
// ─── CLI : jean internet [on|off|status|url <url>] ──────────────────────────
|
||||
|
||||
func cmdInternet(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if crawl4aiURL() == "" {
|
||||
return fmt.Errorf("configure d'abord l'URL : jean internet url <url>")
|
||||
}
|
||||
if err := setInternetEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)")
|
||||
case "off":
|
||||
if err := setInternetEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " accès internet désactivé")
|
||||
case "url":
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: jean internet url <url> (ex: http://localhost:11235)")
|
||||
}
|
||||
u := strings.TrimRight(strings.TrimSpace(args[1]), "/")
|
||||
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
|
||||
return err
|
||||
}
|
||||
reachMu.Lock()
|
||||
reachURL = "" // invalide le cache de reachability
|
||||
reachMu.Unlock()
|
||||
fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
|
||||
case "", "status", "list":
|
||||
state := dim("off")
|
||||
if internetEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||
u := crawl4aiURL()
|
||||
if u == "" {
|
||||
fmt.Printf(" serveur : %s — configure : jean internet url <url>\n", dim("(non configuré)"))
|
||||
return nil
|
||||
}
|
||||
reach := red("injoignable")
|
||||
if crawlReachable() {
|
||||
reach = green("joignable")
|
||||
}
|
||||
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
||||
default:
|
||||
return fmt.Errorf("usage: jean internet [on|off|status|url <url>]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -164,12 +164,22 @@ type Caps struct {
|
||||
// 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
|
||||
// Internet = accès web actif (serveur Crawl4AI configuré + joignable) : ajoute
|
||||
// les outils web_search/web_open/web_read/web_grep. Requiert aussi Agent.
|
||||
Internet bool
|
||||
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
|
||||
// indépendant du mode agent. Voir MemMode.
|
||||
Mem MemMode
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
// specify its own capabilities.
|
||||
func globalCaps() Caps {
|
||||
return Caps{Agent: agentEnabled()}
|
||||
// Internet inclut la joignabilité du serveur Crawl4AI : « actif ET fonctionnel ».
|
||||
// Ainsi le prompt système (tools.go) et les outils fournis (EnabledTools) sont
|
||||
// gouvernés par la MÊME condition — sinon le prompt promet web_search alors que
|
||||
// l'outil n'existe pas, et le modèle le tape en bash (command not found).
|
||||
return Caps{Agent: agentEnabled(), Internet: internetEnabled() && crawlReachable(), Mem: memMode()}
|
||||
}
|
||||
|
||||
// InjectSkills prepends context system messages to msgs: the decisive-agent
|
||||
@@ -203,7 +213,18 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), editTool(), memSearchTool(), memReadTool(), memAddTool(), memEditTool())
|
||||
tools = append(tools, bashTool(), editTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
if caps.Mem != MemOff {
|
||||
tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool())
|
||||
}
|
||||
// Outils web : seulement si le mode agent ET l'accès internet sont actifs.
|
||||
// caps.Internet intègre déjà la joignabilité (globalCaps / override web.go),
|
||||
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||
if caps.Agent && caps.Internet {
|
||||
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
@@ -307,12 +328,12 @@ type streamChunk struct {
|
||||
// llama.cpp's "timings" appears on the final chunk and on intermediate
|
||||
// /completion endpoint responses. Snake-case mapping per llama.cpp source.
|
||||
Timings *struct {
|
||||
PromptN int `json:"prompt_n"`
|
||||
PromptMs float64 `json:"prompt_ms"`
|
||||
PromptPerSecond float64 `json:"prompt_per_second"`
|
||||
PredictedN int `json:"predicted_n"`
|
||||
PredictedMs float64 `json:"predicted_ms"`
|
||||
PredictedPerSec float64 `json:"predicted_per_second"`
|
||||
PromptN int `json:"prompt_n"`
|
||||
PromptMs float64 `json:"prompt_ms"`
|
||||
PromptPerSecond float64 `json:"prompt_per_second"`
|
||||
PredictedN int `json:"predicted_n"`
|
||||
PredictedMs float64 `json:"predicted_ms"`
|
||||
PredictedPerSec float64 `json:"predicted_per_second"`
|
||||
} `json:"timings"`
|
||||
// Chunk final (include_usage) : taille totale du prompt, hors choices.
|
||||
Usage *struct {
|
||||
@@ -495,10 +516,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if cur := toolCalls[0]; cur != nil {
|
||||
key := "command"
|
||||
switch cur.Function.Name {
|
||||
case "mem_search":
|
||||
case "mem_search", "web_search":
|
||||
key = "query"
|
||||
case "mem_read", "mem_add", "mem_edit", "edit":
|
||||
key = "file"
|
||||
case "web_open", "web_read", "web_grep":
|
||||
key = "url"
|
||||
}
|
||||
if p := previewArg(cur.Function.Arguments, key); p != "" && p != lastPreview {
|
||||
lastPreview = p
|
||||
@@ -625,12 +648,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// nothing while a slow shell command runs and looks frozen.
|
||||
label := ""
|
||||
switch tc.Function.Name {
|
||||
case "mem_search":
|
||||
case "mem_search", "web_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit":
|
||||
label, _ = args["file"].(string)
|
||||
case "bash":
|
||||
label, _ = args["command"].(string)
|
||||
case "web_open", "web_read":
|
||||
label, _ = args["url"].(string)
|
||||
case "web_grep":
|
||||
u, _ := args["url"].(string)
|
||||
p, _ := args["pattern"].(string)
|
||||
label = p + " @ " + u
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
|
||||
|
||||
@@ -692,6 +721,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
to = v
|
||||
}
|
||||
result = runShell(label, to)
|
||||
case "web_search":
|
||||
result = toolWebSearch(args)
|
||||
case "web_open":
|
||||
result = toolWebOpen(args)
|
||||
case "web_read":
|
||||
result = toolWebRead(args)
|
||||
case "web_grep":
|
||||
result = toolWebGrep(args)
|
||||
default:
|
||||
result = "[erreur] outil inconnu: " + tc.Function.Name
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// description) dans le .exe pour réduire les faux positifs antivirus. Régénère
|
||||
// les .syso après avoir bumpé la version : `go generate ./...`
|
||||
// (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest)
|
||||
//
|
||||
//go:generate goversioninfo -64 -o resource_windows_amd64.syso versioninfo.json
|
||||
//go:generate goversioninfo -64 -arm -o resource_windows_arm64.syso versioninfo.json
|
||||
package main
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.2.16"
|
||||
const Version = "0.2.17"
|
||||
|
||||
func main() {
|
||||
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
|
||||
@@ -56,6 +57,10 @@ func main() {
|
||||
// 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 "internet", "web-access":
|
||||
mustExit(cmdInternet(args))
|
||||
case "memory", "mem":
|
||||
mustExit(cmdMemory(args))
|
||||
case "serve":
|
||||
mustExit(cmdServe(args))
|
||||
case "test":
|
||||
@@ -102,6 +107,8 @@ Presets:
|
||||
Interaction:
|
||||
chat [system-prompt] chat terminal streamé
|
||||
web [PORT] UI web (défaut :8090) — chat + presets + mode agent
|
||||
internet [on|off|status|url <url>] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep)
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto)
|
||||
|
||||
Accès distant (ajean.link) :
|
||||
link <token> enregistre le token et démarre le lien au relais (token = 1re fois / pour le changer)
|
||||
@@ -177,15 +184,17 @@ func readEtcDefault() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
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 memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") }
|
||||
func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") }
|
||||
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 memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") }
|
||||
func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") }
|
||||
func internetFlag() string { return filepath.Join(JeanHome(), ".internet_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 apiKeyPath() string { return filepath.Join(JeanHome(), ".api_key") }
|
||||
func serviceName() string {
|
||||
if n := os.Getenv("JEAN_SERVICE"); n != "" {
|
||||
return n
|
||||
@@ -202,12 +211,12 @@ func col(code, s string) string {
|
||||
}
|
||||
return "\033[" + code + "m" + s + "\033[0m"
|
||||
}
|
||||
func bold(s string) string { return col("1", s) }
|
||||
func cyan(s string) string { return col("1;36", s) }
|
||||
func green(s string) string { return col("32", s) }
|
||||
func red(s string) string { return col("31", s) }
|
||||
func dim(s string) string { return col("2", s) }
|
||||
func yellow(s string) string { return col("33", s) }
|
||||
func bold(s string) string { return col("1", s) }
|
||||
func cyan(s string) string { return col("1;36", s) }
|
||||
func green(s string) string { return col("32", s) }
|
||||
func red(s string) string { return col("31", s) }
|
||||
func dim(s string) string { return col("2", s) }
|
||||
func yellow(s string) string { return col("33", s) }
|
||||
func magenta(s string) string { return col("35", s) }
|
||||
|
||||
// trimSplit splits and drops empty tokens.
|
||||
|
||||
@@ -330,6 +330,76 @@ func migrateSkillsToMemory() {
|
||||
_ = os.WriteFile(flag, []byte(fmt.Sprintf("%d skills migrés\n", migrated)), 0o644)
|
||||
}
|
||||
|
||||
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
|
||||
// mode agent (shell). Trois modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
|
||||
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
|
||||
type MemMode string
|
||||
|
||||
const (
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
)
|
||||
|
||||
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
|
||||
// actuel). Toute valeur inconnue retombe sur always.
|
||||
func memMode() MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
|
||||
case "off", "0", "false", "none", "no", "non":
|
||||
return MemOff
|
||||
case "ondemand", "on-demand", "demand", "manual", "manuel":
|
||||
return MemOnDemand
|
||||
default: // "always", "auto", "" et inconnus
|
||||
return MemAlways
|
||||
}
|
||||
}
|
||||
|
||||
// setMemMode persiste le mode mémoire dans config.env.
|
||||
func setMemMode(m MemMode) error {
|
||||
return SetConfigKey("MEM_MODE", string(m))
|
||||
}
|
||||
|
||||
// cmdMemory : jean memory [off|ondemand|always|status]
|
||||
func cmdMemory(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
}
|
||||
label := map[MemMode]string{
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
|
||||
}
|
||||
switch sub {
|
||||
case "off", "none", "0", "false":
|
||||
if err := setMemMode(MemOff); err != nil {
|
||||
return err
|
||||
}
|
||||
case "ondemand", "on-demand", "demand", "manual", "manuel":
|
||||
if err := setMemMode(MemOnDemand); err != nil {
|
||||
return err
|
||||
}
|
||||
case "always", "auto", "on":
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
return err
|
||||
}
|
||||
case "", "status", "list":
|
||||
m := memMode()
|
||||
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
|
||||
pages := MemList()
|
||||
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("usage: jean memory [off|ondemand|always|status]")
|
||||
}
|
||||
m := memMode()
|
||||
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
|
||||
return nil
|
||||
}
|
||||
|
||||
// memorySystemPrompt liste les pages mémoire à injecter quand le mode agent est
|
||||
// actif, pour que l'IA sache ce qu'elle a déjà retenu.
|
||||
func memorySystemPrompt(caps Caps) string {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ import (
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -24,10 +25,12 @@ const (
|
||||
// The per-tool "Outil disponible" sections live in machine/skills prompts so
|
||||
// they only appear when the matching feature is on.
|
||||
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.Agent {
|
||||
hasMem := caps.Mem != MemOff
|
||||
// No tool access at all → 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. (Internet requiert l'agent, donc pas testé ici.)
|
||||
if !caps.Agent && !hasMem {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
@@ -36,28 +39,53 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
|
||||
// puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
|
||||
// mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
|
||||
b.WriteString("You are jean, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\n")
|
||||
b.WriteString("Tools:\n")
|
||||
for _, l := range []string{
|
||||
"bash — run a shell command on this machine (inspect files, processes, logs, run scripts).",
|
||||
"edit — patch a file by exact replacement (old → new, old must be unique).",
|
||||
"mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.",
|
||||
} {
|
||||
b.WriteString("- " + l + "\n")
|
||||
b.WriteString("You are jean, an expert assistant operating directly on this machine with real tools.")
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
||||
}
|
||||
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
||||
for _, l := range []string{
|
||||
"When the user tells you to remember something, or shares a preference, fact, decision, or how-to worth keeping, save it with mem_add (or mem_edit to update an existing page) — do it on your own, without being asked.",
|
||||
"Before doing any task or answering, first call mem_search to see whether your memory already holds the answer or how to do it, then mem_read the best page. Do this even when the request has new specifics like a name, a place or a value — your saved method still applies, only the parameter changes.",
|
||||
} {
|
||||
b.WriteString("- " + l + "\n")
|
||||
b.WriteString("\n\nTools:\n")
|
||||
if caps.Agent {
|
||||
b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts).\n")
|
||||
b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n")
|
||||
}
|
||||
if hasMem {
|
||||
b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.\n")
|
||||
}
|
||||
// Politique d'usage de la mémoire selon le mode.
|
||||
switch caps.Mem {
|
||||
case MemAlways:
|
||||
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
||||
b.WriteString("- When the user tells you to remember something, or shares a preference, fact, decision, or how-to worth keeping, save it with mem_add (or mem_edit to update an existing page) — do it on your own, without being asked.\n")
|
||||
b.WriteString("- Before doing any task or answering, first call mem_search to see whether your memory already holds the answer or how to do it, then mem_read the best page. Do this even when the request has new specifics like a name, a place or a value — your saved method still applies, only the parameter changes.\n")
|
||||
case MemOnDemand:
|
||||
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
|
||||
}
|
||||
if caps.Agent {
|
||||
b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n")
|
||||
}
|
||||
}
|
||||
if caps.Internet {
|
||||
b.WriteString("\nWeb access (Crawl4AI): web_search (DuckDuckGo), web_open (fetch a URL → metadata + outline), web_read (read a line range of an opened URL), web_grep (regex in an opened URL). Workflow: web_open first, then web_read/web_grep.\n")
|
||||
year := time.Now().Format("2006")
|
||||
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, fixtures, 'since when') call web_search BEFORE writing any date or version. Your answer must match the dates/facts you actually read.\n")
|
||||
b.WriteString("SEARCH QUERY YEAR RULE: today is in " + year + ". If your query includes a year, use ONLY " + year + " — NEVER write a past year like " + prevYear(year) + " that you remember from training; it silently biases results toward stale pages. Default: put no year at all and let the freshest result win. Don't hedge ('probably', 'I think') about a fact a tool can verify — search instead.\n")
|
||||
}
|
||||
b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
|
||||
b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n")
|
||||
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// prevYear returns the year before the given "2006"-formatted year string, used
|
||||
// to name explicitly the stale year the model must NOT put in search queries.
|
||||
func prevYear(year string) string {
|
||||
n, err := strconv.Atoi(year)
|
||||
if err != nil {
|
||||
return year
|
||||
}
|
||||
return strconv.Itoa(n - 1)
|
||||
}
|
||||
|
||||
// machineSystemPrompt returns a short briefing about the host the model is
|
||||
// running on, so that when machine access is enabled it knows *which* machine
|
||||
// run_shell acts upon (and doesn't claim it has no access to "your PC").
|
||||
|
||||
+71
-7
@@ -245,13 +245,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
</details>
|
||||
<details><summary>Mode agent <span id="agent-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent</label>
|
||||
<div class="muted" style="font-size:11px">⚠️ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) <b>et</b> la <b>mémoire</b> — l'IA peut inspecter, lancer des scripts et lire/écrire ses pages mémoire (timeout 30s, max 300s)</div>
|
||||
<div class="muted" style="font-size:11px">⚠️ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, lire/éditer des fichiers (timeout 30s, max 300s). La mémoire se règle séparément ci-dessous.</div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour</label>
|
||||
<div class="muted" style="font-size:11px">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</div>
|
||||
<div class="skillshead"><span>Mémoire <span id="mem-count" class="mem-count"></span></span><button class="addbtn" onclick="event.preventDefault();event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button></div>
|
||||
<label class="switchrow" style="justify-content:space-between;gap:10px"><span>Mémoire</span>
|
||||
<select id="mem-mode" onchange="setMemMode()" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px">
|
||||
<option value="always">auto (proactive)</option>
|
||||
<option value="ondemand">sur demande</option>
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="mem-mode-desc" class="muted" style="font-size:11px"></div>
|
||||
<div class="skillshead"><span>Pages <span id="mem-count" class="mem-count"></span></span><button class="addbtn" onclick="event.preventDefault();event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button></div>
|
||||
<input id="mem-search" class="mem-search" type="search" placeholder="filtrer la mémoire…" oninput="renderMemList()" style="display:none">
|
||||
<div id="mem-list" class="mem-list muted" style="font-size:12px"></div>
|
||||
</details>
|
||||
<details><summary>Accès internet <span id="internet-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||||
<div class="muted" style="font-size:11px">nécessite le <b>mode agent</b>. Ajoute les outils <b>web_search / web_open / web_read / web_grep</b> via un serveur <b>Crawl4AI</b>. Actifs seulement si le serveur répond.</div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<input id="crawl-url" type="text" placeholder="http://localhost:11235" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit" onchange="saveCrawlUrl()">
|
||||
<button onclick="saveCrawlUrl()">enregistrer</button>
|
||||
</div>
|
||||
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</details>
|
||||
<details><summary>System prompt</summary>
|
||||
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:70px;resize:vertical"></textarea>
|
||||
</details>
|
||||
@@ -540,6 +557,7 @@ async function loadAgent(){
|
||||
document.getElementById('agent-badge').innerHTML = on
|
||||
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
|
||||
document.getElementById('brand').classList.toggle('agent', on);
|
||||
if(s.mem_mode){ document.getElementById('mem-mode').value = s.mem_mode; renderMemModeDesc(s.mem_mode); }
|
||||
memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name));
|
||||
memShown = MEM_PAGE;
|
||||
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
|
||||
@@ -588,7 +606,44 @@ async function toggleToolLimit(){
|
||||
await jpost('/api/agent/tool-limit',{on});
|
||||
loadAgent();
|
||||
}
|
||||
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent()]); }
|
||||
// Mode mémoire (3 états) — indépendant du mode agent.
|
||||
const MEM_DESC={
|
||||
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
|
||||
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
|
||||
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
|
||||
};
|
||||
function renderMemModeDesc(m){ const d=document.getElementById('mem-mode-desc'); if(d) d.textContent=MEM_DESC[m]||''; }
|
||||
async function setMemMode(){
|
||||
const mode=document.getElementById('mem-mode').value;
|
||||
const r=await jpost('/api/memory',{mode});
|
||||
renderMemModeDesc(r.mode||mode);
|
||||
}
|
||||
// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte.
|
||||
let internetOn=false;
|
||||
function renderInternet(s){
|
||||
internetOn = !!s.enabled;
|
||||
document.getElementById('internet-toggle').checked = internetOn;
|
||||
if(document.activeElement !== document.getElementById('crawl-url'))
|
||||
document.getElementById('crawl-url').value = s.url || '';
|
||||
document.getElementById('internet-badge').innerHTML = internetOn
|
||||
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
|
||||
const st=document.getElementById('internet-status');
|
||||
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
|
||||
else if(s.reachable){ st.innerHTML='✅ serveur joignable — outils web actifs'; }
|
||||
else { st.innerHTML='⚠️ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
|
||||
async function toggleInternet(){
|
||||
const on=document.getElementById('internet-toggle').checked;
|
||||
const url=document.getElementById('crawl-url').value.trim();
|
||||
if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
|
||||
renderInternet(await jpost('/api/internet',{enabled:on, url}));
|
||||
}
|
||||
async function saveCrawlUrl(){
|
||||
const url=document.getElementById('crawl-url').value.trim();
|
||||
renderInternet(await jpost('/api/internet',{url}));
|
||||
}
|
||||
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet()]); }
|
||||
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'; }
|
||||
@@ -917,15 +972,24 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCo
|
||||
// Render a tool call as its own conversation message: the command the model
|
||||
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||||
function renderToolMsg(el, tu){
|
||||
const isShell = tu.name==='bash';
|
||||
const isEdit = tu.name==='edit';
|
||||
let lbl = isShell ? 'terminal' : (isEdit ? 'édition' : 'mémoire');
|
||||
// Métadonnées d'affichage par outil : nom court + en-tête avec icône. Les outils
|
||||
// web (web_search/open/read/grep) doivent afficher 🌐, pas le fallback mémoire.
|
||||
const META = {
|
||||
bash: {lbl:'terminal', head:'⚙️ commande'},
|
||||
edit: {lbl:'édition', head:'✏️ édition'},
|
||||
web_search: {lbl:'recherche', head:'🌐 recherche web'},
|
||||
web_open: {lbl:'page web', head:'🌐 ouverture'},
|
||||
web_read: {lbl:'page web', head:'🌐 lecture'},
|
||||
web_grep: {lbl:'page web', head:'🌐 recherche'},
|
||||
};
|
||||
const meta = META[tu.name] || {lbl:'mémoire', head:'🧠 mémoire'};
|
||||
let lbl = meta.lbl;
|
||||
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
setLabel(el, lbl);
|
||||
const body=bodyOf(el); body.innerHTML='';
|
||||
const head=document.createElement('div'); head.className='tool-head';
|
||||
head.textContent = isShell ? '⚙️ commande' : (isEdit ? '✏️ édition' : '🧠 mémoire');
|
||||
head.textContent = meta.head;
|
||||
body.appendChild(head);
|
||||
if(tu.label){
|
||||
const pre=document.createElement('pre'); pre.className='tool-cmd';
|
||||
|
||||
+3
-3
@@ -3,13 +3,13 @@
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 2,
|
||||
"Patch": 16,
|
||||
"Patch": 17,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 2,
|
||||
"Patch": 16,
|
||||
"Patch": 17,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
|
||||
"OriginalFilename": "jean.exe",
|
||||
"ProductName": "Jean",
|
||||
"ProductVersion": "0.2.16",
|
||||
"ProductVersion": "0.2.17",
|
||||
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -86,6 +86,8 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/agent", handleAgent)
|
||||
api("/api/agent/toggle", handleAgentToggle)
|
||||
api("/api/agent/tool-limit", handleToolLimitToggle)
|
||||
api("/api/internet", handleInternet)
|
||||
api("/api/memory", handleMemoryMode)
|
||||
// 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.
|
||||
@@ -437,7 +439,36 @@ func handleAgent(w http.ResponseWriter, r *http.Request) {
|
||||
for _, p := range pages {
|
||||
out = append(out, map[string]any{"name": p.Name, "desc": p.Title})
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "pages": out, "skills": out})
|
||||
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out})
|
||||
}
|
||||
|
||||
// handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always).
|
||||
//
|
||||
// GET → {mode}
|
||||
// POST {mode} → persiste MEM_MODE
|
||||
func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
// On normalise via memMode() en réinjectant la valeur : toute entrée
|
||||
// inconnue retombe sur "always", donc on valide en passant par le parseur.
|
||||
m := MemAlways
|
||||
switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) {
|
||||
case MemOff:
|
||||
m = MemOff
|
||||
case MemOnDemand:
|
||||
m = MemOnDemand
|
||||
case MemAlways:
|
||||
m = MemAlways
|
||||
}
|
||||
if err := setMemMode(m); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())})
|
||||
}
|
||||
|
||||
// handleToolLimitToggle active/désactive le plafond d'appels d'outils par tour
|
||||
@@ -470,6 +501,42 @@ func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "enabled": agentEnabled()})
|
||||
}
|
||||
|
||||
// handleInternet pilote l'accès web de l'IA (serveur Crawl4AI).
|
||||
//
|
||||
// GET → {enabled, url, reachable}
|
||||
// POST {enabled, url} → enregistre CRAWL4AI_URL + le drapeau .internet_enabled
|
||||
func handleInternet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
URL *string `json:"url"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.URL != nil {
|
||||
u := strings.TrimRight(strings.TrimSpace(*req.URL), "/")
|
||||
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
reachMu.Lock()
|
||||
reachURL = "" // invalide le cache de reachability
|
||||
reachMu.Unlock()
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
if err := setInternetEnabled(*req.Enabled); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"enabled": internetEnabled(),
|
||||
"url": crawl4aiURL(),
|
||||
"reachable": crawlReachable(),
|
||||
})
|
||||
}
|
||||
|
||||
func handleSkill(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
if name == "" {
|
||||
@@ -640,6 +707,8 @@ type chatReq struct {
|
||||
Agent *bool `json:"agent"`
|
||||
Tools *bool `json:"tools"`
|
||||
Skills *bool `json:"skills"`
|
||||
// Override par requête de l'accès internet (outils web). nil = config machine.
|
||||
Internet *bool `json:"internet"`
|
||||
}
|
||||
|
||||
// sseHeartbeat garde la réponse SSE active en écrivant un commentaire (`: ping`,
|
||||
@@ -695,6 +764,10 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any)
|
||||
// rétro-compat : anciens clients qui envoyaient deux drapeaux séparés
|
||||
caps.Agent = (body.Tools != nil && *body.Tools) || (body.Skills != nil && *body.Skills)
|
||||
}
|
||||
if body.Internet != nil {
|
||||
// On garde la cohérence prompt/outils : internet demandé ET serveur joignable.
|
||||
caps.Internet = *body.Internet && crawlReachable()
|
||||
}
|
||||
msgs := InjectSkills(body.Messages, caps)
|
||||
extra, _ := runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Err != nil {
|
||||
@@ -752,4 +825,3 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
runChatStream(r.Context(), body, emit)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user