File size: 10,065 Bytes
ec8d46e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | // FENNEC memory v1. The public SHA-256 identifier is never the encryption key.
$('persistent-memory').checked=false;
const memoryEncoder=new TextEncoder(),memoryDecoder=new TextDecoder();
const memoryEndpoint=new URL('fennec-memory.php',location.href);
let memorySession=null,memoryEpoch=0,memoryOpening=null,memoryTimer=null;
const memoryHex=bytes=>Array.from(new Uint8Array(bytes),b=>b.toString(16).padStart(2,'0')).join('');
function memoryBase64(bytes){let value='';for(const byte of new Uint8Array(bytes))value+=String.fromCharCode(byte);return btoa(value)}
function memoryBytes(value){return Uint8Array.from(atob(value),c=>c.charCodeAt(0))}
function memoryStatus(text){$('memory-status').textContent=text;$('memory-summary').textContent=text;$('memory-summary').hidden=!$('persistent-memory').checked}
function memoryCurrent(session){return memorySession===session&&$('persistent-memory').checked&&session.epoch===memoryEpoch}
function memoryReset(){
memoryEpoch++;memorySession=null;memoryOpening=null;clearTimeout(memoryTimer);history=[];
$('memory-history').hidden=true;$('memory-transcript').replaceChildren();$('memory-count').textContent='';
$('memory-retry').hidden=true;$('memory-older').hidden=true;
memoryStatus($('persistent-memory').checked?'Enter your API key to unlock previous conversations.':'Persistent memory is off. Conversations stay in this tab.');
}
async function memoryCredentials(rawKey,provider){
const id=memoryHex(await crypto.subtle.digest('SHA-256',memoryEncoder.encode(rawKey)));
const source=await crypto.subtle.importKey('raw',memoryEncoder.encode(rawKey),'HKDF',false,['deriveKey','deriveBits']);
const params=label=>({name:'HKDF',hash:'SHA-256',salt:memoryEncoder.encode('FENNEC memory v1'),info:memoryEncoder.encode('fennec/'+provider+'/'+label+'/v1')});
const encryptionKey=await crypto.subtle.deriveKey(params('encryption'),source,{name:'AES-GCM',length:256},false,['encrypt','decrypt']);
const token=memoryHex(await crypto.subtle.deriveBits(params('storage-authentication'),source,256));
return {id,provider,encryptionKey,token};
}
async function memoryRequest(session,method='GET',body=null,before=null){
const url=new URL(memoryEndpoint);url.searchParams.set('id',session.id);url.searchParams.set('provider',session.provider);
if(before!==null)url.searchParams.set('before',String(before));
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),15000);
try{
const response=await fetch(url,{method,credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer',signal:controller.signal,
headers:{'X-Fennec-Memory-Token':session.token,Authorization:'Bearer '+session.token,...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})});
let data;try{data=await response.json()}catch{throw Error('Upload and configure fennec-memory.php alongside this HTML.');}
if(!response.ok)throw Error(data.error||'Memory service request failed.');return data;
}catch(error){if(error.name==='AbortError')throw Error('Memory service timed out.');throw error}finally{clearTimeout(timer)}
}
function memoryAAD(session,id){return memoryEncoder.encode('fennec-memory-v1|'+session.provider+'|'+session.id+'|'+id)}
async function memorySeal(session,entry){
const iv=crypto.getRandomValues(new Uint8Array(12));
const ciphertext=await crypto.subtle.encrypt({name:'AES-GCM',iv,additionalData:memoryAAD(session,entry.id)},session.encryptionKey,memoryEncoder.encode(JSON.stringify(entry)));
return {v:1,id:entry.id,iv:memoryBase64(iv),ciphertext:memoryBase64(ciphertext)};
}
async function memoryUnseal(session,envelope){
if(envelope.v!==1||typeof envelope.id!=='string'||!/^[a-f0-9-]{36}$/.test(envelope.id)||typeof envelope.iv!=='string'||typeof envelope.ciphertext!=='string')throw Error('Invalid encrypted memory.');
const iv=memoryBytes(envelope.iv);if(iv.length!==12)throw Error('Invalid memory nonce.');
const plain=await crypto.subtle.decrypt({name:'AES-GCM',iv,additionalData:memoryAAD(session,envelope.id)},session.encryptionKey,memoryBytes(envelope.ciphertext));
const entry=JSON.parse(memoryDecoder.decode(plain));
if(entry.id!==envelope.id||typeof entry.question!=='string'||typeof entry.answer!=='string'||entry.question.length>30000||entry.answer.length>30000||typeof entry.createdAt!=='string'||!Number.isFinite(Date.parse(entry.createdAt)))throw Error('Invalid conversation record.');
return entry;
}
function memoryContext(session){history=[...session.entries.values()].sort((a,b)=>a.seq-b.seq).slice(-7).flatMap(e=>[{role:'user',content:e.question},{role:'assistant',content:e.answer}])}
function memoryRender(session){
if(!memoryCurrent(session))return;
const entries=[...session.entries.values()].sort((a,b)=>b.seq-a.seq),fragment=document.createDocumentFragment();
for(const entry of entries){
const article=document.createElement('article'),date=document.createElement('time');date.dateTime=entry.createdAt;date.textContent=new Date(entry.createdAt).toLocaleString();article.append(date);
for(const [label,value] of [['You',entry.question],['FENNEC',entry.answer]]){const title=document.createElement('strong'),p=document.createElement('p');title.textContent=label;p.textContent=value;p.dir='auto';article.append(title,p)}fragment.append(article);
}
$('memory-transcript').replaceChildren(fragment);$('memory-history').hidden=!entries.length;$('memory-count').textContent='('+entries.length+')';$('memory-older').hidden=session.before===null;
}
async function memoryLoad(session,before=null){
const data=await memoryRequest(session,'GET',null,before);
if(!Array.isArray(data.entries)||data.entries.length>50||!(data.before===null||Number.isSafeInteger(data.before)&&data.before>0))throw Error('Invalid memory service response.');
const decoded=await Promise.all(data.entries.map(async item=>{if(!Number.isSafeInteger(item.seq)||item.seq<1)throw Error('Invalid memory sequence.');return {...await memoryUnseal(session,item.envelope),seq:item.seq}}));
if(!memoryCurrent(session))return;
for(const entry of decoded)session.entries.set(entry.id,entry);session.before=data.before;memoryRender(session);
}
async function memoryEnsure(){
if(!$('persistent-memory').checked)return null;
if(memorySession?.ready)return memorySession;
if(memoryOpening)return memoryOpening;
const rawKey=keyField().value.trim(),provider=currentProvider(),epoch=memoryEpoch;
if(!rawKey){memoryStatus('Enter your API key to unlock previous conversations.');return null}
const opening=(async()=>{try{
if(!window.isSecureContext||!crypto.subtle)throw Error('Persistent memory needs an HTTPS connection.');
if(!['https:','http:'].includes(location.protocol))throw Error('Upload the HTML and PHP files to your HTTPS website.');
memoryStatus('Unlocking previous conversations…');$('memory-retry').hidden=true;
const credentials=await memoryCredentials(rawKey,provider);if(epoch!==memoryEpoch)return null;
const session={...credentials,epoch,entries:new Map(),pending:new Map(),before:null,ready:false};memorySession=session;
await memoryLoad(session);if(!memoryCurrent(session))return null;
session.ready=true;memoryContext(session);
memoryStatus(session.entries.size?'Memory on. Previous conversations restored.':'Memory on. Completed conversations will be encrypted and saved.');return session;
}catch(error){if(epoch===memoryEpoch){memorySession=null;memoryStatus('Memory unavailable: '+error.message+' Chat can continue without saving.');$('memory-retry').hidden=false;}return null}})();
memoryOpening=opening;try{return await opening}finally{if(memoryOpening===opening)memoryOpening=null}
}
async function memorySave(session,entry){
if(!session||!memoryCurrent(session))return;
session.pending.set(entry.id,entry);
try{
memoryStatus('Encrypting and saving conversation…');const envelope=await memorySeal(session,entry);if(!memoryCurrent(session))return;
const data=await memoryRequest(session,'POST',{envelope});if(!Number.isSafeInteger(data.seq)||data.seq<1)throw Error('Invalid save confirmation.');
session.pending.delete(entry.id);if(!memoryCurrent(session))return;
session.entries.set(entry.id,{...entry,seq:data.seq});memoryRender(session);
memoryStatus(session.pending.size?'Some conversations are not saved. Click Retry.':'Memory on. Conversation encrypted and saved.');$('memory-retry').hidden=!session.pending.size;
}catch(error){if(memoryCurrent(session)){memoryStatus('Conversation not saved: '+error.message+' Keep this tab open and click Retry.');$('memory-retry').hidden=false;}}
}
$('persistent-memory').addEventListener('change',()=>{memoryReset();if($('persistent-memory').checked)void memoryEnsure()});
for(const id of ['api-key','arc-key'])$(id).addEventListener('input',()=>{
if(keyField()!==$(id))return;
active?.controller.abort();clearSpeechAnswer();memoryReset();memoryTimer=setTimeout(()=>{if($('persistent-memory').checked)void memoryEnsure()},500);
});
$('memory-retry').onclick=async()=>{$('memory-retry').disabled=true;try{const session=await memoryEnsure();if(session)for(const entry of [...session.pending.values()]){if(!memoryCurrent(session))break;await memorySave(session,entry)}}finally{$('memory-retry').disabled=false}};
$('memory-older').onclick=async()=>{
const session=memorySession;if(!session?.ready||session.before===null)return;$('memory-older').disabled=true;
try{await memoryLoad(session,session.before)}catch(error){if(memoryCurrent(session))memoryStatus('Could not load older conversations: '+error.message)}finally{$('memory-older').disabled=false}
};
function memorySnapshot(){
const enabled=$('persistent-memory').checked,session=memorySession;
const ready=Boolean(enabled&&session?.ready&&memoryCurrent(session));
return {state:!enabled?'off':!ready?'unavailable':session.pending.size?'ready_unsaved':'ready',
loadedExchanges:ready?session.entries.size:0,unsavedExchanges:ready?session.pending.size:0};
}
function memoryBusy(busy){for(const id of ['persistent-memory','api-key','arc-key','memory-retry','connection-done'])$(id).disabled=busy}
|