server.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. from __future__ import annotations
  2. import json
  3. import mimetypes
  4. import os
  5. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  6. from pathlib import Path
  7. from typing import Any
  8. from urllib.parse import unquote, urlparse
  9. from .agent_graph import SmqjhAgentGraph
  10. from .config import load_runtime_config
  11. RUNTIME_ROOT = Path(__file__).resolve().parents[1]
  12. REPO_ROOT = RUNTIME_ROOT.parent
  13. PUBLIC_ROOT = RUNTIME_ROOT / "public"
  14. LOGO_PATH = REPO_ROOT / "smqjh-admin-agent" / "build" / "logo.png"
  15. MAX_BODY_BYTES = 1024 * 1024
  16. CONFIG = load_runtime_config()
  17. AGENT = SmqjhAgentGraph(CONFIG)
  18. class AgentRequestHandler(BaseHTTPRequestHandler):
  19. server_version = "SMQJHAgentPython/0.1"
  20. def do_OPTIONS(self) -> None:
  21. self._send_text(204, "")
  22. def do_GET(self) -> None:
  23. try:
  24. path = urlparse(self.path).path
  25. if path == "/api/health":
  26. self._handle_health()
  27. return
  28. if path == "/api/tools":
  29. self._send_json(200, {"ok": True, "tools": AGENT.tools()})
  30. return
  31. if path == "/assets/logo.png":
  32. self._serve_file(LOGO_PATH, "image/png")
  33. return
  34. self._serve_static(path)
  35. except Exception as error:
  36. self._send_json(500, {"ok": False, "message": str(error) or error.__class__.__name__})
  37. def do_POST(self) -> None:
  38. try:
  39. path = urlparse(self.path).path
  40. if path == "/api/chat/stream":
  41. self._handle_chat_stream()
  42. return
  43. if path != "/api/chat":
  44. self._send_json(404, {"ok": False, "message": "Not found"})
  45. return
  46. payload = self._read_json_body()
  47. message = str(payload.get("message") or "").strip() if isinstance(payload, dict) else ""
  48. if not message:
  49. self._send_json(400, {"ok": False, "message": "message is required"})
  50. return
  51. history = payload.get("history") if isinstance(payload, dict) else []
  52. if not isinstance(history, list):
  53. history = []
  54. result = AGENT.run(message, history)
  55. self._send_json(200, {"ok": True, "result": result})
  56. except Exception as error:
  57. self._send_json(500, {"ok": False, "message": str(error) or error.__class__.__name__})
  58. def _handle_chat_stream(self) -> None:
  59. payload = self._read_json_body()
  60. message = str(payload.get("message") or "").strip() if isinstance(payload, dict) else ""
  61. if not message:
  62. self._send_json(400, {"ok": False, "message": "message is required"})
  63. return
  64. history = payload.get("history") if isinstance(payload, dict) else []
  65. if not isinstance(history, list):
  66. history = []
  67. self.send_response(200)
  68. self._cors_headers()
  69. self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
  70. self.send_header("Cache-Control", "no-cache")
  71. self.end_headers()
  72. def send_event(kind: str, event_payload: Any) -> None:
  73. body = json.dumps(
  74. {"type": kind, "payload": event_payload},
  75. ensure_ascii=False,
  76. default=str,
  77. ).encode("utf-8")
  78. self.wfile.write(body + b"\n")
  79. self.wfile.flush()
  80. try:
  81. send_event("status", {"title": "开始处理", "detail": "正在进入 LangGraph 编排"})
  82. result = AGENT.run(message, history, emit=lambda event: send_event("trace", event))
  83. send_event("result", result)
  84. send_event("done", {})
  85. except (BrokenPipeError, ConnectionResetError):
  86. return
  87. except Exception as error:
  88. send_event("error", {"message": str(error) or error.__class__.__name__})
  89. def log_message(self, format: str, *args: Any) -> None:
  90. if os.getenv("WEB_AGENT_ACCESS_LOG", "").lower() in {"1", "true", "yes"}:
  91. super().log_message(format, *args)
  92. def _handle_health(self) -> None:
  93. try:
  94. mcp = AGENT.health()
  95. self._send_json(
  96. 200,
  97. {
  98. "ok": True,
  99. "runtime": {
  100. "name": "smqjh-agent-runtime",
  101. "mode": "web",
  102. "framework": "Python + LangGraph",
  103. "environmentName": CONFIG.environment_name,
  104. "baseUrl": CONFIG.base_url,
  105. },
  106. "mcp": mcp,
  107. },
  108. )
  109. except Exception as error:
  110. self._send_json(
  111. 200,
  112. {
  113. "ok": False,
  114. "runtime": {
  115. "name": "smqjh-agent-runtime",
  116. "mode": "web",
  117. "framework": "Python + LangGraph",
  118. },
  119. "mcp": {
  120. "ok": False,
  121. "message": str(error) or error.__class__.__name__,
  122. },
  123. },
  124. )
  125. def _serve_static(self, request_path: str) -> None:
  126. clean_path = "/index.html" if request_path == "/" else request_path
  127. relative = unquote(clean_path).lstrip("/")
  128. file_path = (PUBLIC_ROOT / relative).resolve()
  129. try:
  130. file_path.relative_to(PUBLIC_ROOT.resolve())
  131. except ValueError:
  132. self._send_text(403, "Forbidden")
  133. return
  134. self._serve_file(file_path)
  135. def _serve_file(self, file_path: Path, content_type: str | None = None) -> None:
  136. if not file_path.exists() or not file_path.is_file():
  137. self._send_text(404, "Not found")
  138. return
  139. mime = content_type or mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
  140. data = file_path.read_bytes()
  141. self.send_response(200)
  142. self._cors_headers()
  143. self.send_header("Content-Type", _with_charset(mime))
  144. self.send_header("Cache-Control", "no-cache" if file_path.suffix == ".html" else "public, max-age=300")
  145. self.send_header("Content-Length", str(len(data)))
  146. self.end_headers()
  147. self.wfile.write(data)
  148. def _read_json_body(self) -> dict[str, Any]:
  149. length = int(self.headers.get("Content-Length") or "0")
  150. if length > MAX_BODY_BYTES:
  151. raise ValueError("Request body is too large")
  152. raw = self.rfile.read(length).decode("utf-8") if length else "{}"
  153. return json.loads(raw) if raw.strip() else {}
  154. def _send_json(self, status: int, payload: Any) -> None:
  155. body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
  156. self.send_response(status)
  157. self._cors_headers()
  158. self.send_header("Content-Type", "application/json; charset=utf-8")
  159. self.send_header("Content-Length", str(len(body)))
  160. self.end_headers()
  161. self.wfile.write(body)
  162. def _send_text(self, status: int, text: str) -> None:
  163. body = text.encode("utf-8")
  164. self.send_response(status)
  165. self._cors_headers()
  166. self.send_header("Content-Type", "text/plain; charset=utf-8")
  167. self.send_header("Content-Length", str(len(body)))
  168. self.end_headers()
  169. self.wfile.write(body)
  170. def _cors_headers(self) -> None:
  171. self.send_header("Access-Control-Allow-Origin", "*")
  172. self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
  173. self.send_header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-MCP-Token")
  174. def _with_charset(mime: str) -> str:
  175. if mime.startswith("text/") or mime in {"application/javascript", "application/json"}:
  176. return f"{mime}; charset=utf-8"
  177. return mime
  178. def main() -> None:
  179. server = ThreadingHTTPServer((CONFIG.host, CONFIG.port), AgentRequestHandler)
  180. print(f"SMQJH Python Web Agent started: http://127.0.0.1:{CONFIG.port}", flush=True)
  181. print(f"Runtime framework: Python + LangGraph", flush=True)
  182. print(f"MCP endpoint: {CONFIG.mcp.url}", flush=True)
  183. try:
  184. server.serve_forever()
  185. except KeyboardInterrupt:
  186. pass
  187. finally:
  188. server.server_close()
  189. if __name__ == "__main__":
  190. main()