#!/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 sys as _sys, pathlib as _plb
_sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1]))
from src import paths as P
from src import proc as _proc # 无窗口子进程 (2026-09-16 用户令: 不弹命令窗口)
import argparse, datetime as dt, hashlib, http.client, json, os, pathlib, re, socket, subprocess, sys, time, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from concurrent.futures import ThreadPoolExecutor
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 = P.store() / "release-manifest.json"
CONTRACT = P.guanlan() / "facts_contract_v0.json"
OLLAMA = ("127.0.0.1", int(os.environ.get("GUANLAN_OLLAMA_PORT", "11434"))) # 验收用: 指向死端口即可模拟 "本机模型未启动" (不必真停 Ollama)
# prefix → (upstream port, 上游路径映射: 前缀去掉后剩 "" 时用的默认路径, 探活路径, 名称)
# ★ 2026-09-17 (用户令 2「端口单一真源」+ 开箱验证不再打扰运行中的实例):
# 上游端口默认取下面这张表, 但**可被 `configs/serve.json` 覆盖** —— 于是"整套服务换个端口跑"
# 成了配置能表达的事(以前是硬编码, `check_transferable.run_and_probe` 因此**必须**要求默认端口空闲,
# 也就必须先把用户正在跑的服务停掉; 现场实测后果: 用户在验证窗口里点门户「登录」→ /detail/ 报"模块未启动")。
_DEFAULT_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"),
]
_PORT_KEY = {'/detail': 'detail', '/cms': 'cms', '/sim/sys': 'sim_sys', '/sim': 'sim',
'/viewer': 'viewer', '/local-ai': 'ollama'}
def _upstream_ports() -> dict:
"""上游端口: `configs/serve.json` 里同名键覆盖内置默认 (文件读不到就用默认)。"""
import json as _json
import pathlib as _pl
got = {}
try:
cfg_p = _pl.Path(__file__).resolve().parents[1] / 'configs' / 'serve.json'
got = _json.loads(cfg_p.read_text(encoding='utf-8-sig'))
except Exception:
got = {}
return {pre: int(got.get(_PORT_KEY[pre]) or port) for pre, port, *_ in _DEFAULT_ROUTES}
ROUTES = [(pre, _upstream_ports()[pre], a, b, nm) for pre, _, a, b, nm in _DEFAULT_ROUTES]
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"((?
模块不可用 · 观澜'
''
f'
模块不可用: {_h.escape(name)}
路径 {_h.escape(prefix)}/ 对应的内部组件未启动 ({_h.escape(info["upstream"])}), 网关未转向任何云端服务, 也不会伪造内容。
'
f'
处理: {_h.escape(info["hint"])}; 全部模块状态见 /healthz。
← 返回门户
{_h.escape(info["detail"])}
')
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, no_store=False):
self.send_response(code); self.send_header("Content-Type", ctype); self.send_header("Content-Length", str(len(b)))
if no_store:
# 运维控制台/数据重算页**必须**不缓存 (2026-09-16 实逮): 页面 HTML/脚本是随包更新的
# (例如"内嵌版按钮点不动"这次修复), 没有 no-store 时浏览器会用旧页面 —— 用户看到的是
# "改了还是没反应", 排查方向会被带偏。数据面同样: 状态必须每次现取。
self.send_header("Cache-Control", "no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
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 == "/ops" or path.startswith("/ops/"):
# 运维控制台 (停/启服务 · 重算 · 清产物): 页面 + JSON API 都在 scripts/guanlan_ops.py,
# 网关只做转发 —— 页面由**网关自己**提供是刻意的: "停组件服务"要保留它, 否则按钮点完页面就没了。
ops = _ops_module()
body = self._read_body() if self.command == "POST" else b""
code, mime, data = ops.handle(self.command, path, body)
return self._bytes(data, mime, code, no_store=True)
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": P.ont() / "release_r1", "r2": P.ont() / "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//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():
# 统一日志口径 (用户令 2): 之后本进程 stdout/stderr 的每一行都带 时间戳/级别/组件
from src import logfile as _logfile # noqa: E402
_logfile.prefix_stdout('gateway')
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)