mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
v0.6.9 : l'IA écrit enfin les fichiers directement
Nouvel outil write : créer ou réécrire un fichier passe par le disque, plus par le shell. Sous Windows le shell est cmd.exe et son quoting cassait tout script contenant des guillemets imbriqués (echo, python -c, base64) ; l'IA bouclait sans jamais y arriver. Signalé par Arnaud Hocquelet. L'outil bash annonce désormais le vrai shell de la plateforme (cmd.exe sous Windows) au lieu de dire bash partout, pour que le modèle arrête de générer la syntaxe d'un autre shell. Interface : le contenu d'une écriture se remplit ligne à ligne en direct dans la bulle (diffusé à la ligne et pas au token, pour ne pas saturer le rendu mobile), et les compteurs +N -N passent sur l'étiquette pour rester visibles une fois la bulle repliée.
This commit is contained in:
1 parent
73a772520c
commit
7b0a5dbf75
14 files changed
+286
-30
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -3,13 +3,13 @@
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 6,
|
||||
"Patch": 8,
|
||||
"Patch": 9,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 6,
|
||||
"Patch": 8,
|
||||
"Patch": 9,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
||||
"OriginalFilename": "jean.exe",
|
||||
"ProductName": "AJEAN",
|
||||
"ProductVersion": "0.6.8",
|
||||
"ProductVersion": "0.6.9",
|
||||
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -55,7 +55,7 @@ func cmdAgent(args []string) error {
|
||||
if err := setAgentEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (bash) et de sa mémoire")
|
||||
fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (" + shellName() + "), de l'écriture de fichiers et de sa mémoire")
|
||||
case "off":
|
||||
if err := setAgentEnabled(false); err != nil {
|
||||
return err
|
||||
@@ -67,7 +67,7 @@ func cmdAgent(args []string) error {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Mode agent"), state)
|
||||
fmt.Printf(" outils : bash (timeout %ds, max %ds) + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", toolDefaultTimeout, toolMaxTimeout)
|
||||
fmt.Printf(" outils : %s (timeout %ds, max %ds) + write/edit + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", shellName(), toolDefaultTimeout, toolMaxTimeout)
|
||||
mem := MemList()
|
||||
if len(mem) == 0 {
|
||||
fmt.Printf(" mémoire : aucune page — crée %s/<nom>.md\n", memoryDir())
|
||||
|
||||
@@ -106,6 +106,9 @@ func cmdChat(args []string) error {
|
||||
case "bash":
|
||||
icon = "⚙️"
|
||||
verb = "exécution"
|
||||
case "write":
|
||||
icon = "📄"
|
||||
verb = "écriture"
|
||||
case "edit":
|
||||
icon = "✏️"
|
||||
verb = "édition"
|
||||
|
||||
@@ -339,6 +339,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}
|
||||
// Corps en cours de frappe : transitoire (l'état final est le diff), on
|
||||
// ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
|
||||
if ev.ToolUsed.Body != "" {
|
||||
tu["body"] = ev.ToolUsed.Body
|
||||
}
|
||||
// Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste
|
||||
// pour que le diff soit encore là après un rafraîchissement.
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -47,7 +48,8 @@ func baseSystemPrompt(caps Caps) string {
|
||||
}
|
||||
b.WriteString("\n\nTools:\n")
|
||||
if caps.Agent {
|
||||
b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts).\n")
|
||||
b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts). The shell is " + shellName() + ": use its syntax.\n")
|
||||
b.WriteString("- write — create or rewrite a file with exact content.\n")
|
||||
b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n")
|
||||
}
|
||||
if hasMem {
|
||||
@@ -63,7 +65,10 @@ func baseSystemPrompt(caps Caps) string {
|
||||
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
|
||||
}
|
||||
if caps.Agent {
|
||||
b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
|
||||
// Règle courte mais indispensable : sans elle le modèle fabrique ses scripts
|
||||
// avec echo/python -c, ce que cmd.exe massacre (guillemets imbriqués).
|
||||
b.WriteString("\nTo create or rewrite a file, always use write (or edit to patch one) — never echo, cat, python -c or Set-Content through the shell; quoting breaks. Write the script with write, then run it with bash.\n")
|
||||
b.WriteString("For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n")
|
||||
}
|
||||
@@ -164,6 +169,48 @@ func runShell(command string, timeoutSec int) string {
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// shellName is the shell runShell actually spawns on this platform. The model is
|
||||
// told this explicitly: advertising the tool as "bash" on Windows made it emit
|
||||
// bash quoting into cmd.exe, which mangles it (unterminated string literals, and
|
||||
// stray "Commande ECHO activée." landing inside generated files).
|
||||
func shellName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd.exe"
|
||||
}
|
||||
return "bash"
|
||||
}
|
||||
|
||||
// fileWrite writes content to path verbatim, creating parent directories and
|
||||
// replacing any existing file. This is the escape hatch from shell quoting: a
|
||||
// model with only a shell has to build files with echo/python -c, which is
|
||||
// unreliable everywhere and outright broken on cmd.exe.
|
||||
func fileWrite(path, content string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
}
|
||||
// Préserve les permissions d'origine quand le fichier existe déjà (un script
|
||||
// 0755 réécrit doit rester exécutable).
|
||||
mode := os.FileMode(0o644)
|
||||
existed := false
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
mode = fi.Mode()
|
||||
existed = true
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
verb := "créé"
|
||||
if existed {
|
||||
verb = "réécrit"
|
||||
}
|
||||
return fmt.Sprintf("[ok] %s %s (%d octets)", path, verb, len(content))
|
||||
}
|
||||
|
||||
// fileEdit applies a single exact-text replacement to a file on disk: oldText
|
||||
// must appear EXACTLY once (otherwise it errors), so the model can patch a file
|
||||
// without rewriting it whole. Returns a short status string for the tool result.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package jean
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le contenu doit arriver sur le disque à l'octet près : c'est tout l'intérêt de
|
||||
// l'outil write (les guillemets imbriqués d'un script Python ne survivaient pas
|
||||
// au passage par le shell).
|
||||
func TestFileWriteVerbatim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sub", "kpi.py")
|
||||
content := "import sqlite3\nq = \"SELECT * FROM t WHERE n = 'a'\"\nprint(q)\n"
|
||||
|
||||
res := fileWrite(path, content)
|
||||
if !strings.HasPrefix(res, "[ok]") {
|
||||
t.Fatalf("write a échoué: %s", res)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("relecture: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Fatalf("contenu altéré:\n%q\nattendu:\n%q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileWriteOverwrites(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "a.txt")
|
||||
if res := fileWrite(path, "ancien"); !strings.HasPrefix(res, "[ok]") {
|
||||
t.Fatalf("premier write: %s", res)
|
||||
}
|
||||
res := fileWrite(path, "nouveau")
|
||||
if !strings.Contains(res, "réécrit") {
|
||||
t.Fatalf("attendu « réécrit », obtenu: %s", res)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "nouveau" {
|
||||
t.Fatalf("contenu = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileWriteRejectsEmptyPath(t *testing.T) {
|
||||
if res := fileWrite(" ", "x"); !strings.HasPrefix(res, "[erreur]") {
|
||||
t.Fatalf("attendu une erreur, obtenu: %s", res)
|
||||
}
|
||||
}
|
||||
@@ -140,12 +140,30 @@ func editTool() Tool {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "write",
|
||||
Description: "Write a file on disk with the exact content given (creates it, or replaces it whole; parent directories are created). ALWAYS use this to create a script or any text file — NEVER build a file through the shell with echo, cat, python -c or Set-Content: the shell mangles quotes and breaks the file. Content is written verbatim, no escaping needed.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Path of the file to write"},
|
||||
"content": map[string]any{"type": "string", "description": "Full content of the file, verbatim"},
|
||||
},
|
||||
"required": []string{"file", "content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func bashTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "bash",
|
||||
Description: "Execute a shell command (bash) on this machine and return stdout, stderr and the exit code. For inspecting the system, running scripts, reading/editing files, reading logs. Avoid destructive commands unless explicitly asked.",
|
||||
Description: "Execute a shell command on this machine (" + shellName() + ") and return stdout, stderr and the exit code. Use " + shellName() + " syntax, not another shell's. For inspecting the system, running scripts, reading files, reading logs. To CREATE or REWRITE a file, use the write tool instead — never echo/cat/python -c. Avoid destructive commands unless explicitly asked.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -223,7 +241,7 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), editTool())
|
||||
tools = append(tools, bashTool(), writeTool(), editTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
@@ -278,11 +296,29 @@ type ToolUsedEvent struct {
|
||||
Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill)
|
||||
Done bool // false = call announced (command only); true = result is ready
|
||||
Typing bool // true = command still being written (partial), no spinner yet
|
||||
// Body : contenu en cours d'écriture par un outil d'écriture (write/edit,
|
||||
// mem_add/mem_edit), diffusé ligne à ligne pendant que le modèle le tape,
|
||||
// pour que la bulle se remplisse en direct au lieu de rester figée puis de
|
||||
// s'ouvrir d'un coup. Transitoire : seul Diff (état final) est rejoué.
|
||||
Body string
|
||||
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
|
||||
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
|
||||
Diff []DiffLine
|
||||
}
|
||||
|
||||
// writeBodyKey returns the argument holding the text an écriture tool is about
|
||||
// to commit — the part worth showing live in the bubble — or "" for tools that
|
||||
// have no such body (bash, lectures, recherches).
|
||||
func writeBodyKey(tool string) string {
|
||||
switch tool {
|
||||
case "write", "mem_add":
|
||||
return "content"
|
||||
case "edit", "mem_edit":
|
||||
return "new"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// previewArg pulls the (possibly incomplete) string value of key out of a
|
||||
// streaming tool-call arguments JSON, so the UI can show the command being
|
||||
// typed live. Best-effort: it tolerates a truncated tail and basic escapes.
|
||||
@@ -522,7 +558,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ
|
||||
// pour que les consommateurs (terminal, web) aient toujours tout.
|
||||
var stats StatsEvent
|
||||
lastPreview := "" // last command preview emitted (to stream the typing)
|
||||
lastPreview := "" // last command preview emitted (to stream the typing)
|
||||
lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture
|
||||
// Per-completion reasoning-split state (see reasoningOn comment above).
|
||||
sawReasoningField := false
|
||||
thinkOpen := reasoningOn
|
||||
@@ -608,14 +645,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
switch cur.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
key = "query"
|
||||
case "mem_read", "mem_add", "mem_edit", "edit":
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write":
|
||||
key = "file"
|
||||
case "web_open", "web_read", "web_grep":
|
||||
key = "url"
|
||||
}
|
||||
if p := previewArg(cur.Function.Arguments, key); p != "" && p != lastPreview {
|
||||
lastPreview = p
|
||||
if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: p, Typing: true}}) {
|
||||
p := previewArg(cur.Function.Arguments, key)
|
||||
// Corps en cours de frappe pour les outils d'écriture : on le diffuse
|
||||
// à la LIGNE, pas au token. Un événement par token republierait tout le
|
||||
// contenu à chaque fois (coût quadratique, et c'est ce flot qui saturait
|
||||
// le rendu mobile) ; à la ligne, le nombre d'événements est celui du
|
||||
// fichier et l'animation reste fluide.
|
||||
body := ""
|
||||
if bk := writeBodyKey(cur.Function.Name); bk != "" {
|
||||
body = previewArg(cur.Function.Arguments, bk)
|
||||
}
|
||||
grew := body != "" && strings.Count(body, "\n") > lastBodyLines
|
||||
if (p != "" && p != lastPreview) || grew {
|
||||
if p != "" {
|
||||
lastPreview = p
|
||||
}
|
||||
if grew {
|
||||
lastBodyLines = strings.Count(body, "\n")
|
||||
}
|
||||
if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) {
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
@@ -744,7 +797,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
switch tc.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit":
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write":
|
||||
label, _ = args["file"].(string)
|
||||
case "bash":
|
||||
label, _ = args["command"].(string)
|
||||
@@ -827,6 +880,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = fmt.Sprintf("[ok] page '%s' modifiée", label)
|
||||
diff = lineDiff(oldText, newText)
|
||||
}
|
||||
case "write":
|
||||
content, _ := args["content"].(string)
|
||||
result = fileWrite(label, content)
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
diff = addedDiff(content)
|
||||
}
|
||||
case "edit":
|
||||
oldText, _ := args["old"].(string)
|
||||
newText, _ := args["new"].(string)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.6.8"
|
||||
const Version = "0.6.9"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
|
||||
func Main() {
|
||||
|
||||
@@ -273,6 +273,13 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)}
|
||||
.msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)}
|
||||
.msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)}
|
||||
/* Écriture en direct : le bloc se remplit ligne à ligne pendant que le modèle
|
||||
tape. Seule la dernière ligne est en fondu (.fresh) — animer tout le bloc à
|
||||
chaque nouvelle ligne le ferait clignoter. Hauteur bornée + scroll auto en
|
||||
bas (voir 08-chat-render.js) pour qu'un long script ne pousse pas le fil. */
|
||||
.msg.tool pre.diff.live .dl.fresh{animation:difffresh .18s ease-out}
|
||||
@keyframes difffresh{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){.msg.tool pre.diff.live .dl.fresh{animation:none}}
|
||||
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
|
||||
.diff-count .a{color:var(--add)}
|
||||
.diff-count .d{color:var(--del)}
|
||||
@@ -881,7 +888,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<div id="presets"></div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<!-- Mémoire / Accès internet / Paramètres : sous-réglages du mode agent, en cartes. Grisés/inertes quand l'agent est off (voir setAgentGate). -->
|
||||
<div id="mem-block" class="gated-block">
|
||||
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
|
||||
@@ -2621,6 +2628,17 @@ function collapseInstant(el){
|
||||
requestAnimationFrame(()=>{ bw.style.transition=''; });
|
||||
}
|
||||
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
|
||||
// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible
|
||||
// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture
|
||||
// survit au repli, donc on le met là plutôt que dans le corps seul.
|
||||
function setLabelCounts(el, add, del){
|
||||
const lab=el.querySelector('.label');
|
||||
const cnt=document.createElement('span'); cnt.className='diff-count';
|
||||
if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add}));
|
||||
if(add && del) cnt.appendChild(document.createTextNode(' '));
|
||||
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
|
||||
lab.appendChild(cnt);
|
||||
}
|
||||
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN
|
||||
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
|
||||
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
|
||||
@@ -2649,6 +2667,7 @@ function renderToolMsg(el, tu){
|
||||
// (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire.
|
||||
const META = {
|
||||
bash: {lbl:'terminal', head:'commande'},
|
||||
write: {lbl:'fichier', head:'écriture'},
|
||||
edit: {lbl:'édition', head:'édition'},
|
||||
web_search: {lbl:'recherche', head:'recherche web'},
|
||||
web_open: {lbl:'page web', head:'ouverture'},
|
||||
@@ -2669,6 +2688,12 @@ function renderToolMsg(el, tu){
|
||||
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
setLabel(el, lbl);
|
||||
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
|
||||
// reporté sur l'étiquette, pour rester lisible bulle repliée.
|
||||
let add=0, del=0;
|
||||
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
|
||||
else if(tu.body){ add=tu.body.split('\n').length; }
|
||||
if(add||del) setLabelCounts(el, add, del);
|
||||
const body=bodyOf(el); body.innerHTML='';
|
||||
const head=document.createElement('div'); head.className='tool-head';
|
||||
head.textContent = meta.head;
|
||||
@@ -2679,16 +2704,37 @@ function renderToolMsg(el, tu){
|
||||
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
}
|
||||
// Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à
|
||||
// ligne, dans la même forme que le diff final, pour que la bulle se remplisse
|
||||
// sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la
|
||||
// dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement
|
||||
// ferait clignoter le bloc entier.
|
||||
if(tu.body && !(tu.diff && tu.diff.length)){
|
||||
const lines=tu.body.split('\n');
|
||||
const sub=document.createElement('div'); sub.className='tool-sub';
|
||||
sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée)
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre'); pre.className='diff live';
|
||||
lines.forEach((t,i)=>{
|
||||
const ln=document.createElement('span');
|
||||
ln.className='dl add'+(i===lines.length-1?' fresh':'');
|
||||
ln.textContent='+ '+t;
|
||||
if(i===lines.length-1 && tu.typing){
|
||||
const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋';
|
||||
ln.appendChild(car);
|
||||
}
|
||||
pre.appendChild(ln);
|
||||
});
|
||||
body.appendChild(pre);
|
||||
// Le bloc est re-créé à chaque événement : on le recale en bas pour suivre
|
||||
// la ligne en cours (max-height côté CSS l'empêche de pousser le fil).
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
|
||||
// retirées en rouge, contexte en gris — comme un diff de terminal.
|
||||
if(tu.diff && tu.diff.length){
|
||||
const sub=document.createElement('div'); sub.className='tool-sub';
|
||||
let add=0, del=0;
|
||||
tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; });
|
||||
sub.textContent='modifications';
|
||||
const cnt=document.createElement('span'); cnt.className='diff-count';
|
||||
cnt.innerHTML='<span class="a">+'+add+'</span> <span class="d">-'+del+'</span>';
|
||||
sub.appendChild(cnt);
|
||||
sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée)
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre'); pre.className='diff';
|
||||
tu.diff.forEach(l=>{
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<div id="presets"></div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<!-- Mémoire / Accès internet / Paramètres : sous-réglages du mode agent, en cartes. Grisés/inertes quand l'agent est off (voir setAgentGate). -->
|
||||
<div id="mem-block" class="gated-block">
|
||||
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
|
||||
|
||||
@@ -104,6 +104,17 @@ function collapseInstant(el){
|
||||
requestAnimationFrame(()=>{ bw.style.transition=''; });
|
||||
}
|
||||
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
|
||||
// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible
|
||||
// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture
|
||||
// survit au repli, donc on le met là plutôt que dans le corps seul.
|
||||
function setLabelCounts(el, add, del){
|
||||
const lab=el.querySelector('.label');
|
||||
const cnt=document.createElement('span'); cnt.className='diff-count';
|
||||
if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add}));
|
||||
if(add && del) cnt.appendChild(document.createTextNode(' '));
|
||||
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
|
||||
lab.appendChild(cnt);
|
||||
}
|
||||
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN
|
||||
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
|
||||
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
|
||||
@@ -132,6 +143,7 @@ function renderToolMsg(el, tu){
|
||||
// (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire.
|
||||
const META = {
|
||||
bash: {lbl:'terminal', head:'commande'},
|
||||
write: {lbl:'fichier', head:'écriture'},
|
||||
edit: {lbl:'édition', head:'édition'},
|
||||
web_search: {lbl:'recherche', head:'recherche web'},
|
||||
web_open: {lbl:'page web', head:'ouverture'},
|
||||
@@ -152,6 +164,12 @@ function renderToolMsg(el, tu){
|
||||
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
setLabel(el, lbl);
|
||||
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
|
||||
// reporté sur l'étiquette, pour rester lisible bulle repliée.
|
||||
let add=0, del=0;
|
||||
if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
|
||||
else if(tu.body){ add=tu.body.split('\n').length; }
|
||||
if(add||del) setLabelCounts(el, add, del);
|
||||
const body=bodyOf(el); body.innerHTML='';
|
||||
const head=document.createElement('div'); head.className='tool-head';
|
||||
head.textContent = meta.head;
|
||||
@@ -162,16 +180,37 @@ function renderToolMsg(el, tu){
|
||||
if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); }
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
}
|
||||
// Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à
|
||||
// ligne, dans la même forme que le diff final, pour que la bulle se remplisse
|
||||
// sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la
|
||||
// dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement
|
||||
// ferait clignoter le bloc entier.
|
||||
if(tu.body && !(tu.diff && tu.diff.length)){
|
||||
const lines=tu.body.split('\n');
|
||||
const sub=document.createElement('div'); sub.className='tool-sub';
|
||||
sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée)
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre'); pre.className='diff live';
|
||||
lines.forEach((t,i)=>{
|
||||
const ln=document.createElement('span');
|
||||
ln.className='dl add'+(i===lines.length-1?' fresh':'');
|
||||
ln.textContent='+ '+t;
|
||||
if(i===lines.length-1 && tu.typing){
|
||||
const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋';
|
||||
ln.appendChild(car);
|
||||
}
|
||||
pre.appendChild(ln);
|
||||
});
|
||||
body.appendChild(pre);
|
||||
// Le bloc est re-créé à chaque événement : on le recale en bas pour suivre
|
||||
// la ligne en cours (max-height côté CSS l'empêche de pousser le fil).
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
|
||||
// retirées en rouge, contexte en gris — comme un diff de terminal.
|
||||
if(tu.diff && tu.diff.length){
|
||||
const sub=document.createElement('div'); sub.className='tool-sub';
|
||||
let add=0, del=0;
|
||||
tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; });
|
||||
sub.textContent='modifications';
|
||||
const cnt=document.createElement('span'); cnt.className='diff-count';
|
||||
cnt.innerHTML='<span class="a">+'+add+'</span> <span class="d">-'+del+'</span>';
|
||||
sub.appendChild(cnt);
|
||||
sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée)
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre'); pre.className='diff';
|
||||
tu.diff.forEach(l=>{
|
||||
|
||||
@@ -247,6 +247,13 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.msg.tool pre.diff .dl{display:block;padding:1px 10px;white-space:pre-wrap;word-break:break-word;color:var(--dim)}
|
||||
.msg.tool pre.diff .dl.add{color:var(--add);background:var(--add-bg)}
|
||||
.msg.tool pre.diff .dl.del{color:var(--del);background:var(--del-bg)}
|
||||
/* Écriture en direct : le bloc se remplit ligne à ligne pendant que le modèle
|
||||
tape. Seule la dernière ligne est en fondu (.fresh) — animer tout le bloc à
|
||||
chaque nouvelle ligne le ferait clignoter. Hauteur bornée + scroll auto en
|
||||
bas (voir 08-chat-render.js) pour qu'un long script ne pousse pas le fil. */
|
||||
.msg.tool pre.diff.live .dl.fresh{animation:difffresh .18s ease-out}
|
||||
@keyframes difffresh{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){.msg.tool pre.diff.live .dl.fresh{animation:none}}
|
||||
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
|
||||
.diff-count .a{color:var(--add)}
|
||||
.diff-count .d{color:var(--del)}
|
||||
|
||||
Reference in new issue
Block a user