Files
Loki/internal/loki/ui/src/js/14-attach.js
T
MichaelandClaude Opus 5 88e1b38582 Images du fil : miniatures des deux côtés, loupe au clic
Une pièce jointe n'affichait que son nom de fichier, et une image posée
par l'IA sortait à sa taille NATIVE — .chatimg portait la classe mais
aucune règle CSS, donc une capture remplissait la colonne et poussait le
texte hors de vue.

- pièce jointe image : vignette dans le composeur (objectURL local, rien
  n'est encore parti au serveur) et tuile 200x160 dans le message envoyé,
  hydratée comme les captures (fetch authentifié + blob:) ;
- images de l'IA plafonnées à 280px de haut ;
- clic sur n'importe quelle image = loupe plein écran (Échap ou clic sur
  le fond pour fermer). La source est relue AU CLIC, l'hydratation
  remplaçant le src par un blob: après coup.

align-items:flex-start sur .msg-files au passage : une pastille texte
voisine d'une vignette s'étirait à sa hauteur et, arrondie à 999px,
devenait un gros ovale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:35:54 +02:00

371 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===== Pièces jointes du chat ==============================================
// Un fichier joint n'est PAS lu par le navigateur ni injecté dans le contexte :
// il est déposé tel quel dans le dossier de travail de l'IA (uploads/, côté
// serveur), et seul son chemin est annoncé dans le message. Le modèle l'ouvre
// s'il en a besoin, avec ses outils — ce qui marche pour n'importe quel type de
// fichier sans que l'UI ait à en connaître le format.
//
// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la
// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant
// fonctionne sans code particulier.
// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go).
// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent
// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer
// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté.
const ATTACH_MAX = 1024*1024*1024;
const ATTACH_CHUNK = 8*1024*1024;
// Délai par MORCEAU (et pas les 30 s d'un appel /api/* ordinaire) : une tranche de
// 8 Mo, base64 + tunnel E2E, peut dépasser 30 s sur une liaison lente.
const UPLOAD_CHUNK_TIMEOUT_MS = 120000;
let ATTACH = [];
let ATTACH_SEQ = 0;
function fmtSize(n){
if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go';
if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo';
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
}
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
// une vignette cassée qui retombe sur son texte de repli.
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
// objet visuel des deux côtés, seul le contexte (CSS) change.
//
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
// vers le serveur à ce stade).
function fileChip(name, size, opts){
opts=opts||{};
const chip=document.createElement('div');
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
if(opts.thumb){
const t=document.createElement('img');
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
chip.appendChild(t);
chip.classList.add('has-thumb');
}
const n=document.createElement('span');
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
const s=document.createElement('span');
s.className='cf-size'; s.textContent=opts.sizeText||fmtSize(size);
chip.appendChild(n); chip.appendChild(s);
if(opts.onRemove){
const x=document.createElement('button');
x.type='button'; x.textContent='×'; x.title='retirer';
x.setAttribute('aria-label','retirer '+name);
x.onclick=opts.onRemove;
chip.appendChild(x);
}
return chip;
}
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
// un bloc bâtard, moitié texte moitié pastilles.
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
// balise <img> ne peut pas porter la clé de pilotage.
function msgImgTile(f){
const box=document.createElement('div');
box.className='msg-img';
const img=document.createElement('img');
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
img.alt=f.name||''; img.title=f.name||'';
box.appendChild(img);
return box;
}
function addMsgFiles(el, files){
if(!el || !files || !files.length) return;
const box=document.createElement('div');
box.className='msg-files';
for(const f of files){
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
// apprendre — le nom vit dans l'infobulle et dans la loupe.
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
}
el.parentNode.insertBefore(box, el);
hydrateImages(box);
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
markEmptyMsg(el);
return box;
}
// Une bulle sans texte n'a rien à montrer — seules ses pièces jointes parlent.
function markEmptyMsg(el){
const b=el&&el.querySelector('.body');
if(b) el.classList.toggle('empty', b.textContent==='');
}
// La rangée de fichiers précède la bulle : c'est là qu'on vérifie sa présence
// avant d'en ajouter une seconde (replay + bulle en attente).
function hasMsgFiles(el){
const p=el&&el.previousElementSibling;
return !!(p&&p.classList.contains('msg-files'));
}
// ===== Fichiers remis par l'IA ============================================
// Aucun outil, aucune syntaxe maison : l'IA écrit un lien Markdown ordinaire
// vers un fichier de son dossier de travail — [Télécharger](rapport.pdf) — et
// c'est l'UI qui le transforme en téléchargement. Le modèle n'a rien à savoir
// de plus que ce qu'il fait déjà naturellement.
//
// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête
// Authorization que le navigateur ne mettrait pas sur une navigation, et le
// chemin de base change derrière le tunnel (/u/<id>). D'où le fetch + blob.
const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go)
// Décode une tranche base64 en octets.
function b64ToBytes(s){
const bin=atob(s), out=new Uint8Array(bin.length);
for(let i=0;i<bin.length;i++) out[i]=bin.charCodeAt(i);
return out;
}
// Récupère le fichier en tranches base64. C'est la voie OBLIGATOIRE derrière
// app.ajean.link : le proxy chiffré réemballe toute réponse en JSON, et du
// binaire n'y survit pas — on téléchargeait l'enveloppe JSON à la place du
// fichier. Le base64, lui, traverse intact.
async function fetchFileB64(path, size, chip){
const parts=[]; let off=0;
do{
const r=await jfetch('/api/chat/file?b64=1&len='+DL_CHUNK+'&offset='+off+'&path='+encodeURIComponent(path));
const j=await r.json();
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
const bytes=b64ToBytes(j.data||'');
parts.push(bytes);
// Une tranche vide sans marque de fin ne ferait pas avancer la boucle : on
// s'arrête plutôt que de tourner indéfiniment sur le même octet.
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
off=(j.offset||0)+bytes.length;
if(chip && size) chip.title='téléchargement '+Math.round(off*100/size)+' %';
if(j.eof) break;
} while(off<size);
return new Blob(parts);
}
async function downloadWorkspaceFile(path, name, a){
if(a) a.classList.add('busy');
const wasTitle=a?a.title:'';
try{
// La fiche d'abord : elle est minuscule, elle passe partout, et elle dit si
// on est derrière le tunnel — ce que le client ne peut pas deviner seul, le
// proxy lui rendant des réponses JSON parfaitement ordinaires.
const m=await jfetch('/api/chat/file?meta=1&path='+encodeURIComponent(path));
const meta=await m.json().catch(()=>({}));
if(!m.ok || !meta.ok){
toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return;
}
let blob;
if(meta.e2e){
blob=await fetchFileB64(path, meta.size||0, a);
} else {
const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path));
if(!r.ok){
let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){}
toast('téléchargement impossible : '+msg); return;
}
blob=await r.blob();
}
const blobURL=URL.createObjectURL(blob);
const link=document.createElement('a');
link.href=blobURL; link.download=name||meta.name||'fichier';
document.body.appendChild(link); link.click(); link.remove();
// Révocation différée : Safari annule le téléchargement si l'URL disparaît
// dans la foulée du clic.
setTimeout(()=>URL.revokeObjectURL(blobURL), 10000);
}catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); }
finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } }
}
// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle
// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un
// lien mort. Or les noms de fichiers à espaces sont la règle, pas l'exception. On
// les encode donc avant le rendu (fileLinkPath les redécode).
//
// Les cibles avec un titre — [x](chemin "Titre") — sont laissées tranquilles :
// leurs espaces sont syntaxiques.
function encodeMdLinkSpaces(text){
return String(text).replace(/\]\(([^)\n]*)\)/g, (whole, target)=>{
if(target.indexOf(' ')<0 || /["']/.test(target)) return whole;
if(/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return whole; // URL : pas notre affaire
return '](' + target.replace(/ /g, '%20') + ')';
});
}
// Chemin visé par un lien de la réponse, ramené au dossier de travail — ou ''
// si le lien pointe ailleurs (page web, ancre, mailto…).
//
// Le modèle écrit indifféremment un chemin relatif, un chemin absolu du serveur,
// ou le préfixe `sandbox:` qu'il a vu ailleurs : les trois doivent marcher, on
// ne va pas lui demander d'être rigoureux sur un détail pareil.
function fileLinkPath(href){
if(!href) return '';
let h=href.trim();
if(/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(h)) return '';
h=h.replace(/^sandbox:/i,'').replace(/^file:\/\//i,'');
try{ h=decodeURI(h); }catch(_){}
h=h.replace(/\\/g,'/');
// Chemin absolu : on ne garde que ce qui suit le dossier de travail. Le serveur
// revérifie de toute façon — c'est lui qui fait autorité sur le périmètre.
const m=h.match(/(?:^|\/)workspace\/(.+)$/);
if(m) h=m[1];
h=h.replace(/^\/+/,'').replace(/^\.\//,'');
if(!h || h.indexOf('..')>=0) return '';
return h;
}
// Transforme en téléchargements les liens d'une réponse qui visent un fichier.
function markFileLinks(root){
for(const a of root.querySelectorAll('a[href]')){
const p=fileLinkPath(a.getAttribute('href'));
if(!p) continue;
a.classList.add('filelink');
a.removeAttribute('target');
a.setAttribute('href','#');
const name=p.split('/').pop();
a.title='télécharger '+name;
a.onclick=(e)=>{ e.preventDefault(); downloadWorkspaceFile(p, name, a); };
}
}
function attachListEl(){ return document.getElementById('attach-list'); }
function renderAttach(){
const el = attachListEl(); if(!el) return;
el.innerHTML='';
el.classList.toggle('show', ATTACH.length>0);
for(const a of ATTACH){
el.appendChild(fileChip(a.name, a.size, {
cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''),
title: a.error || a.name,
// Un gros fichier prend du temps : on montre l'avancement plutôt qu'un
// anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre.
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
thumb: a.thumb,
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
}));
}
}
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
function dropThumb(a){
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
}
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
// Ce qui part avec le message, pour l'afficher dans sa bulle.
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
// Lecture en base64. readAsDataURL préfixe par "data:<mime>;base64," — le
// serveur sait retirer cet en-tête, on n'a donc rien à découper ici.
function readAsBase64(file){
return new Promise((res,rej)=>{
const fr=new FileReader();
fr.onload=()=>res(String(fr.result||''));
fr.onerror=()=>rej(new Error('lecture impossible'));
fr.readAsDataURL(file);
});
}
// Envoie UN morceau. VIA jfetch, pas un fetch brut : derrière app.ajean.link,
// jfetch est réécrit pour traverser la boîte noire E2E — un POST direct vers
// /api/chat/upload n'atteint PAS ton serveur (le relais est aveugle) et l'envoi
// échouait « impossible d'envoyer ». On fournit notre PROPRE signal pour ne pas
// hériter du plafond de 30 s de jfetch (calibré pour de petits appels), un morceau
// pouvant être plus long ; le download passe déjà par jfetch, on symétrise.
async function sendChunk(payload){
const ac=new AbortController();
const timer=setTimeout(()=>ac.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
try{
const r=await jfetch('/api/chat/upload',{
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload), signal:ac.signal
});
const j=await r.json().catch(()=>({}));
if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status));
return j;
} finally { clearTimeout(timer); }
}
// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a
// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer
// un fichier de la liste ne laisse donc aucune trace sur le disque.
//
// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois
// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe
// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on
// en est, ce qu'un envoi monolithique ne permettait pas.
async function uploadAttach(a){
a.state='up'; a.sent=0; renderAttach();
try{
let id='';
// `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand
// même faire un tour de boucle, sinon rien ne part.
let off=0;
do{
const end=Math.min(off+ATTACH_CHUNK, a.size);
const data=await readAsBase64(a.file.slice(off, end));
const last=end>=a.size;
// `size` au premier morceau : le serveur vérifie la place disque AVANT
// d'entamer un envoi d'un gigaoctet, plutôt que de le découvrir à la fin.
const j=await sendChunk({name:a.name, data:data, id:id, more:!last, size:(id?0:a.size)});
if(j.id) id=j.id;
off=end;
a.sent=off; renderAttach();
if(last){ a.path=j.path; }
} while(off<a.size);
a.state='ok'; a.file=null; // le contenu ne sert plus à rien côté page
}catch(e){
a.state='err'; a.error=(e&&e.message)||'échec';
toast('« '+a.name+' » : '+a.error);
}
renderAttach();
}
function addFiles(files){
for(const f of files||[]){
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
// source d'image disponible à ce stade.
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
}
renderAttach();
}
function onAttachPick(e){
addFiles(e.target.files);
e.target.value=''; // sinon re-choisir le même fichier ne déclenche pas change
}
// Coller un fichier (capture d'écran, fichier copié depuis l'explorateur). On ne
// touche à rien quand le presse-papier ne contient que du texte : le collage
// normal dans la zone de saisie doit rester intact.
function onPaste(e){
const items=(e.clipboardData&&e.clipboardData.files)||[];
if(!items.length) return;
e.preventDefault();
addFiles(items);
}
// Glisser-déposer sur toute la fenêtre. Le compteur de profondeur est nécessaire :
// dragenter/dragleave se déclenchent aussi au passage d'un enfant à l'autre, et
// sans lui le voile clignote dès que le curseur traverse une bulle du fil.
let DRAG_DEPTH=0;
function dropVeil(on){
const v=document.getElementById('dropveil'); if(v) v.classList.toggle('show', on);
}
function hasFiles(e){
const dt=e.dataTransfer; if(!dt) return false;
if(dt.types) for(const t of dt.types){ if(t==='Files') return true; }
return false;
}
window.addEventListener('dragenter', e=>{ if(!hasFiles(e)) return; e.preventDefault(); DRAG_DEPTH++; dropVeil(true); });
window.addEventListener('dragover', e=>{ if(hasFiles(e)) e.preventDefault(); });
window.addEventListener('dragleave', e=>{ if(!hasFiles(e)) return; DRAG_DEPTH=Math.max(0,DRAG_DEPTH-1); if(!DRAG_DEPTH) dropVeil(false); });
window.addEventListener('drop', e=>{
if(!hasFiles(e)) return;
e.preventDefault(); DRAG_DEPTH=0; dropVeil(false);
addFiles(e.dataTransfer.files);
});
// Appelé par send() : c'est ICI que les fichiers partent réellement vers le
// serveur. Ceux qui échouent sont ignorés — leur pastille reste à l'écran en
// rouge, elle dit déjà ce qui s'est passé.
async function attachPaths(){
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();