# -*- 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=>'').join('')+''+d.map(x=>''+ks.map(k=>'').join('')+'').join('')+'
'+k+'
'+(x[k]==null?'':String(x[k]).slice(0,160))+'
'} function esc(s){return String(s||'').replace(/
'+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'
    本页{ui.esc(name)}' + (f'下载 Word 版' if docx else '') + f'下载 Markdown 原文
    ' 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()