| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """观澜中文系统 · 本机离线版网关 (唯一用户入口 http://127.0.0.1:28084/; 部署包 CLAUDE_CODE_端口与交接重构任务.md §1, 用户令 2026-09-06 "从网关开始, 做离线版 18084").
- 只做四件事, 零依赖 (stdlib):
- 1. 统一路径 → 组件端口 (用户看不到 8033/8020/8791/8792/54292): / 门户 · /detail/ 工作台(8033 /v2) · /cms/ (8020) · /sim/ (8791) · /sim/sys/ (8792) · /viewer/ (54292) · /local-ai/ (11434)
- 2. 回包改写: 组件页里的根相对 URL (href/src/fetch('/api/…')) 与绝对 http://127.0.0.1:端口 → 统一前缀; 因此组件源码零改动, 8033 共享脏文件不碰.
- 3. 离线守卫 (绝不静默转云): /detail/api/ask_models 只留本机 Ollama 实有模型; /detail/api/ask|rpt_compose 带非本机模型 → 400 明确拒绝; /local-ai/ 上游拒连 → 503 "本机模型未启动".
- 4. /healthz (全部模块状态, 逐项探活) · /api/version (版本/构建/内容清单/模式).
- 规则表见 ROUTES; 改路由改表不改逻辑. 启动: scripts/guanlan_offline_up.sh (一条命令).
- """
- import argparse, datetime as dt, hashlib, http.client, json, os, pathlib, re, socket, subprocess, sys, time, urllib.parse
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
- ROOT = pathlib.Path(__file__).resolve().parents[1]
- PORTAL = pathlib.Path(os.environ.get("GUANLAN_PORTAL", str(ROOT / "release/portal.html"))) # v2: 门户 = 本仓 release/, 不再指桌面
- STATIC_DIR = pathlib.Path(os.environ.get("GUANLAN_STATIC", str(ROOT / "release"))) # 旧 18084 = Desktop 静态目录, 保留兜底 (如东治理清单等)
- MANIFEST = ROOT / "outputs/rudong/windscada/release-manifest.json"
- CONTRACT = ROOT / "outputs/rudong/guanlan/facts_contract_v0.json"
- OLLAMA = ("127.0.0.1", int(os.environ.get("GUANLAN_OLLAMA_PORT", "11434"))) # 验收用: 指向死端口即可模拟 "本机模型未启动" (不必真停 Ollama)
- # prefix → (upstream port, 上游路径映射: 前缀去掉后剩 "" 时用的默认路径, 探活路径, 名称)
- ROUTES = [
- ("/detail", 18033, "/v2", "/v2", "综合详细分析工作台"),
- ("/cms", 18020, "/?theme=light", "/", "CMS 振动诊断"),
- ("/sim/sys", 18792, "/0_四系统合页.html", "/0_四系统合页.html", "仿真·四系统合页"),
- ("/sim", 18791, "/sc1_sim_demo.html", "/sc1_sim_demo.html", "仿真与回放"),
- ("/viewer", 64292, "/unit-workbench.html", "/unit-workbench.html", "三维拆装工作台"),
- ("/local-ai", OLLAMA[1], "/api/tags", "/api/tags", "本机模型 Ollama"),
- ]
- PORT2PREFIX = {p: pre for pre, p, *_ in ROUTES}
- PORT2PREFIX[28084] = "" # v2 入口绝对链接 → 根
- for _old, _new in ((8033, 18033), (8020, 18020), (8792, 18792), (8791, 18791), (54292, 64292), (18084, 28084)): PORT2PREFIX[_old] = PORT2PREFIX.get(_new, "") # 旧端口绝对链接同样归到 v2 前缀
- HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "content-length", "host"}
- TEXT_RE = re.compile(r"text/html|javascript|text/css|application/json")
- MODEL_PARAM_PATHS = ("/api/ask", "/api/rpt_compose")
- def rewrite(body: str, prefix: str) -> str:
- """组件页根相对 URL → 统一前缀. 只改 href/src/action/content 属性、url()、fetch/open 调用; 不动 // 开头的协议相对与 data:。"""
- if not prefix:
- pass
- else:
- body = re.sub(r'((?:href|src|action|content)=["\'])/(?![/])', r"\1" + prefix + "/", body)
- body = re.sub(r"(url\(['\"]?)/(?![/])", r"\1" + prefix + "/", body)
- body = re.sub(r"((?:fetch|open|EventSource|WebSocket|assign|replace)\(\s*(?:['\"](?:GET|POST)['\"]\s*,\s*)?[`'\"])/(?![/])", r"\1" + prefix + "/", body)
- body = re.sub(r"((?<![\w.])location\s*=\s*[`'\"])/(?![/])", r"\1" + prefix + "/", body) # location.href= 已被 href= 规则覆盖, 此处只管裸 location=
- body = re.sub(r"([`'\"])/api/", r"\1" + prefix + "/api/", body) # 字符串常量拼 URL (如 '/api/ask_status' + qs) 不经 fetch( 直接出现 → 逮 22:21 ask_status 404
- for port, pre in PORT2PREFIX.items():
- body = re.sub(r"https?://(?:127\.0\.0\.1|localhost):%d(?=[/\"'` )<&?#]|$)" % port, pre or "", body)
- body = body.replace('href=""', 'href="/"') # 18084 根裸链
- return body
- def _q(path): # http.client 只吃 ASCII 请求行; 中文路径 (仿真合页) 必须百分号编码
- return urllib.parse.quote(path, safe="/%?=&:+@,;$!*'()~")
- _PORTAL_CACHE = {}
- def portal_bytes():
- """门户 20MB 单文件: 按 (mtime, size) 缓存改写结果, 文件一变即失效; 不缓存过期内容."""
- st = PORTAL.stat(); key = (st.st_mtime_ns, st.st_size)
- if _PORTAL_CACHE.get("key") != key:
- _PORTAL_CACHE.update(key=key, body=rewrite(PORTAL.read_text(encoding="utf-8"), "").encode("utf-8"))
- return _PORTAL_CACHE["body"]
- def _probe(host, port, path, timeout=3.0):
- t0 = time.time()
- try:
- c = http.client.HTTPConnection(host, port, timeout=timeout); c.request("GET", _q(path)); r = c.getresponse(); r.read(65536); c.close()
- return dict(ok=r.status < 400, http=r.status, ms=int((time.time() - t0) * 1000))
- except OSError as e:
- return dict(ok=False, http=None, ms=int((time.time() - t0) * 1000), err=str(e)[:80])
- def ollama_models(timeout=3.0):
- """返回 (running, [模型名]). 拒连 = 未启动, 不猜."""
- try:
- c = http.client.HTTPConnection(*OLLAMA, timeout=timeout); c.request("GET", "/api/tags"); r = c.getresponse(); d = json.loads(r.read()); c.close()
- return True, sorted(m["name"] for m in d.get("models", []))
- except (OSError, ValueError) as e:
- return False, []
- def healthz():
- mods = []
- for pre, port, _, hp, name in ROUTES:
- mods.append(dict(path=pre + "/", name=name, upstream_port=port, **_probe("127.0.0.1", port, hp)))
- try: # 门户文件读失败 (如 Finder 启动的 app 无外接盘权限 EPERM) 必须报 ok=False 带原因, 不能让 /healthz 整个炸成空回复 (2026-09-07 app 冷启动实测)
- mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=PORTAL.exists(), file_sha256=hashlib.sha256(PORTAL.read_bytes()).hexdigest() if PORTAL.exists() else None, portal=str(PORTAL)))
- except OSError as e:
- mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False, file_sha256=None, portal=str(PORTAL), error=f"{type(e).__name__}: {e}"))
- run, models = ollama_models()
- out = dict(status="ok" if all(m["ok"] for m in mods) else "degraded", mode="offline", checked=dt.datetime.now().isoformat(timespec="seconds"), modules=mods,
- local_ai=dict(running=run, models=models, note=None if run else "本机模型未启动 (Ollama %s:%d 拒连); 不会转云" % OLLAMA))
- return out
- def version():
- man = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else {}
- b = man.get("detail_style_baseline", {}); c = man.get("content_contract", {})
- try: head = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip()
- except Exception: head = None
- con = json.loads(CONTRACT.read_text(encoding="utf-8")) if CONTRACT.exists() else {}
- return dict(mode="offline", entry="http://127.0.0.1:28084/", repo_head=head, detail_style_baseline=dict(commit=b.get("git_commit"), built=b.get("built"), fingerprint=b.get("fingerprint"), artifact_sha256=b.get("artifact_sha256")),
- content_contract=dict(version=con.get("contract_version"), sha256=con.get("contract_sha256"), n_claims=con.get("n_claims"), consumers=c.get("consumers_wired")),
- portal_sha256=hashlib.sha256(PORTAL.read_bytes()).hexdigest() if PORTAL.exists() else None, routes=[dict(path=p + "/", name=n) for p, _, _, _, n in ROUTES], gateway=pathlib.Path(__file__).name)
- def down_page(name, prefix, info):
- import html as _h
- return ('<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>模块不可用 · 观澜</title>'
- '<style>body{margin:0;background:#F5F7F8;color:#1B2430;font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.box{max-width:640px;margin:12vh auto;padding:28px 32px;background:#fff;border:1px solid #DDE3E8;border-radius:12px}'
- 'h1{font-size:20px;margin:0 0 8px;color:#1F6F8B}code{background:#F5F7F8;padding:1px 6px;border-radius:4px}a{color:#1F6F8B}</style></head><body><div class="box">'
- f'<h1>模块不可用: {_h.escape(name)}</h1><p>路径 <code>{_h.escape(prefix)}/</code> 对应的内部组件未启动 (<code>{_h.escape(info["upstream"])}</code>), 网关未转向任何云端服务, 也不会伪造内容。</p>'
- f'<p>处理: {_h.escape(info["hint"])}; 全部模块状态见 <a href="/healthz">/healthz</a>。</p><p><a href="/">← 返回门户</a></p><p style="color:#6B7680;font-size:13px">{_h.escape(info["detail"])}</p></div></body></html>')
- class H(BaseHTTPRequestHandler):
- server_version = "guanlan-gateway/0.1"
- protocol_version = "HTTP/1.1"
- def log_message(self, fmt, *a):
- sys.stderr.write("%s %s\n" % (dt.datetime.now().strftime("%H:%M:%S"), fmt % a))
- def _json(self, obj, code=200):
- b = json.dumps(obj, ensure_ascii=False, indent=1).encode("utf-8"); self.send_response(code); self.send_header("Content-Type", "application/json; charset=utf-8"); self.send_header("Content-Length", str(len(b))); self.send_header("Cache-Control", "no-store"); self.end_headers()
- if self.command != "HEAD": self.wfile.write(b)
- def _bytes(self, b, ctype, code=200):
- self.send_response(code); self.send_header("Content-Type", ctype); self.send_header("Content-Length", str(len(b))); self.end_headers()
- if self.command != "HEAD": self.wfile.write(b)
- def _route(self, path):
- for pre, port, default, _, _ in ROUTES:
- if path == pre or path.startswith(pre + "/"):
- rest = path[len(pre):]
- return pre, port, (default if rest in ("", "/") else rest)
- return None
- def _read_body(self):
- n = int(self.headers.get("Content-Length") or 0); return self.rfile.read(n) if n else b""
- def do_GET(self): self._handle()
- def do_POST(self): self._handle()
- def do_HEAD(self): self._handle()
- def _handle(self):
- u = urllib.parse.urlsplit(self.path); path = urllib.parse.unquote(u.path)
- if path == "/healthz": return self._json(healthz())
- if path == "/api/version": return self._json(version())
- if path == "/local-ai/status":
- run, models = ollama_models(); return self._json(dict(running=run, models=models, endpoint="/local-ai/", note=None if run else "本机模型未启动"), 200 if run else 503)
- if path.startswith("/release/"): # E9/E10 裁定: Release 层不合并主库, 由网关只读暴露: /release/ 列表; /release/r1/… (E9 契约层) /release/r2/… (E10 分层覆盖)
- parts = path[len("/release/"):].split("/", 1); name = parts[0]; rest = parts[1] if len(parts) > 1 else ""
- dirs = {"r1": ROOT / "outputs/rudong/ontology/release_r1", "r2": ROOT / "outputs/rudong/ontology/release_r2"}
- if name not in dirs:
- out = {}
- for n, dd in dirs.items():
- man = dd / "manifest.json"; out[n] = json.loads(man.read_text(encoding="utf-8")) if man.exists() else None
- return self._json(dict(layers=out, note="只读; 不合并主库 (E9/E10 裁定); 文件: /release/<r>/manifest.json · objects.json (· r2: layering.json)"))
- rel = dirs[name]; f = (rel / rest).resolve() if rest else None
- if not f or not str(f).startswith(str(rel.resolve())) or not f.is_file():
- man = rel / "manifest.json"; mj = json.loads(man.read_text(encoding="utf-8")) if man.exists() else None; return self._json(dict(release=(mj or {}).get("release"), files=sorted(x.name for x in rel.glob("*.json")) if rel.exists() else [], manifest=mj), 200 if man.exists() else 503)
- return self._bytes(f.read_bytes(), "application/json; charset=utf-8")
- if path in ("/", "/index.html"):
- if not PORTAL.exists(): return self._json(dict(err="门户文件缺失: %s" % PORTAL), 503)
- return self._bytes(portal_bytes(), "text/html; charset=utf-8")
- r = self._route(path)
- if r: return self._proxy(*r, query=u.query)
- return self._static(path)
- def _static(self, path):
- f = (STATIC_DIR / path.lstrip("/")).resolve()
- if not str(f).startswith(str(STATIC_DIR.resolve())) or not f.is_file(): return self._json(dict(err="not found", path=path), 404)
- import mimetypes; ct = mimetypes.guess_type(str(f))[0] or "application/octet-stream"
- if ct.startswith("text/"): ct += "; charset=utf-8"
- b = f.read_bytes()
- if "text/html" in ct: b = rewrite(b.decode("utf-8", "replace"), "").encode("utf-8")
- return self._bytes(b, ct)
- def _offline_guard(self, prefix, port, upath, query):
- """8033 问答/汇报带 model= 参数: 非本机 Ollama 模型一律拒, 不外发. 返回 None = 放行."""
- if port != 8033 or not any(upath.startswith(p) for p in MODEL_PARAM_PATHS): return None
- q = urllib.parse.parse_qs(query); m = q.get("model", [""])[0]
- if not m or "probe" in q: return None
- run, models = ollama_models()
- if not run: return dict(err="本机模型未启动 (Ollama %s:%d 拒连); 离线版不转云" % OLLAMA + "", model=m, code=503)
- if m not in models: return dict(err="离线版只用本机模型, 拒绝外发: %s 不在本机 Ollama 列表" % m, local_models=models, code=400)
- return None
- def _proxy(self, prefix, port, upath, query):
- body = self._read_body()
- gq = query
- if body and port == 8033 and any(upath.startswith(p_) for p_ in MODEL_PARAM_PATHS) and "model=" not in (query or ""):
- # 2026-09-07 真停 Ollama 实测逮: /v2 用 JSON 体 POST {q, model} 问, 守卫只看 query 串 → 绕过, 8033 起问后回 "Connection refused" 原文.
- try:
- jb = json.loads(body.decode("utf-8")); m = jb.get("model") if isinstance(jb, dict) else None
- if m: gq = (query + "&" if query else "") + "model=" + urllib.parse.quote(str(m)) + ("&probe=1" if (isinstance(jb, dict) and jb.get("probe")) else "")
- except Exception: pass
- g = self._offline_guard(prefix, port, upath, gq)
- if g: code = g.pop("code"); return self._json(g, code)
- hdrs = {k: v for k, v in self.headers.items() if k.lower() not in HOP}; hdrs["Host"] = "127.0.0.1:%d" % port; hdrs["Accept-Encoding"] = "identity"
- target = _q(upath) + ("?" + query if query else "")
- try:
- c = http.client.HTTPConnection("127.0.0.1", port, timeout=900); c.request(self.command, target, body=body if body else None, headers=hdrs); r = c.getresponse()
- except OSError as e:
- name = next((n for p, pt, *_, n in ROUTES if pt == port), str(port))
- info = dict(err="模块未启动: %s" % name, path=prefix + "/", upstream="127.0.0.1:%d" % port, detail=str(e)[:80], hint="本机模型未启动" if port == OLLAMA[1] else "运行 scripts/guanlan_offline_up.sh")
- # 故障演练 (部署包测试 4): 页面请求给一张明确的"模块不可用"页 (不白屏、不跳错页、带返回门户), API 请求给 JSON 503
- if "text/html" in (self.headers.get("Accept") or "") and not upath.startswith("/api/"):
- return self._bytes(down_page(name, prefix, info).encode("utf-8"), "text/html; charset=utf-8", 503)
- return self._json(info, 503)
- data = r.read(); ctype = r.getheader("Content-Type", ""); enc = r.getheader("Content-Encoding", "")
- if TEXT_RE.search(ctype) and not enc:
- txt = data.decode("utf-8", "replace")
- if port == 8033 and upath.startswith("/api/ask_models") and "json" in ctype:
- txt = self._filter_models(txt)
- elif "json" not in ctype:
- txt = rewrite(txt, prefix)
- data = txt.encode("utf-8")
- self.send_response(r.status)
- for k, v in r.getheaders():
- if k.lower() in HOP or k.lower() == "content-encoding": continue
- if k.lower() == "location": v = rewrite(v, prefix)
- self.send_header(k, v)
- self.send_header("Content-Length", str(len(data))); self.end_headers()
- try:
- if self.command != "HEAD": self.wfile.write(data)
- except (BrokenPipeError, ConnectionResetError):
- self.log_message("client dropped %s", self.path) # 浏览器/curl 中途断开 = 客户端行为, 记一行不抛栈
- c.close()
- @staticmethod
- def _filter_models(txt):
- try: d = json.loads(txt)
- except ValueError: return txt
- run, local = ollama_models(); allm = d.get("models") or {}; keep = {k: v for k, v in allm.items() if k in local}
- d["models"] = keep; d["offline"] = dict(local_ai_running=run, dropped=sorted(set(allm) - set(keep)), note=None if run else "本机模型未启动 (Ollama 拒连), 本机档暂不可用; 不转云")
- return json.dumps(d, ensure_ascii=False)
- def main():
- ap = argparse.ArgumentParser(); ap.add_argument("--port", type=int, default=28084); ap.add_argument("--host", default="127.0.0.1"); ap.add_argument("--check", action="store_true", help="只打印 healthz 后退出"); a = ap.parse_args()
- if a.check: print(json.dumps(healthz(), ensure_ascii=False, indent=1)); return 0
- srv = ThreadingHTTPServer((a.host, a.port), H); srv.daemon_threads = True
- sys.stderr.write("guanlan-gateway http://%s:%d/ (门户 %s)\n" % (a.host, a.port, PORTAL)); srv.serve_forever()
- if __name__ == "__main__":
- sys.exit(main() or 0)
|