serve.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. # -*- coding: utf-8 -*-
  2. """前端服务 (stdlib): / 固定界面 · /workbench.html 工作台 · /chat.html 交互 (推理循环) · /canvas.html 画布 (模型驱动视图)
  3. JSON API: /api/plugins /api/run /api/ask (单轮调度) /api/agent (多轮推理) /api/views (画布) /api/models"""
  4. import json
  5. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  6. from pathlib import Path
  7. from . import orchestrator, llm, agent # 随迁件(同一子包)
  8. from src.windcms import plugins, ui # 仍留 src.windcms
  9. 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>'}
  10. function esc(s){return String(s||'').replace(/</g,'&lt;')}
  11. 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>'}
  12. 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>"}
  13. function ansHtml(a){return "<div class=ans>"+mdlite(a)+"</div>"}
  14. function viewHtml(v){return '<div class=card><h3>'+esc(v.title)+'</h3><div class=grid>'+(v.panels||[]).map(panelHtml).join('')+'</div></div>'}"""
  15. WB_BODY = """<div class="side">
  16. <div class="card"><h3>分析</h3>
  17. <section id="tid" class="wb-pick" tabindex="-1" aria-label="机组 / 测点选择">
  18. <div class=muted>机组</div><select id="tid_sel" style="width:100%"></select>
  19. <div class=muted style="margin-top:6px">测点</div><select id=sen style="width:100%"></select>
  20. <div class=muted style="margin-top:6px">分析类</div><select id=cat style="width:100%">
  21. <option value="scalar_trend">标量趋势(六窗,功率档,TCM 阈值/参考线)</option>
  22. <option value="spectrum">最新谱(包络/原始 + OEM 特征线 + G2 过/不过)</option>
  23. <option value="verdict_gate">定级(谱线六闸 + 冲击轴分型,六枚举)</option>
  24. <option value="turbine_summary">台级摘要(综合判断 / 过闸线 / 冲击轴 / 终态)</option>
  25. <option value="gate_spectral">谱线闸(指定频率,G2)</option>
  26. <option value="fleet_rank">全场排名(末窗同档 ×fleet)</option>
  27. <option value="compare">多台对比(同标量同档,一图多线)</option>
  28. <option value="kb_search">知识库检索(结论类文档)</option>
  29. </select>
  30. <div id=opts style="margin-top:6px"></div>
  31. <div class=toolbar><button onclick="go()">运行</button><span id=st class=small></span></div>
  32. </section>
  33. <details style="margin-top:12px"><summary class=small>高级:插件 + JSON 参数 / 即用即抛</summary>
  34. <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>
  35. <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>
  36. <h3 style="margin-top:16px">数据导入</h3>
  37. <div class=muted>数据路径(导出包 .rar/.7z/.zip、解码 JSON 目录或波形目录的本机绝对路径)</div>
  38. <input id=imp_path type=text placeholder="把 .rar 拖到这里, 或粘贴其路径" style="width:100%">
  39. <div class=muted style="margin-top:6px">窗名(如 w0818;留空 = 不导新数据只重算)</div>
  40. <input id=imp_win type=text placeholder="w0818" style="width:100%">
  41. <div class=toolbar><button onclick="doImport()">开始导入并分析</button><span id=imp_st class=small></span></div>
  42. <p class=hint>实测:5.3 GB 导出包约 10 分钟;全链(导入→扫谱→六层→融合→报告)约 11 分钟。后台运行,页面可继续用;带锁防重复启动。</p>
  43. <h3 style="margin-top:16px">对照观察</h3>
  44. <div class=muted>机组(多选,⌘/Ctrl 点选)</div><select id=c_t multiple size=6 style="width:100%"></select>
  45. <div class=muted style="margin-top:6px">测点(多选)</div><select id=c_s multiple size=4 style="width:100%"></select>
  46. <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>
  47. <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>
  48. <div class=muted style="margin-top:6px">功率档</div><select id=c_bin style="width:100%"></select>
  49. <div class=toolbar><button onclick="cmp()">组装对照视图</button><span id=cst class=small></span></div></div>
  50. <div class="card" id=out><p class=muted>选机组 → 测点 → 分析类,运行后结果显示在这里(图 / 表 / 文)。对照观察会组装成多面板视图,并同步到画布。</p></div></div>
  51. <script>JS
  52. const SENS=['主轴承前','主轴承后','行星级','中间轴','高速轴转子侧','高速轴发电机侧','发电机DE','发电机NDE'];
  53. 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,'];
  54. 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)}
  55. const sen=document.getElementById('sen');SENS.forEach(s=>{const o=document.createElement('option');o.value=s;o.text=s;sen.add(o)});
  56. 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)};
  57. 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>'}
  58. function num(n,v){return '<div class=muted>'+n+'</div><input id=o_'+n+' type=text value="'+v+'" style="width:100%">'}
  59. function txt(n,v){return num(n,v)}
  60. const cat=document.getElementById('cat');cat.onchange=()=>{document.getElementById('opts').innerHTML=OPT[cat.value]()};cat.onchange();
  61. 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)}
  62. 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)});
  63. 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)});
  64. function vals(id){return Array.from(document.getElementById(id).selectedOptions).map(o=>o.value)}
  65. let impTimer=null;
  66. 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');
  67. if(p&&!w){const g=(p.split('/').pop().match(/\\d+/g)||[]).filter(x=>x.length<4);
  68. 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;}
  69. else{alert('请填窗名 (如 w0818) — 文件名里认不出日期');return}}
  70. if(!confirm('确认启动?\\n路径: '+(p||'(不导入, 在现有窗上重跑)')+'\\n窗名: '+(w||'—')+'\\n约 10-30 分钟后台任务, 会重写全部模型产物。')){return}
  71. st.textContent='已发送, 启动中…';
  72. const r=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:'analyze',args:{input:p,window:w,confirm:true}})}).then(r=>r.json());
  73. st.textContent=(r.text||'').slice(0,90);
  74. if(impTimer)clearInterval(impTimer);
  75. impTimer=setInterval(async()=>{const j=await fetch('/api/run',{method:'POST',body:JSON.stringify({plugin:'job_status',args:{}})}).then(r=>r.json());
  76. st.textContent=(j.text||'').slice(0,130);if((j.text||'').includes('完成')||(j.text||'').includes('ok:')){clearInterval(impTimer)}},8000);}
  77. 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=[];
  78. 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}})}))}
  79. 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}})})))}
  80. if(panels.length>12){alert('面板 '+panels.length+' 块,超过 12,请减少选择');return}
  81. document.getElementById('cst').textContent='组装 '+panels.length+' 块…';const title=(mode=='overlay'?'叠加对照 ':'对照 ')+T.join('/')+' · '+S.join('/')+' · '+Mq.join('/');
  82. 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)}
  83. 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>':'')}
  84. 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)}
  85. 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()});
  86. 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)}
  87. 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)}
  88. </script>""".replace('JS\n', JS_SHOW + '\n')
  89. CHAT_BODY = """<div class="card"><textarea id=q placeholder="例:23号主轴承前端现在什么状态?对比 23 和 16 的主轴承前 Peak,再看 23 的包络谱,提出假设并说明怎么证伪"></textarea>
  90. <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>
  91. <div id=hist></div>
  92. <script>JS
  93. fetch('/api/models').then(r=>r.json()).then(j=>{document.getElementById('model').textContent='模型:'+(j.picked||'无(规则调度回退)')+(j.models.length?' · 可用:'+j.models.join(', '):'')});
  94. 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');
  95. 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)}
  96. async function ask(){const q=document.getElementById('q').value;if(!q)return;const st=document.getElementById('st');
  97. const think=document.getElementById('think').checked;
  98. 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}
  99. st.textContent='连接…';
  100. const d=document.createElement('div');d.className='card';d.innerHTML='<div class=q>'+esc(q)+'</div><div class=small id=live>启动…</div>';
  101. const hist=document.getElementById('hist')||document.body;hist.prepend(d);const live=d.querySelector('#live');live.removeAttribute('id');
  102. const es=new EventSource('/api/agent_stream?q='+encodeURIComponent(q));const t0=Date.now();let n=0;
  103. es.addEventListener('start',()=>{live.textContent='⏱ 推理开始';st.textContent=''});
  104. 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>'});
  105. es.addEventListener('final',e=>{const r=JSON.parse(e.data);const sec=((Date.now()-t0)/1000).toFixed(1);
  106. let views=(r.views||[]).map(viewHtml).join('');
  107. 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>';
  108. setTimeout(()=>{try{es.close()}catch(_){}} ,150000)});
  109. es.addEventListener('review',e=>{es.close();const v=JSON.parse(e.data);const chips=[];
  110. if((v.machine_flags||[]).length)chips.push('机器闸: '+v.machine_flags.join(';'));
  111. (v.issues||[]).forEach(i=>chips.push((i.rule||'')+' '+(i.problem||'')));
  112. const div=document.createElement('div');div.className='small';
  113. 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>':'');
  114. const old=d.querySelector('div.small:last-child');if(old&&old.textContent.startsWith('审核中'))old.remove();
  115. d.appendChild(div)});
  116. es.onerror=()=>{es.close();if(!d.querySelector('pre'))live.innerHTML+='<br>流中断,请重试或用单轮调度'};}
  117. 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)}
  118. document.getElementById('q').addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter')ask()});
  119. </script>""".replace('JS\n', JS_SHOW + '\n')
  120. CANVAS_TAIL = """<div class="card" style="margin-top:14px"><h3>画布 <span class="muted">模型调 render 组装的面板(每 5 s 刷新,最近 20 个)</span></h3>
  121. <div id=views></div></div>
  122. <script>
  123. 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>';}
  124. loadViews();setInterval(loadViews,5000);
  125. </script>"""
  126. CANVAS_BODY = """<p class="muted">模型驱动的视图:本服务的交互页或 dsh 里调用 <code>mcp__windcms__render</code> 组装的面板都会出现在这里(每 5 s 刷新,最近 20 个)。</p><div id=views></div>
  127. <script>JS
  128. 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>'}
  129. load();setInterval(load,5000);
  130. </script>""".replace('JS\n', JS_SHOW + '\n')
  131. # ── 统一评审皮肤的主题守卫 (2026-09-06) ────────────────────────────────────────
  132. # GUANLAN_REVIEW_CSS 是一整块浅色 !important 覆盖, 直接注入会把 ?theme=deep 压成浅色。
  133. # 出门前把每条选择器收窄到 :root:not([data-theme="deep"]) / html:not(...) 之下 → deep 时整块不生效。
  134. # 守卫用 :where() 包 → 特异度贡献为 0, 浅色下每条规则的层叠权重与加守卫前逐条相同 (只做减法不改排序)
  135. _NOT_DEEP = ':where(:not([data-theme="deep"]))' # 贴在 :root / html 上
  136. _UNDER_NOT_DEEP = ':where(html:not([data-theme="deep"])) ' # 作后代前缀
  137. def _guard_sel(sel):
  138. """逐条给选择器加"非 deep"守卫: :root/html 开头就地合并, 其余作后代。"""
  139. outs, cur, depth = [], [], 0
  140. for ch in sel + ',':
  141. if ch == '(':
  142. depth += 1
  143. elif ch == ')':
  144. depth -= 1
  145. if ch == ',' and depth == 0:
  146. one = ''.join(cur).strip()
  147. cur = []
  148. if not one:
  149. continue
  150. if one.startswith(':root'):
  151. outs.append(':root' + _NOT_DEEP + one[5:])
  152. elif one == 'html' or (one.startswith('html') and one[4] in ' .[:>#+~'):
  153. outs.append('html' + _NOT_DEEP + one[4:])
  154. else:
  155. outs.append(_UNDER_NOT_DEEP + one)
  156. else:
  157. cur.append(ch)
  158. return ','.join(outs)
  159. def _scope_css(css):
  160. """整块 CSS 加主题守卫: 只改选择器不动声明; @media 等条件组规则递归; 注释丢弃。"""
  161. res, buf, i, n = [], [], 0, len(css)
  162. while i < n:
  163. if css.startswith('/*', i):
  164. j = css.find('*/', i + 2)
  165. i = n if j < 0 else j + 2
  166. continue
  167. if css[i] == '{':
  168. sel = ''.join(buf).strip()
  169. buf = []
  170. depth, j = 1, i + 1
  171. while j < n and depth:
  172. if css[j] == '{':
  173. depth += 1
  174. elif css[j] == '}':
  175. depth -= 1
  176. j += 1
  177. inner = css[i + 1:j - 1]
  178. if sel.startswith('@'):
  179. at = sel.split()[0].split('(')[0].lower()
  180. grp = at in ('@media', '@supports', '@layer', '@container')
  181. res.append(sel + '{' + (_scope_css(inner) if grp else inner) + '}')
  182. elif sel:
  183. res.append(_guard_sel(sel) + '{' + inner + '}')
  184. i = j
  185. continue
  186. buf.append(css[i])
  187. i += 1
  188. return ''.join(res)
  189. # ── deep 下的动作条 / 锚点反馈 (2026-09-06) ──────────────────────────────────
  190. # .action-path 与 #tid:target 的描边原本只写在 GUANLAN_REVIEW_CSS 里, 而那整块已按主题守卫收窄到
  191. # :not([data-theme="deep"]) → deep 下这两处连同触控 44px 一起消失 (点了没反馈)。
  192. # 修法是给 deep 补一份同语义的 token 版, 而不是把浅色块整体放开 (放开 = deep 又被压成浅色)。
  193. _DEEP_CHROME_CSS = (
  194. 'html[data-theme="deep"] .action-path{display:flex;flex-wrap:wrap;gap:10px;margin:16px 0;padding:10px 14px;'
  195. 'background:var(--panel,#101A2B);border:1px solid var(--line,#1F2E44);border-radius:9px}'
  196. 'html[data-theme="deep"] .action-path a{color:var(--accent-2,#3FC1D3);padding:7px 12px;'
  197. 'border:1px solid var(--line,#1F2E44);border-radius:6px;font-size:13px;text-decoration:none}'
  198. 'html[data-theme="deep"] .action-path a:last-child{background:#16233A}'
  199. 'html[data-theme="deep"] .action-path a:hover{background:#1B2C46;border-color:var(--accent,#1F8FA0)}'
  200. 'html[data-theme="deep"] .action-path a:focus-visible{outline:3px solid var(--accent-2,#3FC1D3);outline-offset:2px}'
  201. 'html[data-theme="deep"] #tid:target,html[data-theme="deep"] #tid:focus-visible'
  202. '{outline:3px solid #3FC1D3;outline-offset:3px;scroll-margin-top:35px}'
  203. '@media(max-width:760px){html[data-theme="deep"] .action-path a'
  204. '{min-height:44px;display:inline-flex;align-items:center;justify-content:center}}')
  205. def _theme_of(path):
  206. """当前主题 = URL 显式 ?theme= (仅 light/deep 认), 无参默认 light。服务端与 ui.THEME_BOOT 同一口径。"""
  207. from urllib.parse import urlparse, parse_qs
  208. v = (parse_qs(urlparse(path).query).get('theme') or [''])[0]
  209. return v if v in ('light', 'deep') else 'light'
  210. _REVIEW_CSS = {}
  211. def review_css_scoped():
  212. if 'v' not in _REVIEW_CSS:
  213. from src.windscada.design import GUANLAN_REVIEW_CSS
  214. _REVIEW_CSS['v'] = ('/* guanlan-unified-review-v1 · scoped: 不作用于 data-theme="deep" */'
  215. + _scope_css(GUANLAN_REVIEW_CSS)
  216. + '/* deep chrome: 动作条/锚点描边补回 */' + _DEEP_CHROME_CSS)
  217. return _REVIEW_CSS['v']
  218. def _err_page(path: str, title: str, detail: str) -> str:
  219. """兜底页 (不依赖 ui.shell, 免得兜底本身再抛异常): 一句人话 + 回门户/控制台的入口。
  220. 2026-09-17 加: 之前产物缺失会让请求**连接被掐断**(页面白屏/网关报"模块未启动"),
  221. 现在无论缺产物还是服务端异常, 用户都能看到发生了什么、该去哪儿。
  222. """
  223. import html as _h
  224. return ('<!doctype html><meta charset="utf-8"><title>' + _h.escape(title) + '</title>'
  225. '<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;color:#183548;background:#f2f6f8;margin:0;'
  226. 'display:flex;align-items:center;justify-content:center;min-height:100vh}'
  227. '.b{background:#fff;border:1px solid #d4e3e7;border-radius:10px;padding:22px 26px;max-width:760px;'
  228. 'box-shadow:0 6px 20px rgba(14,74,87,.08)}h1{font-size:18px;margin:0 0 10px}'
  229. 'code{background:#eef4f6;border-radius:4px;padding:1px 5px}.s{color:#667f8e;font-size:13px}'
  230. 'a{color:#087f89}</style>'
  231. f'<div class="b"><h1>{_h.escape(title)}</h1><p class="s">请求路径 <code>{_h.escape(path)}</code>'
  232. + (f' · {_h.escape(detail)}' if detail else '') + '</p>'
  233. '<p>这一页的数来自 <code>outputs/&lt;场站&gt;/</code> 下的产物。请先放数据再重算:</p>'
  234. '<p><code>scripts/place_raw_data.py --src &lt;现场包目录&gt;</code> → '
  235. '<code>scripts/rebuild_all.py</code> (或运维控制台点「执行重算」)</p>'
  236. '<p><a href="http://127.0.0.1:28084/#cms" target="_top">← 返回观澜门户</a> · '
  237. '<a href="http://127.0.0.1:28084/ops" target="_top">运维控制台</a> · '
  238. '<a href="http://127.0.0.1:28084/healthz" target="_top">全部模块状态</a></p></div>')
  239. def run(cfg, port=8030, model=None):
  240. out = Path(cfg['out'])
  241. c = orchestrator.ctx(cfg)
  242. # 画布并入交互页下半 (2026-08-24 用户: "画布和交互有关系, 移到交互的下半部分"); /canvas.html 保留为独立大屏页
  243. pages = {'/workbench.html': ('工作台', WB_BODY, '万物可插件 · 即用即抛'),
  244. '/chat.html': ('交互', CHAT_BODY, '推理无界 · 结论受闸')} # 画布取消 (2026-08-24 用户): 视图内嵌在回答卡与工作台
  245. class H(BaseHTTPRequestHandler):
  246. def _send(self, body, ctype='text/html; charset=utf-8', code=200):
  247. if 'text/html' in ctype:
  248. text = body.decode('utf-8') if isinstance(body, bytes) else body
  249. if self.path.split('?')[0] in ('/report', '/report.html'):
  250. text = text.replace('<body', '<body data-ui-page="report"', 1)
  251. if '<nav class="action-path"' not in text:
  252. # 主题跟随当前页 (原来硬编码 ?theme=light → 从 deep 点这两个链接会被打回浅色)
  253. _q = '?theme=' + _theme_of(self.path)
  254. actions = ('<nav class="action-path" aria-label="页面操作">'
  255. '<a href="http://127.0.0.1:28084/#cms" target="_top">← 返回观澜门户</a>'
  256. f'<a href="/{_q}">CMS 总览</a>'
  257. f'<a href="/workbench.html{_q}#tid">重新选择机组 / 测点</a></nav>')
  258. if self.path.split('?')[0] == '/workbench.html':
  259. actions = actions.replace(f'/workbench.html{_q}#tid', '#tid')
  260. text = text.replace('</nav></div>', '</nav></div>' + actions, 1)
  261. if '</head>' in text:
  262. body = text.replace('</head>', '<style id="guanlan-unified-review-v1">' + review_css_scoped() + '</style></head>', 1)
  263. b = body if isinstance(body, bytes) else body.encode('utf-8')
  264. self.send_response(code)
  265. if 'text/html' in ctype:
  266. self.send_header('Cache-Control', 'no-store') # 旧页面缓存过慢默认值 (think 勾选/步数12), 禁缓存
  267. self.send_header('Content-Type', ctype)
  268. self.send_header('Content-Length', str(len(b)))
  269. self.end_headers()
  270. self.wfile.write(b)
  271. def do_GET(self):
  272. # 路由按去掉 query 的路径匹配: 带上 ?theme=deep 后原来的全等比较会漏路由 → 报告页 404 (2026-09-06)
  273. if self.path.split('?')[0] in ('/report.html', '/report'):
  274. import glob as _g, os as _os
  275. cands = sorted(_g.glob(str(out / '报告_*.md')), key=_os.path.getmtime)
  276. mdp = cands[-1] if cands else (out / 'report.md')
  277. md = Path(mdp).read_text(encoding='utf-8') if Path(mdp).exists() else '# 报告未生成\n先在终端跑 windcms report'
  278. name = Path(mdp).name
  279. docx = sorted(_g.glob(str(out / '报告_*.docx')), key=_os.path.getmtime)
  280. 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>'
  281. html_md = ui.md2html(md)
  282. # 报警台"图 WTGxx (见逐台页…)"段落 → 内嵌趋势/包络谱面板 (2026-08-24 用户: 图没上来)
  283. import re as _re
  284. from src.windcms import report as _rp
  285. def _inline(m):
  286. _tid = m.group(1)
  287. try:
  288. panels = _rp.key_panels_html(cfg, c, _tid)
  289. except Exception as _e:
  290. print(f'[warn] 内嵌图失败 {_tid}: {_e}', flush=True); panels = ''
  291. return panels + f'<p class="small"><a href="/turbines/{_tid}.html">→ {_tid} 逐台页 (全部测点趋势/谱/证据)</a></p>'
  292. body = dl + '<div class="card report">' + _re.sub(r'<p>图[\s\u3000]+(WTG\d+)[^<]*见逐台页[^<]*</p>', _inline, html_md) + '</div>'
  293. return self._send(ui.shell('评估报告', body, farm_name=cfg['name'], machine=cfg['machine'], active='/report.html', subtitle='标准模版报告 · 结构化层生成 · ' + (__import__('src.windcms.data', fromlist=['x']).span_text(cfg) or '')))
  294. if self.path.startswith('/files/'):
  295. from urllib.parse import unquote
  296. p = out / unquote(self.path[7:])
  297. if p.exists() and p.parent == out:
  298. ct = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' if p.suffix == '.docx' else 'text/markdown; charset=utf-8'
  299. return self._send(p.read_bytes(), ct)
  300. return self._send('not found', code=404)
  301. if self.path.startswith('/api/agent_stream'):
  302. from urllib.parse import urlparse, parse_qs
  303. import time as _t
  304. qs = parse_qs(urlparse(self.path).query)
  305. question = (qs.get('q') or [''])[0]
  306. print(f'[req] /api/agent_stream q={question[:40]}', flush=True)
  307. self.send_response(200)
  308. self.send_header('Content-Type', 'text/event-stream; charset=utf-8')
  309. self.send_header('Cache-Control', 'no-store')
  310. self.end_headers()
  311. def emit(ev, obj):
  312. self.wfile.write(f'event: {ev}\ndata: {json.dumps(obj, ensure_ascii=False, default=str)}\n\n'.encode('utf-8'))
  313. self.wfile.flush()
  314. _t0 = _t.time()
  315. try:
  316. emit('start', {'q': question})
  317. # ★服务端硬预算: 忽略旧页面的 think/大步数 (体感与楔死的老根源); 需要思考链走 POST /api/agent 显式传
  318. r = agent.run(cfg, question, model=llm.pick(fast=True) or model, max_steps=5, max_calls=6, think=False,
  319. on_step=lambda st: emit('step', st))
  320. emit('final', r)
  321. # DeepSeek 审千问 (2026-08-24 用户令): 机器引文闸+r1质疑清单, 异步追加; 失败不影响答案
  322. try:
  323. from src.windcms import cross_review as _cr # P3 已迁算法模块,这里走兼容壳
  324. _facts = json.dumps(r.get('steps', []), ensure_ascii=False, default=str)[:6000]
  325. rv = _cr.review_answer(question, r.get('answer', ''), _facts)
  326. emit('review', rv)
  327. print(f"[review] {rv.get('model')} {rv.get('seconds')}s → {rv.get('verdict')}", flush=True)
  328. except Exception as _e:
  329. emit('review', {'verdict': f'审核不可用: {_e}', 'issues': [], 'machine_flags': []})
  330. print(f'[done] /api/agent_stream {_t.time()-_t0:.1f}s', flush=True)
  331. except (BrokenPipeError, ConnectionResetError):
  332. print(f'[gone] /api/agent_stream {_t.time()-_t0:.1f}s 客户端断开', flush=True)
  333. except Exception:
  334. import traceback as _tb
  335. print('[err] /api/agent_stream\n' + _tb.format_exc()[-400:], flush=True)
  336. return
  337. return self._do_GET_pages_or_gap()
  338. def _do_GET_pages_or_gap(self):
  339. """页面路由的兜底 (2026-09-17 用户令"重算后登录报模块不可用"后的加固)。
  340. 现场症状: 执行「清除产物」后点门户「登录」→ `/detail/`·`/cms/` 打不开(连接被直接掐断),
  341. 日志里是 `FileNotFoundError: outputs/rudong/windcms/index.html` —— 因为 CMS 的页面本身
  342. 是**产物**, 产物没了就直接抛异常, 连一句人话都没有。
  343. 现在改成: 缺产物 → 返回**结构化"无产物"页**(HTTP 200, 与 workbench 侧 err=no_products 同口径);
  344. 其它异常 → 500 页 + 日志, 不再让连接凭空断掉。
  345. """
  346. try:
  347. if not (out / 'index.html').is_file():
  348. return self._send_no_products()
  349. return self._do_GET_pages()
  350. except FileNotFoundError as e:
  351. print(f'[gap] {self.path} 缺产物: {e}', flush=True)
  352. return self._send_no_products(str(e))
  353. except Exception:
  354. import traceback as _tb
  355. print('[err] GET ' + self.path + '\n' + _tb.format_exc()[-600:], flush=True)
  356. return self._send(_err_page(self.path, '500 服务端异常', '见 logs/cms.log 的 [err] 段'),
  357. 'text/html; charset=utf-8', code=500)
  358. def _send_no_products(self, detail: str = ''):
  359. """无产物页: 说清"哪件产物缺了、怎么补", **并说明重算是否正在跑**, 给回门户/重算入口。
  360. ★2026-09-19 用户报"重算后页面没有输出"之后补的那一句: 实况是「清除产物 → 执行重算」要跑很久
  361. (本次 ④b 振动侧摄入处理 150 GB CMS 导出), 期间页面只能显示无产物; 但原话只说"要么还没放数据,
  362. 要么刚清过产物" ⇒ 同一现象被读成"系统坏了"。现在读 `run/ops_job.json` 如实写明在跑与已跑多久。
  363. """
  364. try:
  365. from src import opsjob as _oj
  366. _run_note = _oj.running_text()
  367. except Exception:
  368. _run_note = ''
  369. body = (
  370. '<div class="card"><h3>无产物: 这份 CMS 页面还没有生成</h3>'
  371. f'<p class="small">找不到 <code>{ui.esc(str(out / "index.html"))}</code>'
  372. + (f' —— {ui.esc(detail)}' if detail else '') + '</p>'
  373. + (f'<p style="background:#FFF7E6;border:1px solid #F0D8A8;border-radius:6px;padding:8px 10px">'
  374. f'{ui.esc(_run_note)}</p>' if _run_note else '')
  375. + '<p>页面的数来自 <code>outputs/&lt;场站&gt;/windcms/</code> 下的产物, 产物由 <code>data/raw</code> '
  376. '里的原始件重算出来。当前这台机器上<b>要么还没放数据, 要么刚执行过「清除产物」</b>。</p>'
  377. '<p>下一步:</p>'
  378. '<div class="kv"><span><b>① 放数据</b><code>scripts/place_raw_data.py --src &lt;现场包目录&gt; --scope vib</code></span>'
  379. '<span><b>② 重算</b><code>scripts/rebuild_all.py</code> 或在运维控制台点「执行重算」</span>'
  380. '<span><b>③ 核验</b><code>scripts/raw_data_check.py --deep</code> 与 <code>/healthz</code></span></div>'
  381. '<p class="small">"包内没有生成端"的那批件,运行期<b>不由交付包补齐</b>(用户令 2026-09-17): '
  382. '放原始件后重算, 或由研发补生成端(逐族可逆性见 docs/系统设计说明.md §13.6)。</p>'
  383. '<p><a href="http://127.0.0.1:28084/#cms" target="_top">← 返回观澜门户</a> · '
  384. '<a href="http://127.0.0.1:28084/ops" target="_top">运维控制台 / 执行重算</a></p></div>')
  385. try:
  386. return self._send(ui.shell('无产物', body, farm_name=cfg['name'], machine=cfg['machine'],
  387. active='/', subtitle='CMS 振动诊断 · 产物未生成'), 'text/html; charset=utf-8')
  388. except Exception:
  389. return self._send(_err_page(self.path, '无产物', '产物未生成 (outputs/<场站>/windcms/)'),
  390. 'text/html; charset=utf-8')
  391. def _do_GET_pages(self):
  392. p = self.path.split('?')[0]
  393. if p in ('/', '/index.html'):
  394. # 2026-09-05 深海钢蓝主题 (纯增): 固定页是静态产物, 出门时注入 token 覆盖块 + ?theme=deep/localStorage 切换脚本; 默认外观不变
  395. _h = (out / 'index.html').read_text(encoding='utf-8', errors='ignore')
  396. _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>'
  397. + ui.THEME_BOOT)
  398. _h = _h.replace('</head>', _inj + '</head>', 1) if '</head>' in _h else _inj + _h
  399. return self._send(_h)
  400. if p in pages:
  401. t, body, sub = pages[p]
  402. return self._send(ui.shell(t, body, farm_name=cfg['name'], machine=cfg['machine'], active=p, subtitle=sub))
  403. if p == '/api/plugins':
  404. return self._send(json.dumps(plugins.manifest(), ensure_ascii=False), 'application/json')
  405. if p == '/api/models':
  406. return self._send(json.dumps(dict(models=llm.models(), picked=llm.pick(model)), ensure_ascii=False), 'application/json')
  407. if p == '/api/views':
  408. vp = out / 'views.json'
  409. return self._send(json.dumps(json.load(open(vp, encoding='utf-8')) if vp.exists() else [], ensure_ascii=False, default=str), 'application/json')
  410. f = out / p.lstrip('/')
  411. if f.exists() and f.is_file():
  412. ct = 'text/html; charset=utf-8' if f.suffix == '.html' else 'text/plain; charset=utf-8'
  413. if f.suffix == '.html':
  414. raw = f.read_text(encoding='utf-8', errors='ignore')
  415. 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)}'
  416. ':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>'
  417. + ui.THEME_BOOT)
  418. raw = raw.replace('</head>', light + '</head>', 1) if '</head>' in raw else light + raw
  419. return self._send(raw, ct)
  420. return self._send(f.read_bytes(), ct)
  421. self._send('not found', code=404)
  422. def do_POST(self):
  423. import time as _t, traceback as _tb
  424. _t0 = _t.time()
  425. n = int(self.headers.get('Content-Length', 0))
  426. body = json.loads(self.rfile.read(n) or b'{}')
  427. print(f'[req] {self.path} q={str(body.get("q") or body.get("plugin") or "")[:40]}', flush=True)
  428. try:
  429. return self._post(body, _t0)
  430. except Exception:
  431. print(f'[err] {self.path} {_t.time()-_t0:.1f}s\n' + _tb.format_exc()[-600:], flush=True)
  432. return self._send(json.dumps({'error': 'server exception, 见 serve.log'}), 'application/json', code=500)
  433. def _post(self, body, _t0):
  434. import time as _t
  435. if self.path == '/api/run':
  436. r = plugins.run(c, body.get('plugin', ''), **(body.get('args') or {}))
  437. return self._send(json.dumps(r, ensure_ascii=False, default=str), 'application/json')
  438. if self.path == '/api/ask':
  439. r = orchestrator.ask(cfg, body.get('q', ''), model=body.get('model') or llm.pick(fast=True) or model) # 单轮调度也走快模型
  440. return self._send(json.dumps(r, ensure_ascii=False, default=str), 'application/json')
  441. if self.path == '/api/agent':
  442. # 2026-08-24 提速三根因之三: web 路径旧默认 think=True/步数 12, 把 agent 层的新默认全盖掉了 (用户实测 120-200s 的真凶)
  443. 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)))
  444. print(f'[done] /api/agent {_t.time()-_t0:.1f}s', flush=True)
  445. return self._send(json.dumps(r, ensure_ascii=False, default=str), 'application/json')
  446. self._send('bad', code=400)
  447. def log_message(self, *a):
  448. pass
  449. from src import logfile as _lf; _lf.prefix_stdout('cms') # 统一日志格式 (用户令 2)
  450. print(f'windcms serve → http://127.0.0.1:{port} (模型: {llm.pick(model) or "无, 规则调度回退"})', flush=True)
  451. ThreadingHTTPServer(('127.0.0.1', port), H).serve_forever()