Merge main: dual model dropdowns + richer job logs, adapted for agent-mode.
Docker Release / build-and-push (push) Successful in 1m0s
Docker Release / release (push) Skipped

- llm.py: set_model_overrides(vision, text) replaces the single job override;
  UI picks still beat per-call agent model args, but never name the hybrid
  local model (avoids main's hybrid footgun); local->cloud fallback uses the
  text pick.
- jobs.py: timestamped line-split tee (job_log.py), in-memory log + log_tail
  polls, full log on terminal states (done/error/needs_review/finalization_error),
  log-only disk recovery, error email links to the run log, and failed runs now
  append the full traceback to job.log. Keeps pipeline_mode, job.json, and the
  review gate.
- models.py: vision/text split via architecture modalities, pricing kept;
  /models returns {vision, text, defaults}; /check takes vision_model/text_model
  (replacing model); /health adds text_model. models_catalog.py dropped.
- UI: two priced dropdowns (OpenRouter compute only) + live run-log panel.
- Tests updated for dual overrides and the /models shape; new coverage for
  traceback capture and local-model immunity.
This commit is contained in:
2026-08-02 09:55:09 -05:00
11 changed files with 699 additions and 150 deletions
+104 -27
View File
@@ -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); }
@@ -121,12 +129,22 @@
<label style="display:block;font-weight:400;margin-top:6px">
<input type="radio" name="compute" value="local"> Hybrid &mdash; text stages on local LLM (cheaper, slower)</label>
<div id="modelPick" style="margin-top:10px">
<label for="model" style="font-weight:400">Model <span class="opt" id="modelNote">loading...</span></label>
<select id="model" style="width:100%;margin-top:6px;padding:10px;border-radius:8px;border:1px solid var(--line);background:#0c0e13;color:var(--text)"></select>
<div class="field">
<span>Vision model <span class="opt">(image stages)</span> <span class="opt" id="modelNote">loading...</span></span>
<select id="vision_model" disabled><option value="">Loading models&hellip;</option></select>
</div>
<div class="field">
<span>Text model <span class="opt">(non-image stages)</span></span>
<select id="text_model" disabled><option value="">Loading models&hellip;</option></select>
</div>
</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&hellip;</span></pre>
</div>
<div id="results"></div>
</main>
<div id="viewer">
@@ -144,9 +162,71 @@
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, reviewDirty=false;
function modelLabel(m){
// Include per-1M-token pricing when the catalog provides it.
let s=m.name||m.id;
if(m.prompt_usd_per_mtok!=null)
s+=' — $'+m.prompt_usd_per_mtok+' / $'+m.completion_usd_per_mtok+' per 1M tok';
return s;
}
function fillSelect(sel, items, preferred){
sel.innerHTML='';
(items||[]).forEach(m=>{
const opt=document.createElement('option');
opt.value=m.id; opt.textContent=modelLabel(m);
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;
}
let modelsLoaded=false;
async function loadModels(){
const note=document.getElementById('modelNote');
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);
modelsLoaded=true;
note.textContent='('+(data.text||[]).length+' text / '+(data.vision||[]).length+' vision available)';
}catch(err){
visionSel.innerHTML='<option value="">(default)</option>';
textSel.innerHTML='<option value="">(default)</option>';
visionSel.disabled=false; textSel.disabled=false;
note.textContent='using configured defaults (list unavailable)';
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&hellip;</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());
@@ -160,6 +240,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=>{
@@ -168,8 +249,9 @@ runBtn.addEventListener('click',async e=>{
const compute=(document.querySelector('input[name="compute"]:checked')||{}).value;
fd.append('text_local', compute==='local' ? 'true' : 'false');
if(compute==='openrouter'){
const modelSel=document.getElementById('model');
if(modelSel.value) fd.append('model', modelSel.value);
// Model picks only apply to OpenRouter compute; hybrid keeps its local model.
if(visionSel.value) fd.append('vision_model', visionSel.value);
if(textSel.value) fd.append('text_model', textSel.value);
}
const pipelineMode=(document.querySelector('input[name="pipeline_mode"]:checked')||{}).value||'classic';
fd.append('pipeline_mode',pipelineMode);
@@ -196,21 +278,30 @@ function poll(jobId){
const res=await fetch('/jobs/'+jobId);
if(!res.ok) throw new Error('job not found');
const job=await res.json();
const live=['running','queued','finalizing'].includes(job.status);
if(job.log_tail && job.log_tail.length) showLog(job.log_tail, live);
if(job.status==='running'||job.status==='queued'){
statusEl.innerHTML='<span class="spinner"></span>'+esc(job.stage||'Working...')+
' &middot; 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==='needs_review'||job.status==='reviewing'){
clearInterval(polling); polling=null; runBtn.disabled=false; renderReview(job);
clearInterval(polling); polling=null; runBtn.disabled=false;
if(job.log && job.log.length) showLog(job.log, false);
renderReview(job);
} else if(job.status==='finalizing'){
statusEl.innerHTML='<span class="spinner"></span>Finalizing reviewed report...';
} else if(job.status==='finalization_error'){
clearInterval(polling); polling=null; runBtn.disabled=false;
statusEl.textContent='Finalization failed: '+(job.error||'unknown error');
if(job.log && job.log.length) showLog(job.log, false);
} 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; }
@@ -230,29 +321,11 @@ function syncPipelineOptions(){
document.querySelectorAll('input[name="pipeline_mode"]').forEach(el=>el.addEventListener('change',syncPipelineOptions));
syncPipelineOptions();
// --- model picker (OpenRouter compute only) ---
let modelList=null;
async function loadModels(){
const note=document.getElementById('modelNote'), sel=document.getElementById('model');
try{
const res=await fetch('/models');
if(!res.ok) throw new Error('list unavailable');
const data=await res.json();
modelList=data.models||[];
sel.innerHTML=modelList.map(m=>
'<option value="'+escAttr(m.id)+'"'+(m.id===data.default?' selected':'')+'>'+
esc(m.name||m.id)+' &mdash; $'+esc(m.prompt_usd_per_mtok)+' / $'+esc(m.completion_usd_per_mtok)+
' per 1M tok</option>').join('');
note.textContent='('+modelList.length+' available)';
}catch(e){
sel.innerHTML='';
note.textContent='using configured default (list unavailable)';
}
}
// --- model pickers (OpenRouter compute only) ---
function syncCompute(){
const openrouter=(document.querySelector('input[name="compute"]:checked')||{}).value==='openrouter';
document.getElementById('modelPick').style.display=openrouter?'block':'none';
if(openrouter&&!modelList) loadModels();
if(openrouter&&!modelsLoaded) loadModels();
}
document.querySelectorAll('input[name="compute"]').forEach(el=>el.addEventListener('change',syncCompute));
syncCompute();
@@ -394,6 +467,10 @@ 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>'; }