| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404 |
- # -*- coding: utf-8 -*-
- """前端服务 (stdlib): / 固定界面 · /workbench.html 工作台 · /chat.html 交互 (推理循环) · /canvas.html 画布 (模型驱动视图)
- JSON API: /api/plugins /api/run /api/ask (单轮调度) /api/agent (多轮推理) /api/views (画布) /api/models"""
- import json
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
- from pathlib import Path
- from . import plugins, orchestrator, llm, agent, ui
- JS_SHOW = """function tbl(d){if(!(d&&Array.isArray(d)&&d.length&&typeof d[0]=='object'))return '';const ks=Object.keys(d[0]);return '<table><thead><tr>'+ks.map(k=>'<th>'+k+'</th>').join('')+'</tr></thead><tbody>'+d.map(x=>'<tr>'+ks.map(k=>'<td>'+(x[k]==null?'':String(x[k]).slice(0,160))+'</td>').join('')+'</tr>').join('')+'</tbody></table>'}
- function esc(s){return String(s||'').replace(/</g,'<')}
- function panelHtml(p){const meta='<details class=small><summary>'+esc(p.plugin)+' 详情</summary><div class=small>'+esc(JSON.stringify(p.args||{}))+'</div>'+tbl(p.data)+(p.text?'<pre>'+esc(p.text)+'</pre>':'')+'</details>';return '<div class="panel panel-big">'+(p.svg? p.svg+meta : ((p.text?'<pre>'+esc(p.text)+'</pre>':'')+tbl(p.data)))+'</div>'}
- function mdlite(s){s=esc(s);s=s.replace(/\\*\\*([^*]+)\\*\\*/g,"<b>$1</b>");s=s.replace(/^### (.*)$/gm,"<b>$1</b>");s=s.replace(/^[-•] (.*)$/gm,"<li>$1</li>");return "<p>"+s.replace(/\\n\\n+/g,"</p><p>").replace(/\\n/g,"<br>")+"</p>"}
- function ansHtml(a){return "<div class=ans>"+mdlite(a)+"</div>"}
- function viewHtml(v){return '<div class=card><h3>'+esc(v.title)+'</h3><div class=grid>'+(v.panels||[]).map(panelHtml).join('')+'</div></div>'}"""
- WB_BODY = """<div class="side">
- <div class="card"><h3>分析</h3>
- <section id="tid" class="wb-pick" tabindex="-1" aria-label="机组 / 测点选择">
- <div class=muted>机组</div><select id="tid_sel" style="width:100%"></select>
- <div class=muted style="margin-top:6px">测点</div><select id=sen style="width:100%"></select>
- <div class=muted style="margin-top:6px">分析类</div><select id=cat style="width:100%">
- <option value="scalar_trend">标量趋势(六窗,功率档,TCM 阈值/参考线)</option>
- <option value="spectrum">最新谱(包络/原始 + OEM 特征线 + G2 过/不过)</option>
- <option value="verdict_gate">定级(谱线六闸 + 冲击轴分型,六枚举)</option>
- <option value="turbine_summary">台级摘要(综合判断 / 过闸线 / 冲击轴 / 终态)</option>
- <option value="gate_spectral">谱线闸(指定频率,G2)</option>
- <option value="fleet_rank">全场排名(末窗同档 ×fleet)</option>
- <option value="compare">多台对比(同标量同档,一图多线)</option>
- <option value="kb_search">知识库检索(结论类文档)</option>
- </select>
- <div id=opts style="margin-top:6px"></div>
- <div class=toolbar><button onclick="go()">运行</button><span id=st class=small></span></div>
- </section>
- <details style="margin-top:12px"><summary class=small>高级:插件 + JSON 参数 / 即用即抛</summary>
- <select id=pl size=8 class=list style="margin-top:6px"></select><div id=desc class=hint></div><textarea id=args></textarea><div class=toolbar><button class=ghost onclick="run()">运行插件</button></div>
- <div class="muted" style="margin-top:10px">即用即抛(pandas · scalars/status/l6/fusion/np/pd/HIGH_BIN/sensor('主轴承后'))</div><textarea id=code>result = scalars[(scalars.sensor_name==sensor('主轴承后'))&(scalars.meas_name=='Peak')&(scalars.condition_key==HIGH_BIN)].groupby('turbine').scalar_value.median().sort_values(ascending=False).head(8)</textarea><div class=toolbar><button class=ghost onclick="adhoc()">执行</button></div></details>
- <h3 style="margin-top:16px">数据导入</h3>
- <div class=muted>数据路径(导出包 .rar/.7z/.zip、解码 JSON 目录或波形目录的本机绝对路径)</div>
- <input id=imp_path type=text placeholder="把 .rar 拖到这里, 或粘贴其路径" style="width:100%">
- <div class=muted style="margin-top:6px">窗名(如 w0818;留空 = 不导新数据只重算)</div>
- <input id=imp_win type=text placeholder="w0818" style="width:100%">
- <div class=toolbar><button onclick="doImport()">开始导入并分析</button><span id=imp_st class=small></span></div>
- <p class=hint>实测:5.3 GB 导出包约 10 分钟;全链(导入→扫谱→六层→融合→报告)约 11 分钟。后台运行,页面可继续用;带锁防重复启动。</p>
- <h3 style="margin-top:16px">对照观察</h3>
- <div class=muted>机组(多选,⌘/Ctrl 点选)</div><select id=c_t multiple size=6 style="width:100%"></select>
- <div class=muted style="margin-top:6px">测点(多选)</div><select id=c_s multiple size=4 style="width:100%"></select>
- <div class=muted style="margin-top:6px">量(多选)</div><select id=c_m multiple size=4 style="width:100%"><option selected>Peak</option><option>Rms_HP</option><option>Kurtosis</option><option>rms_200</option><option>包络谱</option><option>原始谱</option></select>
- <div class=muted style="margin-top:6px">排布</div><select id=c_mode style="width:100%"><option value="grid">各自面板(机组×测点×量,最多 12 块)</option><option value="overlay">叠加(同测点同量,多台一图)</option></select>
- <div class=muted style="margin-top:6px">功率档</div><select id=c_bin style="width:100%"></select>
- <div class=toolbar><button onclick="cmp()">组装对照视图</button><span id=cst class=small></span></div></div>
- <div class="card" id=out><p class=muted>选机组 → 测点 → 分析类,运行后结果显示在这里(图 / 表 / 文)。对照观察会组装成多面板视图,并同步到画布。</p></div></div>
- <script>JS
- const SENS=['主轴承前','主轴承后','行星级','中间轴','高速轴转子侧','高速轴发电机侧','发电机DE','发电机NDE'];
- const BINS=['WPS-ActivePower 3601-4400,','WPS-ActivePower 3201-3600,','WPS-ActivePower 2801-3200,','WPS-ActivePower 2401-2800,','WPS-ActivePower 2001-2400,','WPS-ActivePower 1601-2000,','WPS-ActivePower 0-1600,'];
- const tid=document.getElementById('tid_sel');for(let i=1;i<=38;i++){const o=document.createElement('option');o.value='WTG'+String(i).padStart(2,'0');o.text=o.value;tid.add(o)}
- const sen=document.getElementById('sen');SENS.forEach(s=>{const o=document.createElement('option');o.value=s;o.text=s;sen.add(o)});
- const OPT={scalar_trend:()=>sel('meas',['Peak','Rms_HP','Kurtosis','rms_200','CrestFactor'])+sel('bin',BINS),spectrum:()=>sel('kind',['env','fft'])+num('xmax',300),verdict_gate:()=>num('hz',''),turbine_summary:()=>'',gate_spectral:()=>num('hz',135)+sel('kind',['env','fft']),fleet_rank:()=>sel('meas',['Peak','Rms_HP','Kurtosis','rms_200'])+sel('bin',BINS)+num('top',10),compare:()=>txt('turbines','WTG23,WTG16,WTG38')+sel('meas',['Peak','Rms_HP','Kurtosis','rms_200'])+sel('bin',BINS),kb_search:()=>txt('query','23号 主轴承 时延')+num('k',5)};
- function sel(n,vals){return '<div class=muted>'+n+'</div><select id=o_'+n+' style="width:100%">'+vals.map(v=>'<option>'+v+'</option>').join('')+'</select>'}
- function num(n,v){return '<div class=muted>'+n+'</div><input id=o_'+n+' type=text value="'+v+'" style="width:100%">'}
- function txt(n,v){return num(n,v)}
- const cat=document.getElementById('cat');cat.onchange=()=>{document.getElementById('opts').innerHTML=OPT[cat.value]()};cat.onchange();
- const ct=document.getElementById('c_t');for(let i=1;i<=38;i++){const o=document.createElement('option');o.value='WTG'+String(i).padStart(2,'0');o.text=o.value;if(i==23||i==16)o.selected=true;ct.add(o)}
- const cs=document.getElementById('c_s');SENS.forEach((s,i)=>{const o=document.createElement('option');o.value=s;o.text=s;if(i==0)o.selected=true;cs.add(o)});
- const cb=document.getElementById('c_bin');BINS.forEach(b=>{const o=document.createElement('option');o.value=b;o.text=b.replace('WPS-ActivePower ','').replace(',',' kW');cb.add(o)});
- function vals(id){return Array.from(document.getElementById(id).selectedOptions).map(o=>o.value)}
- let impTimer=null;
- async function doImport(){const p=document.getElementById('imp_path').value.trim();let w=document.getElementById('imp_win').value.trim();const st=document.getElementById('imp_st');
- if(p&&!w){const g=(p.split('/').pop().match(/\\d+/g)||[]).filter(x=>x.length<4);
- if(g.length>=2){w='w'+g[g.length-2].padStart(2,'0')+g[g.length-1].padStart(2,'0');document.getElementById('imp_win').value=w;}
- else{alert('请填窗名 (如 w0818) — 文件名里认不出日期');return}}
- if(!confirm('确认启动?\\n路径: '+(p||'(不导入, 在现有窗上重跑)')+'\\n窗名: '+(w||'—')+'\\n约 10-30 分钟后台任务, 会重写全部模型产物。')){return}
- st.textContent='已发送, 启动中…';
- const r=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:'analyze',args:{input:p,window:w,confirm:true}})}).then(r=>r.json());
- st.textContent=(r.text||'').slice(0,90);
- if(impTimer)clearInterval(impTimer);
- impTimer=setInterval(async()=>{const j=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:'job_status',args:{}})}).then(r=>r.json());
- st.textContent=(j.text||'').slice(0,130);if((j.text||'').includes('完成')||(j.text||'').includes('ok:')){clearInterval(impTimer)}},8000);}
- async function cmp(){const T=vals('c_t'),S=vals('c_s'),Mq=vals('c_m'),mode=document.getElementById('c_mode').value,bin=cb.value;if(!T.length||!S.length||!Mq.length){alert('请至少各选一个');return}const panels=[];
- if(mode=='overlay'){S.forEach(s=>Mq.forEach(m=>{if(m=='包络谱'||m=='原始谱'){T.forEach(t=>panels.push({plugin:'spectrum',args:{turbine:t,sensor:s,kind:m=='包络谱'?'env':'fft'}}))}else panels.push({plugin:'compare',args:{turbines:T.join(','),sensor:s,meas:m,bin}})}))}
- else{T.forEach(t=>S.forEach(s=>Mq.forEach(m=>{if(m=='包络谱'||m=='原始谱')panels.push({plugin:'spectrum',args:{turbine:t,sensor:s,kind:m=='包络谱'?'env':'fft'}});else panels.push({plugin:'scalar_trend',args:{turbine:t,sensor:s,meas:m,bin}})})))}
- if(panels.length>12){alert('面板 '+panels.length+' 块,超过 12,请减少选择');return}
- document.getElementById('cst').textContent='组装 '+panels.length+' 块…';const title=(mode=='overlay'?'叠加对照 ':'对照 ')+T.join('/')+' · '+S.join('/')+' · '+Mq.join('/');
- const r=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:'render',args:{title,panels:JSON.stringify(panels)}})}).then(r=>r.json());document.getElementById('cst').textContent='';show(r)}
- function show(r){const o=document.getElementById('out');if(r.kind=='view'){o.innerHTML=viewHtml(r.data);return}o.innerHTML=(r.svg||'')+tbl(r.data)+(r.text?'<pre>'+esc(r.text)+'</pre>':'')}
- async function go(){const c=cat.value;const a={};const needT=['scalar_trend','spectrum','verdict_gate','turbine_summary','gate_spectral'];const needS=['scalar_trend','spectrum','verdict_gate','gate_spectral','fleet_rank','compare'];if(needT.includes(c))a.turbine=tid.value;if(needS.includes(c))a.sensor=sen.value;document.querySelectorAll('#opts [id^=o_]').forEach(e=>{const k=e.id.slice(2);if(e.value!=='')a[k]=isNaN(e.value)||k=='turbines'||k=='query'||k=='bin'||k=='meas'||k=='kind'?e.value:+e.value});document.getElementById('st').textContent='运行中…';const r=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:c,args:a})}).then(r=>r.json());document.getElementById('st').textContent='';show(r)}
- let M=[];fetch('/api/plugins').then(r=>r.json()).then(j=>{M=j;const s=document.getElementById('pl');j.forEach(p=>{const o=document.createElement('option');o.value=p.name;o.text=p.name+' — '+p.desc.slice(0,60);s.add(o)});s.onchange=()=>{const p=M.find(x=>x.name==s.value);document.getElementById('desc').textContent=p.desc;document.getElementById('args').value=JSON.stringify(p.params,null,1)};s.selectedIndex=0;s.onchange()});
- async function run(){const n=document.getElementById('pl').value;let a={};try{a=JSON.parse(document.getElementById('args').value||'{}')}catch(e){alert('参数 JSON 错');return}document.getElementById('st').textContent='运行中…';const r=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:n,args:a})}).then(r=>r.json());document.getElementById('st').textContent='';show(r)}
- async function adhoc(){const r=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:'adhoc',args:{code:document.getElementById('code').value}})}).then(r=>r.json());show(r)}
- </script>""".replace('JS\n', JS_SHOW + '\n')
- CHAT_BODY = """<div class="card"><textarea id=q placeholder="例:23号主轴承前端现在什么状态?对比 23 和 16 的主轴承前 Peak,再看 23 的包络谱,提出假设并说明怎么证伪"></textarea>
- <div class=toolbar><label class=small><input type=checkbox id=think> 思考链(慢 3×,仅探索假设时开)</label><label class=small>步数 <input id=steps type=number value=5 style="width:64px"></label><button onclick="ask()">提问(多轮推理)</button><button class=ghost onclick="ask1()">单轮调度</button><span id=st class=small></span><span id=model class=small style="margin-left:auto"></span></div></div><div id=log></div>
- <div id=hist></div>
- <script>JS
- fetch('/api/models').then(r=>r.json()).then(j=>{document.getElementById('model').textContent='模型:'+(j.picked||'无(规则调度回退)')+(j.models.length?' · 可用:'+j.models.join(', '):'')});
- function card(q,r){const d=document.createElement('div');d.className='card';let views=(r.views||[]).map(viewHtml).join('');let steps=(r.steps||[]).map(s=>'## '+(s.tool||'')+' '+JSON.stringify(s.args||s.grounding_blocked||{})+'\\n'+(s.result||'')).join('\\n\\n');let hyp=(r.hypotheses||[]).map(h=>'#'+h.id+' ['+h.status+'] '+h.statement+' | 证据: '+h.evidence+' | 证伪: '+h.falsifier).join('\\n');
- d.innerHTML='<div class=q>'+esc(q)+'</div>'+ansHtml(r.answer)+'<div class=small>来源:'+esc(r.source)+' · 步数 '+(r.steps||[]).length+' · '+(r.seconds||'')+' s</div>'+(hyp?'<details open><summary>假设簿</summary><pre>'+esc(hyp)+'</pre></details>':'')+views+'<details><summary>工具步骤(权威事实)</summary><pre>'+esc(steps)+'</pre></details>';document.getElementById('log').prepend(d)}
- async function ask(){const q=document.getElementById('q').value;if(!q)return;const st=document.getElementById('st');
- const think=document.getElementById('think').checked;
- if(think){st.textContent='思考链模式(慢)…';try{const r=await fetch('/api/agent',{method:'POST',body:JSON.stringify({q,think:true,max_steps:+document.getElementById('steps').value||5})}).then(r=>r.json());st.textContent='';card(q,r)}catch(e){st.textContent='失败: '+e}return}
- st.textContent='连接…';
- const d=document.createElement('div');d.className='card';d.innerHTML='<div class=q>'+esc(q)+'</div><div class=small id=live>启动…</div>';
- const hist=document.getElementById('hist')||document.body;hist.prepend(d);const live=d.querySelector('#live');live.removeAttribute('id');
- const es=new EventSource('/api/agent_stream?q='+encodeURIComponent(q));const t0=Date.now();let n=0;
- es.addEventListener('start',()=>{live.textContent='⏱ 推理开始';st.textContent=''});
- es.addEventListener('step',e=>{const s=JSON.parse(e.data);n++;live.innerHTML+='<br>'+n+'. '+esc(s.tool)+' <span class=muted>'+esc(JSON.stringify(s.args||{}).slice(0,60))+' · '+s.seconds+'s</span>'});
- es.addEventListener('final',e=>{const r=JSON.parse(e.data);const sec=((Date.now()-t0)/1000).toFixed(1);
- let views=(r.views||[]).map(viewHtml).join('');
- d.innerHTML='<div class=q>'+esc(q)+'</div>'+ansHtml(r.answer)+views+'<div class=small>来源:'+esc(r.source)+' · 步数 '+(r.steps||[]).length+' · '+sec+' s</div><div class=small style="opacity:.75">审核中 (DeepSeek 引文核对, 约40秒)…</div>';
- setTimeout(()=>{try{es.close()}catch(_){}} ,150000)});
- es.addEventListener('review',e=>{es.close();const v=JSON.parse(e.data);const chips=[];
- if((v.machine_flags||[]).length)chips.push('机器闸: '+v.machine_flags.join(';'));
- (v.issues||[]).forEach(i=>chips.push((i.rule||'')+' '+(i.problem||'')));
- const div=document.createElement('div');div.className='small';
- div.innerHTML='审核('+esc(v.model||'机器闸')+' · '+esc(String(v.seconds||''))+'s): <b>'+esc(v.verdict||'')+'</b>'+(chips.length?'<br>· '+chips.map(esc).join('<br>· '):'')+((v.discarded||[]).length?'<br><span style="opacity:.6">已废弃'+v.discarded.length+'条失实质疑</span>':'');
- const old=d.querySelector('div.small:last-child');if(old&&old.textContent.startsWith('审核中'))old.remove();
- d.appendChild(div)});
- es.onerror=()=>{es.close();if(!d.querySelector('pre'))live.innerHTML+='<br>流中断,请重试或用单轮调度'};}
- async function ask1(){const q=document.getElementById('q').value;if(!q)return;document.getElementById('st').textContent='调度中…';const r=await fetch('/api/ask',{method:'POST',body:JSON.stringify({q})}).then(r=>r.json());document.getElementById('st').textContent='';r.views=r.results.filter(x=>x.svg).map(x=>({title:x.plugin,panels:[x]}));card(q,r)}
- document.getElementById('q').addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter')ask()});
- </script>""".replace('JS\n', JS_SHOW + '\n')
- CANVAS_TAIL = """<div class="card" style="margin-top:14px"><h3>画布 <span class="muted">模型调 render 组装的面板(每 5 s 刷新,最近 20 个)</span></h3>
- <div id=views></div></div>
- <script>
- async function loadViews(){const v=await fetch('/api/views').then(r=>r.json());const el=document.getElementById('views');const h=v.slice().reverse().map(viewHtml).join('');el.innerHTML=h||'<p class="small muted">还没有面板。在上面问一句“对比 23 和 16 的主轴承前 Peak,并把包络谱一起放上来”,模型会调 render 组图到这里。</p>';}
- loadViews();setInterval(loadViews,5000);
- </script>"""
- CANVAS_BODY = """<p class="muted">模型驱动的视图:本服务的交互页或 dsh 里调用 <code>mcp__windcms__render</code> 组装的面板都会出现在这里(每 5 s 刷新,最近 20 个)。</p><div id=views></div>
- <script>JS
- async function load(){const v=await fetch('/api/views').then(r=>r.json());document.getElementById('views').innerHTML=v.slice().reverse().map(viewHtml).join('')||'<div class=card><p class=muted>还没有视图。</p></div>'}
- load();setInterval(load,5000);
- </script>""".replace('JS\n', JS_SHOW + '\n')
- # ── 统一评审皮肤的主题守卫 (2026-09-06) ────────────────────────────────────────
- # GUANLAN_REVIEW_CSS 是一整块浅色 !important 覆盖, 直接注入会把 ?theme=deep 压成浅色。
- # 出门前把每条选择器收窄到 :root:not([data-theme="deep"]) / html:not(...) 之下 → deep 时整块不生效。
- # 守卫用 :where() 包 → 特异度贡献为 0, 浅色下每条规则的层叠权重与加守卫前逐条相同 (只做减法不改排序)
- _NOT_DEEP = ':where(:not([data-theme="deep"]))' # 贴在 :root / html 上
- _UNDER_NOT_DEEP = ':where(html:not([data-theme="deep"])) ' # 作后代前缀
- def _guard_sel(sel):
- """逐条给选择器加"非 deep"守卫: :root/html 开头就地合并, 其余作后代。"""
- outs, cur, depth = [], [], 0
- for ch in sel + ',':
- if ch == '(':
- depth += 1
- elif ch == ')':
- depth -= 1
- if ch == ',' and depth == 0:
- one = ''.join(cur).strip()
- cur = []
- if not one:
- continue
- if one.startswith(':root'):
- outs.append(':root' + _NOT_DEEP + one[5:])
- elif one == 'html' or (one.startswith('html') and one[4] in ' .[:>#+~'):
- outs.append('html' + _NOT_DEEP + one[4:])
- else:
- outs.append(_UNDER_NOT_DEEP + one)
- else:
- cur.append(ch)
- return ','.join(outs)
- def _scope_css(css):
- """整块 CSS 加主题守卫: 只改选择器不动声明; @media 等条件组规则递归; 注释丢弃。"""
- res, buf, i, n = [], [], 0, len(css)
- while i < n:
- if css.startswith('/*', i):
- j = css.find('*/', i + 2)
- i = n if j < 0 else j + 2
- continue
- if css[i] == '{':
- sel = ''.join(buf).strip()
- buf = []
- depth, j = 1, i + 1
- while j < n and depth:
- if css[j] == '{':
- depth += 1
- elif css[j] == '}':
- depth -= 1
- j += 1
- inner = css[i + 1:j - 1]
- if sel.startswith('@'):
- at = sel.split()[0].split('(')[0].lower()
- grp = at in ('@media', '@supports', '@layer', '@container')
- res.append(sel + '{' + (_scope_css(inner) if grp else inner) + '}')
- elif sel:
- res.append(_guard_sel(sel) + '{' + inner + '}')
- i = j
- continue
- buf.append(css[i])
- i += 1
- return ''.join(res)
- # ── deep 下的动作条 / 锚点反馈 (2026-09-06) ──────────────────────────────────
- # .action-path 与 #tid:target 的描边原本只写在 GUANLAN_REVIEW_CSS 里, 而那整块已按主题守卫收窄到
- # :not([data-theme="deep"]) → deep 下这两处连同触控 44px 一起消失 (点了没反馈)。
- # 修法是给 deep 补一份同语义的 token 版, 而不是把浅色块整体放开 (放开 = deep 又被压成浅色)。
- _DEEP_CHROME_CSS = (
- 'html[data-theme="deep"] .action-path{display:flex;flex-wrap:wrap;gap:10px;margin:16px 0;padding:10px 14px;'
- 'background:var(--panel,#101A2B);border:1px solid var(--line,#1F2E44);border-radius:9px}'
- 'html[data-theme="deep"] .action-path a{color:var(--accent-2,#3FC1D3);padding:7px 12px;'
- 'border:1px solid var(--line,#1F2E44);border-radius:6px;font-size:13px;text-decoration:none}'
- 'html[data-theme="deep"] .action-path a:last-child{background:#16233A}'
- 'html[data-theme="deep"] .action-path a:hover{background:#1B2C46;border-color:var(--accent,#1F8FA0)}'
- 'html[data-theme="deep"] .action-path a:focus-visible{outline:3px solid var(--accent-2,#3FC1D3);outline-offset:2px}'
- 'html[data-theme="deep"] #tid:target,html[data-theme="deep"] #tid:focus-visible'
- '{outline:3px solid #3FC1D3;outline-offset:3px;scroll-margin-top:35px}'
- '@media(max-width:760px){html[data-theme="deep"] .action-path a'
- '{min-height:44px;display:inline-flex;align-items:center;justify-content:center}}')
- def _theme_of(path):
- """当前主题 = URL 显式 ?theme= (仅 light/deep 认), 无参默认 light。服务端与 ui.THEME_BOOT 同一口径。"""
- from urllib.parse import urlparse, parse_qs
- v = (parse_qs(urlparse(path).query).get('theme') or [''])[0]
- return v if v in ('light', 'deep') else 'light'
- _REVIEW_CSS = {}
- def review_css_scoped():
- if 'v' not in _REVIEW_CSS:
- from src.windscada.design import GUANLAN_REVIEW_CSS
- _REVIEW_CSS['v'] = ('/* guanlan-unified-review-v1 · scoped: 不作用于 data-theme="deep" */'
- + _scope_css(GUANLAN_REVIEW_CSS)
- + '/* deep chrome: 动作条/锚点描边补回 */' + _DEEP_CHROME_CSS)
- return _REVIEW_CSS['v']
- def run(cfg, port=8030, model=None):
- out = Path(cfg['out'])
- c = orchestrator.ctx(cfg)
- # 画布并入交互页下半 (2026-08-24 用户: "画布和交互有关系, 移到交互的下半部分"); /canvas.html 保留为独立大屏页
- pages = {'/workbench.html': ('工作台', WB_BODY, '万物可插件 · 即用即抛'),
- '/chat.html': ('交互', CHAT_BODY, '推理无界 · 结论受闸')} # 画布取消 (2026-08-24 用户): 视图内嵌在回答卡与工作台
- class H(BaseHTTPRequestHandler):
- def _send(self, body, ctype='text/html; charset=utf-8', code=200):
- if 'text/html' in ctype:
- text = body.decode('utf-8') if isinstance(body, bytes) else body
- if self.path.split('?')[0] in ('/report', '/report.html'):
- text = text.replace('<body', '<body data-ui-page="report"', 1)
- if '<nav class="action-path"' not in text:
- # 主题跟随当前页 (原来硬编码 ?theme=light → 从 deep 点这两个链接会被打回浅色)
- _q = '?theme=' + _theme_of(self.path)
- actions = ('<nav class="action-path" aria-label="页面操作">'
- '<a href="http://127.0.0.1:28084/#cms" target="_top">← 返回观澜门户</a>'
- f'<a href="/{_q}">CMS 总览</a>'
- f'<a href="/workbench.html{_q}#tid">重新选择机组 / 测点</a></nav>')
- if self.path.split('?')[0] == '/workbench.html':
- actions = actions.replace(f'/workbench.html{_q}#tid', '#tid')
- text = text.replace('</nav></div>', '</nav></div>' + actions, 1)
- if '</head>' in text:
- body = text.replace('</head>', '<style id="guanlan-unified-review-v1">' + review_css_scoped() + '</style></head>', 1)
- b = body if isinstance(body, bytes) else body.encode('utf-8')
- self.send_response(code)
- if 'text/html' in ctype:
- self.send_header('Cache-Control', 'no-store') # 旧页面缓存过慢默认值 (think 勾选/步数12), 禁缓存
- self.send_header('Content-Type', ctype)
- self.send_header('Content-Length', str(len(b)))
- self.end_headers()
- self.wfile.write(b)
- def do_GET(self):
- # 路由按去掉 query 的路径匹配: 带上 ?theme=deep 后原来的全等比较会漏路由 → 报告页 404 (2026-09-06)
- if self.path.split('?')[0] in ('/report.html', '/report'):
- import glob as _g, os as _os
- cands = sorted(_g.glob(str(out / '报告_*.md')), key=_os.path.getmtime)
- mdp = cands[-1] if cands else (out / 'report.md')
- md = Path(mdp).read_text(encoding='utf-8') if Path(mdp).exists() else '# 报告未生成\n先在终端跑 windcms report'
- name = Path(mdp).name
- docx = sorted(_g.glob(str(out / '报告_*.docx')), key=_os.path.getmtime)
- dl = f'<div class="card"><div class="kv"><span><b>本页</b>{ui.esc(name)}</span>' + (f'<span><a href="/files/{ui.esc(Path(docx[-1]).name)}">下载 Word 版</a></span>' if docx else '') + f'<span><a href="/files/{ui.esc(name)}">下载 Markdown 原文</a></span></div></div>'
- html_md = ui.md2html(md)
- # 报警台"图 WTGxx (见逐台页…)"段落 → 内嵌趋势/包络谱面板 (2026-08-24 用户: 图没上来)
- import re as _re
- from . import report as _rp
- def _inline(m):
- _tid = m.group(1)
- try:
- panels = _rp.key_panels_html(cfg, c, _tid)
- except Exception as _e:
- print(f'[warn] 内嵌图失败 {_tid}: {_e}', flush=True); panels = ''
- return panels + f'<p class="small"><a href="/turbines/{_tid}.html">→ {_tid} 逐台页 (全部测点趋势/谱/证据)</a></p>'
- body = dl + '<div class="card report">' + _re.sub(r'<p>图[\s\u3000]+(WTG\d+)[^<]*见逐台页[^<]*</p>', _inline, html_md) + '</div>'
- return self._send(ui.shell('评估报告', body, farm_name=cfg['name'], machine=cfg['machine'], active='/report.html', subtitle='标准模版报告 · 结构化层生成'))
- if self.path.startswith('/files/'):
- from urllib.parse import unquote
- p = out / unquote(self.path[7:])
- if p.exists() and p.parent == out:
- ct = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' if p.suffix == '.docx' else 'text/markdown; charset=utf-8'
- return self._send(p.read_bytes(), ct)
- return self._send('not found', code=404)
- if self.path.startswith('/api/agent_stream'):
- from urllib.parse import urlparse, parse_qs
- import time as _t
- qs = parse_qs(urlparse(self.path).query)
- question = (qs.get('q') or [''])[0]
- print(f'[req] /api/agent_stream q={question[:40]}', flush=True)
- self.send_response(200)
- self.send_header('Content-Type', 'text/event-stream; charset=utf-8')
- self.send_header('Cache-Control', 'no-store')
- self.end_headers()
- def emit(ev, obj):
- self.wfile.write(f'event: {ev}\ndata: {json.dumps(obj, ensure_ascii=False, default=str)}\n\n'.encode('utf-8'))
- self.wfile.flush()
- _t0 = _t.time()
- try:
- emit('start', {'q': question})
- # ★服务端硬预算: 忽略旧页面的 think/大步数 (体感与楔死的老根源); 需要思考链走 POST /api/agent 显式传
- r = agent.run(cfg, question, model=llm.pick(fast=True) or model, max_steps=5, max_calls=6, think=False,
- on_step=lambda st: emit('step', st))
- emit('final', r)
- # DeepSeek 审千问 (2026-08-24 用户令): 机器引文闸+r1质疑清单, 异步追加; 失败不影响答案
- try:
- from . import cross_review as _cr
- _facts = json.dumps(r.get('steps', []), ensure_ascii=False, default=str)[:6000]
- rv = _cr.review_answer(question, r.get('answer', ''), _facts)
- emit('review', rv)
- print(f"[review] {rv.get('model')} {rv.get('seconds')}s → {rv.get('verdict')}", flush=True)
- except Exception as _e:
- emit('review', {'verdict': f'审核不可用: {_e}', 'issues': [], 'machine_flags': []})
- print(f'[done] /api/agent_stream {_t.time()-_t0:.1f}s', flush=True)
- except (BrokenPipeError, ConnectionResetError):
- print(f'[gone] /api/agent_stream {_t.time()-_t0:.1f}s 客户端断开', flush=True)
- except Exception:
- import traceback as _tb
- print('[err] /api/agent_stream\n' + _tb.format_exc()[-400:], flush=True)
- return
- return self._do_GET_pages()
- def _do_GET_pages(self):
- p = self.path.split('?')[0]
- if p in ('/', '/index.html'):
- # 2026-09-05 深海钢蓝主题 (纯增): 固定页是静态产物, 出门时注入 token 覆盖块 + ?theme=deep/localStorage 切换脚本; 默认外观不变
- _h = (out / 'index.html').read_text(encoding='utf-8', errors='ignore')
- _inj = ('<style>:root[data-theme="deep"]{--bg:#070C14;--panel:#101A2B;--ink:#E6EDF5;--muted:#8393A8;--line:#1F2E44;--accent:#1F8FA0;--accent-2:#3FC1D3;--shadow:0 1px 2px rgba(0,0,0,.5),0 8px 24px rgba(0,0,0,.35)}:root[data-theme="light"]{--bg:#f2f6f8;--panel:#fff;--ink:#183548;--muted:#667f8e;--line:#d4e3e7;--accent:#087f89;--accent-2:#169eaa;--shadow:0 3px 14px rgba(14,74,87,.08)}html[data-theme="light"] body{background:linear-gradient(145deg,#f8fbfc 0%,#eef7f8 45%,#f5f8fa 100%)}html[data-theme="light"] body::before{content:"";position:fixed;z-index:-1;inset:0 auto auto 0;width:100%;height:190px;pointer-events:none;background:radial-gradient(620px 170px at 83% 0,#bce8ea80,transparent 68%),linear-gradient(105deg,#edf8f8,#f8fbfc)}html[data-theme="light"] .top{position:relative;overflow:hidden;background:linear-gradient(100deg,#eaf7f7,#fff 67%,#e1f4f5);border-bottom:1px solid #c9e2e4;box-shadow:0 2px 12px rgba(10,89,99,.06)}html[data-theme="light"] .top::after{content:"";position:absolute;right:18px;top:0;width:360px;height:100%;opacity:.35;pointer-events:none;background-image:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 360 80\'%3E%3Cg fill=\'none\' stroke=\'%23008792\' stroke-width=\'1.5\'%3E%3Cpath d=\'M0 67 Q90 45 180 64 T360 55\'/%3E%3Cpath d=\'M255 66V23M255 23l-25 15m25-15 26 14M255 23l2-27M315 68V37m0 0-18 11m18-11 20 10m-20-10 1-20\'/%3E%3Ccircle cx=\'255\' cy=\'23\' r=\'3\' fill=\'%23008792\'/%3E%3Ccircle cx=\'315\' cy=\'37\' r=\'3\' fill=\'%23008792\'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right center}html[data-theme="light"] .top>*{position:relative;z-index:1}html[data-theme="light"] .card,html[data-theme="light"] .panel{border-color:#d6e5e8;box-shadow:0 5px 18px rgba(13,75,88,.07)}</style>'
- + ui.THEME_BOOT)
- _h = _h.replace('</head>', _inj + '</head>', 1) if '</head>' in _h else _inj + _h
- return self._send(_h)
- if p in pages:
- t, body, sub = pages[p]
- return self._send(ui.shell(t, body, farm_name=cfg['name'], machine=cfg['machine'], active=p, subtitle=sub))
- if p == '/api/plugins':
- return self._send(json.dumps(plugins.manifest(), ensure_ascii=False), 'application/json')
- if p == '/api/models':
- return self._send(json.dumps(dict(models=llm.models(), picked=llm.pick(model)), ensure_ascii=False), 'application/json')
- if p == '/api/views':
- vp = out / 'views.json'
- return self._send(json.dumps(json.load(open(vp, encoding='utf-8')) if vp.exists() else [], ensure_ascii=False, default=str), 'application/json')
- f = out / p.lstrip('/')
- if f.exists() and f.is_file():
- ct = 'text/html; charset=utf-8' if f.suffix == '.html' else 'text/plain; charset=utf-8'
- if f.suffix == '.html':
- raw = f.read_text(encoding='utf-8', errors='ignore')
- light = ('<style>:root[data-theme="light"]{--bg:#f2f6f8;--panel:#fff;--ink:#183548;--muted:#667f8e;--line:#d4e3e7;--accent:#087f89;--accent-2:#169eaa;--shadow:0 3px 14px rgba(14,74,87,.08)}'
- ':root[data-theme="deep"]{--bg:#070C14;--panel:#101A2B;--ink:#E6EDF5;--muted:#8393A8;--line:#1F2E44;--accent:#1F8FA0;--accent-2:#3FC1D3;--shadow:0 1px 2px rgba(0,0,0,.5),0 8px 24px rgba(0,0,0,.35)}</style>'
- + ui.THEME_BOOT)
- raw = raw.replace('</head>', light + '</head>', 1) if '</head>' in raw else light + raw
- return self._send(raw, ct)
- return self._send(f.read_bytes(), ct)
- self._send('not found', code=404)
- def do_POST(self):
- import time as _t, traceback as _tb
- _t0 = _t.time()
- n = int(self.headers.get('Content-Length', 0))
- body = json.loads(self.rfile.read(n) or b'{}')
- print(f'[req] {self.path} q={str(body.get("q") or body.get("plugin") or "")[:40]}', flush=True)
- try:
- return self._post(body, _t0)
- except Exception:
- print(f'[err] {self.path} {_t.time()-_t0:.1f}s\n' + _tb.format_exc()[-600:], flush=True)
- return self._send(json.dumps({'error': 'server exception, 见 serve.log'}), 'application/json', code=500)
- def _post(self, body, _t0):
- import time as _t
- if self.path == '/api/run':
- r = plugins.run(c, body.get('plugin', ''), **(body.get('args') or {}))
- return self._send(json.dumps(r, ensure_ascii=False, default=str), 'application/json')
- if self.path == '/api/ask':
- r = orchestrator.ask(cfg, body.get('q', ''), model=body.get('model') or llm.pick(fast=True) or model) # 单轮调度也走快模型
- return self._send(json.dumps(r, ensure_ascii=False, default=str), 'application/json')
- if self.path == '/api/agent':
- # 2026-08-24 提速三根因之三: web 路径旧默认 think=True/步数 12, 把 agent 层的新默认全盖掉了 (用户实测 120-200s 的真凶)
- r = agent.run(cfg, body.get('q', ''), model=body.get('model') or model, max_steps=min(int(body.get('max_steps') or 5), 8), max_calls=6, think=bool(body.get('think', False)))
- print(f'[done] /api/agent {_t.time()-_t0:.1f}s', flush=True)
- return self._send(json.dumps(r, ensure_ascii=False, default=str), 'application/json')
- self._send('bad', code=400)
- def log_message(self, *a):
- pass
- from src import logfile as _lf; _lf.prefix_stdout('cms') # 统一日志格式 (用户令 2)
- print(f'windcms serve → http://127.0.0.1:{port} (模型: {llm.pick(model) or "无, 规则调度回退"})', flush=True)
- ThreadingHTTPServer(('127.0.0.1', port), H).serve_forever()
|