Add per-job run logs and separate vision/text model selection.
Capture pipeline stdout into job.log + API/UI so failed runs can be reviewed, and let users pick OpenRouter vision vs text models independently. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+87
-2
@@ -60,7 +60,15 @@
|
||||
border:1px solid var(--line); background:#0c0e13; color:var(--text); font-size:16px; outline:none; }
|
||||
.email-card input[type=email]::placeholder { color:#6b7280; }
|
||||
.email-card input[type=email]:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(91,140,255,.18); }
|
||||
.email-card select { width:100%; padding:10px 12px; border-radius:8px; margin-top:6px;
|
||||
border:1px solid var(--line); background:#0c0e13; color:var(--text); font-size:14px; }
|
||||
.email-card .field { margin-top:12px; }
|
||||
.email-card .field > span { display:block; font-size:13px; color:var(--muted); margin-bottom:2px; }
|
||||
.btn.full { width:100%; padding:14px; font-size:15px; margin-top:0; }
|
||||
.logbox { background:#0c0e13; border:1px solid var(--line); border-radius:8px; padding:12px 14px;
|
||||
margin-top:10px; max-height:320px; overflow:auto; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
color:#c6cdd8; white-space:pre-wrap; word-break:break-word; }
|
||||
.logbox .empty-log { color:var(--muted); }
|
||||
.note { background:var(--panel); border:1px solid var(--line); border-radius:10px;
|
||||
padding:16px 18px; margin:18px 0; }
|
||||
.note b { color:var(--text); }
|
||||
@@ -103,9 +111,21 @@
|
||||
<input type="radio" name="compute" value="openrouter" checked> OpenRouter — all stages (fastest, paid)</label>
|
||||
<label style="display:block;font-weight:400;margin-top:6px">
|
||||
<input type="radio" name="compute" value="local"> Hybrid — text stages on local LLM (cheaper, slower)</label>
|
||||
<div class="field">
|
||||
<span>Vision model <span class="opt">(image stages)</span></span>
|
||||
<select id="vision_model" disabled><option value="">Loading models…</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span>Text model <span class="opt">(non-image stages / hybrid fallback)</span></span>
|
||||
<select id="text_model" disabled><option value="">Loading models…</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn full" id="run" disabled>Run conflict check</button>
|
||||
<div class="status" id="status"></div>
|
||||
<div id="liveLog" style="display:none" class="note">
|
||||
<b>Run log</b> <span class="opt" id="logHint">(updates live)</span>
|
||||
<pre class="logbox" id="logBox"><span class="empty-log">Waiting for output…</span></pre>
|
||||
</div>
|
||||
<div id="results"></div>
|
||||
</main>
|
||||
<div id="viewer">
|
||||
@@ -123,9 +143,61 @@
|
||||
const drop=document.getElementById('drop'), fileInput=document.getElementById('file'),
|
||||
runBtn=document.getElementById('run'), statusEl=document.getElementById('status'),
|
||||
results=document.getElementById('results'), dropLabel=document.getElementById('dropLabel'),
|
||||
emailEl=document.getElementById('email');
|
||||
emailEl=document.getElementById('email'),
|
||||
visionSel=document.getElementById('vision_model'),
|
||||
textSel=document.getElementById('text_model'),
|
||||
liveLog=document.getElementById('liveLog'),
|
||||
logBox=document.getElementById('logBox'),
|
||||
logHint=document.getElementById('logHint');
|
||||
let chosen=null, polling=null, currentJobId=null, sheetPage={}, viewerZoom=1;
|
||||
|
||||
function fillSelect(sel, items, preferred){
|
||||
sel.innerHTML='';
|
||||
(items||[]).forEach(m=>{
|
||||
const opt=document.createElement('option');
|
||||
opt.value=m.id; opt.textContent=m.name||m.id;
|
||||
if(m.id===preferred) opt.selected=true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if(!sel.options.length){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=preferred||''; opt.textContent=preferred||'(no models)';
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.disabled=false;
|
||||
}
|
||||
|
||||
async function loadModels(){
|
||||
try{
|
||||
const res=await fetch('/models');
|
||||
if(!res.ok) throw new Error('models HTTP '+res.status);
|
||||
const data=await res.json();
|
||||
const defs=data.defaults||{};
|
||||
fillSelect(visionSel, data.vision, defs.vision);
|
||||
fillSelect(textSel, data.text, defs.text);
|
||||
if(data.error){
|
||||
console.warn('Model catalog degraded:', data.error);
|
||||
}
|
||||
}catch(err){
|
||||
visionSel.innerHTML='<option value="">(default)</option>';
|
||||
textSel.innerHTML='<option value="">(default)</option>';
|
||||
visionSel.disabled=false; textSel.disabled=false;
|
||||
console.warn('Could not load models:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function showLog(lines, live){
|
||||
liveLog.style.display='block';
|
||||
logHint.textContent=live?'(updates live)':'(saved with this job)';
|
||||
const arr=lines||[];
|
||||
if(!arr.length){
|
||||
logBox.innerHTML='<span class="empty-log">No log lines yet…</span>';
|
||||
return;
|
||||
}
|
||||
logBox.textContent=arr.join('\n');
|
||||
logBox.scrollTop=logBox.scrollHeight;
|
||||
}
|
||||
|
||||
function setFile(f){ chosen=f; dropLabel.textContent=f?('Selected: '+f.name):'Drop a PDF drawing set here, or click to choose';
|
||||
runBtn.disabled=!f; }
|
||||
dropLabel.addEventListener('click',()=>fileInput.click());
|
||||
@@ -139,6 +211,7 @@ runBtn.addEventListener('click',async e=>{
|
||||
if(!chosen) return;
|
||||
runBtn.disabled=true; results.innerHTML='';
|
||||
statusEl.innerHTML='<span class="spinner"></span>Uploading...';
|
||||
showLog([], true);
|
||||
const fd=new FormData(); fd.append('file',chosen);
|
||||
const email=(emailEl.value||'').trim(); if(email) fd.append('notification_email',email);
|
||||
['project_name','address','occupancy','work_type'].forEach(id=>{
|
||||
@@ -146,6 +219,8 @@ runBtn.addEventListener('click',async e=>{
|
||||
});
|
||||
const compute=(document.querySelector('input[name="compute"]:checked')||{}).value;
|
||||
fd.append('text_local', compute==='local' ? 'true' : 'false');
|
||||
if(visionSel.value) fd.append('vision_model', visionSel.value);
|
||||
if(textSel.value) fd.append('text_model', textSel.value);
|
||||
try{
|
||||
const res=await fetch('/check',{method:'POST',body:fd});
|
||||
if(!res.ok){ const err=await res.json().catch(()=>({detail:res.statusText}));
|
||||
@@ -169,14 +244,19 @@ function poll(jobId){
|
||||
const res=await fetch('/jobs/'+jobId);
|
||||
if(!res.ok) throw new Error('job not found');
|
||||
const job=await res.json();
|
||||
if(job.log_tail && job.log_tail.length) showLog(job.log_tail, job.status==='running'||job.status==='queued');
|
||||
if(job.status==='running'||job.status==='queued'){
|
||||
statusEl.innerHTML='<span class="spinner"></span>'+esc(job.stage||'Working...')+
|
||||
' · you can leave this page';
|
||||
} else if(job.status==='done'){
|
||||
clearInterval(polling); polling=null; runBtn.disabled=false; render(job.report);
|
||||
clearInterval(polling); polling=null; runBtn.disabled=false;
|
||||
if(job.log && job.log.length) showLog(job.log, false);
|
||||
render(job.report);
|
||||
} else if(job.status==='error'){
|
||||
clearInterval(polling); polling=null; runBtn.disabled=false;
|
||||
statusEl.textContent='Run failed: '+(job.error||'unknown error');
|
||||
if(job.log && job.log.length) showLog(job.log, false);
|
||||
else if(job.log_tail && job.log_tail.length) showLog(job.log_tail, false);
|
||||
}
|
||||
}catch(err){ clearInterval(polling); polling=null; runBtn.disabled=false;
|
||||
statusEl.textContent='Error: '+err.message; }
|
||||
@@ -286,12 +366,17 @@ function render(rep){
|
||||
html+='</details>';
|
||||
}
|
||||
|
||||
html+='<div class="meta" style="margin-top:14px">Full run log: <a href="/jobs/'+
|
||||
esc(currentJobId)+'/log?plain=1" target="_blank" rel="noopener">/jobs/'+
|
||||
esc(currentJobId)+'/log</a> (also saved as job.log on the server)</div>';
|
||||
|
||||
results.innerHTML=html;
|
||||
}
|
||||
function stat(v,l){ return '<div class="stat"><b>'+esc(v)+'</b><span>'+esc(l)+'</span></div>'; }
|
||||
|
||||
// If opened from an email link (/?job=<id>), load that job's results directly.
|
||||
(function init(){
|
||||
loadModels();
|
||||
const jobId=new URLSearchParams(location.search).get('job');
|
||||
if(jobId){ statusEl.innerHTML='<span class="spinner"></span>Loading job '+esc(jobId)+'...'; poll(jobId); }
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user