v0.6.5 : telechargement de modeles bien plus rapide, barre de progression et annulation

- Telechargement d'un .gguf en 8 connexions paralleles (requetes Range,
  ecriture en place avec WriteAt) au lieu d'un seul flux TCP : sur le CDN
  Hugging Face le gain est typiquement de 3 a 6x. Repli automatique sur le
  flux unique si la source ne gere pas les ranges.
- Client HTTP dedie (pool de connexions reutilisees, HTTP/2 desactive) et
  reprise par tranche en cas de coupure. Reglable via JEAN_DL_CONNS.
- Barre de progression integree dans la modale de preset, avec vitesse et
  temps restant.
- Bouton d'annulation : coupe vraiment le telechargement et supprime le
  fichier partiel. Fermer la modale ne l'interrompt plus, l'UI se rebranche
  dessus a la reouverture.
- Les .gguf.part orphelins (crash, restart du service) sont nettoyes au
  demarrage.
- La liste de selection du modele s'ouvre alignee a gauche et sur toute la
  largeur de la ligne.
This commit is contained in:
nathaninline committed 2026-07-30 15:06:01 +02:00
1 parent 1360eb1943
commit 065264a055
11 files changed
+722 -97

No files matched your search

Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 6,
"Patch": 4,
"Patch": 5,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 6,
"Patch": 4,
"Patch": 5,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.6.4",
"ProductVersion": "0.6.5",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+342 -45
View File
@@ -1,6 +1,7 @@
package jean
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -10,8 +11,10 @@ import (
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -156,9 +159,14 @@ type dlState struct {
URL string `json:"url"`
Total int64 `json:"total"`
Done int64 `json:"done"`
Speed int64 `json:"speed"` // bytes/s, smoothed over the last samples
Conns int `json:"conns"` // parallel connections actually used
Finished bool `json:"finished"`
Canceled bool `json:"canceled"`
Err string `json:"error"`
StartedAt int64 `json:"started_at"`
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
}
var (
@@ -166,6 +174,44 @@ var (
dlDownloads = map[string]*dlState{} // keyed by filename
)
// dlClient is shared by all download workers so connections to the HF CDN are
// pooled and reused across chunks instead of re-handshaking TLS each time.
var dlClient = &http.Client{
Timeout: 0, // large files: no overall timeout
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 64,
MaxIdleConnsPerHost: 64,
MaxConnsPerHost: 0,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 20 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// HTTP/1.1: truly parallel sockets, no shared h2 flow-control window.
ForceAttemptHTTP2: false,
WriteBufferSize: 64 << 10,
ReadBufferSize: 256 << 10,
},
}
// dlConns is the number of parallel range requests used per download.
// Overridable with JEAN_DL_CONNS (1 disables parallelism).
func dlConns() int {
n := 8
if v := strings.TrimSpace(os.Getenv("JEAN_DL_CONNS")); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
n = p
}
}
if n > 16 {
n = 16
}
return n
}
// dlMinChunk is the smallest slice worth a dedicated connection (16 MiB), so a
// small file doesn't get split into a swarm of tiny requests.
const dlMinChunk = 16 << 20
// normalizeHFURL turns a Hugging Face "blob" page URL into a direct "resolve"
// download URL, and leaves already-direct URLs untouched. Returns the URL to
// fetch and the target filename.
@@ -228,79 +274,177 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
return
}
st := &dlState{Filename: name, URL: dlURL, StartedAt: time.Now().Unix()}
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{Filename: name, URL: dlURL, StartedAt: time.Now().Unix(), cancel: cancel}
dlDownloads[name] = st
dlMu.Unlock()
go runDownload(st, dlURL, dest)
go runDownload(ctx, st, dlURL, dest)
sendJSON(w, 200, map[string]any{"ok": true, "filename": name})
}
// runDownload streams the URL to a .part file then renames it on success.
func runDownload(st *dlState, dlURL, dest string) {
finish := func(e error) {
dlMu.Lock()
if e != nil {
st.Err = e.Error()
}
st.Finished = true
dlMu.Unlock()
}
req, err := http.NewRequest("GET", dlURL, nil)
// dlRequest builds a GET for the download URL, carrying the HF token when set
// (gated/private repos) and an optional Range header.
func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, "GET", dlURL, nil)
if err != nil {
finish(err)
return
return nil, err
}
// HF gated/private repos may need a token; reuse the same key store if set.
if k := os.Getenv("HF_TOKEN"); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
client := &http.Client{Timeout: 0} // large files: no overall timeout
resp, err := client.Do(req)
req.Header.Set("User-Agent", "jean/"+Version)
req.Header.Set("Accept-Encoding", "identity") // never gzip a .gguf: it breaks ranges
if rng != "" {
req.Header.Set("Range", rng)
}
return req, nil
}
// contentRangeTotal parses the total size out of a "bytes 0-0/12345" header.
func contentRangeTotal(v string) int64 {
i := strings.LastIndexByte(v, '/')
if i < 0 {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(v[i+1:]), 10, 64)
if err != nil || n <= 0 {
return 0
}
return n
}
// dlProbe asks the server for the first byte to learn the total size and
// whether ranges are supported (206 + Content-Range).
func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
req, err := dlRequest(ctx, dlURL, "bytes=0-0")
if err != nil {
return 0, false, err
}
resp, err := dlClient.Do(req)
if err != nil {
return 0, false, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
switch resp.StatusCode {
case 206:
if t := contentRangeTotal(resp.Header.Get("Content-Range")); t > 0 {
return t, true, nil
}
return 0, false, nil
case 200:
// Server ignored the Range: single stream, ContentLength is the size.
return resp.ContentLength, false, nil
default:
return 0, false, fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
}
}
// runDownload fetches the URL into a .part file then renames it on success.
// When the source honours byte ranges (Hugging Face's CDN does), the file is
// split across several connections written in place with WriteAt, which is what
// makes a multi-GB .gguf saturate the link instead of a single TCP stream.
// Cancelling the context aborts every worker and the partial .part is removed,
// so a cancelled download leaves nothing behind.
func runDownload(ctx context.Context, st *dlState, dlURL, dest string) {
tmp := dest + ".part"
var done int64 // atomic: total bytes written across all workers
finish := func(e error) {
dlMu.Lock()
switch {
case ctx.Err() != nil:
st.Canceled = true
st.Speed = 0
case e != nil:
st.Err = e.Error()
st.Speed = 0
default:
st.Done = atomic.LoadInt64(&done)
}
st.Finished = true
st.cancel = nil
dlMu.Unlock()
}
total, ranged, err := dlProbe(ctx, dlURL)
if err != nil {
finish(err)
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
finish(fmt.Errorf("HTTP %d depuis la source", resp.StatusCode))
return
conns := 1
if ranged && total > 0 {
conns = dlConns()
if max := int((total + dlMinChunk - 1) / dlMinChunk); conns > max {
conns = max
}
if conns < 1 {
conns = 1
}
}
dlMu.Lock()
st.Total = resp.ContentLength
st.Total = total
st.Conns = conns
dlMu.Unlock()
tmp := dest + ".part"
f, err := os.Create(tmp)
if err != nil {
finish(err)
return
}
buf := make([]byte, 1<<20) // 1 MiB
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := f.Write(buf[:n]); werr != nil {
f.Close()
_ = os.Remove(tmp)
finish(werr)
return
}
dlMu.Lock()
st.Done += int64(n)
dlMu.Unlock()
}
if rerr == io.EOF {
break
}
if rerr != nil {
f.Close()
_ = os.Remove(tmp)
finish(rerr)
fail := func(e error) {
f.Close()
_ = os.Remove(tmp)
finish(e)
}
if conns > 1 {
// Preallocate so the filesystem can lay the file out contiguously and
// concurrent WriteAt calls never race to extend it.
if err := f.Truncate(total); err != nil {
fail(err)
return
}
}
// Publish progress + a smoothed speed once a second.
stop := make(chan struct{})
go func() {
t := time.NewTicker(time.Second)
defer t.Stop()
last, lastAt := int64(0), time.Now()
for {
select {
case <-stop:
return
case now := <-t.C:
cur := atomic.LoadInt64(&done)
dt := now.Sub(lastAt).Seconds()
dlMu.Lock()
st.Done = cur
if dt > 0 {
inst := int64(float64(cur-last) / dt)
if st.Speed == 0 {
st.Speed = inst
} else {
st.Speed = (st.Speed*2 + inst) / 3 // EMA, smooths CDN jitter
}
}
dlMu.Unlock()
last, lastAt = cur, now
}
}
}()
err = dlFetch(ctx, f, dlURL, total, conns, &done)
close(stop)
if err != nil {
fail(err) // includes cancellation: the .part is deleted either way
return
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
finish(err)
@@ -314,6 +458,159 @@ func runDownload(st *dlState, dlURL, dest string) {
finish(nil)
}
// dlFetch writes the whole body into f, either as one stream or as `conns`
// parallel byte ranges. done is incremented atomically as bytes land on disk.
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
if conns <= 1 {
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
}
size := total / int64(conns)
var wg sync.WaitGroup
errs := make([]error, conns)
for i := 0; i < conns; i++ {
start := int64(i) * size
end := start + size - 1
if i == conns-1 {
end = total - 1
}
wg.Add(1)
go func(i int, start, end int64) {
defer wg.Done()
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
}(i, start, end)
}
wg.Wait()
for _, e := range errs {
if e != nil {
return e
}
}
return nil
}
// dlChunk downloads [start,end] into f at the right offset, retrying from where
// it stopped if the connection drops mid-chunk. With whole=true it streams the
// entire body sequentially (server without range support, unknown size).
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
const attempts = 4
pos := start
var lastErr error
for try := 0; try < attempts; try++ {
if err := ctx.Err(); err != nil {
return err // cancelled: never retry
}
if try > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(try) * time.Second):
}
}
rng := ""
if !whole {
if pos > end {
return nil
}
rng = fmt.Sprintf("bytes=%d-%d", pos, end)
} else if pos > start {
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
}
req, err := dlRequest(ctx, dlURL, rng)
if err != nil {
return err
}
resp, err := dlClient.Do(req)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
resp.Body.Close()
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
}
if resp.StatusCode == 200 && pos > start {
// Resume refused: the body restarts from 0, rewind our bookkeeping.
atomic.AddInt64(done, start-pos)
pos = start
}
n, cerr := dlCopy(f, resp.Body, pos, done)
resp.Body.Close()
pos += n
if cerr == nil {
return nil
}
lastErr = cerr
}
return lastErr
}
// dlCopy streams src into f starting at off, reporting bytes written. It
// returns the byte count even on error so the caller can resume.
func dlCopy(f *os.File, src io.Reader, off int64, done *int64) (int64, error) {
buf := make([]byte, 1<<20) // 1 MiB
var written int64
for {
n, rerr := src.Read(buf)
if n > 0 {
if _, werr := f.WriteAt(buf[:n], off+written); werr != nil {
return written, werr
}
written += int64(n)
atomic.AddInt64(done, int64(n))
}
if rerr == io.EOF {
return written, nil
}
if rerr != nil {
return written, rerr
}
}
}
// handleModelDownloadCancel aborts an in-flight download; runDownload then
// deletes its .part file, so nothing partial survives.
func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
name := filepath.Base(strings.TrimSpace(req.Filename))
dlMu.Lock()
st, ok := dlDownloads[name]
var cancel context.CancelFunc
if ok {
cancel = st.cancel
}
dlMu.Unlock()
if !ok {
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement pour " + name})
return
}
if cancel != nil {
cancel()
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// cleanStalePartFiles removes leftover *.gguf.part files in JEAN_HOME at
// startup. A download killed by a crash or a service restart can't be resumed
// (its state lived in memory), so the partial file would otherwise sit there
// forever eating disk.
func cleanStalePartFiles() {
matches, err := filepath.Glob(filepath.Join(JeanHome(), "*.gguf.part"))
if err != nil {
return
}
for _, p := range matches {
if err := os.Remove(p); err == nil {
fmt.Printf("[models] téléchargement incomplet supprimé : %s\n", filepath.Base(p))
}
}
}
// handleModelDownloadStatus returns the state of all known downloads this run.
func handleModelDownloadStatus(w http.ResponseWriter, r *http.Request) {
dlMu.Lock()
+134
View File
@@ -0,0 +1,134 @@
package jean
import (
"bytes"
"context"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
// serveBlob serves data with (or without) byte-range support.
func serveBlob(data []byte, ranges bool) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !ranges {
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
return
}
http.ServeContent(w, r, "m.gguf", time.Time{}, bytes.NewReader(data))
}))
}
// Un téléchargement annulé ne doit laisser NI le .gguf final NI le .part.
func TestRunDownloadCancelLeavesNothing(t *testing.T) {
t.Setenv("JEAN_DL_CONNS", "4")
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") == "bytes=0-0" { // sonde : réponse immédiate
w.Header().Set("Content-Range", "bytes 0-0/"+strconv.Itoa(64<<20))
w.WriteHeader(206)
_, _ = w.Write([]byte{0})
return
}
// Corps qui traîne : l'annulation doit l'interrompre.
w.WriteHeader(206)
w.(http.Flusher).Flush()
select {
case <-release:
case <-r.Context().Done():
}
}))
defer srv.Close()
defer close(release)
dir := t.TempDir()
dest := filepath.Join(dir, "m.gguf")
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{Filename: "m.gguf", cancel: cancel}
done := make(chan struct{})
go func() { runDownload(ctx, st, srv.URL+"/m.gguf", dest); close(done) }()
time.Sleep(300 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("runDownload n'a pas rendu la main après annulation")
}
if !st.Canceled || !st.Finished {
t.Fatalf("état attendu annulé+terminé, got canceled=%v finished=%v err=%q", st.Canceled, st.Finished, st.Err)
}
if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
t.Fatal(".part laissé sur le disque après annulation")
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatal("fichier final créé alors que le téléchargement a été annulé")
}
}
// cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf.
func TestCleanStalePartFiles(t *testing.T) {
dir := t.TempDir()
t.Setenv("JEAN_HOME", dir)
keep := filepath.Join(dir, "bon.gguf")
stale := filepath.Join(dir, "coupe.gguf.part")
for _, p := range []string{keep, stale} {
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
cleanStalePartFiles()
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatal(".part orphelin non supprimé")
}
if _, err := os.Stat(keep); err != nil {
t.Fatal(".gguf valide supprimé par erreur")
}
}
func TestRunDownloadParallelAndFallback(t *testing.T) {
data := make([]byte, 48<<20) // > 3×dlMinChunk so the split actually kicks in
rand.New(rand.NewSource(1)).Read(data)
t.Setenv("JEAN_DL_CONNS", "4")
for _, ranges := range []bool{true, false} {
srv := serveBlob(data, ranges)
dir := t.TempDir()
dest := filepath.Join(dir, "m.gguf")
st := &dlState{Filename: "m.gguf"}
runDownload(context.Background(), st, srv.URL+"/m.gguf", dest)
srv.Close()
if st.Err != "" {
t.Fatalf("ranges=%v: erreur %s", ranges, st.Err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("ranges=%v: %v", ranges, err)
}
if len(got) != len(data) {
t.Fatalf("ranges=%v: taille %d != %d", ranges, len(got), len(data))
}
for i := range got {
if got[i] != data[i] {
t.Fatalf("ranges=%v: octet %d différent", ranges, i)
}
}
if st.Done != int64(len(data)) {
t.Fatalf("ranges=%v: done=%d", ranges, st.Done)
}
if ranges && st.Conns < 2 {
t.Fatalf("attendu du parallélisme, conns=%d", st.Conns)
}
if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
t.Fatalf("ranges=%v: .part laissé derrière", ranges)
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.6.4"
const Version = "0.6.5"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() {
+119 -24
View File
@@ -560,6 +560,12 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-selc select{appearance:none;-webkit-appearance:none;border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;text-align-last:right;padding:4px 16px 4px 0;cursor:pointer;max-width:100%;text-overflow:ellipsis}
.pe-selc select:focus{outline:none}
.pe-selc::after{content:"›";position:absolute;right:0;transform:rotate(90deg);color:var(--dim);font-size:14px;pointer-events:none}
/* Variante pleine largeur : la liste native s'ouvre alignée à gauche de la
ligne (et non collée au bord droit) et le nom de fichier a la place de
s'afficher en entier. */
.pe-selc.wide{width:100%}
.pe-selc.wide select{flex:1;text-align:left;text-align-last:left;padding-right:18px}
.pe-selc.wide::after{right:2px}
/* Ligne « champ pleine largeur » (Nom, chemin, config brute…). */
.pe-row.stack{flex-direction:column;align-items:stretch;gap:8px}
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s}
@@ -585,6 +591,17 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-seg .be-note{display:none}
.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px}
.pe-note{font-size:11px;color:var(--dim);line-height:1.45}
/* Barre de progression (téléchargement de modèle). Sans total connu, elle
défile en boucle via .indet. */
.pe-bar{height:4px;border-radius:999px;background:color-mix(in srgb,var(--dim) 26%,transparent);overflow:hidden}
.pe-bar>i{display:block;height:100%;width:0;border-radius:999px;background:var(--accent);transition:width .35s linear}
.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none}
.pe-bar.done>i{width:100%}
@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}}
/* Action discrète en texte (annuler un téléchargement…). */
.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer}
.pe-link:hover{color:var(--text)}
.pe-link:disabled{opacity:.5;cursor:default;text-decoration:none}
/* Interrupteur à glissière. */
.pe-switch{position:relative;flex:none;width:40px;height:24px}
.pe-switch input{position:absolute;opacity:0;width:0;height:0}
@@ -1189,9 +1206,9 @@ button:hover{border-color:var(--dim);color:var(--text)}
<div class="pe-group">
<div class="pe-gh">Modèle</div>
<div class="pe-list">
<div class="pe-row">
<div class="pe-row stack">
<span class="pe-row-l">Fichier</span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-model" onchange="onPickModel()"></select></span></span>
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
@@ -1203,7 +1220,9 @@ button:hover{border-color:var(--dim);color:var(--text)}
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
</div>
</div>
@@ -2020,7 +2039,8 @@ async function openItem(kind, key){
modelRow.style.display = 'flex';
settingsRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
document.getElementById('m-hf-progress').style.display = 'none';
resetDlUI();
attachDownload(); // téléchargement encore en cours côté serveur ?
document.getElementById('m-del-model').checked = false;
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
@@ -2240,7 +2260,9 @@ function toggleRaw(){
}
const openPreset = (id)=>openItem('preset', id);
const openMem = (n)=>openItem('mem', n);
function closeModal(){ document.getElementById('modal').style.display = 'none'; }
// Fermer la modale n'annule PAS un téléchargement en cours (il vit côté
// serveur) : on arrête juste de l'interroger.
function closeModal(){ document.getElementById('modal').style.display = 'none'; stopDlPoll(); }
async function saveItem(){
const K = KINDS[editingKind];
const name = document.getElementById('m-name').value.trim();
@@ -2275,39 +2297,112 @@ async function delItem(){
} else { toast('supprimé'); }
closeModal(); K.reload();
}
// Download a .gguf from Hugging Face, polling progress until done.
let dlPoll = null;
// Download a .gguf from Hugging Face. Le téléchargement vit côté serveur : fermer
// la modale ne l'interrompt pas, on se rebranche dessus à la réouverture
// (attachDownload) et seul « Annuler » l'arrête vraiment.
let dlPoll = null, dlName = '';
function dlEls(){
return {
btn: document.getElementById('m-hf-btn'),
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
function stopDlPoll(){ if(dlPoll){ clearInterval(dlPoll); dlPoll=null; } }
// Remet la zone de téléchargement à zéro (réouverture de la modale).
function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
}
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
const btn = document.getElementById('m-hf-btn');
const prog = document.getElementById('m-hf-progress');
btn.disabled = true;
prog.style.display = 'block';
prog.textContent = 'démarrage…';
const e = dlEls();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.prog.textContent = 'démarrage…';
e.bar.style.display = 'block';
e.bar.className = 'pe-bar indet';
e.bar.firstElementChild.style.width = '';
const r = await jpost('/api/models/download', {url});
if(!r.ok){ prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>'; btn.disabled=false; return; }
const fname = r.filename;
if(dlPoll) clearInterval(dlPoll);
dlPoll = setInterval(async ()=>{
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return;
}
watchDownload(r.filename);
}
// Annule le téléchargement en cours : le serveur coupe les connexions et
// supprime le fichier partiel.
async function cancelDownload(){
if(!dlName) return;
const e = dlEls();
e.cancel.disabled = true;
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
dlName = fname;
stopDlPoll();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
if(!st){ return; }
if(!st) return;
if(st.canceled){
e.prog.textContent = 'téléchargement annulé — '+fname;
e.bar.style.display = 'none'; stop(); return;
}
if(st.error){
prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return;
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
e.bar.style.display = 'none'; stop(); return;
}
if(st.finished){
prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
clearInterval(dlPoll); dlPoll=null; btn.disabled=false;
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await populateModelPicker();
document.getElementById('m-model').value = fname; onPickModel();
return;
}
const pct = st.total>0 ? Math.round(st.done*100/st.total) : 0;
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : '';
prog.textContent = '↓ '+fmtSize(st.done)+tot+' — '+fname;
}, 800);
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
e.bar.firstElementChild.style.width = Math.min(100, pct).toFixed(1)+'%';
} else {
e.bar.className = 'pe-bar indet';
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
const m = Math.floor(eta/60), s = eta%60;
extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s');
}
}
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+' — '+fname;
};
dlPoll = setInterval(tick, 800);
tick();
}
// À l'ouverture de la modale : se rebrancher sur un téléchargement encore en
// cours côté serveur (modale fermée entre-temps, page rechargée, autre appareil).
async function attachDownload(){
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>!d.finished);
if(st) watchDownload(st.filename);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
// up. Re-stick when they scroll back near bottom themselves.
+4 -2
View File
@@ -395,9 +395,9 @@
<div class="pe-group">
<div class="pe-gh">Modèle</div>
<div class="pe-list">
<div class="pe-row">
<div class="pe-row stack">
<span class="pe-row-l">Fichier</span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-model" onchange="onPickModel()"></select></span></span>
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
@@ -409,7 +409,9 @@
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">↓</button>
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
</div>
</div>
+98 -22
View File
@@ -78,7 +78,8 @@ async function openItem(kind, key){
modelRow.style.display = 'flex';
settingsRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
document.getElementById('m-hf-progress').style.display = 'none';
resetDlUI();
attachDownload(); // téléchargement encore en cours côté serveur ?
document.getElementById('m-del-model').checked = false;
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
@@ -298,7 +299,9 @@ function toggleRaw(){
}
const openPreset = (id)=>openItem('preset', id);
const openMem = (n)=>openItem('mem', n);
function closeModal(){ document.getElementById('modal').style.display = 'none'; }
// Fermer la modale n'annule PAS un téléchargement en cours (il vit côté
// serveur) : on arrête juste de l'interroger.
function closeModal(){ document.getElementById('modal').style.display = 'none'; stopDlPoll(); }
async function saveItem(){
const K = KINDS[editingKind];
const name = document.getElementById('m-name').value.trim();
@@ -333,39 +336,112 @@ async function delItem(){
} else { toast('supprimé'); }
closeModal(); K.reload();
}
// Download a .gguf from Hugging Face, polling progress until done.
let dlPoll = null;
// Download a .gguf from Hugging Face. Le téléchargement vit côté serveur : fermer
// la modale ne l'interrompt pas, on se rebranche dessus à la réouverture
// (attachDownload) et seul « Annuler » l'arrête vraiment.
let dlPoll = null, dlName = '';
function dlEls(){
return {
btn: document.getElementById('m-hf-btn'),
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
function stopDlPoll(){ if(dlPoll){ clearInterval(dlPoll); dlPoll=null; } }
// Remet la zone de téléchargement à zéro (réouverture de la modale).
function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
}
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
const btn = document.getElementById('m-hf-btn');
const prog = document.getElementById('m-hf-progress');
btn.disabled = true;
prog.style.display = 'block';
prog.textContent = 'démarrage…';
const e = dlEls();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.prog.textContent = 'démarrage…';
e.bar.style.display = 'block';
e.bar.className = 'pe-bar indet';
e.bar.firstElementChild.style.width = '';
const r = await jpost('/api/models/download', {url});
if(!r.ok){ prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>'; btn.disabled=false; return; }
const fname = r.filename;
if(dlPoll) clearInterval(dlPoll);
dlPoll = setInterval(async ()=>{
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return;
}
watchDownload(r.filename);
}
// Annule le téléchargement en cours : le serveur coupe les connexions et
// supprime le fichier partiel.
async function cancelDownload(){
if(!dlName) return;
const e = dlEls();
e.cancel.disabled = true;
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
dlName = fname;
stopDlPoll();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
if(!st){ return; }
if(!st) return;
if(st.canceled){
e.prog.textContent = 'téléchargement annulé — '+fname;
e.bar.style.display = 'none'; stop(); return;
}
if(st.error){
prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
clearInterval(dlPoll); dlPoll=null; btn.disabled=false; return;
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
e.bar.style.display = 'none'; stop(); return;
}
if(st.finished){
prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
clearInterval(dlPoll); dlPoll=null; btn.disabled=false;
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await populateModelPicker();
document.getElementById('m-model').value = fname; onPickModel();
return;
}
const pct = st.total>0 ? Math.round(st.done*100/st.total) : 0;
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+pct+'%)' : '';
prog.textContent = '↓ '+fmtSize(st.done)+tot+' — '+fname;
}, 800);
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
e.bar.firstElementChild.style.width = Math.min(100, pct).toFixed(1)+'%';
} else {
e.bar.className = 'pe-bar indet';
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
const m = Math.floor(eta/60), s = eta%60;
extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s');
}
}
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+' — '+fname;
};
dlPoll = setInterval(tick, 800);
tick();
}
// À l'ouverture de la modale : se rebrancher sur un téléchargement encore en
// cours côté serveur (modale fermée entre-temps, page rechargée, autre appareil).
async function attachDownload(){
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>!d.finished);
if(st) watchDownload(st.filename);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
// up. Re-stick when they scroll back near bottom themselves.
+17
View File
@@ -534,6 +534,12 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-selc select{appearance:none;-webkit-appearance:none;border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;text-align-last:right;padding:4px 16px 4px 0;cursor:pointer;max-width:100%;text-overflow:ellipsis}
.pe-selc select:focus{outline:none}
.pe-selc::after{content:"›";position:absolute;right:0;transform:rotate(90deg);color:var(--dim);font-size:14px;pointer-events:none}
/* Variante pleine largeur : la liste native s'ouvre alignée à gauche de la
ligne (et non collée au bord droit) et le nom de fichier a la place de
s'afficher en entier. */
.pe-selc.wide{width:100%}
.pe-selc.wide select{flex:1;text-align:left;text-align-last:left;padding-right:18px}
.pe-selc.wide::after{right:2px}
/* Ligne « champ pleine largeur » (Nom, chemin, config brute…). */
.pe-row.stack{flex-direction:column;align-items:stretch;gap:8px}
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s}
@@ -559,6 +565,17 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-seg .be-note{display:none}
.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px}
.pe-note{font-size:11px;color:var(--dim);line-height:1.45}
/* Barre de progression (téléchargement de modèle). Sans total connu, elle
défile en boucle via .indet. */
.pe-bar{height:4px;border-radius:999px;background:color-mix(in srgb,var(--dim) 26%,transparent);overflow:hidden}
.pe-bar>i{display:block;height:100%;width:0;border-radius:999px;background:var(--accent);transition:width .35s linear}
.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none}
.pe-bar.done>i{width:100%}
@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}}
/* Action discrète en texte (annuler un téléchargement…). */
.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer}
.pe-link:hover{color:var(--text)}
.pe-link:disabled{opacity:.5;cursor:default;text-decoration:none}
/* Interrupteur à glissière. */
.pe-switch{position:relative;flex:none;width:40px;height:24px}
.pe-switch input{position:absolute;opacity:0;width:0;height:0}
+4
View File
@@ -67,6 +67,9 @@ func newWebMux() *http.ServeMux {
// Pré-chauffe les serveurs MCP en tâche de fond : sinon le handshake (plusieurs
// secondes pour un serveur lancé via npx) est payé par le premier message.
MCPPrewarm()
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles()
mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -92,6 +95,7 @@ func newWebMux() *http.ServeMux {
api("/api/models/delete", handleModelDelete)
api("/api/models/download", handleModelDownload)
api("/api/models/download/status", handleModelDownloadStatus)
api("/api/models/download/cancel", handleModelDownloadCancel)
api("/api/backends", handleBackends)
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp