| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- """Local static server and credential-safe Volcengine Ark image proxy."""
- import json
- import os
- import re
- import base64
- import binascii
- import urllib.error
- import urllib.request
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
- PORT = int(os.environ.get("PORT", "8000"))
- MAX_BODY_BYTES = 16 * 1024 * 1024
- ARK_IMAGE_URL = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
- ARK_CHAT_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
- ARK_VISION_MODEL = os.environ.get("ARK_VISION_MODEL", "doubao-1.5-vision-pro-32k-250115").strip()
- RUNTIME_ARK_API_KEY = os.environ.get("ARK_API_KEY", "").strip()
- EDIT_PROMPT = (
- "严格保持参考户型图的外轮廓、承重墙、隔墙、门洞、窗洞的位置、比例和数量完全不变。"
- "删除全部中英文文字、尺寸数字、红蓝标注线、箭头、家具、柜体、装饰图案、面积标注、logo和背景水印。"
- "不要增加、移动、弯曲或删减任何墙体、门洞和窗洞。输出完整、居中、无裁切的专业建筑平面底图:"
- "纯白背景,墙体使用清晰深灰色粗线,门窗使用简洁细线,房间内部留白;不得包含文字、数字、家具、标注、logo或水印。"
- )
- DATA_URL_RE = re.compile(r"^data:image/([A-Za-z0-9.+-]+);base64,(.+)$", re.S)
- def _normalize_image(value):
- """校验并规范化前端传来的图片:必须是标准 data URI,base64 合法且不超过10MB。
- 去除空白/换行、小写化MIME、jpg→jpeg,确保转发给方舟的参数永远是合法格式,
- 从根源上避免模型侧报 invalid_parameter_error(URL 无效)。
- """
- image = str(value or "").strip()
- image = re.sub(r"\s+", "", image)
- match = DATA_URL_RE.match(image)
- if not match:
- raise ValueError("图片必须是标准 Base64 Data URL(data:image/<格式>;base64,...),请重新上传户型图")
- fmt = match.group(1).lower()
- if fmt == "jpg":
- fmt = "jpeg"
- if fmt not in ("jpeg", "png", "webp", "bmp", "tiff", "gif"):
- raise ValueError("图片格式不支持:" + fmt)
- payload = match.group(2)
- try:
- raw = base64.b64decode(payload, validate=True)
- except (ValueError, binascii.Error):
- raise ValueError("Base64 图片内容无效,请重新上传户型图")
- if len(raw) > 10 * 1024 * 1024:
- raise ValueError("图片超过10MB限制,请压缩后重新上传")
- return "data:image/" + fmt + ";base64," + payload
- class Handler(SimpleHTTPRequestHandler):
- # HTTP/1.1 keep-alive:避免 HTTP/1.0 每请求关连接,预览面板复用连接时遇到 RST 而 ERR_ABORTED
- protocol_version = "HTTP/1.1"
- def _vite(self):
- # Trae 预览会注入 /@vite/client 热更新脚本:返回 200 空JS,避免 404 中断主文档加载
- self.send_response(200)
- self.send_header("Content-Type", "text/javascript")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", "2")
- self.end_headers()
- self.wfile.write(b"//")
- def _strip_cond(self):
- # 剥离条件请求头 → 永远 200 全量正文:
- # 预览面板需改写 HTML 正文注入热更新脚本,304 无正文会使其放弃本次加载并重发(net::ERR_ABORTED)
- for h in ("If-Modified-Since", "If-None-Match"):
- if h in self.headers:
- del self.headers[h]
- def send_header(self, keyword, value):
- # Python 3.14 的 send_head 用 "Content-type"(小写t),比较必须忽略大小写
- if keyword.lower() == "content-type" and "text/html" in value:
- super().send_header("Cache-Control", "no-store")
- super().send_header(keyword, value)
- def do_GET(self):
- # 【加固】静态文件服务遇到浏览器中断连接(预览重载/取消)时静默忽略,
- # 避免异常冒泡导致线程 traceback 刷屏甚至进程退出
- try:
- if self.path.split("?", 1)[0].startswith("/@vite/"):
- self._vite()
- return
- self._strip_cond()
- super().do_GET()
- except (BrokenPipeError, ConnectionResetError, TimeoutError):
- pass
- def do_HEAD(self):
- try:
- if self.path.split("?", 1)[0].startswith("/@vite/"):
- self._vite()
- return
- self._strip_cond()
- super().do_HEAD()
- except (BrokenPipeError, ConnectionResetError, TimeoutError):
- pass
- def _json_error(self, status, message):
- body = json.dumps({"code": "LocalProxyError", "message": message}, ensure_ascii=False).encode("utf-8")
- self.send_response(status)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
- def _read_body(self, limit=MAX_BODY_BYTES):
- """无论后续成功或报错,都必须先把请求体完整读完再响应。
- 否则提前返回并关闭连接时,套接字里残留的未读请求数据会触发 TCP RST,
- 浏览器端表现为 net::ERR_CONNECTION_RESET(尤其是上传大图片 base64 时)。
- """
- try:
- length = int(self.headers.get("Content-Length", "0") or "0")
- except ValueError:
- length = 0
- if length <= 0:
- if length < 0:
- raise ValueError("请求内容无效")
- return b""
- oversized = length > limit
- received = 0
- chunks = []
- while received < length:
- chunk = self.rfile.read(min(length - received, 262144))
- if not chunk:
- break
- received += len(chunk)
- if not oversized:
- chunks.append(chunk)
- if received < length:
- raise ValueError("上传内容不完整")
- if oversized:
- raise ValueError("上传内容超过大小限制,请压缩后重试")
- return b"".join(chunks)
- def do_POST(self):
- global RUNTIME_ARK_API_KEY
- route = self.path.split("?", 1)[0]
- if route == "/api/export/excel":
- try:
- raw = self._read_body(MAX_BODY_BYTES)
- if not raw:
- raise ValueError("Excel文件为空")
- payload = json.loads(raw.decode("utf-8"))
- filename = os.path.basename(str(payload.get("filename", "报价单.xlsx"))).strip()
- if not filename.lower().endswith(".xlsx"):
- filename += ".xlsx"
- filename = "".join(c for c in filename if c not in '<>:"/\\|?*') or "报价单.xlsx"
- binary = base64.b64decode(str(payload.get("data", "")), validate=True)
- if not binary:
- raise ValueError("Excel文件内容为空")
- # 允许前端指定绝对目录(仅限本机盘符路径),缺省回退桌面
- dir_param = str(payload.get("dir", "")).strip()
- if dir_param and re.match(r"^[A-Za-z]:[\\/]", dir_param) and os.path.isdir(dir_param):
- desktop = dir_param
- else:
- desktop = os.path.join(os.path.expanduser("~"), "Desktop")
- os.makedirs(desktop, exist_ok=True)
- target = os.path.join(desktop, filename)
- stem, ext = os.path.splitext(target)
- counter = 2
- while os.path.exists(target):
- target = f"{stem}_{counter}{ext}"
- counter += 1
- with open(target, "wb") as output:
- output.write(binary)
- result = json.dumps({"saved": True, "filename": os.path.basename(target), "path": target}, ensure_ascii=False).encode("utf-8")
- except (ValueError, json.JSONDecodeError, UnicodeDecodeError, base64.binascii.Error) as error:
- self._json_error(400, str(error))
- return
- self.send_response(200)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(result)))
- self.end_headers()
- self.wfile.write(result)
- return
- if route == "/api/ark/config":
- try:
- raw = self._read_body(4096)
- if not raw:
- raise ValueError("请求内容无效")
- payload = json.loads(raw.decode("utf-8"))
- key = str(payload.get("api_key", "")).strip()
- if not key.startswith("ark-") or len(key) < 20 or len(key) > 256:
- raise ValueError("模型 Key 格式不正确")
- RUNTIME_ARK_API_KEY = key
- except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
- self._json_error(400, str(error))
- return
- result = json.dumps({"configured": True}, ensure_ascii=False).encode("utf-8")
- self.send_response(200)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(result)))
- self.end_headers()
- self.wfile.write(result)
- return
- if route == "/api/ark/dimension-detect":
- # 先完整读取并校验请求体(含图片规范化),再检查 Key,
- # 保证任何分支都不会在未读完请求体时提前关闭连接
- try:
- raw = self._read_body(MAX_BODY_BYTES)
- if not raw:
- raise ValueError("上传内容为空")
- incoming = json.loads(raw.decode("utf-8"))
- image = _normalize_image(incoming.get("image", ""))
- except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
- self._json_error(400, str(error))
- return
- api_key = RUNTIME_ARK_API_KEY
- if not api_key:
- self._json_error(503, "本机后端尚未配置 ARK_API_KEY")
- return
- prompt = (
- "你是建筑户型图尺寸标注识别器。检查图片中已经存在的线性真实尺寸标注,"
- "识别尺寸数字及其对应的双箭头、箭头或尺寸线两端。忽略面积数值、房间名称、柜体编号和计算说明。"
- "只输出JSON,不要Markdown:{\"dimensions\":[{\"value\":3150,\"unit\":\"mm\","
- "\"x1\":5.2,\"y1\":42.1,\"x2\":5.2,\"y2\":68.4,\"confidence\":0.96}]}."
- "x1,y1,x2,y2是箭头两端相对于整张图片宽高的0到100百分比坐标。"
- "仅返回有明确尺寸数字且能定位对应线段两端的项目;没有则返回{\"dimensions\":[]}。"
- )
- body = json.dumps({
- "model": ARK_VISION_MODEL,
- "messages": [{"role": "user", "content": [
- {"type": "text", "text": prompt},
- {"type": "image_url", "image_url": {"url": image}}
- ]}],
- "stream": False,
- "max_tokens": 1600,
- "temperature": 0.1,
- }, ensure_ascii=False).encode("utf-8")
- request = urllib.request.Request(ARK_CHAT_URL, data=body, method="POST", headers={
- "Authorization": "Bearer " + api_key,
- "Content-Type": "application/json",
- })
- try:
- with urllib.request.urlopen(request, timeout=120) as response:
- result, status = response.read(), response.status
- except urllib.error.HTTPError as error:
- result, status = error.read(), error.code
- except Exception as error:
- self._json_error(502, "连接火山方舟视觉模型失败:" + str(error))
- return
- self.send_response(status)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(result)))
- self.end_headers()
- self.wfile.write(result)
- return
- if route != "/api/ark/image-edit":
- try:
- self._read_body(MAX_BODY_BYTES)
- except ValueError:
- pass
- self._json_error(404, "接口不存在")
- return
- # 同样先读完请求体并校验,再检查 Key,避免提前关闭连接导致 ERR_CONNECTION_RESET
- try:
- raw = self._read_body(MAX_BODY_BYTES)
- if not raw:
- raise ValueError("上传内容为空")
- incoming = json.loads(raw.decode("utf-8"))
- image = _normalize_image(incoming.get("image", ""))
- except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
- self._json_error(400, str(error))
- return
- api_key = RUNTIME_ARK_API_KEY
- if not api_key:
- self._json_error(503, "本机后端尚未配置 ARK_API_KEY")
- return
- body = json.dumps({
- "model": "doubao-seedream-4-5-251128",
- "prompt": EDIT_PROMPT,
- "image": image,
- "size": "2K",
- "sequential_image_generation": "disabled",
- "stream": False,
- "response_format": "b64_json",
- "watermark": False,
- "guidance_scale": 8.5,
- }, ensure_ascii=False).encode("utf-8")
- request = urllib.request.Request(
- ARK_IMAGE_URL,
- data=body,
- method="POST",
- headers={
- "Authorization": "Bearer " + api_key,
- "Content-Type": "application/json",
- },
- )
- try:
- with urllib.request.urlopen(request, timeout=180) as response:
- result, status = response.read(), response.status
- except urllib.error.HTTPError as error:
- result, status = error.read(), error.code
- except Exception as error:
- self._json_error(502, "连接火山方舟失败:" + str(error))
- return
- self.send_response(status)
- self.send_header("Content-Type", "application/json; charset=utf-8")
- self.send_header("Cache-Control", "no-store")
- self.send_header("Content-Length", str(len(result)))
- self.end_headers()
- self.wfile.write(result)
- class SafeThreadingHTTPServer(ThreadingHTTPServer):
- # 【加固】单个请求线程异常只记日志不杀进程,保证预览服务长期在线
- def handle_error(self, request, client_address):
- import sys
- print(f"[server] 请求处理异常(已忽略,服务继续): {client_address}", file=sys.stderr)
- if __name__ == "__main__":
- print(f"Secure preview: http://localhost:{PORT}/AI快速报价_Demo2.html")
- if not os.environ.get("ARK_API_KEY"):
- print("Warning: ARK_API_KEY is not configured; Doubao image editing is disabled.")
- SafeThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|