pocket-phd / app.js
PabloRod11's picture
Preserve unsaved results and allow stopping local AI
505c183 verified
Raw History Blame Contribute Delete
14.3 kB
'use strict';
const $ = id => document.getElementById(id);
let currentRun = null, investigationController = null;
const array = value => Array.isArray(value) ? value : [];
const printable = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2);
function element(tag, content, className) { const node = document.createElement(tag); if(content !== undefined) node.textContent = printable(content); if(className) node.className = className; return node; }
const storageKey = 'pocket-phd-static-runs';
function saveRun(run, runs) {
try {localStorage.setItem(storageKey,JSON.stringify([run,...runs].slice(0,50)));}
catch {run.storage_warning='Browser storage is unavailable or full. This result is not saved; export it before leaving this page.';}
return run;
}
function savedRuns() { try { return JSON.parse(localStorage.getItem(storageKey) || '[]'); } catch { throw new Error('Browser notebook storage is unavailable.'); } }
async function request(path, options={}) {
if(path==='/api/config') return {live_available:Boolean(navigator.gpu),model:'Local WebGPU model — load before running'};
let runs;
try {runs=savedRuns();} catch(error) {if(path!=='/api/research') throw error;runs=[];}
if(path==='/api/research') {
const input=JSON.parse(options.body);
if(input.mode==='live' && !window.PocketResearch.ready()) throw new Error('Load the free local AI first.');
const evidence=await window.PocketResearch.prepareEvidence(input);
input.sources=evidence.sources;
if(input.mode==='live') {
if(!evidence.sources.length) throw new Error('Local AI needs evidence. Enable Crossref retrieval or paste source records with excerpts.');
investigationController=new AbortController();$('stop-run').hidden=false;
const run=await window.PocketResearch.investigate(input,evidence, message=>status(message,'pending'),investigationController.signal);
run.id=crypto.randomUUID();run.created_at=new Date().toISOString();
return saveRun(run,runs);
}
if(input.sources.length>20) throw new Error('Use at most 20 evidence records.');
const response=await fetch('./demo-template.json');
if(!response.ok) throw new Error('Demo template could not be loaded.');
const template=await response.json();
const substitute=value=> typeof value==='string' ? value.replaceAll('POCKET_RESEARCH_TOPIC',input.question.replace(/[ ?.]+$/,'')) : Array.isArray(value) ? value.map(substitute) : value && typeof value==='object' ? Object.fromEntries(Object.entries(value).map(([key,item])=>[key,substitute(item)])) : value;
const run=substitute(template);
run.question=input.question;run.id=crypto.randomUUID();run.created_at=new Date().toISOString();
run.sources=input.sources;run.retrieval=evidence.retrieval;
run.evidence_status=run.sources.length ? 'supplied records only' : 'no evidence';
if(run.sources.length) {
run.limitations=run.limitations.filter(item=>!item.startsWith('No evidence was supplied.'));
run.gaps.forEach(gap=>gap.evidence_status='demo template; no source entailment claimed');
run.agents[2].output.gaps.forEach(gap=>gap.evidence_status='demo template; no source entailment claimed');
}
run.agents[0].output.research_question=input.question;
const scout=run.agents[1].output;
scout.synthesis=`${run.sources.length} supplied records are available. This demo counts records and does not interpret or verify findings.`;
scout.citations=run.sources.map(source=>source.id);
run.limitations.push(...evidence.limitations,'Method demo uses templates. Select Local AI for model-generated proposals. Notes are stored in this browser.');
return saveRun(run,runs);
}
if(path==='/api/runs') return runs;
const id=decodeURIComponent(path.split('/').pop());
const run=runs.find(item=>item.id===id);
if(!run) throw new Error('Investigation not found in this browser.');
if(options.method==='DELETE') {localStorage.setItem(storageKey,JSON.stringify(runs.filter(item=>item.id!==id)));return {deleted:true};}
return run;
}
function status(message, type='') { const node = $('status'); node.hidden = false; node.className = type; node.textContent = message; }
function fields(parent, data, excluded=[]) { const list = element('dl'); Object.entries(data || {}).forEach(([key, value]) => { if(excluded.includes(key) || value === null || value === undefined) return; list.append(element('dt', key.replaceAll('_', ' ')), element('dd', Array.isArray(value) ? value.map(printable).join('\n') : printable(value))); }); parent.append(list); }
function card(data, index, label) { const node=element('article', undefined, 'card'); node.append(element('span', `${label} ${String(index+1).padStart(2,'0')}`, 'tag'), element('h4', data.title || data.name || `${label} ${index+1}`)); return node; }
function renderGaps(run) { const target=$('gaps'); target.replaceChildren(); array(run.gaps).forEach((gap,index)=> { const node=card(gap,index,'Gap'); if(gap.description) node.append(element('p',gap.description)); fields(node,gap,['id','title','description']); target.append(node); }); if(!target.childNodes.length) target.append(element('p','No knowledge gaps returned for this run.','empty')); }
function renderHypotheses(run) { const target=$('hypotheses'); target.replaceChildren(); array(run.hypotheses).forEach((hypothesis,index)=> { const node=card(hypothesis,index,'Hypothesis'); fields(node,hypothesis,['id','title','gap_id']); const plans=array(run.experiments).filter(plan=>plan.hypothesis_id===hypothesis.id); if(plans.length){ const details=element('details'); details.append(element('summary','Explore the test plan')); plans.forEach(plan=>{const section=element('section'); section.append(element('h4',plan.title || 'Experiment'));fields(section,plan,['id','hypothesis_id','title']);details.append(section);});node.append(details);} target.append(node); }); if(!target.childNodes.length) target.append(element('p','No hypotheses returned for this run.','empty')); }
function safeURL(value) { try {const url=new URL(value); return ['https:','http:'].includes(url.protocol) ? url.href : null;}catch{return null;} }
function renderSources(run) { const target=$('evidence');target.replaceChildren();if(run.retrieval) {const info=element('article',undefined,'notice');info.append(element('h3','Literature search'));fields(info,run.retrieval);target.append(info);}array(run.sources).forEach((source,index)=>{const node=card(source,index,source.id || 'Source');node.classList.add('source-card');node.append(element('p',[source.year,Array.isArray(source.authors)?source.authors.join(', '):source.authors].filter(Boolean).join(' · '),'meta'));if(source.abstract)node.append(element('p',source.abstract));if(source.provenance)node.append(element('p',`Provenance: ${printable(source.provenance)}`,'meta'));const url=safeURL(source.url);if(url){const link=element('a',source.url,'source-link');link.href=url;link.target='_blank';link.rel='noopener noreferrer';node.append(link);}else if(source.url)node.append(element('p',source.url));target.append(node);});if(!array(run.sources).length)target.append(element('p','No sources were returned. Findings without source support should be treated as exploratory proposals.','empty')); }
function renderAgents(run) {const target=$('agents');target.replaceChildren();array(run.agents).forEach((agent,index)=>{const node=card(agent,index,'Agent');node.append(element('span',agent.status || 'Recorded','tag'),element('pre',agent.error || printable(agent.output) || 'No output recorded.','agent-output'));target.append(node);});if(!target.childNodes.length)target.append(element('p','No agent outputs were returned.','empty'));}
function showRun(run) {currentRun=run;$('results').hidden=false;$('results-title').textContent=run.question || 'Research investigation';$('run-mode').textContent=run.mode==='demo'?'METHOD DEMO · ILLUSTRATIVE OUTPUT':'LIVE INVESTIGATION · NOVELTY UNVERIFIED';$('result-notice').textContent=run.mode==='demo'?'This is an illustrative method demonstration. Its deterministic hypotheses are not validated research findings or a comprehensive review.':'Model-generated proposals require source verification, domain review, and replication. Novelty has not been established.';if(run.status && run.status!=='complete'){$('result-notice').textContent=`Investigation ${run.status}. ${run.error || 'Some agent stages did not complete. Review the agent notebook for details.'}`;$('result-notice').classList.add('run-failure');}else{$('result-notice').classList.remove('run-failure');}renderGaps(run);renderHypotheses(run);renderSources(run);renderAgents(run);$('limitations').replaceChildren();array(run.limitations).forEach(limit=>$('limitations').append(element('p',limit)));if(!array(run.limitations).length)$('limitations').append(element('p','No run-specific limitations were returned. This tool cannot establish novelty or validate causal claims.'));$('results').scrollIntoView({behavior:'smooth',block:'start'});}
async function history() {try{const runs=await request('/api/runs');const target=$('history-list');target.replaceChildren();runs.forEach(run=>{const button=element('button',run.question || run.id,'history-item');button.type='button';button.append(element('small',[run.mode,run.created_at ? new Date(run.created_at).toLocaleString():null].filter(Boolean).join(' · ')));button.onclick=async()=>{try{showRun(await request(`/api/runs/${encodeURIComponent(run.id)}`));}catch(error){status(error.message,'error');}};target.append(button);});if(!runs.length)target.append(element('p','Your investigations will appear here.','hint'));}catch{$('history-list').replaceChildren(element('p','Research history is temporarily unavailable.','hint'));}}
$('research-form').addEventListener('submit',async event=>{event.preventDefault();let sources=[];try{const pasted=$('sources').value.trim();if(pasted){sources=JSON.parse(pasted);if(!Array.isArray(sources))throw new Error('Sources must be a JSON array.');if(sources.some(source=>!source || typeof source!=='object' || typeof source.title!=='string' || !source.title.trim()))throw new Error('Each source needs a title. URLs and excerpts are optional.');}}catch(error){status(`Check your evidence: ${error.message}`,'error');return;}const question=$('question').value.trim();if(!question){status('Enter a research question.','error');return;}const mode=document.querySelector('input[name="mode"]:checked').value;$('run-button').disabled=true;$('research-form').setAttribute('aria-busy','true');status(mode==='live'?'The six-agent investigation is running. Live research may take several minutes; stage outputs will appear when the investigation completes.':'Running the six-role browser demonstration…','pending');try{const run=await request('/api/research',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question,mode,sources,retrieve:$('retrieve').checked,search_query:$('search-query').value.trim()})});showRun(run);status(run.storage_warning || (run.status==='failed'?'The investigation failed. Review the saved agent notebook for details.':run.status==='partial'?'A partial investigation was saved. Review incomplete stages in the agent notebook.':'Investigation saved to your research notebook.'),run.storage_warning || run.status==='failed'?'error':'');await history();}catch(error){status(error.message,'error');}finally{investigationController=null;$('stop-run').hidden=true;$('run-button').disabled=false;$('research-form').removeAttribute('aria-busy');}});
$('example').onclick=()=>{$('question').value='How might retrieval practice and sleep timing interact to improve long-term learning in university students?';$('search-query').value='retrieval practice sleep memory';$('question').focus();};
$('refresh-history').onclick=history;
document.querySelectorAll('[data-tab]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-tab]').forEach(item=>{if(item===button)item.setAttribute('aria-current','page');else item.removeAttribute('aria-current');});document.querySelectorAll('.tab-panel').forEach(panel=>panel.hidden=panel.id!==button.dataset.tab);});
function download(extension){
if(!currentRun)return;
const content=extension==='json'?JSON.stringify(currentRun,null,2):`# ${currentRun.question}\n\n${currentRun.mode==='demo'?'Illustrative method demo':'Local AI proposals'}. Novelty unverified.\n\n`+['gaps','hypotheses','experiments','sources','agents','limitations'].map(key=>`## ${key}\n\n`+JSON.stringify(currentRun[key],null,2)).join('\n\n');
const url=URL.createObjectURL(new Blob([content],{type:extension==='json'?'application/json':'text/markdown'}));
const link=element('a');link.href=url;link.download=`pocket-phd-${currentRun.id}.${extension}`;document.body.append(link);link.click();link.remove();URL.revokeObjectURL(url);
}
$('delete-run').onclick=async()=>{if(!currentRun || !confirm('Delete this saved investigation? This cannot be undone.'))return;try{await request(`/api/runs/${encodeURIComponent(currentRun.id)}`,{method:'DELETE',headers:{'X-Pocket-PhD':'local'}});currentRun=null;$('results').hidden=true;status('Investigation deleted.');await history();}catch(error){status(error.message,'error');}};
$('export-json').onclick=()=>download('json');
$('export-md').onclick=()=>download('md');
(async()=>{try{const config=await request('/api/config');document.querySelector('input[value="live"]').disabled=!config.live_available;$('live-label').classList.toggle('unavailable',!config.live_available);$('live-note').textContent=config.live_available?`Configured: ${config.model || 'research model'}`:'WebGPU unavailable — try Chrome or Edge';}catch{$('live-note').textContent='Model configuration unavailable';}await history();})();
$('load-model').onclick=async()=>{ $('load-model').disabled=true; try {await window.PocketResearch.load(message=>{$('model-status').textContent=message;}); $('live-note').textContent='Model loaded — runs on this device';document.querySelector('input[value="live"]').disabled=false;} catch(error){$('model-status').textContent=error.message;} finally{$('load-model').disabled=false;} };
$('stop-run').onclick=()=>{investigationController?.abort();$('live-note').textContent='Stopped — reload the local model before another AI run';};