# -*- 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 ''+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 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 . 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='标准模版报告 · 结构化层生成')) 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 = ('' + 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()