mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +02:00
Captures de pages web (Playwright) affichées dans le fil
Outil web_screenshot : Chromium piloté par Playwright photographie une page RENDUE (JavaScript exécuté) dans le dossier de travail, et rend au modèle la ligne markdown exacte à recopier — lui laisser composer l'URL reviendrait à lui faire inventer un chemin, donc une image cassée. INDÉPENDANT DE LA VISION, souvent confondu : visionEnabled() (clé MMPROJ) ne décide que d'une chose, l'envoi d'images AU MODÈLE. Capturer et afficher ne passent pas par le modèle — sans projecteur, l'agent photographie sans regarder, ce qui suffit à illustrer une conversation. - route /api/chat/image : sert UNIQUEMENT des images du dossier de travail, en ligne. handleChatFile force le téléchargement de tout pour qu'un .html du modèle ne s'exécute pas dans l'origine de l'UI ; ici la même règle est tenue autrement — type déduit du CONTENU (pas de l'extension), nosniff, et CSP default-src 'none'. Un faux .png contenant du HTML est refusé en 415. - UI : une balise <img> ne peut pas porter d'en-tête Authorization, or /api/* exige la clé dès qu'elle est définie. Les images sont donc récupérées par fetch authentifié puis posées en blob:. - l'outil n'est déclaré au modèle QUE si Playwright est réellement présent : annoncer un outil absent envoie le modèle en boucle de réessai. - Node 22 (NodeSource) au lieu du Node 18 d'Ubuntu, exigé par Playwright et par la plupart des serveurs MCP. PLAYWRIGHT=0 bâtit une image sans navigateur. - CONFIG ACTIVE affichait « llama.cpp personnalisé » pour le moteur de l'image : il annonce maintenant « llama.cpp de l'image ». Description de l'outil tenue au plus court : TestSystemPromptStaysLean a attrapé le dépassement du budget de préambule (7888 car pour 7500). Vérifié dans l'image : capture réelle d'une page, servie en image/png avec CSP ; faux PNG rejeté (415) ; évasion du workspace bloquée (403). Playwright ajoute 816 Mo à l'image.
This commit is contained in:
10 files changed
+315
-5
No files matched your search
+18
-1
@@ -35,10 +35,27 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
|
|||||||
|
|
||||||
# git pour les outils de l'agent ; nodejs/npm pour les serveurs MCP lancés
|
# git pour les outils de l'agent ; nodejs/npm pour les serveurs MCP lancés
|
||||||
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
|
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
|
||||||
|
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
|
||||||
|
# et la plupart des serveurs MCP réclament Node ≥ 20.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git nodejs npm tini \
|
git tini ca-certificates curl gnupg \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
|
||||||
|
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
|
||||||
|
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
|
||||||
|
# légère — l'outil se désactive alors tout seul (le binaire absent est détecté).
|
||||||
|
ARG PLAYWRIGHT=1
|
||||||
|
ARG PLAYWRIGHT_VERSION=latest
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers
|
||||||
|
RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||||
|
npm i -g playwright@${PLAYWRIGHT_VERSION} \
|
||||||
|
&& playwright install --with-deps chromium \
|
||||||
|
&& rm -rf /root/.npm /var/lib/apt/lists/* ; \
|
||||||
|
fi
|
||||||
|
|
||||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ func cmdChat(args []string) error {
|
|||||||
case "web_search", "web_open", "web_read", "web_grep":
|
case "web_search", "web_open", "web_read", "web_grep":
|
||||||
icon = "🌐"
|
icon = "🌐"
|
||||||
verb = "web"
|
verb = "web"
|
||||||
|
case "web_screenshot":
|
||||||
|
icon = "📸"
|
||||||
|
verb = "capture"
|
||||||
}
|
}
|
||||||
if inReason {
|
if inReason {
|
||||||
fmt.Print("\n")
|
fmt.Print("\n")
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// chat_screenshot.go — outil web_screenshot : capture d'une page web RENDUE
|
||||||
|
// (JavaScript exécuté) via le navigateur Chromium piloté par Playwright.
|
||||||
|
//
|
||||||
|
// Indépendant de la vision : la capture est un fichier PNG écrit dans le dossier
|
||||||
|
// de travail, que l'UI affiche dans le fil (route /api/chat/image). Le modèle,
|
||||||
|
// lui, ne la VOIT que si un projecteur multimodal est configuré (MMPROJ) — deux
|
||||||
|
// mécanismes distincts qu'il ne faut pas confondre. Sans vision, l'agent
|
||||||
|
// photographie sans regarder : c'est utile pour TOI, pas pour lui.
|
||||||
|
//
|
||||||
|
// Playwright est installé dans l'image Docker (voir Dockerfile). Hors conteneur,
|
||||||
|
// ou si l'image a été bâtie avec PLAYWRIGHT=0, l'outil n'est pas déclaré du tout
|
||||||
|
// plutôt que d'être annoncé au modèle puis d'échouer — un outil qu'on annonce et
|
||||||
|
// qui ne marche pas déclenche des boucles de réessai.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureDir : sous-dossier du workspace où atterrissent les captures.
|
||||||
|
const captureDir = "captures"
|
||||||
|
|
||||||
|
// screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a
|
||||||
|
// son propre délai interne, celui-ci est le garde-fou externe.
|
||||||
|
const screenshotTimeout = 90 * time.Second
|
||||||
|
|
||||||
|
// playwrightBin renvoie le chemin du CLI Playwright, ou "" s'il est absent.
|
||||||
|
func playwrightBin() string {
|
||||||
|
p, err := exec.LookPath("playwright")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenshotAvailable() bool { return playwrightBin() != "" }
|
||||||
|
|
||||||
|
func webScreenshotTool() Tool {
|
||||||
|
return Tool{Type: "function", Function: ToolFunction{
|
||||||
|
Name: "web_screenshot",
|
||||||
|
// Description tenue au plus court : les schémas d'outils partent dans
|
||||||
|
// CHAQUE requête et le préambule a un budget (TestSystemPromptStaysLean).
|
||||||
|
Description: "Photographie une page web (PNG, JS exécuté) pour la MONTRER. " +
|
||||||
|
"La réponse donne la ligne markdown à recopier. Tu ne vois pas l'image.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"url": map[string]any{"type": "string", "description": "URL complète"},
|
||||||
|
"full_page": map[string]any{"type": "boolean", "description": "Page entière. Défaut true."},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeSlug réduit un hôte à un nom de fichier sûr.
|
||||||
|
var slugRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||||
|
|
||||||
|
func safeSlug(s string) string {
|
||||||
|
s = slugRe.ReplaceAllString(s, "-")
|
||||||
|
s = strings.Trim(s, "-.")
|
||||||
|
if r := []rune(s); len(r) > 40 {
|
||||||
|
s = string(r[:40])
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
s = "page"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolWebScreenshot(args map[string]any) string {
|
||||||
|
bin := playwrightBin()
|
||||||
|
if bin == "" {
|
||||||
|
return "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0)."
|
||||||
|
}
|
||||||
|
url, _ := args["url"].(string)
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
if url == "" {
|
||||||
|
return "[erreur] url manquante"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
return "[erreur] url invalide : elle doit commencer par http:// ou https://"
|
||||||
|
}
|
||||||
|
// width n'est plus déclaré dans le schéma (budget de préambule), mais reste
|
||||||
|
// honoré s'il arrive quand même : un modèle qui l'invente obtient le
|
||||||
|
// comportement attendu plutôt qu'un paramètre ignoré en silence.
|
||||||
|
width := 1280
|
||||||
|
if v, ok := args["width"].(float64); ok && v >= 320 && v <= 3840 {
|
||||||
|
width = int(v)
|
||||||
|
}
|
||||||
|
fullPage := true
|
||||||
|
if v, ok := args["full_page"].(bool); ok {
|
||||||
|
fullPage = v
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(agentWorkspace(), captureDir)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "[erreur] création du dossier de captures : " + err.Error()
|
||||||
|
}
|
||||||
|
// Nom lisible et unique : hôte + horodatage. Deux captures de la même page
|
||||||
|
// ne s'écrasent donc pas, et le fil garde les deux états.
|
||||||
|
host := url
|
||||||
|
if i := strings.Index(host, "://"); i >= 0 {
|
||||||
|
host = host[i+3:]
|
||||||
|
}
|
||||||
|
if i := strings.IndexAny(host, "/?#"); i >= 0 {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("%s-%s.png", safeSlug(host), time.Now().Format("20060102-150405"))
|
||||||
|
out := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
cmdArgs := []string{"screenshot", "--browser", "chromium",
|
||||||
|
"--viewport-size", fmt.Sprintf("%d,800", width),
|
||||||
|
"--wait-for-timeout", "2500"}
|
||||||
|
if fullPage {
|
||||||
|
cmdArgs = append(cmdArgs, "--full-page")
|
||||||
|
}
|
||||||
|
cmdArgs = append(cmdArgs, url, out)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), screenshotTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, bin, cmdArgs...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
combined, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return "[erreur] la page n'a pas fini de charger en 90 s"
|
||||||
|
}
|
||||||
|
return "[erreur] capture impossible : " + strings.TrimSpace(lastLines(string(combined), 3))
|
||||||
|
}
|
||||||
|
st, err := os.Stat(out)
|
||||||
|
if err != nil || st.Size() == 0 {
|
||||||
|
return "[erreur] Playwright n'a produit aucune image"
|
||||||
|
}
|
||||||
|
|
||||||
|
rel := captureDir + "/" + name
|
||||||
|
// On rend au modèle la ligne EXACTE à recopier : lui laisser composer l'URL
|
||||||
|
// d'affichage revient à lui faire inventer un chemin, donc une image cassée.
|
||||||
|
return fmt.Sprintf("Capture enregistrée (%s, %d Ko).\n"+
|
||||||
|
"Pour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n"+
|
||||||
|
"",
|
||||||
|
rel, st.Size()/1024, host, rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastLines garde les n dernières lignes non vides d'une sortie d'erreur —
|
||||||
|
// Playwright est bavard, seule la fin porte la cause.
|
||||||
|
func lastLines(s string, n int) string {
|
||||||
|
var keep []string
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
keep = append(keep, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keep) > n {
|
||||||
|
keep = keep[len(keep)-n:]
|
||||||
|
}
|
||||||
|
return strings.Join(keep, " · ")
|
||||||
|
}
|
||||||
@@ -257,6 +257,12 @@ func EnabledTools(caps Caps) []Tool {
|
|||||||
// 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(), webOpenTool(), webReadTool(), webGrepTool())
|
||||||
|
// 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
|
||||||
|
// réessai sur un échec systématique.
|
||||||
|
if screenshotAvailable() {
|
||||||
|
tools = append(tools, webScreenshotTool())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
||||||
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
||||||
@@ -1054,6 +1060,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
|||||||
result = capWebOutput(toolWebRead(args))
|
result = capWebOutput(toolWebRead(args))
|
||||||
case "web_grep":
|
case "web_grep":
|
||||||
result = capWebOutput(toolWebGrep(args))
|
result = capWebOutput(toolWebGrep(args))
|
||||||
|
case "web_screenshot":
|
||||||
|
result = toolWebScreenshot(args)
|
||||||
default:
|
default:
|
||||||
if isMCPTool(tc.Function.Name) {
|
if isMCPTool(tc.Function.Name) {
|
||||||
result = mcpCall(tc.Function.Name, args)
|
result = mcpCall(tc.Function.Name, args)
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
|||||||
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
|
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
|
||||||
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
|
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
|
||||||
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
|
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
|
||||||
|
/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et
|
||||||
|
cliquables pour ouvrir en grand dans un onglet. */
|
||||||
|
.msg .body img{max-width:100%;height:auto;border-radius:8px;border:1px solid var(--border);display:block;margin:8px 0}
|
||||||
/* pastille d'état service, en haut à droite de la barre latérale */
|
/* pastille d'état service, en haut à droite de la barre latérale */
|
||||||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
||||||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||||||
@@ -2508,7 +2511,10 @@ async function loadCfg(){
|
|||||||
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
||||||
// toujours le chemin complet, quel que soit le libellé.
|
// toujours le chemin complet, quel que soit le libellé.
|
||||||
let v;
|
let v;
|
||||||
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
|
||||||
|
// ranger dans « personnalisé » qui laisse croire à un bricolage.
|
||||||
|
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
|
||||||
|
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
||||||
else if(lc && lc.in_use) v='llama.cpp compilé';
|
else if(lc && lc.in_use) v='llama.cpp compilé';
|
||||||
else v='llama.cpp personnalisé : '+c.BIN;
|
else v='llama.cpp personnalisé : '+c.BIN;
|
||||||
rows.push(row('MOTEUR', v, c.BIN));
|
rows.push(row('MOTEUR', v, c.BIN));
|
||||||
@@ -4161,7 +4167,34 @@ function setStats(el, text){
|
|||||||
}
|
}
|
||||||
function bodyOf(el){ return el.querySelector('.body'); }
|
function bodyOf(el){ return el.querySelector('.body'); }
|
||||||
// Render markdown into a message body in place; safe because md() escapes HTML.
|
// Render markdown into a message body in place; safe because md() escapes HTML.
|
||||||
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); scrollMaybe(); }
|
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); hydrateImages(b); scrollMaybe(); }
|
||||||
|
// Images servies par Loki (/api/chat/image) : une balise <img> ne peut pas
|
||||||
|
// porter d'en-tête Authorization, or /api/* exige la clé de pilotage dès qu'elle
|
||||||
|
// est définie. On récupère donc l'image par fetch authentifié et on la pose en
|
||||||
|
// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images
|
||||||
|
// cassées. Les URLs externes (http…) ne sont pas touchées.
|
||||||
|
function hydrateImages(root){
|
||||||
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
|
if(img.dataset.hydrated) return;
|
||||||
|
img.dataset.hydrated = '1';
|
||||||
|
const src = img.getAttribute('src');
|
||||||
|
try{
|
||||||
|
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
||||||
|
if(!r.ok) throw new Error(r.status);
|
||||||
|
const url = URL.createObjectURL(await r.blob());
|
||||||
|
img.src = url;
|
||||||
|
img.classList.add('chatimg');
|
||||||
|
// La révocation attend le chargement : révoquer tout de suite laisserait
|
||||||
|
// une image vide sur les navigateurs qui décodent en différé.
|
||||||
|
img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true});
|
||||||
|
}catch(e){
|
||||||
|
const note = document.createElement('span');
|
||||||
|
note.className = 'muted';
|
||||||
|
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
|
||||||
|
img.replaceWith(note);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// Render a tool call as its own conversation message: the command the model
|
// 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.
|
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||||||
function renderToolMsg(el, tu){
|
function renderToolMsg(el, tu){
|
||||||
|
|||||||
@@ -162,7 +162,10 @@ async function loadCfg(){
|
|||||||
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
||||||
// toujours le chemin complet, quel que soit le libellé.
|
// toujours le chemin complet, quel que soit le libellé.
|
||||||
let v;
|
let v;
|
||||||
if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
|
||||||
|
// ranger dans « personnalisé » qui laisse croire à un bricolage.
|
||||||
|
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
|
||||||
|
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
||||||
else if(lc && lc.in_use) v='llama.cpp compilé';
|
else if(lc && lc.in_use) v='llama.cpp compilé';
|
||||||
else v='llama.cpp personnalisé : '+c.BIN;
|
else v='llama.cpp personnalisé : '+c.BIN;
|
||||||
rows.push(row('MOTEUR', v, c.BIN));
|
rows.push(row('MOTEUR', v, c.BIN));
|
||||||
|
|||||||
@@ -140,7 +140,34 @@ function setStats(el, text){
|
|||||||
}
|
}
|
||||||
function bodyOf(el){ return el.querySelector('.body'); }
|
function bodyOf(el){ return el.querySelector('.body'); }
|
||||||
// Render markdown into a message body in place; safe because md() escapes HTML.
|
// Render markdown into a message body in place; safe because md() escapes HTML.
|
||||||
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); scrollMaybe(); }
|
function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); hydrateImages(b); scrollMaybe(); }
|
||||||
|
// Images servies par Loki (/api/chat/image) : une balise <img> ne peut pas
|
||||||
|
// porter d'en-tête Authorization, or /api/* exige la clé de pilotage dès qu'elle
|
||||||
|
// est définie. On récupère donc l'image par fetch authentifié et on la pose en
|
||||||
|
// blob:. Sans ça, toute instance protégée par une clé n'affichait que des images
|
||||||
|
// cassées. Les URLs externes (http…) ne sont pas touchées.
|
||||||
|
function hydrateImages(root){
|
||||||
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
|
if(img.dataset.hydrated) return;
|
||||||
|
img.dataset.hydrated = '1';
|
||||||
|
const src = img.getAttribute('src');
|
||||||
|
try{
|
||||||
|
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
||||||
|
if(!r.ok) throw new Error(r.status);
|
||||||
|
const url = URL.createObjectURL(await r.blob());
|
||||||
|
img.src = url;
|
||||||
|
img.classList.add('chatimg');
|
||||||
|
// La révocation attend le chargement : révoquer tout de suite laisserait
|
||||||
|
// une image vide sur les navigateurs qui décodent en différé.
|
||||||
|
img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true});
|
||||||
|
}catch(e){
|
||||||
|
const note = document.createElement('span');
|
||||||
|
note.className = 'muted';
|
||||||
|
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
|
||||||
|
img.replaceWith(note);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// Render a tool call as its own conversation message: the command the model
|
// 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.
|
// wrote, then the response it got back. textContent keeps it injection-safe.
|
||||||
function renderToolMsg(el, tu){
|
function renderToolMsg(el, tu){
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
|||||||
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
|
du style de .label (petites capitales) mais sans forcer la casse du prénom. */
|
||||||
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
|
.msg .label .av{font-style:normal;font-size:13px;margin-right:5px;vertical-align:-1px}
|
||||||
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
|
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
|
||||||
|
/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et
|
||||||
|
cliquables pour ouvrir en grand dans un onglet. */
|
||||||
|
.msg .body img{max-width:100%;height:auto;border-radius:8px;border:1px solid var(--border);display:block;margin:8px 0}
|
||||||
/* pastille d'état service, en haut à droite de la barre latérale */
|
/* pastille d'état service, en haut à droite de la barre latérale */
|
||||||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
||||||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||||
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
||||||
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/stop", handleChatStop) // interrompt la génération en cours
|
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||||
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)
|
||||||
// Discussions : liste, création, bascule, renommage, suppression.
|
// Discussions : liste, création, bascule, renommage, suppression.
|
||||||
|
|||||||
@@ -336,6 +336,55 @@ func handleChatFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeFile(w, r, abs)
|
http.ServeFile(w, r, abs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleChatImage sert une image du dossier de travail POUR AFFICHAGE dans le
|
||||||
|
// fil (captures d'écran, images produites par l'agent). Route distincte de
|
||||||
|
// handleChatFile, qui force le téléchargement de TOUT : la règle « rien ne
|
||||||
|
// s'exécute dans l'origine de l'UI » est ici tenue autrement, en ne servant
|
||||||
|
// jamais que des images —
|
||||||
|
// - le type est déduit du CONTENU (les 512 premiers octets), pas de
|
||||||
|
// l'extension : un .png qui contient du HTML est refusé ;
|
||||||
|
// - nosniff empêche le navigateur de revenir sur ce type ;
|
||||||
|
// - une CSP « default-src 'none' » neutralise le document au cas où.
|
||||||
|
func handleChatImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rel := r.URL.Query().Get("path")
|
||||||
|
if strings.TrimSpace(rel) == "" {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
|
||||||
|
if _, ok := workspaceRel(abs); !ok {
|
||||||
|
sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(abs)
|
||||||
|
if err != nil || st.IsDir() {
|
||||||
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
head := make([]byte, 512)
|
||||||
|
n, _ := io.ReadFull(f, head)
|
||||||
|
mime := http.DetectContentType(head[:n])
|
||||||
|
if !strings.HasPrefix(mime, "image/") {
|
||||||
|
sendJSON(w, 415, map[string]any{"ok": false, "error": "ce fichier n'est pas une image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(filepath.Base(abs)))
|
||||||
|
http.ServeContent(w, r, filepath.Base(abs), st.ModTime(), f)
|
||||||
|
}
|
||||||
|
|
||||||
type uploadReq struct {
|
type uploadReq struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Data string `json:"data"` // base64 d'UN morceau (accepte un data: URL complet)
|
Data string `json:"data"` // base64 d'UN morceau (accepte un data: URL complet)
|
||||||
|
|||||||
Reference in new issue
Block a user