tina4-nodejs 3.13.100 → 3.13.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -234,11 +234,11 @@ ${t.traceback||""}`;window.__switchTab("chat"),setTimeout(()=>{window.__prefillC
234
234
  </tr>`).join("")}
235
235
  </tbody>
236
236
  </table>
237
- `)}function Xy(i,e){const t=[],n={},r={};i.forEach((s,o)=>{n[Rd(s)]=o,r[s]=o});for(const[s,o]of Object.entries(e)){const a=r[s];if(a!==void 0)for(const l of o){const O=r[l];if(O!==void 0){O!==a&&t.push([a,O]);continue}const c=l.replace(/^\.\//,"").replace(/^\.\.\//,"").split(/[./]/);let d;for(let h=c.length-1;h>=0;h--){const f=c[h].toLowerCase();if(f&&f!=="js"&&f!=="py"&&f!=="rb"&&f!=="ts"&&f!=="index"&&(d=n[f],d!==void 0))break}d===void 0&&(d=n[Rd(l)]),d!==void 0&&a!==d&&t.push([a,d])}}return t}function Rd(i){const e=i.replace(/\\/g,"/").split("/").pop()||"",t=e.lastIndexOf(".");return(t>0?e.substring(0,t):e).toLowerCase()}function _y(i,e,t,n){var gi,$n,ps;const r=e.offsetWidth||900,s=Math.max(450,Math.min(650,r*.45)),o=Math.max(...i.map(G=>G.loc))||1,a=Math.max(...i.map(G=>G.dep_count||0))||1,l=Math.max(...i.map(G=>G.coupling_afferent||0))||1,O=i.some(G=>(G.coupling_afferent||0)>0),c=14,d=Math.min(70,r/10);function h(G){const I=Math.min((G.avg_complexity||0)/10,1),D=G.has_tests?0:1,de=Math.min((G.dep_count||0)/5,1),j=I*.4+D*.4+de*.2,W=Math.max(0,Math.min(1,j)),H=Math.round(120*(1-W)),te=Math.round(70+W*30),pe=Math.round(42+18*(1-W));return`hsl(${H},${te}%,${pe}%)`}function f(G){return G.loc/o*.4+(G.avg_complexity||0)/10*.4+(G.dep_count||0)/a*.2}const p=[...i].sort((G,I)=>f(G)-f(I)),m=r/2,$=s/2,g=[];let y=0,S=0;for(const G of p){const I=c+Math.sqrt(f(G))*(d-c),D=h(G);let de=!1;for(let j=0;j<800;j++){const W=m+S*Math.cos(y),H=$+S*Math.sin(y);let te=!1;for(const pe of g){const he=W-pe.x,ke=H-pe.y;if(Math.sqrt(he*he+ke*ke)<I+pe.r+2){te=!0;break}}if(!te&&W>I+2&&W<r-I-2&&H>I+25&&H<s-I-2){g.push({x:W,y:H,vx:0,vy:0,r:I,color:D,f:G}),de=!0;break}y+=.2,S+=.04}de||g.push({x:m+(Math.random()-.5)*r*.3,y:$+(Math.random()-.5)*s*.3,vx:0,vy:0,r:I,color:D,f:G})}const x=Xy(g.map(G=>G.f.path),t),w=document.createElement("canvas");w.width=r,w.height=s,w.style.cssText="display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a";const R=n==="framework"?'<span style="color:#cba6f7;font-weight:600">(Framework)</span> Add code to src/ to see your project':"";e.innerHTML=`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;font-size:0.85rem">Code Landscape ${R}</h3><span style="font-size:0.65rem;color:var(--muted)">Drag bubbles | Dbl-click to drill down</span></div><div style="position:relative" id="metrics-canvas-wrap"></div><div style="margin-top:0.4rem;font-size:0.62rem;color:var(--muted);display:flex;gap:0.9rem;flex-wrap:wrap">${O?'<span>Centre = most depended-on</span><span><span style="color:hsl(210,85%,60%)">&#9679;</span> stable ring</span><span><span style="color:hsl(30,85%,60%)">&#9679;</span> unstable ring</span>':""}<span id="metrics-hover-info" class="text-mono"></span></div>`,document.getElementById("metrics-canvas-wrap").appendChild(w);const _=document.getElementById("metrics-hover-info"),A=document.createElement("div");A.style.cssText="position:absolute;top:8px;left:8px;z-index:2;display:flex;gap:4px;flex-direction:column",A.innerHTML=`
237
+ `)}function Xy(i,e){const t=[],n={},r={};i.forEach((s,o)=>{n[Rd(s)]=o,r[s]=o});for(const[s,o]of Object.entries(e)){const a=r[s];if(a!==void 0)for(const l of o){const O=r[l];if(O!==void 0){O!==a&&t.push([a,O]);continue}const c=l.replace(/^\.\//,"").replace(/^\.\.\//,"").split(/[./]/);let d;for(let h=c.length-1;h>=0;h--){const f=c[h].toLowerCase();if(f&&f!=="js"&&f!=="py"&&f!=="rb"&&f!=="ts"&&f!=="index"&&(d=n[f],d!==void 0))break}d===void 0&&(d=n[Rd(l)]),d!==void 0&&a!==d&&t.push([a,d])}}return t}function Rd(i){const e=i.replace(/\\/g,"/").split("/").pop()||"",t=e.lastIndexOf(".");return(t>0?e.substring(0,t):e).toLowerCase()}function _y(i,e,t,n){var gi,$n,ps;const r=e.offsetWidth||900,s=Math.max(450,Math.min(650,r*.45)),o=Math.max(...i.map(G=>G.loc))||1,a=Math.max(...i.map(G=>G.dep_count||0))||1,l=Math.max(...i.map(G=>G.coupling_afferent||0))||1,O=i.some(G=>(G.coupling_afferent||0)>0),c=14,d=Math.min(70,r/10);function h(G){const I=Math.min((G.avg_complexity||0)/10,1),D=G.has_referencing_test?0:1,de=Math.min((G.dep_count||0)/5,1),j=I*.4+D*.4+de*.2,W=Math.max(0,Math.min(1,j)),H=Math.round(120*(1-W)),te=Math.round(70+W*30),pe=Math.round(42+18*(1-W));return`hsl(${H},${te}%,${pe}%)`}function f(G){return G.loc/o*.4+(G.avg_complexity||0)/10*.4+(G.dep_count||0)/a*.2}const p=[...i].sort((G,I)=>f(G)-f(I)),m=r/2,$=s/2,g=[];let y=0,S=0;for(const G of p){const I=c+Math.sqrt(f(G))*(d-c),D=h(G);let de=!1;for(let j=0;j<800;j++){const W=m+S*Math.cos(y),H=$+S*Math.sin(y);let te=!1;for(const pe of g){const he=W-pe.x,ke=H-pe.y;if(Math.sqrt(he*he+ke*ke)<I+pe.r+2){te=!0;break}}if(!te&&W>I+2&&W<r-I-2&&H>I+25&&H<s-I-2){g.push({x:W,y:H,vx:0,vy:0,r:I,color:D,f:G}),de=!0;break}y+=.2,S+=.04}de||g.push({x:m+(Math.random()-.5)*r*.3,y:$+(Math.random()-.5)*s*.3,vx:0,vy:0,r:I,color:D,f:G})}const x=Xy(g.map(G=>G.f.path),t),w=document.createElement("canvas");w.width=r,w.height=s,w.style.cssText="display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a";const R=n==="framework"?'<span style="color:#cba6f7;font-weight:600">(Framework)</span> Add code to src/ to see your project':"";e.innerHTML=`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;font-size:0.85rem">Code Landscape ${R}</h3><span style="font-size:0.65rem;color:var(--muted)">Drag bubbles | Dbl-click to drill down</span></div><div style="position:relative" id="metrics-canvas-wrap"></div><div style="margin-top:0.4rem;font-size:0.62rem;color:var(--muted);display:flex;gap:0.9rem;flex-wrap:wrap">${O?'<span>Centre = most depended-on</span><span><span style="color:hsl(210,85%,60%)">&#9679;</span> stable ring</span><span><span style="color:hsl(30,85%,60%)">&#9679;</span> unstable ring</span>':""}<span id="metrics-hover-info" class="text-mono"></span></div>`,document.getElementById("metrics-canvas-wrap").appendChild(w);const _=document.getElementById("metrics-hover-info"),A=document.createElement("div");A.style.cssText="position:absolute;top:8px;left:8px;z-index:2;display:flex;gap:4px;flex-direction:column",A.innerHTML=`
238
238
  <button class="btn btn-sm" id="metrics-zoom-in" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">+</button>
239
239
  <button class="btn btn-sm" id="metrics-zoom-out" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">&minus;</button>
240
240
  <button class="btn btn-sm" id="metrics-zoom-fit" style="width:28px;height:28px;padding:0;font-size:10px;font-weight:700;line-height:1">Fit</button>
241
- `,document.getElementById("metrics-canvas-wrap").appendChild(A),(gi=document.getElementById("metrics-zoom-in"))==null||gi.addEventListener("click",()=>{le=Math.min(5,le*1.3)}),($n=document.getElementById("metrics-zoom-out"))==null||$n.addEventListener("click",()=>{le=Math.max(.3,le*.7)}),(ps=document.getElementById("metrics-zoom-fit"))==null||ps.addEventListener("click",()=>{le=1,U=0,oe=0});const P=w.getContext("2d");let v=-1,k=-1,X=0,z=0,U=0,oe=0,le=1,rt=!1,mn=0,Gt=0,us=0,ft=0;function De(){for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j],H=m-W.x,te=$-W.y,pe=.3+W.r/d*.7,he=O?.3+(W.f.coupling_afferent||0)/l*.7:pe,ke=.008*pe*he;W.vx+=H*ke,W.vy+=te*ke}for(const[j,W]of x){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20,Bt=(ke-Dt)*.002,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx+=ms,H.vy+=$s),W!==k&&(te.vx-=ms,te.vy-=$s)}for(let j=0;j<g.length;j++)for(let W=j+1;W<g.length;W++){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20;if(ke<Dt){const Bt=40*(Dt-ke)/Dt,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx-=ms,H.vy-=$s),W!==k&&(te.vx+=ms,te.vy+=$s)}}for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j];W.vx*=.65,W.vy*=.65;const H=2;W.vx=Math.max(-H,Math.min(H,W.vx)),W.vy=Math.max(-H,Math.min(H,W.vy)),W.x+=W.vx,W.y+=W.vy,W.x=Math.max(W.r+2,Math.min(r-W.r-2,W.x)),W.y=Math.max(W.r+25,Math.min(s-W.r-2,W.y))}}function st(){var G;De(),P.clearRect(0,0,r,s),P.save(),P.translate(U,oe),P.scale(le,le),P.strokeStyle="rgba(255,255,255,0.03)",P.lineWidth=1/le;for(let I=0;I<r/le;I+=50)P.beginPath(),P.moveTo(I,0),P.lineTo(I,s/le),P.stroke();for(let I=0;I<s/le;I+=50)P.beginPath(),P.moveTo(0,I),P.lineTo(r/le,I),P.stroke();for(const[I,D]of x){const de=g[I],j=g[D],W=j.x-de.x,H=j.y-de.y,te=Math.sqrt(W*W+H*H)||1,pe=v===I||v===D;P.beginPath(),P.moveTo(de.x+W/te*de.r,de.y+H/te*de.r);const he=j.x-W/te*j.r,ke=j.y-H/te*j.r;P.lineTo(he,ke),P.strokeStyle=pe?"rgba(139,180,250,0.9)":"rgba(255,255,255,0.15)",P.lineWidth=pe?3:1,P.stroke();const Dt=pe?12:6,Bt=Math.atan2(H,W);P.beginPath(),P.moveTo(he,ke),P.lineTo(he-Dt*Math.cos(Bt-.4),ke-Dt*Math.sin(Bt-.4)),P.lineTo(he-Dt*Math.cos(Bt+.4),ke-Dt*Math.sin(Bt+.4)),P.closePath(),P.fillStyle=P.strokeStyle,P.fill()}for(let I=0;I<g.length;I++){const D=g[I],de=I===v,j=de?D.r+4:D.r;de&&(P.beginPath(),P.arc(D.x,D.y,j+8,0,Math.PI*2),P.fillStyle="rgba(255,255,255,0.08)",P.fill()),P.beginPath(),P.arc(D.x,D.y,j,0,Math.PI*2),P.fillStyle=D.color,P.globalAlpha=de?1:.85,P.fill(),P.globalAlpha=1,P.strokeStyle=de?"rgba(255,255,255,0.6)":"rgba(255,255,255,0.25)",P.lineWidth=de?2.5:1.5,P.stroke();const W=D.f.instability;O&&typeof W=="number"&&(D.f.coupling_afferent||0)+(D.f.coupling_efferent||0)>0&&(P.beginPath(),P.arc(D.x,D.y,j+3,0,Math.PI*2),P.strokeStyle=`hsl(${Math.round(210-180*W)},85%,60%)`,P.lineWidth=1+W*2.5,P.stroke());const H=((G=D.f.path.split("/").pop())==null?void 0:G.replace(/\.\w+$/,""))||"?";if(j>16){const he=Math.max(8,Math.min(13,j*.38));P.fillStyle="#fff",P.font=`600 ${he}px monospace`,P.textAlign="center",P.fillText(H,D.x,D.y-2),P.fillStyle="rgba(255,255,255,0.65)",P.font=`${he-1}px monospace`,P.fillText(`${D.f.loc} LOC`,D.x,D.y+he)}const te=Math.max(9,j*.3),pe=te*.7;if(j>14&&D.f.dep_count>0){const he=D.y-j+pe+3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#ea580c",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("D",D.x,he+te*.35)}if(j>14&&D.f.has_tests){const he=D.y+j-pe-3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#16a34a",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("T",D.x,he+te*.35)}}if(P.restore(),v>=0){const I=g[v].f,D=I.coupling_afferent??0,de=I.coupling_efferent??0,j=typeof I.instability=="number"?I.instability.toFixed(2):"-";_.textContent=`${I.path} ca ${D} / ce ${de} / I ${j} / MI ${I.maintainability}`}else _.textContent&&(_.textContent="");requestAnimationFrame(st)}w.addEventListener("mousemove",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(rt){U=us+(G.clientX-mn),oe=ft+(G.clientY-Gt);return}if(k>=0){ut=!0,g[k].x=D+X,g[k].y=de+z,g[k].vx=0,g[k].vy=0;return}v=-1;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){v=j;break}}w.style.cursor=v>=0?"grab":"default"}),w.addEventListener("mousedown",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(G.button===2){rt=!0,mn=G.clientX,Gt=G.clientY,us=U,ft=oe,w.style.cursor="move";return}v>=0&&(k=v,X=g[k].x-D,z=g[k].y-de,ut=!1,w.style.cursor="grabbing")});let ut=!1;w.addEventListener("mouseup",G=>{if(rt){rt=!1,w.style.cursor="default";return}if(k>=0){ut||Xa(g[k].f.path),w.style.cursor="grab",k=-1,ut=!1;return}}),w.addEventListener("mouseleave",()=>{v=-1,k=-1,rt=!1}),w.addEventListener("dblclick",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){Xa(W.f.path);break}}}),w.addEventListener("contextmenu",G=>G.preventDefault()),requestAnimationFrame(st)}async function Xa(i){const e=document.getElementById("metrics-detail");if(!e)return;e.innerHTML='<p class="text-muted">Loading file analysis...</p>';const t=await L("/metrics/file?path="+encodeURIComponent(i));if(t.error){e.innerHTML=`<p style="color:var(--danger)">${Q(t.error)}</p>`;return}const n=t.functions||[],r=Math.max(1,...n.map(s=>s.complexity));e.innerHTML=`
241
+ `,document.getElementById("metrics-canvas-wrap").appendChild(A),(gi=document.getElementById("metrics-zoom-in"))==null||gi.addEventListener("click",()=>{le=Math.min(5,le*1.3)}),($n=document.getElementById("metrics-zoom-out"))==null||$n.addEventListener("click",()=>{le=Math.max(.3,le*.7)}),(ps=document.getElementById("metrics-zoom-fit"))==null||ps.addEventListener("click",()=>{le=1,U=0,oe=0});const P=w.getContext("2d");let v=-1,k=-1,X=0,z=0,U=0,oe=0,le=1,rt=!1,mn=0,Gt=0,us=0,ft=0;function De(){for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j],H=m-W.x,te=$-W.y,pe=.3+W.r/d*.7,he=O?.3+(W.f.coupling_afferent||0)/l*.7:pe,ke=.008*pe*he;W.vx+=H*ke,W.vy+=te*ke}for(const[j,W]of x){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20,Bt=(ke-Dt)*.002,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx+=ms,H.vy+=$s),W!==k&&(te.vx-=ms,te.vy-=$s)}for(let j=0;j<g.length;j++)for(let W=j+1;W<g.length;W++){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20;if(ke<Dt){const Bt=40*(Dt-ke)/Dt,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx-=ms,H.vy-=$s),W!==k&&(te.vx+=ms,te.vy+=$s)}}for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j];W.vx*=.65,W.vy*=.65;const H=2;W.vx=Math.max(-H,Math.min(H,W.vx)),W.vy=Math.max(-H,Math.min(H,W.vy)),W.x+=W.vx,W.y+=W.vy,W.x=Math.max(W.r+2,Math.min(r-W.r-2,W.x)),W.y=Math.max(W.r+25,Math.min(s-W.r-2,W.y))}}function st(){var G;De(),P.clearRect(0,0,r,s),P.save(),P.translate(U,oe),P.scale(le,le),P.strokeStyle="rgba(255,255,255,0.03)",P.lineWidth=1/le;for(let I=0;I<r/le;I+=50)P.beginPath(),P.moveTo(I,0),P.lineTo(I,s/le),P.stroke();for(let I=0;I<s/le;I+=50)P.beginPath(),P.moveTo(0,I),P.lineTo(r/le,I),P.stroke();for(const[I,D]of x){const de=g[I],j=g[D],W=j.x-de.x,H=j.y-de.y,te=Math.sqrt(W*W+H*H)||1,pe=v===I||v===D;P.beginPath(),P.moveTo(de.x+W/te*de.r,de.y+H/te*de.r);const he=j.x-W/te*j.r,ke=j.y-H/te*j.r;P.lineTo(he,ke),P.strokeStyle=pe?"rgba(139,180,250,0.9)":"rgba(255,255,255,0.15)",P.lineWidth=pe?3:1,P.stroke();const Dt=pe?12:6,Bt=Math.atan2(H,W);P.beginPath(),P.moveTo(he,ke),P.lineTo(he-Dt*Math.cos(Bt-.4),ke-Dt*Math.sin(Bt-.4)),P.lineTo(he-Dt*Math.cos(Bt+.4),ke-Dt*Math.sin(Bt+.4)),P.closePath(),P.fillStyle=P.strokeStyle,P.fill()}for(let I=0;I<g.length;I++){const D=g[I],de=I===v,j=de?D.r+4:D.r;de&&(P.beginPath(),P.arc(D.x,D.y,j+8,0,Math.PI*2),P.fillStyle="rgba(255,255,255,0.08)",P.fill()),P.beginPath(),P.arc(D.x,D.y,j,0,Math.PI*2),P.fillStyle=D.color,P.globalAlpha=de?1:.85,P.fill(),P.globalAlpha=1,P.strokeStyle=de?"rgba(255,255,255,0.6)":"rgba(255,255,255,0.25)",P.lineWidth=de?2.5:1.5,P.stroke();const W=D.f.instability;O&&typeof W=="number"&&(D.f.coupling_afferent||0)+(D.f.coupling_efferent||0)>0&&(P.beginPath(),P.arc(D.x,D.y,j+3,0,Math.PI*2),P.strokeStyle=`hsl(${Math.round(210-180*W)},85%,60%)`,P.lineWidth=1+W*2.5,P.stroke());const H=((G=D.f.path.split("/").pop())==null?void 0:G.replace(/\.\w+$/,""))||"?";if(j>16){const he=Math.max(8,Math.min(13,j*.38));P.fillStyle="#fff",P.font=`600 ${he}px monospace`,P.textAlign="center",P.fillText(H,D.x,D.y-2),P.fillStyle="rgba(255,255,255,0.65)",P.font=`${he-1}px monospace`,P.fillText(`${D.f.loc} LOC`,D.x,D.y+he)}const te=Math.max(9,j*.3),pe=te*.7;if(j>14&&D.f.dep_count>0){const he=D.y-j+pe+3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#ea580c",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("D",D.x,he+te*.35)}if(j>14&&D.f.has_referencing_test){const he=D.y+j-pe-3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#16a34a",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("T",D.x,he+te*.35)}}if(P.restore(),v>=0){const I=g[v].f,D=I.coupling_afferent??0,de=I.coupling_efferent??0,j=typeof I.instability=="number"?I.instability.toFixed(2):"-";_.textContent=`${I.path} ca ${D} / ce ${de} / I ${j} / MI ${I.maintainability}`}else _.textContent&&(_.textContent="");requestAnimationFrame(st)}w.addEventListener("mousemove",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(rt){U=us+(G.clientX-mn),oe=ft+(G.clientY-Gt);return}if(k>=0){ut=!0,g[k].x=D+X,g[k].y=de+z,g[k].vx=0,g[k].vy=0;return}v=-1;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){v=j;break}}w.style.cursor=v>=0?"grab":"default"}),w.addEventListener("mousedown",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(G.button===2){rt=!0,mn=G.clientX,Gt=G.clientY,us=U,ft=oe,w.style.cursor="move";return}v>=0&&(k=v,X=g[k].x-D,z=g[k].y-de,ut=!1,w.style.cursor="grabbing")});let ut=!1;w.addEventListener("mouseup",G=>{if(rt){rt=!1,w.style.cursor="default";return}if(k>=0){ut||Xa(g[k].f.path),w.style.cursor="grab",k=-1,ut=!1;return}}),w.addEventListener("mouseleave",()=>{v=-1,k=-1,rt=!1}),w.addEventListener("dblclick",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){Xa(W.f.path);break}}}),w.addEventListener("contextmenu",G=>G.preventDefault()),requestAnimationFrame(st)}async function Xa(i){const e=document.getElementById("metrics-detail");if(!e)return;e.innerHTML='<p class="text-muted">Loading file analysis...</p>';const t=await L("/metrics/file?path="+encodeURIComponent(i));if(t.error){e.innerHTML=`<p style="color:var(--danger)">${Q(t.error)}</p>`;return}const n=t.functions||[],r=Math.max(1,...n.map(s=>s.complexity));e.innerHTML=`
242
242
  <div style="background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;padding:1rem">
243
243
  <div class="flex items-center" style="justify-content:space-between;margin-bottom:0.75rem">
244
244
  <h3 style="font-size:0.9rem">${Q(t.path)}</h3>
@@ -0,0 +1,288 @@
1
+ /** Zero-dependency app-facing AI client (ADR-0053). */
2
+ import http, { type IncomingMessage } from "node:http";
3
+ import https from "node:https";
4
+
5
+ export class AiError extends Error {}
6
+ export class AiConfigError extends AiError {}
7
+ export class AiTimeoutError extends AiError {}
8
+ export class AiParseError extends AiError {}
9
+ export class AiHTTPError extends AiError {
10
+ constructor(message: string, public readonly status: number | null = null) { super(message); }
11
+ }
12
+
13
+ export interface ChatResponse {
14
+ text: string;
15
+ model: string;
16
+ usage: { promptTokens: number; completionTokens: number; totalTokens: number };
17
+ finishReason: string | null;
18
+ raw: Record<string, unknown>;
19
+ }
20
+ export interface AiMessage { role: "system" | "user" | "assistant"; content: string }
21
+ export interface AiChatOptions {
22
+ model?: string;
23
+ temperature?: number;
24
+ maxTokens?: number;
25
+ stream?: boolean;
26
+ timeout?: number;
27
+ provider?: "local" | "openai" | "anthropic";
28
+ }
29
+ export interface AiEmbedOptions { model?: string; timeout?: number; provider?: "local" | "openai" | "anthropic" }
30
+
31
+ interface Config {
32
+ provider: "local" | "openai" | "anthropic";
33
+ url: string;
34
+ model: string;
35
+ key: string | null;
36
+ totalTimeout: number;
37
+ connectTimeout: number;
38
+ maxRetries: number;
39
+ }
40
+ interface OpenResponse { response: IncomingMessage; cleanup: () => void }
41
+
42
+ export class Ai {
43
+ static chat(messages: AiMessage[], options: AiChatOptions & { stream: true }): AsyncGenerator<string>;
44
+ static chat(messages: AiMessage[], options?: AiChatOptions & { stream?: false }): Promise<ChatResponse>;
45
+ static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<string> {
46
+ this.validateMessages(messages);
47
+ const config = this.config("chat", options);
48
+ const body = this.chatBody(config, messages, options);
49
+ const headers = this.headers(config);
50
+ return options.stream ? this.streamRequest(config, headers, body) : this.chatResponse(config, headers, body);
51
+ }
52
+
53
+ static async complete(prompt: string, options: Omit<AiChatOptions, "stream"> = {}): Promise<string> {
54
+ if (typeof prompt !== "string") throw new AiConfigError("AI prompt must be a string");
55
+ return (await this.chat([{ role: "user", content: prompt }], { ...options, stream: false })).text;
56
+ }
57
+
58
+ static async embed(textOrTexts: string | string[], options: AiEmbedOptions = {}): Promise<number[] | number[][]> {
59
+ const single = typeof textOrTexts === "string";
60
+ if (!single && (!Array.isArray(textOrTexts) || textOrTexts.length === 0 || !textOrTexts.every((item) => typeof item === "string"))) {
61
+ throw new AiConfigError("AI embedding input must be a string or a non-empty list of strings");
62
+ }
63
+ const config = this.config("embed", options);
64
+ if (config.provider === "anthropic") throw new AiConfigError("Anthropic does not provide the embedding endpoint in this contract");
65
+ const raw = await this.requestJson(config, this.headers(config), { model: config.model, input: textOrTexts });
66
+ try {
67
+ const data = (raw.data as Array<{ index?: number; embedding?: unknown }>).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
68
+ const vectors = data.map((item) => item.embedding);
69
+ const expected = single ? 1 : textOrTexts.length;
70
+ if (vectors.length !== expected || !vectors.every((vector) => Array.isArray(vector) && vector.length > 0 && vector.every((value) => typeof value === "number" && Number.isFinite(value)))) throw new Error();
71
+ return single ? vectors[0] as number[] : vectors as number[][];
72
+ } catch {
73
+ throw new AiParseError("AI provider returned a malformed embedding response");
74
+ }
75
+ }
76
+
77
+ private static validateMessages(messages: AiMessage[]): void {
78
+ if (!Array.isArray(messages) || messages.length === 0 || !messages.every((message) => message && ["system", "user", "assistant"].includes(message.role) && typeof message.content === "string")) {
79
+ throw new AiConfigError("AI messages must contain supported roles and string content");
80
+ }
81
+ }
82
+
83
+ private static number(name: string, fallback: number, minimum: number): number {
84
+ const value = process.env[name] === undefined ? fallback : Number(process.env[name]);
85
+ if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
86
+ return value;
87
+ }
88
+
89
+ private static config(capability: "chat" | "embed", options: AiChatOptions | AiEmbedOptions): Config {
90
+ const provider = (options.provider ?? process.env.TINA4_AI_PROVIDER ?? "local").trim().toLowerCase();
91
+ if (provider !== "local" && provider !== "openai" && provider !== "anthropic") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic");
92
+ const key = process.env.TINA4_AI_KEY || null;
93
+ if ((provider === "openai" || provider === "anthropic") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
94
+ const defaults: Record<Config["provider"], [string, string]> = {
95
+ local: ["http://localhost:11437", "llama3.2"],
96
+ openai: ["https://api.openai.com/v1", "gpt-4o-mini"],
97
+ anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"],
98
+ };
99
+ const rawUrl = capability === "embed" && process.env.TINA4_EMBED_URL ? process.env.TINA4_EMBED_URL : (process.env.TINA4_AI_URL ?? defaults[provider][0]);
100
+ const model = (options.model ?? process.env.TINA4_AI_MODEL ?? defaults[provider][1]).trim();
101
+ if (!model) throw new AiConfigError("AI model must be a non-empty string");
102
+ const totalTimeout = options.timeout === undefined ? this.number("TINA4_AI_TIMEOUT", 60, 0.001) : Number(options.timeout);
103
+ if (!Number.isFinite(totalTimeout) || totalTimeout <= 0) throw new AiConfigError("AI timeout must be greater than zero");
104
+ return { provider, url: this.endpoint(rawUrl, capability, provider), model, key, totalTimeout, connectTimeout: this.number("TINA4_AI_CONNECT_TIMEOUT", 10, 0.001), maxRetries: Math.trunc(this.number("TINA4_AI_MAX_RETRIES", 2, 0)) };
105
+ }
106
+
107
+ private static endpoint(value: string, capability: "chat" | "embed", provider: Config["provider"]): string {
108
+ let url: URL;
109
+ try { url = new URL(value); } catch { throw new AiConfigError("AI URL must be an http or https URL"); }
110
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new AiConfigError("AI URL must be an http or https URL");
111
+ const path = url.pathname.replace(/\/+$/, "");
112
+ if (path === "" || path === "/v1" || path === "/api") {
113
+ const suffix = provider === "anthropic" ? "/messages" : capability === "embed" ? "/embeddings" : "/chat/completions";
114
+ url.pathname = (path || "/v1") + suffix;
115
+ }
116
+ return url.toString();
117
+ }
118
+
119
+ private static headers(config: Config): Record<string, string> {
120
+ const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json" };
121
+ if (config.provider === "openai") headers.authorization = `Bearer ${config.key}`;
122
+ if (config.provider === "anthropic") { headers["x-api-key"] = config.key!; headers["anthropic-version"] = "2023-06-01"; }
123
+ return headers;
124
+ }
125
+
126
+ private static chatBody(config: Config, messages: AiMessage[], options: AiChatOptions): Record<string, unknown> {
127
+ const body: Record<string, unknown> = { model: config.model, messages, stream: options.stream ?? false };
128
+ if (options.temperature !== undefined) body.temperature = options.temperature;
129
+ if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens;
130
+ if (config.provider === "anthropic") {
131
+ const system = messages.filter((message) => message.role === "system").map((message) => message.content);
132
+ body.messages = messages.filter((message) => message.role !== "system");
133
+ body.max_tokens = options.maxTokens ?? 1024;
134
+ if (system.length) body.system = system.join("\n\n");
135
+ }
136
+ return body;
137
+ }
138
+
139
+ private static open(config: Config, deadline: number, headers: Record<string, string>, body: Record<string, unknown>): Promise<OpenResponse> {
140
+ const remainingMs = deadline - performance.now();
141
+ if (remainingMs <= 0) return Promise.reject(new AiTimeoutError("AI total request timeout expired"));
142
+ const url = new URL(config.url);
143
+ const payload = JSON.stringify(body);
144
+ const controller = new AbortController();
145
+ const totalTimer = setTimeout(() => controller.abort(new AiTimeoutError("AI total request timeout expired")), remainingMs);
146
+ return new Promise((resolve, reject) => {
147
+ const client = url.protocol === "https:" ? https : http;
148
+ const request = client.request(url, { method: "POST", headers: { ...headers, "content-length": Buffer.byteLength(payload) }, signal: controller.signal }, (response) => {
149
+ clearTimeout(connectTimer);
150
+ resolve({ response, cleanup: () => { clearTimeout(totalTimer); clearTimeout(connectTimer); } });
151
+ });
152
+ const connectTimer = setTimeout(() => request.destroy(new AiTimeoutError("AI connection timeout expired")), Math.min(config.connectTimeout * 1000, remainingMs));
153
+ request.on("socket", (socket) => {
154
+ if (!socket.connecting) clearTimeout(connectTimer);
155
+ socket.once(url.protocol === "https:" ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
156
+ });
157
+ request.once("error", (error) => {
158
+ clearTimeout(totalTimer); clearTimeout(connectTimer);
159
+ if (error instanceof AiError) reject(error);
160
+ else if (controller.signal.aborted) reject(new AiTimeoutError("AI total request timeout expired"));
161
+ else reject(new AiHTTPError(`AI transport failed (${error.name})`));
162
+ });
163
+ request.end(payload);
164
+ });
165
+ }
166
+
167
+ private static async readBody(response: IncomingMessage): Promise<string> {
168
+ const chunks: Buffer[] = [];
169
+ for await (const chunk of response) chunks.push(Buffer.from(chunk));
170
+ return Buffer.concat(chunks).toString("utf8");
171
+ }
172
+
173
+ private static retryDelay(headers: http.IncomingHttpHeaders, deadline: number): Promise<void> {
174
+ const value = Array.isArray(headers["retry-after"]) ? headers["retry-after"][0] : headers["retry-after"];
175
+ const requested = value !== undefined && Number.isFinite(Number(value)) ? Math.max(0, Number(value) * 1000) : 100;
176
+ const delay = Math.min(requested, Math.max(0, deadline - performance.now()));
177
+ return new Promise((resolve) => setTimeout(resolve, delay));
178
+ }
179
+
180
+ private static async requestJson(config: Config, headers: Record<string, string>, body: Record<string, unknown>): Promise<Record<string, unknown>> {
181
+ const deadline = performance.now() + config.totalTimeout * 1000;
182
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
183
+ let opened: OpenResponse | null = null;
184
+ try {
185
+ opened = await this.open(config, deadline, headers, body);
186
+ const status = opened.response.statusCode ?? 0;
187
+ const responseHeaders = opened.response.headers;
188
+ const raw = await this.readBody(opened.response);
189
+ opened.cleanup(); opened = null;
190
+ if (status < 200 || status >= 300) {
191
+ if ((status === 429 || status >= 500) && attempt < config.maxRetries) { await this.retryDelay(responseHeaders, deadline); continue; }
192
+ throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
193
+ }
194
+ let parsed: unknown;
195
+ try { parsed = JSON.parse(raw); } catch { throw new AiParseError("AI provider returned malformed JSON"); }
196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new AiParseError("AI provider returned a non-object JSON response");
197
+ return parsed as Record<string, unknown>;
198
+ } catch (error) {
199
+ opened?.cleanup();
200
+ if (error instanceof AiParseError || (error instanceof AiHTTPError && error.status !== null)) throw error;
201
+ if (attempt >= config.maxRetries) throw error;
202
+ }
203
+ }
204
+ throw new AiHTTPError("AI request failed");
205
+ }
206
+
207
+ private static normalizeChat(provider: Config["provider"], raw: Record<string, unknown>): ChatResponse {
208
+ try {
209
+ if (provider === "anthropic") {
210
+ const content = raw.content as Array<{ type?: string; text?: unknown }>;
211
+ const parts = content.filter((item) => (item.type ?? "text") === "text").map((item) => item.text);
212
+ if (!parts.length || !parts.every((part) => typeof part === "string")) throw new Error();
213
+ const usage = (raw.usage ?? {}) as Record<string, unknown>;
214
+ const promptTokens = Number(usage.input_tokens ?? 0); const completionTokens = Number(usage.output_tokens ?? 0);
215
+ return { text: parts.join(""), model: String(raw.model ?? ""), usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens }, finishReason: raw.stop_reason == null ? null : String(raw.stop_reason), raw };
216
+ }
217
+ const choice = (raw.choices as Array<Record<string, unknown>>)[0];
218
+ const text = (choice.message as Record<string, unknown>).content;
219
+ if (typeof text !== "string") throw new Error();
220
+ const usage = (raw.usage ?? {}) as Record<string, unknown>;
221
+ return { text, model: String(raw.model ?? ""), usage: { promptTokens: Number(usage.prompt_tokens ?? 0), completionTokens: Number(usage.completion_tokens ?? 0), totalTokens: Number(usage.total_tokens ?? 0) }, finishReason: choice.finish_reason == null ? null : String(choice.finish_reason), raw };
222
+ } catch { throw new AiParseError("AI provider returned a malformed chat response"); }
223
+ }
224
+
225
+ private static async chatResponse(config: Config, headers: Record<string, string>, body: Record<string, unknown>): Promise<ChatResponse> {
226
+ return this.normalizeChat(config.provider, await this.requestJson(config, headers, body));
227
+ }
228
+
229
+ private static streamDelta(provider: Config["provider"], data: string): { completed: boolean; text?: string } {
230
+ if (data === "[DONE]") return { completed: true };
231
+ let event: Record<string, unknown>;
232
+ try { event = JSON.parse(data) as Record<string, unknown>; } catch { throw new AiParseError("AI provider returned malformed stream data"); }
233
+ const text = provider === "anthropic"
234
+ ? (event.type === "content_block_delta" ? (event.delta as Record<string, unknown>)?.text : undefined)
235
+ : (((event.choices as Array<Record<string, unknown>>)?.[0]?.delta as Record<string, unknown>)?.content);
236
+ if (text !== undefined && text !== null && typeof text !== "string") throw new AiParseError("AI provider returned malformed stream data");
237
+ return { completed: false, text: text as string | undefined };
238
+ }
239
+
240
+ private static async *streamData(response: IncomingMessage): AsyncGenerator<string> {
241
+ let buffer = "";
242
+ for await (const chunk of response) {
243
+ buffer += Buffer.from(chunk).toString("utf8");
244
+ let newline: number;
245
+ while ((newline = buffer.indexOf("\n")) >= 0) {
246
+ const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1);
247
+ if (line.startsWith("data:")) yield line.slice(5).trim();
248
+ }
249
+ }
250
+ }
251
+
252
+ private static streamError(error: unknown): AiError {
253
+ if (error instanceof AiError) return error;
254
+ if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
255
+ return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
256
+ }
257
+
258
+ private static async *streamRequest(config: Config, headers: Record<string, string>, body: Record<string, unknown>): AsyncGenerator<string> {
259
+ const deadline = performance.now() + config.totalTimeout * 1000;
260
+ let yielded = false;
261
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
262
+ let opened: OpenResponse | null = null;
263
+ try {
264
+ opened = await this.open(config, deadline, { ...headers, accept: "text/event-stream" }, body);
265
+ const status = opened.response.statusCode ?? 0;
266
+ if (status < 200 || status >= 300) {
267
+ await this.readBody(opened.response);
268
+ if ((status === 429 || status >= 500) && attempt < config.maxRetries) { await this.retryDelay(opened.response.headers, deadline); opened.cleanup(); opened = null; continue; }
269
+ throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
270
+ }
271
+ let completed = false;
272
+ for await (const data of this.streamData(opened.response)) {
273
+ const delta = this.streamDelta(config.provider, data);
274
+ if (delta.completed) { completed = true; break; }
275
+ if (delta.text === undefined) continue;
276
+ yielded = true; yield delta.text;
277
+ }
278
+ opened.cleanup(); opened = null;
279
+ if (completed) return;
280
+ throw new AiParseError("AI provider stream ended before [DONE]");
281
+ } catch (error) {
282
+ opened?.cleanup();
283
+ const failure = this.streamError(error);
284
+ if (failure instanceof AiParseError || (failure instanceof AiHTTPError && failure.status !== null) || yielded || attempt >= config.maxRetries) throw failure;
285
+ }
286
+ }
287
+ }
288
+ }
@@ -17,7 +17,7 @@ import type { Router } from "./router.js";
17
17
  import type { RouteHandler, Tina4Request } from "./types.js";
18
18
  import { DevMailbox } from "./devMailbox.js";
19
19
  import { isTruthy } from "./dotenv.js";
20
- import { quickMetrics, fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js";
20
+ import { fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js";
21
21
  import { registerFeedbackRoutes } from "./feedback.js";
22
22
  import { getDefaultDevServer, mcpEnabled, isRequestAllowed, isLoopback } from "./mcp.js";
23
23
  import { timingSafeEqual } from "node:crypto";
@@ -596,7 +596,6 @@ export class DevAdmin {
596
596
  { method: "GET", pattern: "/__dev/api/gallery", handler: handleGalleryList },
597
597
  { method: "POST", pattern: "/__dev/api/gallery/deploy", handler: handleGalleryDeploy(router) },
598
598
  // Metrics
599
- { method: "GET", pattern: "/__dev/api/metrics", handler: (_req: any, res: any) => { res.json(quickMetrics()); } },
600
599
  // No fallback (ADR-0002): a missing or stale CLI is a 503 naming the
601
600
  // install command, never zeros that read as a healthy codebase.
602
601
  { method: "GET", pattern: "/__dev/api/metrics/full", handler: (_req: any, res: any) => {
@@ -107,6 +107,8 @@ export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htm
107
107
  export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
108
108
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
109
109
  export type { AiTool } from "./ai.js";
110
+ export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
111
+ export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions } from "./aiClient.js";
110
112
  export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
111
113
  export { LiteBackend } from "./queueBackends/liteBackend.js";
112
114
  export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";