Merge main: dual model dropdowns + richer job logs, adapted for agent-mode.
- 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:
+104
-27
@@ -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 — 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…</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…</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…</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…</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...')+
|
||||
' · 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)+' — $'+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>'; }
|
||||
|
||||
Reference in New Issue
Block a user