# -*- 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 orchestrator, llm, agent # 随迁件(同一子包)
from src.windcms import plugins, ui # 仍留 src.windcms
JS_SHOW = """function tbl(d){if(!(d&&Array.isArray(d)&&d.length&&typeof d[0]=='object'))return '';const ks=Object.keys(d[0]);return '
'+ks.map(k=>'| '+k+' | ').join('')+'
'+d.map(x=>''+ks.map(k=>'| '+(x[k]==null?'':String(x[k]).slice(0,160))+' | ').join('')+'
').join('')+'
'}
function esc(s){return String(s||'').replace(/'+esc(p.plugin)+' 详情'+esc(JSON.stringify(p.args||{}))+'
'+tbl(p.data)+(p.text?''+esc(p.text)+'
':'')+'';return ''+(p.svg? p.svg+meta : ((p.text?'
'+esc(p.text)+'
':'')+tbl(p.data)))+'
'}
function mdlite(s){s=esc(s);s=s.replace(/\\*\\*([^*]+)\\*\\*/g,"$1");s=s.replace(/^### (.*)$/gm,"$1");s=s.replace(/^[-•] (.*)$/gm,"$1");return ""+s.replace(/\\n\\n+/g,"
").replace(/\\n/g,"
")+"
"}
function ansHtml(a){return ""+mdlite(a)+"
"}
function viewHtml(v){return ''+esc(v.title)+'
'+(v.panels||[]).map(panelHtml).join('')+'
'}"""
WB_BODY = """
分析
机组
测点
分析类
高级:插件 + JSON 参数 / 即用即抛
即用即抛(pandas · scalars/status/l6/fusion/np/pd/HIGH_BIN/sensor('主轴承后'))
数据导入
数据路径(导出包 .rar/.7z/.zip、解码 JSON 目录或波形目录的本机绝对路径)
窗名(如 w0818;留空 = 不导新数据只重算)
实测:5.3 GB 导出包约 10 分钟;全链(导入→扫谱→六层→融合→报告)约 11 分钟。后台运行,页面可继续用;带锁防重复启动。
对照观察
机组(多选,⌘/Ctrl 点选)
测点(多选)
量(多选)
排布
功率档
选机组 → 测点 → 分析类,运行后结果显示在这里(图 / 表 / 文)。对照观察会组装成多面板视图,并同步到画布。
""".replace('JS\n', JS_SHOW + '\n')
CHAT_BODY = """
""".replace('JS\n', JS_SHOW + '\n')
CANVAS_TAIL = """画布 模型调 render 组装的面板(每 5 s 刷新,最近 20 个)
"""
CANVAS_BODY = """模型驱动的视图:本服务的交互页或 dsh 里调用 mcp__windcms__render 组装的面板都会出现在这里(每 5 s 刷新,最近 20 个)。
""".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 _err_page(path: str, title: str, detail: str) -> str:
"""兜底页 (不依赖 ui.shell, 免得兜底本身再抛异常): 一句人话 + 回门户/控制台的入口。
2026-09-17 加: 之前产物缺失会让请求**连接被掐断**(页面白屏/网关报"模块未启动"),
现在无论缺产物还是服务端异常, 用户都能看到发生了什么、该去哪儿。
"""
import html as _h
return ('' + _h.escape(title) + ''
''
f'{_h.escape(title)}
请求路径 {_h.escape(path)}'
+ (f' · {_h.escape(detail)}' if detail else '') + '
'
'
这一页的数来自 outputs/<场站>/ 下的产物。请先放数据再重算:
'
'
scripts/place_raw_data.py --src <现场包目录> → '
'scripts/rebuild_all.py (或运维控制台点「执行重算」)
'
'
← 返回观澜门户 · '
'运维控制台 · '
'全部模块状态
')
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(''
'← 返回观澜门户'
f'CMS 总览'
f'重新选择机组 / 测点')
if self.path.split('?')[0] == '/workbench.html':
actions = actions.replace(f'/workbench.html{_q}#tid', '#tid')
text = text.replace('', '' + actions, 1)
if '' in text:
body = text.replace('', '', 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''
html_md = ui.md2html(md)
# 报警台"图 WTGxx (见逐台页…)"段落 → 内嵌趋势/包络谱面板 (2026-08-24 用户: 图没上来)
import re as _re
from src.windcms 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'→ {_tid} 逐台页 (全部测点趋势/谱/证据)
'
body = dl + '' + _re.sub(r'
图[\s\u3000]+(WTG\d+)[^<]*见逐台页[^<]*
', _inline, html_md) + '
'
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 '')))
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 src.windcms import cross_review as _cr # P3 已迁算法模块,这里走兼容壳
_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_or_gap()
def _do_GET_pages_or_gap(self):
"""页面路由的兜底 (2026-09-17 用户令"重算后登录报模块不可用"后的加固)。
现场症状: 执行「清除产物」后点门户「登录」→ `/detail/`·`/cms/` 打不开(连接被直接掐断),
日志里是 `FileNotFoundError: outputs/rudong/windcms/index.html` —— 因为 CMS 的页面本身
是**产物**, 产物没了就直接抛异常, 连一句人话都没有。
现在改成: 缺产物 → 返回**结构化"无产物"页**(HTTP 200, 与 workbench 侧 err=no_products 同口径);
其它异常 → 500 页 + 日志, 不再让连接凭空断掉。
"""
try:
if not (out / 'index.html').is_file():
return self._send_no_products()
return self._do_GET_pages()
except FileNotFoundError as e:
print(f'[gap] {self.path} 缺产物: {e}', flush=True)
return self._send_no_products(str(e))
except Exception:
import traceback as _tb
print('[err] GET ' + self.path + '\n' + _tb.format_exc()[-600:], flush=True)
return self._send(_err_page(self.path, '500 服务端异常', '见 logs/cms.log 的 [err] 段'),
'text/html; charset=utf-8', code=500)
def _send_no_products(self, detail: str = ''):
"""无产物页: 说清"哪件产物缺了、怎么补", **并说明重算是否正在跑**, 给回门户/重算入口。
★2026-09-19 用户报"重算后页面没有输出"之后补的那一句: 实况是「清除产物 → 执行重算」要跑很久
(本次 ④b 振动侧摄入处理 150 GB CMS 导出), 期间页面只能显示无产物; 但原话只说"要么还没放数据,
要么刚清过产物" ⇒ 同一现象被读成"系统坏了"。现在读 `run/ops_job.json` 如实写明在跑与已跑多久。
"""
try:
from src import opsjob as _oj
_run_note = _oj.running_text()
except Exception:
_run_note = ''
body = (
'无产物: 这份 CMS 页面还没有生成
'
f'
找不到 {ui.esc(str(out / "index.html"))}'
+ (f' —— {ui.esc(detail)}' if detail else '') + '
'
+ (f'
'
f'{ui.esc(_run_note)}
' if _run_note else '')
+ '
页面的数来自 outputs/<场站>/windcms/ 下的产物, 产物由 data/raw '
'里的原始件重算出来。当前这台机器上要么还没放数据, 要么刚执行过「清除产物」。
'
'
下一步:
'
'
① 放数据scripts/place_raw_data.py --src <现场包目录> --scope vib'
'② 重算scripts/rebuild_all.py 或在运维控制台点「执行重算」'
'③ 核验scripts/raw_data_check.py --deep 与 /healthz
'
'
"包内没有生成端"的那批件,运行期不由交付包补齐(用户令 2026-09-17): '
'放原始件后重算, 或由研发补生成端(逐族可逆性见 docs/系统设计说明.md §13.6)。
'
'
← 返回观澜门户 · '
'运维控制台 / 执行重算
')
try:
return self._send(ui.shell('无产物', body, farm_name=cfg['name'], machine=cfg['machine'],
active='/', subtitle='CMS 振动诊断 · 产物未生成'), 'text/html; charset=utf-8')
except Exception:
return self._send(_err_page(self.path, '无产物', '产物未生成 (outputs/<场站>/windcms/)'),
'text/html; charset=utf-8')
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 = (''
+ ui.THEME_BOOT)
_h = _h.replace('', _inj + '', 1) if '' 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 = (''
+ ui.THEME_BOOT)
raw = raw.replace('', light + '', 1) if '' 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()