mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-15 19:29:16 +02:00
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>
371 lines
17 KiB
JavaScript
371 lines
17 KiB
JavaScript
// ===== 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();
|