mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge branch 'feat/ajean-lot-images' : recherche d'images, aperçu du navigateur, vignettes, journal d'installation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c2a2cc5748
23 files changed
+841
-36
No files matched your search
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
|
|||||||
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
|
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
|
||||||
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
|
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
|
||||||
type sinkWriter struct {
|
type sinkWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf []byte
|
buf []byte
|
||||||
|
lastProg time.Time // dernière ligne de progression (\r) émise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sinkProgressEvery : une barre de progression (tqdm, téléchargements de
|
||||||
|
// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa
|
||||||
|
// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement
|
||||||
|
// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient.
|
||||||
|
const sinkProgressEvery = 10 * time.Second
|
||||||
|
|
||||||
func (s *sinkWriter) Write(p []byte) (int, error) {
|
func (s *sinkWriter) Write(p []byte) (int, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.buf = append(s.buf, p...)
|
s.buf = append(s.buf, p...)
|
||||||
for {
|
for {
|
||||||
i := strings.IndexByte(string(s.buf), '\n')
|
i := strings.IndexAny(string(s.buf), "\r\n")
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
|
if s.buf[i] == '\n' {
|
||||||
|
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
|
||||||
|
s.buf = s.buf[i+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut
|
||||||
|
// arriver dans l'écriture suivante : on attend de le savoir.
|
||||||
|
if i+1 >= len(s.buf) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s.buf[i+1] == '\n' {
|
||||||
|
emitBuildLine(string(s.buf[:i]))
|
||||||
|
s.buf = s.buf[i+2:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery {
|
||||||
|
s.lastProg = time.Now()
|
||||||
|
emitBuildLine(prog)
|
||||||
|
}
|
||||||
s.buf = s.buf[i+1:]
|
s.buf = s.buf[i+1:]
|
||||||
}
|
}
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans
|
||||||
|
// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures.
|
||||||
|
func TestSinkWriterProgression(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
setBuildSink(func(l string) { got = append(got, l) })
|
||||||
|
defer setBuildSink(nil)
|
||||||
|
|
||||||
|
s := &sinkWriter{}
|
||||||
|
s.Write([]byte("Step 5\r"))
|
||||||
|
s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r"))
|
||||||
|
s.Write([]byte(" modèle : 3 / 76 GB\r"))
|
||||||
|
s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées
|
||||||
|
s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n"))
|
||||||
|
|
||||||
|
// « 3 » finissait sa propre écriture : il attendait de savoir si un saut de
|
||||||
|
// ligne suivait, et part donc avec l'écriture suivante.
|
||||||
|
want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("lignes %q, attendu %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@ func cmdChat(args []string) error {
|
|||||||
case "edit":
|
case "edit":
|
||||||
icon = "✏️"
|
icon = "✏️"
|
||||||
verb = "édition"
|
verb = "édition"
|
||||||
case "web_search", "web_open", "web_read", "web_grep":
|
case "web_search", "web_images", "web_open", "web_read", "web_grep":
|
||||||
icon = "🌐"
|
icon = "🌐"
|
||||||
verb = "web"
|
verb = "web"
|
||||||
case "web_screenshot":
|
case "web_screenshot":
|
||||||
|
|||||||
@@ -354,6 +354,17 @@ func compactLog(log []LogEvent) []LogEvent {
|
|||||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||||
flush()
|
flush()
|
||||||
if done, _ := tu["done"].(bool); done {
|
if done, _ := tu["done"].(bool); done {
|
||||||
|
// L'aperçu du navigateur (shot) n'est que du direct : on ne le
|
||||||
|
// garde pas une fois le tour fini (voir cu_autoshot.go).
|
||||||
|
if _, has := tu["shot"]; has {
|
||||||
|
cp := make(map[string]any, len(tu))
|
||||||
|
for k, v := range tu {
|
||||||
|
if k != "shot" {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev.Delta = map[string]any{"tool_used": cp}
|
||||||
|
}
|
||||||
out = append(out, ev)
|
out = append(out, ev)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -945,6 +956,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
|||||||
if ev.ToolUsed.Image != "" {
|
if ev.ToolUsed.Image != "" {
|
||||||
tu["image"] = ev.ToolUsed.Image
|
tu["image"] = ev.ToolUsed.Image
|
||||||
}
|
}
|
||||||
|
if ev.ToolUsed.Shot != "" {
|
||||||
|
tu["shot"] = ev.ToolUsed.Shot
|
||||||
|
}
|
||||||
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||||
if ev.ToolUsed.ResultChars > 0 {
|
if ev.ToolUsed.ResultChars > 0 {
|
||||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ func cmdInternet(args []string) error {
|
|||||||
if err := setInternetEnabled(true); err != nil {
|
if err := setInternetEnabled(true); err != nil {
|
||||||
return err
|
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)")
|
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_images/web_open/web_read/web_grep (si le mode agent est actif)")
|
||||||
case "off":
|
case "off":
|
||||||
if err := setInternetEnabled(false); err != nil {
|
if err := setInternetEnabled(false); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -352,7 +352,7 @@ func cmdInternet(args []string) error {
|
|||||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||||
if webEngine() == engineGo {
|
if webEngine() == engineGo {
|
||||||
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
|
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
|
||||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
||||||
@@ -366,7 +366,7 @@ func cmdInternet(args []string) error {
|
|||||||
reach = green("joignable")
|
reach = green("joignable")
|
||||||
}
|
}
|
||||||
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
||||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// web_images : la recherche d'images DuckDuckGo, pendant de web_search. Sans
|
||||||
|
// elle, « montre-moi des photos de X » obligeait le modèle à ouvrir des pages
|
||||||
|
// dans le navigateur et à en renvoyer des captures (lent, et des images de
|
||||||
|
// pages au lieu de photos).
|
||||||
|
//
|
||||||
|
// DuckDuckGo sert les images par i.js, qui exige un jeton « vqd » lu dans la
|
||||||
|
// page de recherche. Même client HTTP que le moteur intégré, quel que soit le
|
||||||
|
// moteur web actif : Crawl4AI n'apporterait rien à un appel JSON.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func webImagesTool() Tool {
|
||||||
|
return Tool{Type: "function", Function: ToolFunction{
|
||||||
|
// Schéma minimal : le préambule a un budget (chat_prompt_size_test.go).
|
||||||
|
// limit n'est pas annoncé ; toolWebImages l'accepte encore (6 par défaut).
|
||||||
|
Name: "web_images",
|
||||||
|
Description: "Find images.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"query": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"query"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageResult struct {
|
||||||
|
Image string `json:"image"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
|
||||||
|
|
||||||
|
func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", goFetchUA)
|
||||||
|
req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
|
||||||
|
if referer != "" {
|
||||||
|
req.Header.Set("Referer", referer)
|
||||||
|
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||||
|
}
|
||||||
|
resp, err := goHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("échec de la requête : %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func duckduckgoImages(query string, limit int) ([]imageResult, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
|
||||||
|
body, err := ddgGet(ctx, page, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := ddgVqdRe.FindSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)")
|
||||||
|
}
|
||||||
|
api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
|
||||||
|
body, err = ddgGet(ctx, api, "https://duckduckgo.com/")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Results []imageResult `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("réponse illisible : %v", err)
|
||||||
|
}
|
||||||
|
var out []imageResult
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, it := range r.Results {
|
||||||
|
if it.Image == "" || seen[it.Image] || !strings.HasPrefix(it.Image, "http") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[it.Image] = true
|
||||||
|
out = append(out, it)
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolWebImages(args map[string]any) string {
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
limit := 6
|
||||||
|
if v, ok := args["limit"].(float64); ok {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 1
|
||||||
|
}
|
||||||
|
if limit > 20 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
results, err := duckduckgoImages(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return "❌ Recherche d'images échouée : " + err.Error()
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("Aucune image pour « %s »", query)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# Images : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
fmt.Fprintf(&b, "%d. %s (%dx%d)\n image : %s\n page : %s\n\n", i+1, r.Title, r.Width, r.Height, r.Image, r.URL)
|
||||||
|
}
|
||||||
|
b.WriteString("To show an image to the user, write  in your answer.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -225,6 +225,7 @@ func cdpLaunch() (*cdpSession, error) {
|
|||||||
args = append([]string{"--headless=new"}, args...)
|
args = append([]string{"--headless=new"}, args...)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(bin, args...)
|
cmd := exec.Command(bin, args...)
|
||||||
|
prepareBoundChild(cmd) // tué avec Loki, même brutalement (sys_childbind_*.go)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
os.RemoveAll(userDir)
|
os.RemoveAll(userDir)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// cu_autoshot.go — aperçu EN DIRECT du navigateur piloté (computer use). Après
|
||||||
|
// chaque action browser_*, on capture la page pour que l'UTILISATEUR voie ce que
|
||||||
|
// fait l'IA. C'est de l'interface seulement :
|
||||||
|
// - jamais envoyé au modèle (zéro token, vision non requise) ;
|
||||||
|
// - jamais écrit sur le disque : un petit anneau en mémoire vive ;
|
||||||
|
// - retiré du journal en fin de tour (compactLogLocked), et l'UI efface la carte
|
||||||
|
// à la fin du tour. Une capture à GARDER passe toujours par
|
||||||
|
// browser_screenshot, qui l'enregistre dans le dossier de travail.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cuShotRing = 8 // captures gardées en RAM (l'UI n'affiche que la dernière)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cuShotMu sync.Mutex
|
||||||
|
cuShotData = map[string][]byte{}
|
||||||
|
cuShotIDs []string
|
||||||
|
)
|
||||||
|
|
||||||
|
// cuAutoShotTool : outils après lesquels on capture (ceux qui changent la page).
|
||||||
|
func cuAutoShotTool(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "browser_open", "browser_click", "browser_click_xy", "browser_type", "browser_key", "browser_scroll", "browser_find":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n", a...) }
|
||||||
|
|
||||||
|
// cuAutoShot capture la page en pleine résolution (JPEG de bonne qualité) et
|
||||||
|
// renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais
|
||||||
|
// bloquant pour l'outil).
|
||||||
|
func cuAutoShot() string {
|
||||||
|
s, err := cdpGet()
|
||||||
|
if err != nil {
|
||||||
|
cuLog("aperçu : navigateur indisponible : %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := s.screenshot()
|
||||||
|
if err != nil {
|
||||||
|
cuLog("aperçu : capture impossible : %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
cuLog("aperçu : image illisible (%d octets) : %v", len(raw), err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
cuShotMu.Lock()
|
||||||
|
cuShotData[id] = buf.Bytes()
|
||||||
|
cuShotIDs = append(cuShotIDs, id)
|
||||||
|
for len(cuShotIDs) > cuShotRing {
|
||||||
|
delete(cuShotData, cuShotIDs[0])
|
||||||
|
cuShotIDs = cuShotIDs[1:]
|
||||||
|
}
|
||||||
|
cuShotMu.Unlock()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCUShot (GET ?id=) : une capture en base64 (JSON, pour traverser aussi le
|
||||||
|
// tunnel chiffré du relais). 404 si elle est déjà sortie de l'anneau.
|
||||||
|
func handleCUShot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
|
cuShotMu.Lock()
|
||||||
|
b := cuShotData[id]
|
||||||
|
cuShotMu.Unlock()
|
||||||
|
if b == nil {
|
||||||
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "capture expirée"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true, "mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(b)})
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// L'aperçu du navigateur (shot) n'est que du direct : la compaction de fin de
|
||||||
|
// tour le retire du journal, le reste de l'événement d'outil est gardé.
|
||||||
|
func TestCompactLogRetireLAperçuNavigateur(t *testing.T) {
|
||||||
|
log := []LogEvent{
|
||||||
|
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": false}}},
|
||||||
|
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": true, "result": "ok", "shot": "abc"}}},
|
||||||
|
}
|
||||||
|
out := compactLog(log)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("un seul événement d'outil attendu, %d", len(out))
|
||||||
|
}
|
||||||
|
tu := out[0].Delta["tool_used"].(map[string]any)
|
||||||
|
if _, has := tu["shot"]; has {
|
||||||
|
t.Fatal("l'aperçu aurait dû quitter le journal")
|
||||||
|
}
|
||||||
|
if tu["result"] != "ok" || tu["name"] != "browser_click" {
|
||||||
|
t.Fatalf("événement abîmé : %v", tu)
|
||||||
|
}
|
||||||
|
if _, has := log[1].Delta["tool_used"].(map[string]any)["shot"]; !has {
|
||||||
|
t.Fatal("le journal d'origine ne doit pas être modifié en place")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCUAutoShotTool(t *testing.T) {
|
||||||
|
for _, n := range []string{"browser_open", "browser_click", "browser_type", "browser_scroll"} {
|
||||||
|
if !cuAutoShotTool(n) {
|
||||||
|
t.Errorf("%s devrait déclencher l'aperçu", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []string{"browser_screenshot", "browser_snapshot", "bash", "web_images"} {
|
||||||
|
if cuAutoShotTool(n) {
|
||||||
|
t.Errorf("%s ne devrait pas déclencher l'aperçu", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -494,7 +494,7 @@ func EnabledTools(caps Caps) []Tool {
|
|||||||
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
|
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
|
||||||
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||||
if caps.Agent && caps.Internet {
|
if caps.Agent && caps.Internet {
|
||||||
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
|
tools = append(tools, webSearchTool(), webImagesTool(), webOpenTool(), webReadTool(), webGrepTool())
|
||||||
}
|
}
|
||||||
// Capture d'écran : seulement si Playwright est réellement présent dans
|
// Capture d'écran : seulement si Playwright est réellement présent dans
|
||||||
// l'image (annoncer un outil absent enverrait le modèle en boucle de
|
// l'image (annoncer un outil absent enverrait le modèle en boucle de
|
||||||
@@ -602,6 +602,10 @@ type ToolUsedEvent struct {
|
|||||||
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
|
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
|
||||||
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
|
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
|
||||||
Image string
|
Image string
|
||||||
|
// Shot : identifiant de la capture du navigateur prise après une action
|
||||||
|
// browser_* (cu_autoshot.go). Aperçu EN DIRECT seulement : gardé en RAM,
|
||||||
|
// jamais envoyé au modèle, retiré du journal en fin de tour.
|
||||||
|
Shot string
|
||||||
// ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en
|
// ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en
|
||||||
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle.
|
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle.
|
||||||
ResultChars int
|
ResultChars int
|
||||||
@@ -1103,7 +1107,7 @@ func isNetTimeout(err error) bool {
|
|||||||
func toolCallLabel(name string, args map[string]any) string {
|
func toolCallLabel(name string, args map[string]any) string {
|
||||||
label := ""
|
label := ""
|
||||||
switch name {
|
switch name {
|
||||||
case "mem_search", "web_search", "recall_search":
|
case "mem_search", "web_search", "web_images", "recall_search":
|
||||||
label, _ = args["query"].(string)
|
label, _ = args["query"].(string)
|
||||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
|
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
|
||||||
label, _ = args["file"].(string)
|
label, _ = args["file"].(string)
|
||||||
@@ -1876,7 +1880,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
|||||||
if cur := toolCalls[0]; cur != nil {
|
if cur := toolCalls[0]; cur != nil {
|
||||||
key := "command"
|
key := "command"
|
||||||
switch cur.Function.Name {
|
switch cur.Function.Name {
|
||||||
case "mem_search", "web_search", "recall_search":
|
case "mem_search", "web_search", "web_images", "recall_search":
|
||||||
key = "query"
|
key = "query"
|
||||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||||
key = "file"
|
key = "file"
|
||||||
@@ -2494,6 +2498,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
|||||||
}
|
}
|
||||||
case "web_search":
|
case "web_search":
|
||||||
result = capWebOutput(toolWebSearch(args))
|
result = capWebOutput(toolWebSearch(args))
|
||||||
|
case "web_images":
|
||||||
|
result = capWebOutput(toolWebImages(args))
|
||||||
case "web_open":
|
case "web_open":
|
||||||
result = capWebOutput(toolWebOpen(args))
|
result = capWebOutput(toolWebOpen(args))
|
||||||
case "web_read":
|
case "web_read":
|
||||||
@@ -2535,8 +2541,14 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
|||||||
result = "[erreur] outil inconnu: " + tc.Function.Name
|
result = "[erreur] outil inconnu: " + tc.Function.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Aperçu en direct du navigateur piloté (repris d'AJEAN) : une
|
||||||
|
// capture après chaque action qui change la page, pour l'UI seule.
|
||||||
|
liveShot := ""
|
||||||
if !strings.HasPrefix(result, "[erreur]") {
|
if !strings.HasPrefix(result, "[erreur]") {
|
||||||
doneCalls[callKey] = result
|
doneCalls[callKey] = result
|
||||||
|
if cuAutoShotTool(tc.Function.Name) {
|
||||||
|
liveShot = cuAutoShot()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Capture d'écran : l'image part avec l'événement pour être
|
// Capture d'écran : l'image part avec l'événement pour être
|
||||||
// affichée dans la bulle, quoi que le modèle en fasse ensuite.
|
// affichée dans la bulle, quoi que le modèle en fasse ensuite.
|
||||||
@@ -2544,7 +2556,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
|||||||
if tc.Function.Name == "web_screenshot" {
|
if tc.Function.Name == "web_screenshot" {
|
||||||
shot = capturedRelPath(result)
|
shot = capturedRelPath(result)
|
||||||
}
|
}
|
||||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)})
|
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot, Shot: liveShot}, result)})
|
||||||
// Plafond pour le MODÈLE seulement : l'UI vient de recevoir le
|
// Plafond pour le MODÈLE seulement : l'UI vient de recevoir le
|
||||||
// résultat complet (aperçu + « voir plus »).
|
// résultat complet (aperçu + « voir plus »).
|
||||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
|
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package loki
|
||||||
|
|
||||||
|
// Lier le Chrome du computer use à la vie de Loki (repris d'AJEAN) : le noyau
|
||||||
|
// lui envoie SIGKILL quand Loki meurt, même tué brutalement. Sans ça, un Chrome
|
||||||
|
// orphelin restait dans le conteneur après un redémarrage de l'UI. Ses
|
||||||
|
// sous-processus suivent la mort du processus principal de Chrome.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareBoundChild(cmd *exec.Cmd) {
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package loki
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// Hors Linux, pas d'équivalent simple à Pdeathsig : le navigateur est fermé à
|
||||||
|
// l'arrêt propre de Loki. (AJEAN passe par un job object sous Windows.)
|
||||||
|
func prepareBoundChild(cmd *exec.Cmd) {}
|
||||||
+145
-12
@@ -2305,6 +2305,20 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
transition:background .15s,color .15s,border-color .15s}
|
transition:background .15s,color .15s,border-color .15s}
|
||||||
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
||||||
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
||||||
|
/* Aperçu en direct du navigateur piloté (09-stream.js showCuShot, repris d'AJEAN). */
|
||||||
|
.cu-shot{flex-shrink:0;align-self:flex-start;width:min(640px,100%);margin:2px 0 6px;overflow:hidden;
|
||||||
|
transition:height .35s cubic-bezier(.2,.7,.3,1),opacity .3s ease,margin .35s ease;animation:cushotcard .3s ease both}
|
||||||
|
.cu-shot.out{opacity:0;margin:0}
|
||||||
|
@keyframes cushotcard{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
|
.cu-shot-cap{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dim);margin:0 0 6px 2px;min-width:0}
|
||||||
|
.cu-shot-cap svg{flex:none}
|
||||||
|
.cu-shot-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.cu-shot-img{position:relative;border-radius:12px;overflow:hidden;background:var(--row-bg);
|
||||||
|
box-shadow:0 4px 18px rgba(0,0,0,.10);aspect-ratio:16 / 10}
|
||||||
|
.cu-shot-img img.cu-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top;
|
||||||
|
opacity:0;transition:opacity .45s ease;cursor:zoom-in}
|
||||||
|
.cu-shot-img img.cu-frame.in{opacity:1}
|
||||||
|
@media (prefers-reduced-motion:reduce){.cu-shot,.cu-shot-img img.cu-frame{transition:none;animation:none}}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
|
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
|
||||||
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
|
||||||
@@ -2709,7 +2723,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="net-block" class="gated-group">
|
<div id="net-block" class="gated-group">
|
||||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_images / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||||||
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
||||||
@@ -7392,7 +7406,47 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink
|
|||||||
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
||||||
// casserait les rendus suivants qui réutilisent la même entrée.
|
// casserait les rendus suivants qui réutilisent la même entrée.
|
||||||
const IMG_CACHE = new Map();
|
const IMG_CACHE = new Map();
|
||||||
|
// Vignettes du fil (repris d'AJEAN) : ~560 px suffisent à l'affichage, le
|
||||||
|
// serveur les calcule (?thumb=). Chargement DEUX PAR DEUX avec un nouvel essai :
|
||||||
|
// en accès distant, une rafale de captures saturait le tunnel et certaines
|
||||||
|
// n'arrivaient jamais.
|
||||||
|
const CHAT_THUMB_PX = 560;
|
||||||
|
let chatImgActive = 0;
|
||||||
|
const chatImgWaiting = [];
|
||||||
|
function chatImgQueue(job){
|
||||||
|
return new Promise((resolve, reject)=>{
|
||||||
|
const run = async ()=>{
|
||||||
|
chatImgActive++;
|
||||||
|
try{
|
||||||
|
try{ resolve(await job()); }
|
||||||
|
catch(_){ await new Promise(r=>setTimeout(r, 800)); resolve(await job()); }
|
||||||
|
}catch(e){ reject(e); }
|
||||||
|
finally{ chatImgActive--; const next = chatImgWaiting.shift(); if(next) next(); }
|
||||||
|
};
|
||||||
|
if(chatImgActive < 2) run(); else chatImgWaiting.push(run);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function chatImgBlob(url){
|
||||||
|
const hit = IMG_CACHE.get(url);
|
||||||
|
if(hit) return hit;
|
||||||
|
const r = await jfetch(url);
|
||||||
|
if(!r.ok) throw new Error(r.status);
|
||||||
|
const obj = URL.createObjectURL(await r.blob());
|
||||||
|
IMG_CACHE.set(url, obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
function hydrateImages(root){
|
function hydrateImages(root){
|
||||||
|
// Images du web (outil web_images, repris d'AJEAN) : même rendu et même
|
||||||
|
// loupe que les nôtres, sans Referer, et masquées si le site les refuse.
|
||||||
|
root.querySelectorAll('img[src^="http://"],img[src^="https://"]').forEach(img => {
|
||||||
|
if(img.dataset.hydrated) return;
|
||||||
|
img.dataset.hydrated = '1';
|
||||||
|
img.classList.add('chatimg');
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.referrerPolicy = 'no-referrer';
|
||||||
|
bindZoom(img, img.getAttribute('alt') || '');
|
||||||
|
img.addEventListener('error', ()=>{ img.style.display = 'none'; });
|
||||||
|
});
|
||||||
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
if(img.dataset.hydrated) return;
|
if(img.dataset.hydrated) return;
|
||||||
img.dataset.hydrated = '1';
|
img.dataset.hydrated = '1';
|
||||||
@@ -7404,15 +7458,21 @@ function hydrateImages(root){
|
|||||||
const m = /[?&]path=([^&]*)/.exec(src);
|
const m = /[?&]path=([^&]*)/.exec(src);
|
||||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||||
}
|
}
|
||||||
bindZoom(img, name);
|
const full = src.startsWith('/') ? src : '/' + src;
|
||||||
const cached = IMG_CACHE.get(src);
|
// Loupe : l'ORIGINAL, chargé seulement à l'agrandissement (le fil n'affiche
|
||||||
|
// qu'une vignette calculée par le serveur).
|
||||||
|
if(!img.dataset.zoom){
|
||||||
|
img.dataset.zoom = '1';
|
||||||
|
img.addEventListener('click', async ()=>{
|
||||||
|
try{ openLightbox(await chatImgBlob(full), name); }
|
||||||
|
catch(_){ openLightbox(img.currentSrc || img.src, name); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const thumb = full + (full.includes('?') ? '&' : '?') + 'thumb=' + CHAT_THUMB_PX;
|
||||||
|
const cached = IMG_CACHE.get(thumb);
|
||||||
if(cached){ img.src = cached; return; }
|
if(cached){ img.src = cached; return; }
|
||||||
try{
|
try{
|
||||||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
img.src = await chatImgQueue(()=>chatImgBlob(thumb));
|
||||||
if(!r.ok) throw new Error(r.status);
|
|
||||||
const url = URL.createObjectURL(await r.blob());
|
|
||||||
IMG_CACHE.set(src, url);
|
|
||||||
img.src = url;
|
|
||||||
}catch(e){
|
}catch(e){
|
||||||
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
||||||
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
||||||
@@ -7437,6 +7497,7 @@ function renderToolMsg(el, tu){
|
|||||||
write: {lbl:'fichier', head:'écriture'},
|
write: {lbl:'fichier', head:'écriture'},
|
||||||
edit: {lbl:'édition', head:'édition'},
|
edit: {lbl:'édition', head:'édition'},
|
||||||
web_search: {lbl:'recherche', head:'recherche web'},
|
web_search: {lbl:'recherche', head:'recherche web'},
|
||||||
|
web_images: {lbl:'images', head:"recherche d'images"},
|
||||||
web_open: {lbl:'page web', head:'ouverture'},
|
web_open: {lbl:'page web', head:'ouverture'},
|
||||||
web_read: {lbl:'page web', head:'lecture'},
|
web_read: {lbl:'page web', head:'lecture'},
|
||||||
web_grep: {lbl:'page web', head:'recherche'},
|
web_grep: {lbl:'page web', head:'recherche'},
|
||||||
@@ -7835,7 +7896,7 @@ function confirmPending(text){
|
|||||||
let REPLAYING=true;
|
let REPLAYING=true;
|
||||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||||
let T=null;
|
let T=null;
|
||||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
|
function newTurn(){ T={ cuShotEl:null, reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
|
||||||
newTurn();
|
newTurn();
|
||||||
|
|
||||||
// --- Temps de travail du tour ------------------------------------------------
|
// --- Temps de travail du tour ------------------------------------------------
|
||||||
@@ -7938,6 +7999,54 @@ function paintStats(el, speed){
|
|||||||
}
|
}
|
||||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
||||||
|
// Aperçu EN DIRECT du navigateur piloté (repris d'AJEAN). Après chaque action
|
||||||
|
// browser_*, le serveur garde une capture en mémoire (tu.shot = son id). Une
|
||||||
|
// seule carte par tour : la nouvelle image arrive en fondu par-dessus
|
||||||
|
// l'ancienne. Purement de l'interface : la carte s'efface à la fin du tour et
|
||||||
|
// rien n'est gardé. Une capture à conserver passe par browser_screenshot.
|
||||||
|
async function showCuShot(tu){
|
||||||
|
if(REPLAYING) return;
|
||||||
|
let card=T.cuShotEl;
|
||||||
|
if(!card){
|
||||||
|
card=document.createElement('div'); card.className='cu-shot';
|
||||||
|
card.innerHTML='<div class="cu-shot-cap"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/></svg><span class="cu-shot-t">navigateur en direct</span></div><div class="cu-shot-img"></div>';
|
||||||
|
T.cuShotEl=card;
|
||||||
|
}
|
||||||
|
if(tu.name==='browser_open' && tu.label) card.querySelector('.cu-shot-t').textContent=tu.label;
|
||||||
|
// Juste avant l'indicateur d'activité s'il est là, sinon en bas du fil.
|
||||||
|
const chat=chatEl(), anchor=(T.typingEl && T.typingEl.parentNode===chat) ? T.typingEl : null;
|
||||||
|
if(card.parentNode!==chat || card.nextSibling!==anchor){ if(anchor) chat.insertBefore(card, anchor); else chat.appendChild(card); }
|
||||||
|
const my=(card._n=(card._n||0)+1); // ordre d'arrivée des captures
|
||||||
|
let r; try{ r=await jget('/api/chat/cushot?id='+encodeURIComponent(tu.shot)); }catch(_){ return; }
|
||||||
|
if(!r || !r.ok || T.cuShotEl!==card) return; // carte déjà retirée (tour fini)
|
||||||
|
const box=card.querySelector('.cu-shot-img');
|
||||||
|
const img=new Image(); img.alt=''; img.className='cu-frame';
|
||||||
|
img.onclick=()=>openLightbox(img.src, card.querySelector('.cu-shot-t').textContent);
|
||||||
|
img.src='data:'+(r.mime||'image/jpeg')+';base64,'+r.data;
|
||||||
|
// Décodée AVANT d'entrer dans la page : jamais d'image à moitié peinte.
|
||||||
|
try{ await img.decode(); }catch(_){ return; }
|
||||||
|
// Une capture plus récente est déjà affichée (réponses dans le désordre) : on jette.
|
||||||
|
if(T.cuShotEl!==card || my<(card._shown||0)) return;
|
||||||
|
card._shown=my;
|
||||||
|
box.appendChild(img);
|
||||||
|
if(!box.style.aspectRatio) box.style.aspectRatio=img.naturalWidth+' / '+img.naturalHeight;
|
||||||
|
setTimeout(()=>img.classList.add('in'), 20);
|
||||||
|
// Fondu terminé : on retire les images PLUS ANCIENNES seulement.
|
||||||
|
setTimeout(()=>{ let x=img.previousElementSibling; while(x){ const p=x.previousElementSibling; x.remove(); x=p; } }, 480);
|
||||||
|
scrollMaybe();
|
||||||
|
}
|
||||||
|
// Fin du tour : l'aperçu s'efface en douceur. Toutes les cartes du fil, au cas
|
||||||
|
// où un tour précédent en aurait laissé une.
|
||||||
|
function hideCuShot(){
|
||||||
|
if(T) T.cuShotEl=null;
|
||||||
|
const chat=chatEl(); if(!chat) return;
|
||||||
|
chat.querySelectorAll(':scope > .cu-shot').forEach(card=>{
|
||||||
|
if(card.classList.contains('out')) return;
|
||||||
|
card.style.height=card.offsetHeight+'px'; void card.offsetHeight;
|
||||||
|
card.classList.add('out'); card.style.height='0px';
|
||||||
|
setTimeout(()=>card.remove(), 380);
|
||||||
|
});
|
||||||
|
}
|
||||||
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
||||||
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
|
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
|
||||||
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
|
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
|
||||||
@@ -8287,7 +8396,7 @@ function handleDelta(d){
|
|||||||
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||||||
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||||||
// replay, qui rejoue tous les tours passés d'un bloc.
|
// replay, qui rejoue tous les tours passés d'un bloc.
|
||||||
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
if(d.turn_done){ hideCuShot(); settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
||||||
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
|
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
|
||||||
// le temps réellement passé et non continuer d'avancer.
|
// le temps réellement passé et non continuer d'avancer.
|
||||||
T.doneTs = d.ts || T.doneTs || 0;
|
T.doneTs = d.ts || T.doneTs || 0;
|
||||||
@@ -8296,7 +8405,7 @@ function handleDelta(d){
|
|||||||
paintTurnClock(); // fige le total au-dessus de la carte
|
paintTurnClock(); // fige le total au-dessus de la carte
|
||||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
||||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
if(d.error){ hideCuShot(); settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
||||||
@@ -8333,6 +8442,7 @@ function handleDelta(d){
|
|||||||
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
||||||
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
||||||
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
||||||
|
if(tu.done && tu.shot) showCuShot(tu);
|
||||||
if(tu.done){
|
if(tu.done){
|
||||||
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||||
@@ -8987,10 +9097,21 @@ function lcStartPolling(){
|
|||||||
lcPollJob();
|
lcPollJob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Une seule requête à la fois : l'intervalle d'une seconde relançait un appel
|
||||||
|
// avant la réponse du précédent quand le serveur était occupé (téléchargement,
|
||||||
|
// accès distant), et les deux ajoutaient les MÊMES lignes au journal.
|
||||||
|
let lcPollInflight = false;
|
||||||
async function lcPollJob(quiet){
|
async function lcPollJob(quiet){
|
||||||
|
if(lcPollInflight) return;
|
||||||
|
lcPollInflight = true;
|
||||||
|
const from = lcLogNext;
|
||||||
let j;
|
let j;
|
||||||
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
|
try{ j = await jget('/api/llamacpp/job?from='+from); }catch(_){ return; }
|
||||||
|
finally{ lcPollInflight = false; }
|
||||||
if(!j.exists) return;
|
if(!j.exists) return;
|
||||||
|
// Le journal a été remis à zéro ou avancé entre-temps (nouveau job) : ces
|
||||||
|
// lignes ne suivent plus ce qui est affiché.
|
||||||
|
if(from !== lcLogNext) return;
|
||||||
const phaseEl = document.getElementById('lc-job-phase');
|
const phaseEl = document.getElementById('lc-job-phase');
|
||||||
if(j.lines && j.lines.length){
|
if(j.lines && j.lines.length){
|
||||||
const pre = document.getElementById('lc-log');
|
const pre = document.getElementById('lc-log');
|
||||||
@@ -9893,6 +10014,18 @@ function markFileLinks(root){
|
|||||||
for(const a of root.querySelectorAll('a[href]')){
|
for(const a of root.querySelectorAll('a[href]')){
|
||||||
const p=fileLinkPath(a.getAttribute('href'));
|
const p=fileLinkPath(a.getAttribute('href'));
|
||||||
if(!p) continue;
|
if(!p) continue;
|
||||||
|
// Lien vers une IMAGE ([le graphique](graph.png)) : on l'affiche au lieu d'un
|
||||||
|
// simple bouton de téléchargement (repris d'AJEAN). hydrateImages, appelé
|
||||||
|
// juste après, la charge en vignette ; un clic ouvre l'original. Formats que
|
||||||
|
// le navigateur affiche seulement (pas de SVG servi tel quel, ni de HEIC).
|
||||||
|
if(/\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(p)){
|
||||||
|
const img=document.createElement('img');
|
||||||
|
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(p));
|
||||||
|
img.alt=(a.textContent||'').trim() || p.split('/').pop();
|
||||||
|
img.loading='lazy';
|
||||||
|
a.replaceWith(img);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
a.classList.add('filelink');
|
a.classList.add('filelink');
|
||||||
a.removeAttribute('target');
|
a.removeAttribute('target');
|
||||||
a.setAttribute('href','#');
|
a.setAttribute('href','#');
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="net-block" class="gated-group">
|
<div id="net-block" class="gated-group">
|
||||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_images / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||||||
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
||||||
|
|||||||
@@ -230,7 +230,47 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink
|
|||||||
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
||||||
// casserait les rendus suivants qui réutilisent la même entrée.
|
// casserait les rendus suivants qui réutilisent la même entrée.
|
||||||
const IMG_CACHE = new Map();
|
const IMG_CACHE = new Map();
|
||||||
|
// Vignettes du fil (repris d'AJEAN) : ~560 px suffisent à l'affichage, le
|
||||||
|
// serveur les calcule (?thumb=). Chargement DEUX PAR DEUX avec un nouvel essai :
|
||||||
|
// en accès distant, une rafale de captures saturait le tunnel et certaines
|
||||||
|
// n'arrivaient jamais.
|
||||||
|
const CHAT_THUMB_PX = 560;
|
||||||
|
let chatImgActive = 0;
|
||||||
|
const chatImgWaiting = [];
|
||||||
|
function chatImgQueue(job){
|
||||||
|
return new Promise((resolve, reject)=>{
|
||||||
|
const run = async ()=>{
|
||||||
|
chatImgActive++;
|
||||||
|
try{
|
||||||
|
try{ resolve(await job()); }
|
||||||
|
catch(_){ await new Promise(r=>setTimeout(r, 800)); resolve(await job()); }
|
||||||
|
}catch(e){ reject(e); }
|
||||||
|
finally{ chatImgActive--; const next = chatImgWaiting.shift(); if(next) next(); }
|
||||||
|
};
|
||||||
|
if(chatImgActive < 2) run(); else chatImgWaiting.push(run);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function chatImgBlob(url){
|
||||||
|
const hit = IMG_CACHE.get(url);
|
||||||
|
if(hit) return hit;
|
||||||
|
const r = await jfetch(url);
|
||||||
|
if(!r.ok) throw new Error(r.status);
|
||||||
|
const obj = URL.createObjectURL(await r.blob());
|
||||||
|
IMG_CACHE.set(url, obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
function hydrateImages(root){
|
function hydrateImages(root){
|
||||||
|
// Images du web (outil web_images, repris d'AJEAN) : même rendu et même
|
||||||
|
// loupe que les nôtres, sans Referer, et masquées si le site les refuse.
|
||||||
|
root.querySelectorAll('img[src^="http://"],img[src^="https://"]').forEach(img => {
|
||||||
|
if(img.dataset.hydrated) return;
|
||||||
|
img.dataset.hydrated = '1';
|
||||||
|
img.classList.add('chatimg');
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.referrerPolicy = 'no-referrer';
|
||||||
|
bindZoom(img, img.getAttribute('alt') || '');
|
||||||
|
img.addEventListener('error', ()=>{ img.style.display = 'none'; });
|
||||||
|
});
|
||||||
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
if(img.dataset.hydrated) return;
|
if(img.dataset.hydrated) return;
|
||||||
img.dataset.hydrated = '1';
|
img.dataset.hydrated = '1';
|
||||||
@@ -242,15 +282,21 @@ function hydrateImages(root){
|
|||||||
const m = /[?&]path=([^&]*)/.exec(src);
|
const m = /[?&]path=([^&]*)/.exec(src);
|
||||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||||
}
|
}
|
||||||
bindZoom(img, name);
|
const full = src.startsWith('/') ? src : '/' + src;
|
||||||
const cached = IMG_CACHE.get(src);
|
// Loupe : l'ORIGINAL, chargé seulement à l'agrandissement (le fil n'affiche
|
||||||
|
// qu'une vignette calculée par le serveur).
|
||||||
|
if(!img.dataset.zoom){
|
||||||
|
img.dataset.zoom = '1';
|
||||||
|
img.addEventListener('click', async ()=>{
|
||||||
|
try{ openLightbox(await chatImgBlob(full), name); }
|
||||||
|
catch(_){ openLightbox(img.currentSrc || img.src, name); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const thumb = full + (full.includes('?') ? '&' : '?') + 'thumb=' + CHAT_THUMB_PX;
|
||||||
|
const cached = IMG_CACHE.get(thumb);
|
||||||
if(cached){ img.src = cached; return; }
|
if(cached){ img.src = cached; return; }
|
||||||
try{
|
try{
|
||||||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
img.src = await chatImgQueue(()=>chatImgBlob(thumb));
|
||||||
if(!r.ok) throw new Error(r.status);
|
|
||||||
const url = URL.createObjectURL(await r.blob());
|
|
||||||
IMG_CACHE.set(src, url);
|
|
||||||
img.src = url;
|
|
||||||
}catch(e){
|
}catch(e){
|
||||||
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
||||||
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
||||||
@@ -275,6 +321,7 @@ function renderToolMsg(el, tu){
|
|||||||
write: {lbl:'fichier', head:'écriture'},
|
write: {lbl:'fichier', head:'écriture'},
|
||||||
edit: {lbl:'édition', head:'édition'},
|
edit: {lbl:'édition', head:'édition'},
|
||||||
web_search: {lbl:'recherche', head:'recherche web'},
|
web_search: {lbl:'recherche', head:'recherche web'},
|
||||||
|
web_images: {lbl:'images', head:"recherche d'images"},
|
||||||
web_open: {lbl:'page web', head:'ouverture'},
|
web_open: {lbl:'page web', head:'ouverture'},
|
||||||
web_read: {lbl:'page web', head:'lecture'},
|
web_read: {lbl:'page web', head:'lecture'},
|
||||||
web_grep: {lbl:'page web', head:'recherche'},
|
web_grep: {lbl:'page web', head:'recherche'},
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function confirmPending(text){
|
|||||||
let REPLAYING=true;
|
let REPLAYING=true;
|
||||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||||
let T=null;
|
let T=null;
|
||||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
|
function newTurn(){ T={ cuShotEl:null, reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
|
||||||
newTurn();
|
newTurn();
|
||||||
|
|
||||||
// --- Temps de travail du tour ------------------------------------------------
|
// --- Temps de travail du tour ------------------------------------------------
|
||||||
@@ -187,6 +187,54 @@ function paintStats(el, speed){
|
|||||||
}
|
}
|
||||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
||||||
|
// Aperçu EN DIRECT du navigateur piloté (repris d'AJEAN). Après chaque action
|
||||||
|
// browser_*, le serveur garde une capture en mémoire (tu.shot = son id). Une
|
||||||
|
// seule carte par tour : la nouvelle image arrive en fondu par-dessus
|
||||||
|
// l'ancienne. Purement de l'interface : la carte s'efface à la fin du tour et
|
||||||
|
// rien n'est gardé. Une capture à conserver passe par browser_screenshot.
|
||||||
|
async function showCuShot(tu){
|
||||||
|
if(REPLAYING) return;
|
||||||
|
let card=T.cuShotEl;
|
||||||
|
if(!card){
|
||||||
|
card=document.createElement('div'); card.className='cu-shot';
|
||||||
|
card.innerHTML='<div class="cu-shot-cap"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/></svg><span class="cu-shot-t">navigateur en direct</span></div><div class="cu-shot-img"></div>';
|
||||||
|
T.cuShotEl=card;
|
||||||
|
}
|
||||||
|
if(tu.name==='browser_open' && tu.label) card.querySelector('.cu-shot-t').textContent=tu.label;
|
||||||
|
// Juste avant l'indicateur d'activité s'il est là, sinon en bas du fil.
|
||||||
|
const chat=chatEl(), anchor=(T.typingEl && T.typingEl.parentNode===chat) ? T.typingEl : null;
|
||||||
|
if(card.parentNode!==chat || card.nextSibling!==anchor){ if(anchor) chat.insertBefore(card, anchor); else chat.appendChild(card); }
|
||||||
|
const my=(card._n=(card._n||0)+1); // ordre d'arrivée des captures
|
||||||
|
let r; try{ r=await jget('/api/chat/cushot?id='+encodeURIComponent(tu.shot)); }catch(_){ return; }
|
||||||
|
if(!r || !r.ok || T.cuShotEl!==card) return; // carte déjà retirée (tour fini)
|
||||||
|
const box=card.querySelector('.cu-shot-img');
|
||||||
|
const img=new Image(); img.alt=''; img.className='cu-frame';
|
||||||
|
img.onclick=()=>openLightbox(img.src, card.querySelector('.cu-shot-t').textContent);
|
||||||
|
img.src='data:'+(r.mime||'image/jpeg')+';base64,'+r.data;
|
||||||
|
// Décodée AVANT d'entrer dans la page : jamais d'image à moitié peinte.
|
||||||
|
try{ await img.decode(); }catch(_){ return; }
|
||||||
|
// Une capture plus récente est déjà affichée (réponses dans le désordre) : on jette.
|
||||||
|
if(T.cuShotEl!==card || my<(card._shown||0)) return;
|
||||||
|
card._shown=my;
|
||||||
|
box.appendChild(img);
|
||||||
|
if(!box.style.aspectRatio) box.style.aspectRatio=img.naturalWidth+' / '+img.naturalHeight;
|
||||||
|
setTimeout(()=>img.classList.add('in'), 20);
|
||||||
|
// Fondu terminé : on retire les images PLUS ANCIENNES seulement.
|
||||||
|
setTimeout(()=>{ let x=img.previousElementSibling; while(x){ const p=x.previousElementSibling; x.remove(); x=p; } }, 480);
|
||||||
|
scrollMaybe();
|
||||||
|
}
|
||||||
|
// Fin du tour : l'aperçu s'efface en douceur. Toutes les cartes du fil, au cas
|
||||||
|
// où un tour précédent en aurait laissé une.
|
||||||
|
function hideCuShot(){
|
||||||
|
if(T) T.cuShotEl=null;
|
||||||
|
const chat=chatEl(); if(!chat) return;
|
||||||
|
chat.querySelectorAll(':scope > .cu-shot').forEach(card=>{
|
||||||
|
if(card.classList.contains('out')) return;
|
||||||
|
card.style.height=card.offsetHeight+'px'; void card.offsetHeight;
|
||||||
|
card.classList.add('out'); card.style.height='0px';
|
||||||
|
setTimeout(()=>card.remove(), 380);
|
||||||
|
});
|
||||||
|
}
|
||||||
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
||||||
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
|
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
|
||||||
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
|
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
|
||||||
@@ -536,7 +584,7 @@ function handleDelta(d){
|
|||||||
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||||||
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||||||
// replay, qui rejoue tous les tours passés d'un bloc.
|
// replay, qui rejoue tous les tours passés d'un bloc.
|
||||||
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
if(d.turn_done){ hideCuShot(); settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
||||||
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
|
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
|
||||||
// le temps réellement passé et non continuer d'avancer.
|
// le temps réellement passé et non continuer d'avancer.
|
||||||
T.doneTs = d.ts || T.doneTs || 0;
|
T.doneTs = d.ts || T.doneTs || 0;
|
||||||
@@ -545,7 +593,7 @@ function handleDelta(d){
|
|||||||
paintTurnClock(); // fige le total au-dessus de la carte
|
paintTurnClock(); // fige le total au-dessus de la carte
|
||||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
||||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
if(d.error){ hideCuShot(); settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
||||||
@@ -582,6 +630,7 @@ function handleDelta(d){
|
|||||||
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
||||||
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
||||||
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
||||||
|
if(tu.done && tu.shot) showCuShot(tu);
|
||||||
if(tu.done){
|
if(tu.done){
|
||||||
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||||
|
|||||||
@@ -453,10 +453,21 @@ function lcStartPolling(){
|
|||||||
lcPollJob();
|
lcPollJob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Une seule requête à la fois : l'intervalle d'une seconde relançait un appel
|
||||||
|
// avant la réponse du précédent quand le serveur était occupé (téléchargement,
|
||||||
|
// accès distant), et les deux ajoutaient les MÊMES lignes au journal.
|
||||||
|
let lcPollInflight = false;
|
||||||
async function lcPollJob(quiet){
|
async function lcPollJob(quiet){
|
||||||
|
if(lcPollInflight) return;
|
||||||
|
lcPollInflight = true;
|
||||||
|
const from = lcLogNext;
|
||||||
let j;
|
let j;
|
||||||
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
|
try{ j = await jget('/api/llamacpp/job?from='+from); }catch(_){ return; }
|
||||||
|
finally{ lcPollInflight = false; }
|
||||||
if(!j.exists) return;
|
if(!j.exists) return;
|
||||||
|
// Le journal a été remis à zéro ou avancé entre-temps (nouveau job) : ces
|
||||||
|
// lignes ne suivent plus ce qui est affiché.
|
||||||
|
if(from !== lcLogNext) return;
|
||||||
const phaseEl = document.getElementById('lc-job-phase');
|
const phaseEl = document.getElementById('lc-job-phase');
|
||||||
if(j.lines && j.lines.length){
|
if(j.lines && j.lines.length){
|
||||||
const pre = document.getElementById('lc-log');
|
const pre = document.getElementById('lc-log');
|
||||||
|
|||||||
@@ -215,6 +215,18 @@ function markFileLinks(root){
|
|||||||
for(const a of root.querySelectorAll('a[href]')){
|
for(const a of root.querySelectorAll('a[href]')){
|
||||||
const p=fileLinkPath(a.getAttribute('href'));
|
const p=fileLinkPath(a.getAttribute('href'));
|
||||||
if(!p) continue;
|
if(!p) continue;
|
||||||
|
// Lien vers une IMAGE ([le graphique](graph.png)) : on l'affiche au lieu d'un
|
||||||
|
// simple bouton de téléchargement (repris d'AJEAN). hydrateImages, appelé
|
||||||
|
// juste après, la charge en vignette ; un clic ouvre l'original. Formats que
|
||||||
|
// le navigateur affiche seulement (pas de SVG servi tel quel, ni de HEIC).
|
||||||
|
if(/\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(p)){
|
||||||
|
const img=document.createElement('img');
|
||||||
|
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(p));
|
||||||
|
img.alt=(a.textContent||'').trim() || p.split('/').pop();
|
||||||
|
img.loading='lazy';
|
||||||
|
a.replaceWith(img);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
a.classList.add('filelink');
|
a.classList.add('filelink');
|
||||||
a.removeAttribute('target');
|
a.removeAttribute('target');
|
||||||
a.setAttribute('href','#');
|
a.setAttribute('href','#');
|
||||||
|
|||||||
@@ -2270,3 +2270,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
transition:background .15s,color .15s,border-color .15s}
|
transition:background .15s,color .15s,border-color .15s}
|
||||||
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
||||||
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
||||||
|
/* Aperçu en direct du navigateur piloté (09-stream.js showCuShot, repris d'AJEAN). */
|
||||||
|
.cu-shot{flex-shrink:0;align-self:flex-start;width:min(640px,100%);margin:2px 0 6px;overflow:hidden;
|
||||||
|
transition:height .35s cubic-bezier(.2,.7,.3,1),opacity .3s ease,margin .35s ease;animation:cushotcard .3s ease both}
|
||||||
|
.cu-shot.out{opacity:0;margin:0}
|
||||||
|
@keyframes cushotcard{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||||
|
.cu-shot-cap{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dim);margin:0 0 6px 2px;min-width:0}
|
||||||
|
.cu-shot-cap svg{flex:none}
|
||||||
|
.cu-shot-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.cu-shot-img{position:relative;border-radius:12px;overflow:hidden;background:var(--row-bg);
|
||||||
|
box-shadow:0 4px 18px rgba(0,0,0,.10);aspect-ratio:16 / 10}
|
||||||
|
.cu-shot-img img.cu-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top;
|
||||||
|
opacity:0;transition:opacity .45s ease;cursor:zoom-in}
|
||||||
|
.cu-shot-img img.cu-frame.in{opacity:1}
|
||||||
|
@media (prefers-reduced-motion:reduce){.cu-shot,.cu-shot-img img.cu-frame{transition:none;animation:none}}
|
||||||
@@ -362,6 +362,7 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
|
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
|
||||||
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||||
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
||||||
|
api("/api/chat/cushot", handleCUShot) // aperçu en direct du navigateur piloté (RAM, jamais gardé)
|
||||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||||
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
|
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
|
||||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// Vignettes des images du fil (voir handleChatImage, paramètre thumb ; repris d'AJEAN). Une
|
||||||
|
// vignette est calculée une fois par fichier et par taille, puis gardée en
|
||||||
|
// mémoire tant que le fichier ne change pas (date et taille).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/jpeg"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "image/gif" // décodeurs enregistrés pour image.Decode
|
||||||
|
_ "image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
thumbMaxSourceBytes = 40 << 20 // au-delà, pas de vignette : le client prendra l'original
|
||||||
|
thumbCacheMax = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
type thumbEntry struct {
|
||||||
|
mod time.Time
|
||||||
|
size int64
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
thumbMu sync.Mutex
|
||||||
|
thumbCache = map[string]thumbEntry{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// chatThumb renvoie une vignette JPEG dont le grand côté vaut px (borné
|
||||||
|
// 64..1600). Erreur si le fichier n'est pas une image décodable ici (webp…) :
|
||||||
|
// le client se rabat alors sur l'original.
|
||||||
|
func chatThumb(path string, st os.FileInfo, px int) ([]byte, string, error) {
|
||||||
|
if px < 64 {
|
||||||
|
px = 64
|
||||||
|
}
|
||||||
|
if px > 1600 {
|
||||||
|
px = 1600
|
||||||
|
}
|
||||||
|
if st.Size() > thumbMaxSourceBytes {
|
||||||
|
return nil, "", fmt.Errorf("image trop lourde pour une vignette")
|
||||||
|
}
|
||||||
|
key := path + "\x00" + strconv.Itoa(px)
|
||||||
|
thumbMu.Lock()
|
||||||
|
if e, ok := thumbCache[key]; ok && e.mod.Equal(st.ModTime()) && e.size == st.Size() {
|
||||||
|
thumbMu.Unlock()
|
||||||
|
return e.data, "image/jpeg", nil
|
||||||
|
}
|
||||||
|
thumbMu.Unlock()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
src, _, err := image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("format non pris en charge pour une vignette")
|
||||||
|
}
|
||||||
|
if o := exifOrientation(raw); o >= 2 && o <= 8 {
|
||||||
|
src = applyOrientation(src, o)
|
||||||
|
}
|
||||||
|
if b := src.Bounds(); b.Dx() > px || b.Dy() > px {
|
||||||
|
src = scaleDownToMax(src, px)
|
||||||
|
}
|
||||||
|
// Fond blanc sous la transparence : en JPEG elle deviendrait noire.
|
||||||
|
flat := image.NewRGBA(src.Bounds())
|
||||||
|
draw.Draw(flat, flat.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(flat, flat.Bounds(), src, src.Bounds().Min, draw.Over)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, flat, &jpeg.Options{Quality: 78}); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
data := buf.Bytes()
|
||||||
|
|
||||||
|
thumbMu.Lock()
|
||||||
|
if len(thumbCache) >= thumbCacheMax {
|
||||||
|
for k := range thumbCache { // éviction grossière : la place compte plus que l'ordre
|
||||||
|
delete(thumbCache, k)
|
||||||
|
if len(thumbCache) < thumbCacheMax/2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thumbCache[key] = thumbEntry{mod: st.ModTime(), size: st.Size(), data: data}
|
||||||
|
thumbMu.Unlock()
|
||||||
|
return data, "image/jpeg", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatThumbReduitEtCache(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "capture.png")
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 2400, 1500))
|
||||||
|
x := uint32(1)
|
||||||
|
for i := range src.Pix { // bruit : se comprime comme une photo, pas comme un motif
|
||||||
|
x ^= x << 13
|
||||||
|
x ^= x >> 17
|
||||||
|
x ^= x << 5
|
||||||
|
src.Pix[i] = byte(x)
|
||||||
|
}
|
||||||
|
src.Set(0, 0, color.NRGBA{0, 0, 0, 0})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = png.Encode(&buf, src)
|
||||||
|
_ = os.WriteFile(p, buf.Bytes(), 0o644)
|
||||||
|
st, _ := os.Stat(p)
|
||||||
|
data, mime, err := chatThumb(p, st, 560)
|
||||||
|
if err != nil || mime != "image/jpeg" {
|
||||||
|
t.Fatalf("vignette : %v %s", err, mime)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil || img.Bounds().Dx() != 560 {
|
||||||
|
t.Fatalf("taille inattendue : %v %v", err, img.Bounds())
|
||||||
|
}
|
||||||
|
if len(data) >= buf.Len() {
|
||||||
|
t.Fatalf("vignette (%d o) pas plus légère que l'original (%d o)", len(data), buf.Len())
|
||||||
|
}
|
||||||
|
again, _, _ := chatThumb(p, st, 560)
|
||||||
|
if &again[0] != &data[0] {
|
||||||
|
t.Fatal("la 2e demande aurait dû venir du cache")
|
||||||
|
}
|
||||||
|
if _, _, err := chatThumb(filepath.Join(dir, "x.webp"), st, 560); err == nil {
|
||||||
|
t.Fatal("un fichier illisible doit renvoyer une erreur (repli client sur l'original)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,6 +361,21 @@ func handleChatImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"})
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Vignette légère (?thumb=<px>) : le fil n'a besoin que de ~560 px, pas
|
||||||
|
// d'une capture de 3 Mo — en accès distant, chaque image partait en entier
|
||||||
|
// dans le tunnel chiffré (repris d'AJEAN). Format non décodable ici (webp…) :
|
||||||
|
// on sert l'original.
|
||||||
|
if t := r.URL.Query().Get("thumb"); t != "" {
|
||||||
|
px, _ := strconv.Atoi(t)
|
||||||
|
if data, mime, err := chatThumb(abs, st, px); err == nil {
|
||||||
|
w.Header().Set("Content-Type", mime)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
f, err := os.Open(abs)
|
f, err := os.Open(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
|||||||
Reference in new issue
Block a user