secure_preview_server.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """Local static server and credential-safe Volcengine Ark image proxy."""
  2. import json
  3. import os
  4. import re
  5. import base64
  6. import binascii
  7. import urllib.error
  8. import urllib.request
  9. from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
  10. PORT = int(os.environ.get("PORT", "8000"))
  11. MAX_BODY_BYTES = 16 * 1024 * 1024
  12. ARK_IMAGE_URL = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
  13. ARK_CHAT_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
  14. ARK_VISION_MODEL = os.environ.get("ARK_VISION_MODEL", "doubao-1.5-vision-pro-32k-250115").strip()
  15. RUNTIME_ARK_API_KEY = os.environ.get("ARK_API_KEY", "").strip()
  16. EDIT_PROMPT = (
  17. "严格保持参考户型图的外轮廓、承重墙、隔墙、门洞、窗洞的位置、比例和数量完全不变。"
  18. "删除全部中英文文字、尺寸数字、红蓝标注线、箭头、家具、柜体、装饰图案、面积标注、logo和背景水印。"
  19. "不要增加、移动、弯曲或删减任何墙体、门洞和窗洞。输出完整、居中、无裁切的专业建筑平面底图:"
  20. "纯白背景,墙体使用清晰深灰色粗线,门窗使用简洁细线,房间内部留白;不得包含文字、数字、家具、标注、logo或水印。"
  21. )
  22. DATA_URL_RE = re.compile(r"^data:image/([A-Za-z0-9.+-]+);base64,(.+)$", re.S)
  23. def _normalize_image(value):
  24. """校验并规范化前端传来的图片:必须是标准 data URI,base64 合法且不超过10MB。
  25. 去除空白/换行、小写化MIME、jpg→jpeg,确保转发给方舟的参数永远是合法格式,
  26. 从根源上避免模型侧报 invalid_parameter_error(URL 无效)。
  27. """
  28. image = str(value or "").strip()
  29. image = re.sub(r"\s+", "", image)
  30. match = DATA_URL_RE.match(image)
  31. if not match:
  32. raise ValueError("图片必须是标准 Base64 Data URL(data:image/<格式>;base64,...),请重新上传户型图")
  33. fmt = match.group(1).lower()
  34. if fmt == "jpg":
  35. fmt = "jpeg"
  36. if fmt not in ("jpeg", "png", "webp", "bmp", "tiff", "gif"):
  37. raise ValueError("图片格式不支持:" + fmt)
  38. payload = match.group(2)
  39. try:
  40. raw = base64.b64decode(payload, validate=True)
  41. except (ValueError, binascii.Error):
  42. raise ValueError("Base64 图片内容无效,请重新上传户型图")
  43. if len(raw) > 10 * 1024 * 1024:
  44. raise ValueError("图片超过10MB限制,请压缩后重新上传")
  45. return "data:image/" + fmt + ";base64," + payload
  46. class Handler(SimpleHTTPRequestHandler):
  47. # HTTP/1.1 keep-alive:避免 HTTP/1.0 每请求关连接,预览面板复用连接时遇到 RST 而 ERR_ABORTED
  48. protocol_version = "HTTP/1.1"
  49. def _vite(self):
  50. # Trae 预览会注入 /@vite/client 热更新脚本:返回 200 空JS,避免 404 中断主文档加载
  51. self.send_response(200)
  52. self.send_header("Content-Type", "text/javascript")
  53. self.send_header("Cache-Control", "no-store")
  54. self.send_header("Content-Length", "2")
  55. self.end_headers()
  56. self.wfile.write(b"//")
  57. def _strip_cond(self):
  58. # 剥离条件请求头 → 永远 200 全量正文:
  59. # 预览面板需改写 HTML 正文注入热更新脚本,304 无正文会使其放弃本次加载并重发(net::ERR_ABORTED)
  60. for h in ("If-Modified-Since", "If-None-Match"):
  61. if h in self.headers:
  62. del self.headers[h]
  63. def send_header(self, keyword, value):
  64. # Python 3.14 的 send_head 用 "Content-type"(小写t),比较必须忽略大小写
  65. if keyword.lower() == "content-type" and "text/html" in value:
  66. super().send_header("Cache-Control", "no-store")
  67. super().send_header(keyword, value)
  68. def do_GET(self):
  69. # 【加固】静态文件服务遇到浏览器中断连接(预览重载/取消)时静默忽略,
  70. # 避免异常冒泡导致线程 traceback 刷屏甚至进程退出
  71. try:
  72. if self.path.split("?", 1)[0].startswith("/@vite/"):
  73. self._vite()
  74. return
  75. self._strip_cond()
  76. super().do_GET()
  77. except (BrokenPipeError, ConnectionResetError, TimeoutError):
  78. pass
  79. def do_HEAD(self):
  80. try:
  81. if self.path.split("?", 1)[0].startswith("/@vite/"):
  82. self._vite()
  83. return
  84. self._strip_cond()
  85. super().do_HEAD()
  86. except (BrokenPipeError, ConnectionResetError, TimeoutError):
  87. pass
  88. def _json_error(self, status, message):
  89. body = json.dumps({"code": "LocalProxyError", "message": message}, ensure_ascii=False).encode("utf-8")
  90. self.send_response(status)
  91. self.send_header("Content-Type", "application/json; charset=utf-8")
  92. self.send_header("Content-Length", str(len(body)))
  93. self.end_headers()
  94. self.wfile.write(body)
  95. def _read_body(self, limit=MAX_BODY_BYTES):
  96. """无论后续成功或报错,都必须先把请求体完整读完再响应。
  97. 否则提前返回并关闭连接时,套接字里残留的未读请求数据会触发 TCP RST,
  98. 浏览器端表现为 net::ERR_CONNECTION_RESET(尤其是上传大图片 base64 时)。
  99. """
  100. try:
  101. length = int(self.headers.get("Content-Length", "0") or "0")
  102. except ValueError:
  103. length = 0
  104. if length <= 0:
  105. if length < 0:
  106. raise ValueError("请求内容无效")
  107. return b""
  108. oversized = length > limit
  109. received = 0
  110. chunks = []
  111. while received < length:
  112. chunk = self.rfile.read(min(length - received, 262144))
  113. if not chunk:
  114. break
  115. received += len(chunk)
  116. if not oversized:
  117. chunks.append(chunk)
  118. if received < length:
  119. raise ValueError("上传内容不完整")
  120. if oversized:
  121. raise ValueError("上传内容超过大小限制,请压缩后重试")
  122. return b"".join(chunks)
  123. def do_POST(self):
  124. global RUNTIME_ARK_API_KEY
  125. route = self.path.split("?", 1)[0]
  126. if route == "/api/export/excel":
  127. try:
  128. raw = self._read_body(MAX_BODY_BYTES)
  129. if not raw:
  130. raise ValueError("Excel文件为空")
  131. payload = json.loads(raw.decode("utf-8"))
  132. filename = os.path.basename(str(payload.get("filename", "报价单.xlsx"))).strip()
  133. if not filename.lower().endswith(".xlsx"):
  134. filename += ".xlsx"
  135. filename = "".join(c for c in filename if c not in '<>:"/\\|?*') or "报价单.xlsx"
  136. binary = base64.b64decode(str(payload.get("data", "")), validate=True)
  137. if not binary:
  138. raise ValueError("Excel文件内容为空")
  139. # 允许前端指定绝对目录(仅限本机盘符路径),缺省回退桌面
  140. dir_param = str(payload.get("dir", "")).strip()
  141. if dir_param and re.match(r"^[A-Za-z]:[\\/]", dir_param) and os.path.isdir(dir_param):
  142. desktop = dir_param
  143. else:
  144. desktop = os.path.join(os.path.expanduser("~"), "Desktop")
  145. os.makedirs(desktop, exist_ok=True)
  146. target = os.path.join(desktop, filename)
  147. stem, ext = os.path.splitext(target)
  148. counter = 2
  149. while os.path.exists(target):
  150. target = f"{stem}_{counter}{ext}"
  151. counter += 1
  152. with open(target, "wb") as output:
  153. output.write(binary)
  154. result = json.dumps({"saved": True, "filename": os.path.basename(target), "path": target}, ensure_ascii=False).encode("utf-8")
  155. except (ValueError, json.JSONDecodeError, UnicodeDecodeError, base64.binascii.Error) as error:
  156. self._json_error(400, str(error))
  157. return
  158. self.send_response(200)
  159. self.send_header("Content-Type", "application/json; charset=utf-8")
  160. self.send_header("Cache-Control", "no-store")
  161. self.send_header("Content-Length", str(len(result)))
  162. self.end_headers()
  163. self.wfile.write(result)
  164. return
  165. if route == "/api/ark/config":
  166. try:
  167. raw = self._read_body(4096)
  168. if not raw:
  169. raise ValueError("请求内容无效")
  170. payload = json.loads(raw.decode("utf-8"))
  171. key = str(payload.get("api_key", "")).strip()
  172. if not key.startswith("ark-") or len(key) < 20 or len(key) > 256:
  173. raise ValueError("模型 Key 格式不正确")
  174. RUNTIME_ARK_API_KEY = key
  175. except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
  176. self._json_error(400, str(error))
  177. return
  178. result = json.dumps({"configured": True}, ensure_ascii=False).encode("utf-8")
  179. self.send_response(200)
  180. self.send_header("Content-Type", "application/json; charset=utf-8")
  181. self.send_header("Cache-Control", "no-store")
  182. self.send_header("Content-Length", str(len(result)))
  183. self.end_headers()
  184. self.wfile.write(result)
  185. return
  186. if route == "/api/ark/dimension-detect":
  187. # 先完整读取并校验请求体(含图片规范化),再检查 Key,
  188. # 保证任何分支都不会在未读完请求体时提前关闭连接
  189. try:
  190. raw = self._read_body(MAX_BODY_BYTES)
  191. if not raw:
  192. raise ValueError("上传内容为空")
  193. incoming = json.loads(raw.decode("utf-8"))
  194. image = _normalize_image(incoming.get("image", ""))
  195. except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
  196. self._json_error(400, str(error))
  197. return
  198. api_key = RUNTIME_ARK_API_KEY
  199. if not api_key:
  200. self._json_error(503, "本机后端尚未配置 ARK_API_KEY")
  201. return
  202. prompt = (
  203. "你是建筑户型图尺寸标注识别器。检查图片中已经存在的线性真实尺寸标注,"
  204. "识别尺寸数字及其对应的双箭头、箭头或尺寸线两端。忽略面积数值、房间名称、柜体编号和计算说明。"
  205. "只输出JSON,不要Markdown:{\"dimensions\":[{\"value\":3150,\"unit\":\"mm\","
  206. "\"x1\":5.2,\"y1\":42.1,\"x2\":5.2,\"y2\":68.4,\"confidence\":0.96}]}."
  207. "x1,y1,x2,y2是箭头两端相对于整张图片宽高的0到100百分比坐标。"
  208. "仅返回有明确尺寸数字且能定位对应线段两端的项目;没有则返回{\"dimensions\":[]}。"
  209. )
  210. body = json.dumps({
  211. "model": ARK_VISION_MODEL,
  212. "messages": [{"role": "user", "content": [
  213. {"type": "text", "text": prompt},
  214. {"type": "image_url", "image_url": {"url": image}}
  215. ]}],
  216. "stream": False,
  217. "max_tokens": 1600,
  218. "temperature": 0.1,
  219. }, ensure_ascii=False).encode("utf-8")
  220. request = urllib.request.Request(ARK_CHAT_URL, data=body, method="POST", headers={
  221. "Authorization": "Bearer " + api_key,
  222. "Content-Type": "application/json",
  223. })
  224. try:
  225. with urllib.request.urlopen(request, timeout=120) as response:
  226. result, status = response.read(), response.status
  227. except urllib.error.HTTPError as error:
  228. result, status = error.read(), error.code
  229. except Exception as error:
  230. self._json_error(502, "连接火山方舟视觉模型失败:" + str(error))
  231. return
  232. self.send_response(status)
  233. self.send_header("Content-Type", "application/json; charset=utf-8")
  234. self.send_header("Cache-Control", "no-store")
  235. self.send_header("Content-Length", str(len(result)))
  236. self.end_headers()
  237. self.wfile.write(result)
  238. return
  239. if route != "/api/ark/image-edit":
  240. try:
  241. self._read_body(MAX_BODY_BYTES)
  242. except ValueError:
  243. pass
  244. self._json_error(404, "接口不存在")
  245. return
  246. # 同样先读完请求体并校验,再检查 Key,避免提前关闭连接导致 ERR_CONNECTION_RESET
  247. try:
  248. raw = self._read_body(MAX_BODY_BYTES)
  249. if not raw:
  250. raise ValueError("上传内容为空")
  251. incoming = json.loads(raw.decode("utf-8"))
  252. image = _normalize_image(incoming.get("image", ""))
  253. except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
  254. self._json_error(400, str(error))
  255. return
  256. api_key = RUNTIME_ARK_API_KEY
  257. if not api_key:
  258. self._json_error(503, "本机后端尚未配置 ARK_API_KEY")
  259. return
  260. body = json.dumps({
  261. "model": "doubao-seedream-4-5-251128",
  262. "prompt": EDIT_PROMPT,
  263. "image": image,
  264. "size": "2K",
  265. "sequential_image_generation": "disabled",
  266. "stream": False,
  267. "response_format": "b64_json",
  268. "watermark": False,
  269. "guidance_scale": 8.5,
  270. }, ensure_ascii=False).encode("utf-8")
  271. request = urllib.request.Request(
  272. ARK_IMAGE_URL,
  273. data=body,
  274. method="POST",
  275. headers={
  276. "Authorization": "Bearer " + api_key,
  277. "Content-Type": "application/json",
  278. },
  279. )
  280. try:
  281. with urllib.request.urlopen(request, timeout=180) as response:
  282. result, status = response.read(), response.status
  283. except urllib.error.HTTPError as error:
  284. result, status = error.read(), error.code
  285. except Exception as error:
  286. self._json_error(502, "连接火山方舟失败:" + str(error))
  287. return
  288. self.send_response(status)
  289. self.send_header("Content-Type", "application/json; charset=utf-8")
  290. self.send_header("Cache-Control", "no-store")
  291. self.send_header("Content-Length", str(len(result)))
  292. self.end_headers()
  293. self.wfile.write(result)
  294. class SafeThreadingHTTPServer(ThreadingHTTPServer):
  295. # 【加固】单个请求线程异常只记日志不杀进程,保证预览服务长期在线
  296. def handle_error(self, request, client_address):
  297. import sys
  298. print(f"[server] 请求处理异常(已忽略,服务继续): {client_address}", file=sys.stderr)
  299. if __name__ == "__main__":
  300. print(f"Secure preview: http://localhost:{PORT}/AI快速报价_Demo2.html")
  301. if not os.environ.get("ARK_API_KEY"):
  302. print("Warning: ARK_API_KEY is not configured; Doubao image editing is disabled.")
  303. SafeThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()