瀏覽代碼

Added Record screen, transcribe, mobile screen share bug fix.

Sravan 1 周之前
父節點
當前提交
28f616d829
共有 5 個檔案被更改,包括 320 行新增32 行删除
  1. 4
    0
      server/db.js
  2. 106
    6
      server/public/connect.html
  3. 74
    22
      server/public/console.html
  4. 39
    4
      server/public/share.html
  5. 97
    0
      server/server.js

+ 4
- 0
server/db.js 查看文件

@@ -77,4 +77,8 @@ CREATE TABLE IF NOT EXISTS sessions_log (
77 77
 );
78 78
 `);
79 79
 
80
+// Migration: stored recording filename for a session (null if not recorded)
81
+try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ }
82
+try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ }
83
+
80 84
 module.exports = db;

+ 106
- 6
server/public/connect.html 查看文件

@@ -39,6 +39,10 @@
39 39
   .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
40 40
   .profile .pmenu a:hover{background:#f1f5f9}
41 41
   .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
42
+  .pwwrap{position:relative;}
43
+  .pwwrap input{padding-right:2.7rem;}
44
+  .eye{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;margin:0;}
45
+  .eye:hover{color:var(--blue);}
42 46
 </style>
43 47
 </head>
44 48
 <body>
@@ -52,7 +56,8 @@
52 56
 
53 57
 <script>
54 58
 let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
55
-let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
59
+const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
60
+let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
56 61
 async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
57 62
 function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
58 63
 function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
@@ -82,13 +87,16 @@ function onEnter(ids, fn){ ids.forEach(id => { const el=document.getElementById(
82 87
 })();
83 88
 
84 89
 // ---- LOGIN ----
90
+const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
91
+const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
92
+function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
85 93
 function renderLogin(){
86 94
   agentChip.textContent='';
87 95
   card.innerHTML = `
88
-    <h1>Agent sign in</h1>
96
+    <h1>Sign in</h1>
89 97
     <div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
90 98
     <span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
91
-    <span class="lbl">Password</span><input id="pw" type="password" placeholder="password">
99
+    <span class="lbl">Password</span><div class="pwwrap"><input id="pw" type="password" placeholder="password"><button type="button" class="eye" data-for="pw" aria-label="Show password"></button></div>
92 100
     <label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="rememberMe" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
93 101
     <button class="btn" id="loginBtn" style="width:100%">Sign in</button>
94 102
     <div class="status err" id="err"></div>`;
@@ -101,6 +109,7 @@ function renderLogin(){
101 109
     };
102 110
     document.getElementById('loginBtn').onclick=doSignIn;
103 111
     onEnter(['email','pw'], doSignIn);
112
+    wireEyes();
104 113
   }
105 114
 }
106 115
 
@@ -143,6 +152,7 @@ function connectWS(){
143 152
       const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
144 153
       ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
145 154
     case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
155
+    case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
146 156
     case 'session-denied': renderEnded('The customer declined the request.'); break;
147 157
     case 'session-ended': {
148 158
       const msgs={'share-cancelled':'The customer cancelled the screen selection. Ask them to refresh their page for a new code.',
@@ -163,6 +173,7 @@ function renderWaiting(){
163 173
 }
164 174
 
165 175
 function renderEnded(msg){
176
+  try{ stopRecording(); }catch(_){}
166 177
   removeSessionUI();
167 178
   if(pc){ try{pc.close();}catch(e){} pc=null; }
168 179
   video.style.display='none'; bar.classList.remove('show');
@@ -180,6 +191,89 @@ const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" strok
180 191
 const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
181 192
 const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
182 193
 const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
194
+const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>';
195
+const SVG_RECSTOP='<svg viewBox="0 0 24 24" width="15" height="15" fill="#fff"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
196
+let mediaRecorder=null, recChunks=[], recCtx=null;
197
+const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
198
+let recog=null, recogActive=false, transcriptLines=[];
199
+let recTimerInt=null, recStartTs=0;
200
+function fmtElapsedA(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
201
+function showRecTimer(on){
202
+  let c=document.getElementById('recTimer');
203
+  if(on){
204
+    if(!c){ c=document.createElement('div'); c.id='recTimer';
205
+      c.style.cssText='display:inline-flex;align-items:center;gap:6px;color:#fff;font-weight:700;font-size:.85rem;background:#dc2626;padding:.5rem .7rem;border-radius:12px';
206
+      c.innerHTML='<span style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:recpulseA 1.2s infinite"></span><span id="recTimerVal">00:00</span>';
207
+      const bar=document.getElementById('sessionBar'), rb=document.getElementById('recBtn');
208
+      if(bar&&rb){ bar.insertBefore(c, rb); } else if(bar){ bar.appendChild(c); }
209
+      if(!document.getElementById('recPulseStyleA')){const st=document.createElement('style');st.id='recPulseStyleA';st.textContent='@keyframes recpulseA{0%,100%{opacity:1}50%{opacity:.2}}';document.head.appendChild(st);}
210
+    }
211
+    recStartTs=Date.now(); clearInterval(recTimerInt);
212
+    const upd=()=>{ const v=document.getElementById('recTimerVal'); if(v) v.textContent=fmtElapsedA(Date.now()-recStartTs); };
213
+    upd(); recTimerInt=setInterval(upd,1000);
214
+  } else { clearInterval(recTimerInt); recTimerInt=null; if(c) c.remove(); }
215
+}
216
+function addLine(role, name, text, isChat){ transcriptLines.push({ t: Date.now(), role: role, name: name||'', text: text, chat: !!isChat }); }
217
+function startTranscription(){
218
+  transcriptLines=[];
219
+  if(!SR) return;
220
+  try{
221
+    recog=new SR(); recog.continuous=true; recog.interimResults=false; recog.lang='en-US';
222
+    recog.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ if(e.results[i].isFinal){ const txt=(e.results[i][0].transcript||'').trim(); if(txt) addLine('agent',(me&&(me.name||me.email))||'Agent',txt,false); } } };
223
+    recog.onerror=()=>{};
224
+    recog.onend=()=>{ if(recogActive){ try{recog.start();}catch(_){} } };
225
+    recogActive=true; recog.start();
226
+  }catch(e){}
227
+}
228
+function stopTranscription(){ recogActive=false; if(recog){ try{recog.stop();}catch(_){} recog=null; } }
229
+function buildTranscriptText(){
230
+  const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t);
231
+  const pad=(n)=>String(n).padStart(2,'0');
232
+  const head='BizGaze Support — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
233
+  const body=lines.map(l=>{ const d=new Date(l.t); const ts='['+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+']'; const who=(l.role==='agent'?'Agent':'Customer')+(l.name?' ('+l.name+')':'')+(l.chat?' [chat]':''); return ts+' '+who+': '+l.text; }).join('\n');
234
+  return head+(body||'(no speech captured)')+'\n';
235
+}
236
+async function uploadTranscript(){
237
+  if(!transcriptLines.length) return;
238
+  try{ await fetch('/api/transcript?sessionId='+encodeURIComponent(sessionId),{method:'POST',headers:{'Content-Type':'text/plain'},body:buildTranscriptText()}); }catch(_){}
239
+}
240
+function recBtnUpdate(on){const b=document.getElementById('recBtn');if(!b)return;b.innerHTML='<span style="display:inline-flex">'+(on?SVG_RECSTOP:SVG_REC)+'</span>';b.title=on?'Stop recording':'Record';b.style.background=on?'#dc2626':'#0ea5e9';}
241
+function startRecording(){
242
+  const remote=video.srcObject;
243
+  const _vt=remote&&remote.getVideoTracks&&remote.getVideoTracks()[0];
244
+  if(!_vt||_vt.readyState!=='live'){ alert('No live screen to record. The customer may have disconnected.'); return; }
245
+  if(pc&&pc.connectionState&&pc.connectionState!=='connected'){ alert('Not connected to the customer right now.'); return; }
246
+  try{
247
+    recCtx=new (window.AudioContext||window.webkitAudioContext)();
248
+    const dest=recCtx.createMediaStreamDestination();
249
+    if(remote.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(remote.getAudioTracks())).connect(dest);}catch(_){} }
250
+    if(window.__mic&&window.__mic.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(window.__mic.getAudioTracks())).connect(dest);}catch(_){} }
251
+    const mixed=new MediaStream();
252
+    mixed.addTrack(remote.getVideoTracks()[0]);
253
+    dest.stream.getAudioTracks().forEach(t=>mixed.addTrack(t));
254
+    let mime='video/webm;codecs=vp8,opus'; if(!(window.MediaRecorder&&MediaRecorder.isTypeSupported(mime))) mime='video/webm';
255
+    recChunks=[];
256
+    mediaRecorder=new MediaRecorder(mixed,{mimeType:mime});
257
+    mediaRecorder.ondataavailable=(e)=>{ if(e.data&&e.data.size) recChunks.push(e.data); };
258
+    mediaRecorder.onstop=async()=>{
259
+      try{ const blob=new Blob(recChunks,{type:'video/webm'}); if(blob.size) await fetch('/api/recording?sessionId='+encodeURIComponent(sessionId),{method:'POST',headers:{'Content-Type':'video/webm'},body:blob}); }catch(_){}
260
+      try{recCtx&&recCtx.close();}catch(_){} recCtx=null;
261
+    };
262
+    mediaRecorder.start(1000);
263
+    startTranscription();
264
+    recBtnUpdate(true);
265
+    showRecTimer(true);
266
+    try{ws.send(JSON.stringify({type:'recording',sessionId,on:true}));}catch(_){}
267
+  }catch(e){ alert('Recording could not start on this browser.'); }
268
+}
269
+function stopRecording(){
270
+  if(mediaRecorder&&mediaRecorder.state!=='inactive'){ try{mediaRecorder.stop();}catch(_){} }
271
+  stopTranscription();
272
+  uploadTranscript();
273
+  recBtnUpdate(false);
274
+  showRecTimer(false);
275
+  try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){}
276
+}
183 277
 function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
184 278
 function buildBar(){
185 279
   if(document.getElementById('sessionBar'))return;
@@ -187,11 +281,13 @@ function buildBar(){
187 281
   bar.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
188 282
   const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
189 283
   const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
284
+  const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); });
190 285
   const end=_btn('endBtn2',SVG_END,'End','#dc2626');
191
-  bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
286
+  bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
192 287
   document.body.appendChild(bar);
193 288
   mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
194 289
   chat.onclick=toggleChat;
290
+  rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
195 291
   end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){}  };
196 292
   buildChatPanel();
197 293
   document.addEventListener('pointerdown',ensureAudio,{once:true});
@@ -216,19 +312,23 @@ let __ac=null;
216 312
 function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
217 313
 function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
218 314
 try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
219
-function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
315
+function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
220 316
 function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
221 317
 
222 318
 async function setupPeer(){
223 319
   await ensureIce();
224 320
   pc=new RTCPeerConnection(ICE);
225 321
   inputChannel=pc.createDataChannel('input',{ordered:true});
226
-  pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}}; } };
322
+  pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{const mm=JSON.parse(e.data);addChat(mm);if(recogActive)addLine('customer',mm.name||'Customer',mm.text,true);}catch(_){}}; } };
227 323
   pc.ontrack=(ev)=>{
228 324
     if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; return; }
229 325
     video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; video.style.display='block'; video.focus(); buildBar();
230 326
   };
231 327
   pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
328
+  pc.onconnectionstatechange=()=>{ if(!pc)return; const s=pc.connectionState;
329
+    if(s==='failed'){ renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); }
330
+    else if(s==='disconnected'){ clearTimeout(pc._dt); pc._dt=setTimeout(()=>{ if(pc&&(pc.connectionState==='disconnected'||pc.connectionState==='failed')) renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); },8000); }
331
+    else if(s==='connected'){ clearTimeout(pc._dt); } };
232 332
 }
233 333
 const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
234 334
 const rel=(e)=>{const r=video.getBoundingClientRect();return{x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height};};

+ 74
- 22
server/public/console.html 查看文件

@@ -45,6 +45,15 @@
45 45
   .filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
46 46
   .filters .f{flex:1;min-width:140px;}
47 47
   .filters .lbl{margin:.1rem 0 .15rem;}
48
+  .srch{max-width:320px;margin:.2rem 0 .9rem;}
49
+  .pager{display:flex;gap:.5rem;align-items:center;justify-content:flex-end;margin-top:.8rem;font-size:.82rem;color:var(--muted);}
50
+  .pager button{padding:.32rem .7rem;font-size:.8rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
51
+  .pager button:hover:not(:disabled){background:var(--blue-soft);}
52
+  .pager button:disabled{opacity:.4;cursor:default;}
53
+  .pwwrap{position:relative;}
54
+  .pwwrap input{padding-right:2.6rem;}
55
+  .eye{position:absolute;right:.35rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;}
56
+  .eye:hover{background:none;color:var(--blue);}
48 57
   .profile{position:relative}
49 58
   .profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
50 59
   .profile .pbtn:hover{background:rgba(255,255,255,.24)}
@@ -63,6 +72,10 @@
63 72
 <main id="app"></main>
64 73
 
65 74
 <script>
75
+const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
76
+const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
77
+function pwField(id,ph){return '<div class="pwwrap"><input id="'+id+'" type="password" placeholder="'+ph+'"><button type="button" class="eye" data-for="'+id+'" aria-label="Show password"></button></div>';}
78
+function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
66 79
 function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
67 80
 function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
68 81
 function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
@@ -100,7 +113,7 @@ async function authView() {
100 113
         <span class="lbl">Email</span>
101 114
         <input id="li_email" placeholder="you@bizgaze.com" type="email">
102 115
         <span class="lbl">Password</span>
103
-        <input id="li_pw" placeholder="password" type="password">
116
+        ${pwField("li_pw","password")}
104 117
         <label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
105 118
         <button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
106 119
         <p id="li_err" class="muted"></p>
@@ -111,12 +124,13 @@ async function authView() {
111 124
         <span class="lbl">Email</span>
112 125
         <input id="rg_email" placeholder="you@bizgaze.com" type="email">
113 126
         <span class="lbl">Password</span>
114
-        <input id="rg_pw" placeholder="min 8 characters" type="password">
127
+        ${pwField("rg_pw","min 8 characters")}
115 128
         <button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
116 129
         <p id="rg_err" class="muted"></p>
117 130
       </div>` : ''}
118 131
     </div>`);
119 132
   document.getElementById('li_btn').onclick = doLogin;
133
+  wireEyes();
120 134
   onEnter(['li_email','li_pw'], doLogin);
121 135
   if (regOpen) {
122 136
     document.getElementById('tabLogin').onclick = () => toggle(true);
@@ -160,7 +174,9 @@ async function dashboard(me) {
160 174
     </div>
161 175
     <div class="card" id="agentsCard">
162 176
       <h2>Agents</h2>
177
+      <input id="agSearch" class="srch" placeholder="Search agents by name or email">
163 178
       <table id="agents"><thead><tr><th>Email</th><th>Display name</th><th>Role</th><th>Status</th><th style="width:280px"></th></tr></thead><tbody></tbody></table>
179
+      <div id="agPager" class="pager"></div>
164 180
       <div class="row" style="margin-top:1rem;flex-wrap:wrap">
165 181
         <input id="agEmail" placeholder="agent email" style="max-width:200px">
166 182
         <input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
@@ -182,7 +198,8 @@ async function dashboard(me) {
182 198
         <button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
183 199
         <button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
184 200
       </div>
185
-      <table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr></thead><tbody></tbody></table>
201
+      <table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
202
+      <div id="repPager" class="pager"></div>
186 203
       <p id="repSummary" class="muted" style="margin-top:.6rem"></p>
187 204
     </div>`);
188 205
 
@@ -208,9 +225,15 @@ async function addAgent() {
208 225
   } catch (e) { agOut.textContent = e.message; }
209 226
 }
210 227
 
211
-async function loadAgents() {
212
-  const rows = await api('/api/users', null, 'GET');
213
-  document.querySelector('#agents tbody').innerHTML = rows.map((u) => `
228
+const PER_PAGE = 5;
229
+function pagerHTML(page, pages, total, fn){
230
+  if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
231
+  return `<button ${page<=1?'disabled':''} onclick="${fn}(${page-1})">‹ Prev</button>`
232
+       + `<span>Page ${page} of ${pages} · ${total} total</span>`
233
+       + `<button ${page>=pages?'disabled':''} onclick="${fn}(${page+1})">Next ›</button>`;
234
+}
235
+let AGENTS_ALL = [], agentPage = 1, agentSearch = '';
236
+function agentRowHTML(u){ return `
214 237
     <tr>
215 238
       <td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
216 239
       <td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
@@ -223,7 +246,22 @@ async function loadAgents() {
223 246
         }
224 247
         ${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
225 248
       </td>
226
-    </tr>`).join('');
249
+    </tr>`; }
250
+async function loadAgents() {
251
+  AGENTS_ALL = await api('/api/users', null, 'GET');
252
+  agentPage = 1;
253
+  const s = document.getElementById('agSearch');
254
+  if (s && !s._w){ s._w = 1; s.addEventListener('input', () => { agentSearch = s.value.trim().toLowerCase(); agentPage = 1; renderAgents(); }); }
255
+  renderAgents();
256
+}
257
+window.agentGo = (p) => { agentPage = p; renderAgents(); };
258
+function renderAgents(){
259
+  const all = agentSearch ? AGENTS_ALL.filter(u => ((u.name||'')+' '+(u.email||'')).toLowerCase().includes(agentSearch)) : AGENTS_ALL;
260
+  const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
261
+  if (agentPage > pages) agentPage = pages;
262
+  const slice = all.slice((agentPage-1)*PER_PAGE, (agentPage-1)*PER_PAGE + PER_PAGE);
263
+  document.querySelector('#agents tbody').innerHTML = slice.map(agentRowHTML).join('') || '<tr><td colspan=5 class="muted">No matching agents.</td></tr>';
264
+  document.getElementById('agPager').innerHTML = pagerHTML(agentPage, pages, all.length, 'agentGo');
227 265
 }
228 266
 
229 267
 window.resetPw = async (id, email) => {
@@ -268,27 +306,41 @@ function fmtDuration(ms) {
268 306
   return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
269 307
 }
270 308
 
271
-let REPORT_ROWS = [];
272
-async function loadReport() {
273
-  const q = new URLSearchParams();
274
-  if (fAgent.value) q.set('agent', fAgent.value);
275
-  if (fFrom.value) q.set('from', fFrom.value);
276
-  if (fTo.value) q.set('to', fTo.value);
277
-  const rows = await api('/api/report?' + q.toString(), null, 'GET');
278
-  REPORT_ROWS = rows;
279
-  document.querySelector('#report tbody').innerHTML = rows.map((r) => {
280
-    const d = new Date(r.started_at);
281
-    const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
282
-    return `<tr>
309
+let REPORT_ROWS = [], reportPage = 1, reportSearch = '';
310
+function reportRowHTML(r){
311
+  const d = new Date(r.started_at);
312
+  const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
313
+  return `<tr>
283 314
       <td>${d.toLocaleDateString()}</td>
284 315
       <td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
285 316
       <td>${esc(r.agent_name || r.agent_email || '—')}</td>
286 317
       <td>${esc(r.ticket || 'Direct session')}</td>
287 318
       <td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
319
+      <td>${[
320
+        r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>⬇ Video</a>` : '',
321
+        r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>⬇ Text</a>` : ''
322
+      ].join('') || '<span class="muted">—</span>'}</td>
288 323
     </tr>`;
289
-  }).join('') || '<tr><td colspan=5 class="muted">No sessions in this period.</td></tr>';
290
-  const total = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
291
-  repSummary.textContent = rows.length ? `${rows.length} session(s) · total time ${fmtDuration(total)}` : '';
324
+}
325
+async function loadReport() {
326
+  const q = new URLSearchParams();
327
+  if (fAgent.value) q.set('agent', fAgent.value);
328
+  if (fFrom.value) q.set('from', fFrom.value);
329
+  if (fTo.value) q.set('to', fTo.value);
330
+  REPORT_ROWS = await api('/api/report?' + q.toString(), null, 'GET');
331
+  reportPage = 1;
332
+  renderReport();
333
+}
334
+window.reportGo = (p) => { reportPage = p; renderReport(); };
335
+function renderReport(){
336
+  const all = reportSearch ? REPORT_ROWS.filter(r => ((r.agent_name||'')+' '+(r.agent_email||'')+' '+(r.ticket||'')).toLowerCase().includes(reportSearch)) : REPORT_ROWS;
337
+  const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
338
+  if (reportPage > pages) reportPage = pages;
339
+  const slice = all.slice((reportPage-1)*PER_PAGE, (reportPage-1)*PER_PAGE + PER_PAGE);
340
+  document.querySelector('#report tbody').innerHTML = slice.map(reportRowHTML).join('') || '<tr><td colspan=6 class="muted">No sessions match.</td></tr>';
341
+  document.getElementById('repPager').innerHTML = pagerHTML(reportPage, pages, all.length, 'reportGo');
342
+  const total = all.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
343
+  repSummary.textContent = all.length ? `${all.length} session(s) · total time ${fmtDuration(total)}` : '';
292 344
 }
293 345
 
294 346
 function reportData() {

+ 39
- 4
server/public/share.html 查看文件

@@ -45,7 +45,7 @@
45 45
 </head>
46 46
 <body>
47 47
 <div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
48
-<a href="/" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)">&#8592; Home</a>
48
+<a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)">&#8592; Home</a>
49 49
 <div class="stage">
50 50
   <div class="brandpanel">
51 51
     <img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
@@ -73,7 +73,8 @@
73 73
 let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
74 74
 let SHARER_NAME='Customer';
75 75
 try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
76
-let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
76
+const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
77
+let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
77 78
 async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
78 79
 function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
79 80
 function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
@@ -101,6 +102,7 @@ ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
101 102
   case 'start-stream': sessionId=m.sessionId; await startStreaming(); break;
102 103
   case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
103 104
   case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
105
+  case 'recording': recNotice(m.on); if(m.on) startCustTranscription(); else stopCustTranscription(); break;
104 106
   case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break;
105 107
   case 'error': setStatus(m.message,''); break;
106 108
 }};
@@ -133,20 +135,53 @@ async function startStreaming(){
133 135
   catch(err){ if(mic){try{mic.getTracks().forEach(t=>t.stop());}catch(_){}} try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
134 136
   if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} }
135 137
   indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on');
138
+  { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
139
+  window.onbeforeunload=function(){ if((localStream||document.getElementById('sessionBar'))&&!sessionOver){ return 'Leaving or refreshing this page will end your screen sharing session.'; } };
136 140
   pc=new RTCPeerConnection(ICE);
137 141
   buildBar();
138 142
   localStream.getTracks().forEach(t=>pc.addTrack(t,localStream));
139 143
   pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
140 144
   pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
141 145
   pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
142
-  pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } };
146
+  pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } };
143 147
   chatChannel=pc.createDataChannel('chat',{ordered:true});
144 148
   chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
145 149
   const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
146 150
   ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
147 151
   localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
148 152
 }
153
+const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
154
+let crecog=null, crecogActive=false, sessionOver=false;
155
+function startCustTranscription(){
156
+  if(!SR){ return; }
157
+  try{
158
+    crecog=new SR(); crecog.continuous=true; crecog.interimResults=false; crecog.lang='en-US';
159
+    crecog.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ if(e.results[i].isFinal){ const txt=(e.results[i][0].transcript||'').trim(); if(txt){ try{ws.send(JSON.stringify({type:'transcript',sessionId,role:'customer',name:SHARER_NAME,text:txt,chat:false}));}catch(_){} } } } };
160
+    crecog.onerror=()=>{};
161
+    crecog.onend=()=>{ if(crecogActive){ try{crecog.start();}catch(_){} } };
162
+    crecogActive=true; crecog.start();
163
+  }catch(e){}
164
+}
165
+function stopCustTranscription(){ crecogActive=false; if(crecog){ try{crecog.stop();}catch(_){} crecog=null; } }
166
+let recTimerInt=null, recStartTs=0;
167
+function fmtElapsed(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
168
+function recNotice(on){
169
+  if(on&&sessionOver) return;
170
+  let n=document.getElementById('recNotice');
171
+  if(on){
172
+    if(!n){ n=document.createElement('div'); n.id='recNotice';
173
+      n.style.cssText='position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#b91c1c;color:#fff;font-weight:600;font-size:.9rem;padding:.5rem 1rem;border-radius:999px;box-shadow:0 6px 18px rgba(0,0,0,.3);display:flex;align-items:center;gap:.5rem';
174
+      n.innerHTML='<span style="width:10px;height:10px;border-radius:50%;background:#fff;display:inline-block;animation:recPulse 1.2s infinite"></span> This session is being recorded \u00b7 <span id="recTimeVal">00:00</span>';
175
+      document.body.appendChild(n);
176
+      if(!document.getElementById('recPulseStyle')){const st=document.createElement('style');st.id='recPulseStyle';st.textContent='@keyframes recPulse{0%,100%{opacity:1}50%{opacity:.25}}';document.head.appendChild(st);}
177
+    }
178
+    recStartTs=Date.now(); clearInterval(recTimerInt);
179
+    const upd=()=>{ const t=document.getElementById('recTimeVal'); if(t) t.textContent=fmtElapsed(Date.now()-recStartTs); };
180
+    upd(); recTimerInt=setInterval(upd,1000);
181
+  } else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
182
+}
149 183
 function endShareSession(msgText){
184
+  sessionOver=true; window.onbeforeunload=null; { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
150 185
   removeSessionUI();
151 186
   indicator.classList.remove('show');
152 187
   if(window.__mic){try{window.__mic.getTracks().forEach(t=>t.stop());}catch(_){}window.__mic=null;}
@@ -155,7 +190,7 @@ function endShareSession(msgText){
155 190
   var card=document.querySelector('.panelside .card');
156 191
   if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
157 192
 }
158
-function teardown(){indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
193
+function teardown(){sessionOver=true;window.onbeforeunload=null;{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
159 194
 
160 195
 let chatOpen=false;
161 196
 const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';

+ 97
- 0
server/server.js 查看文件

@@ -11,6 +11,10 @@ const A = require('./auth');
11 11
 const PORT = process.env.PORT || 8090;
12 12
 const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
13 13
 const PUBLIC_DIR = path.join(__dirname, 'public');
14
+const REC_DIR = path.join(__dirname, 'recordings');
15
+try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
16
+const TRANS_DIR = path.join(__dirname, 'transcripts');
17
+try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
14 18
 const SESSION_TTL = 1000 * 60 * 60 * 24; // 24h auto-logout
15 19
 
16 20
 // ---------- helpers ----------
@@ -322,6 +326,51 @@ route('GET', '/api/audit', async (req, res) => {
322 326
   json(res, 200, rows);
323 327
 });
324 328
 
329
+// ---------- session recording: upload (agent) + download (team) ----------
330
+const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap
331
+route('POST', '/api/recording', async (req, res) => {
332
+  const u = currentUser(req);
333
+  if (!u) return json(res, 401, { error: 'unauthorized' });
334
+  const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
335
+  if (!sid) return json(res, 400, { error: 'sessionId required' });
336
+  const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
337
+  if (!row) return json(res, 404, { error: 'no such session' });
338
+  const chunks = []; let total = 0, aborted = false;
339
+  req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
340
+  req.on('end', () => {
341
+    if (aborted) return json(res, 413, { error: 'recording too large' });
342
+    const fname = sid + '.webm';
343
+    try {
344
+      fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks));
345
+      db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, sid);
346
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid });
347
+      json(res, 200, { ok: true });
348
+    } catch (e) { json(res, 500, { error: 'could not save recording' }); }
349
+  });
350
+  req.on('error', () => { try { res.end(); } catch (e) {} });
351
+});
352
+
353
+route('POST', '/api/transcript', async (req, res) => {
354
+  const u = currentUser(req);
355
+  if (!u) return json(res, 401, { error: 'unauthorized' });
356
+  const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
357
+  if (!sid) return json(res, 400, { error: 'sessionId required' });
358
+  const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
359
+  if (!row) return json(res, 404, { error: 'no such session' });
360
+  const chunks = []; let total = 0, aborted = false;
361
+  req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
362
+  req.on('end', () => {
363
+    if (aborted) return json(res, 413, { error: 'transcript too large' });
364
+    const fname = sid + '.txt';
365
+    try {
366
+      fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks));
367
+      db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, sid);
368
+      json(res, 200, { ok: true });
369
+    } catch (e) { json(res, 500, { error: 'could not save transcript' }); }
370
+  });
371
+  req.on('error', () => { try { res.end(); } catch (e) {} });
372
+});
373
+
325 374
 // ---------- static + router ----------
326 375
 const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
327 376
 function serveStatic(req, res) {
@@ -343,6 +392,40 @@ function serveStatic(req, res) {
343 392
 const server = http.createServer(async (req, res) => {
344 393
   const key = `${req.method} ${req.url.split('?')[0]}`;
345 394
   if (routes[key]) return routes[key](req, res);
395
+  if (req.method === 'GET' && req.url.split('?')[0].startsWith('/transcripts/')) {
396
+    const u = currentUser(req);
397
+    if (!u) return json(res, 401, { error: 'unauthorized' });
398
+    const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
399
+    const sid = name.replace(/\.txt$/i, '');
400
+    const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
401
+    if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
402
+    const fp = path.join(TRANS_DIR, row.transcript);
403
+    if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
404
+    return fs.stat(fp, (err, st) => {
405
+      if (err) return json(res, 404, { error: 'not found' });
406
+      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="transcript-' + sid + '.txt"', 'Cache-Control': 'no-store' });
407
+      const rs = fs.createReadStream(fp);
408
+      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
409
+      rs.pipe(res);
410
+    });
411
+  }
412
+  if (req.method === 'GET' && req.url.split('?')[0].startsWith('/recordings/')) {
413
+    const u = currentUser(req);
414
+    if (!u) return json(res, 401, { error: 'unauthorized' });
415
+    const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
416
+    const sid = name.replace(/\.webm$/i, '');
417
+    const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
418
+    if (!row || !row.recording) return json(res, 404, { error: 'not found' });
419
+    const fp = path.join(REC_DIR, row.recording);
420
+    if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
421
+    return fs.stat(fp, (err, st) => {
422
+      if (err) return json(res, 404, { error: 'not found' });
423
+      res.writeHead(200, { 'Content-Type': 'video/webm', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.webm"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
424
+      const rs = fs.createReadStream(fp);
425
+      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
426
+      rs.pipe(res);
427
+    });
428
+  }
346 429
   if (req.method === 'GET') return serveStatic(req, res);
347 430
   json(res, 404, { error: 'not found' });
348 431
 });
@@ -465,6 +548,20 @@ function handle(ws, m, req) {
465 548
       if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
466 549
       break;
467 550
     }
551
+    case 'transcript': {
552
+      const sess = liveSessions.get(m.sessionId || ws.sessionId);
553
+      if (!sess) return;
554
+      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
555
+      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
556
+      break;
557
+    }
558
+    case 'recording': {
559
+      const sess = liveSessions.get(m.sessionId || ws.sessionId);
560
+      if (!sess) return;
561
+      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
562
+      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
563
+      break;
564
+    }
468 565
     case 'end-session': {
469 566
       endSession(ws.sessionId, m.reason || null);
470 567
       break;

Loading…
取消
儲存