diff --git a/chat.go b/chat.go index 88c7b2d..14e8bb8 100644 --- a/chat.go +++ b/chat.go @@ -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") diff --git a/internet.go b/internet.go new file mode 100644 index 0000000..835d972 --- /dev/null +++ b/internet.go @@ -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 ] ────────────────────────── + +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 ") + } + 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 (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 \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 ]") + } + return nil +} diff --git a/llm.go b/llm.go index 00efb07..89f7801 100644 --- a/llm.go +++ b/llm.go @@ -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 } diff --git a/main.go b/main.go index bb9e38c..7f96e32 100644 --- a/main.go +++ b/main.go @@ -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//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 ] 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 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. diff --git a/memory.go b/memory.go index cb23b8d..a5088d6 100644 --- a/memory.go +++ b/memory.go @@ -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 { diff --git a/resource_windows_amd64.syso b/resource_windows_amd64.syso index 8ad2867..0115b8f 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 7fe244c..9e06950 100644 Binary files a/resource_windows_arm64.syso and b/resource_windows_arm64.syso differ diff --git a/tools.go b/tools.go index f066a60..b0f19b0 100644 --- a/tools.go +++ b/tools.go @@ -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 // 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"). diff --git a/ui/index.html b/ui/index.html index 46e0add..c3f6f19 100644 --- a/ui/index.html +++ b/ui/index.html @@ -245,13 +245,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
Mode agent -
⚠️ active l'accès machine (shell complet via bash sur le serveur) et la mémoire — l'IA peut inspecter, lancer des scripts et lire/écrire ses pages mémoire (timeout 30s, max 300s)
+
⚠️ active l'accès machine (shell complet via bash 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.
activé par défaut : coupe l'IA après ~8 appels d'outils (message [stop: trop d'appels d'outils]). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).
-
Mémoire
+ +
+
Pages
+
Accès internet + +
nécessite le mode agent. Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI. Actifs seulement si le serveur répond.
+
+ + +
+
+
System prompt
@@ -540,6 +557,7 @@ async function loadAgent(){ document.getElementById('agent-badge').innerHTML = on ? 'on' : 'off'; 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 + ? 'on' : 'off'; + 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'; diff --git a/versioninfo.json b/versioninfo.json index fd93edf..6f63518 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -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": { diff --git a/web.go b/web.go index 45748d1..fabb17b 100644 --- a/web.go +++ b/web.go @@ -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) } -