CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
134 lines
5.8 KiB
Python
134 lines
5.8 KiB
Python
"""Serve the D3 Structure (RAG) graph over localhost for the default browser.
|
|
|
|
This is what keeps the FULL D3 knowledge-graph experience (drag/zoom, legend
|
|
filters, search, tooltips, click-a-node-to-open-its-folder) available in
|
|
builds without QtWebEngine — e.g. the standalone PyInstaller .exe. The tab
|
|
renders the same HTML as the embedded WebEngine view, but hands it to this
|
|
tiny HTTP server and opens the user's browser at its URL; node clicks come
|
|
back over an ``/open`` request instead of the QWebChannel bridge.
|
|
|
|
Security: the server binds to 127.0.0.1 only and every request must carry a
|
|
random per-session token, so another local process (or a web page attempting
|
|
DNS rebinding) can neither read the graph nor trigger folder-opens.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Callable, Optional
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
_PLACEHOLDER = ("<!DOCTYPE html><html><body style='background:#111;color:#ddd;"
|
|
"font-family:sans-serif'><p>No graph yet — scan one in the "
|
|
"Structure (RAG) tab first.</p></body></html>")
|
|
|
|
|
|
class GraphServer:
|
|
"""Lazy singleton-per-instance localhost server for the D3 graph page."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Chuẩn bị máy chủ; chưa mở cổng nào.
|
|
|
|
Một token ngẫu nhiên được sinh ngay lúc này và mọi yêu cầu đều phải mang
|
|
nó: máy chủ nghe trên localhost, nhưng mọi tiến trình khác trên cùng máy đều
|
|
gọi được localhost.
|
|
"""
|
|
self._html = _PLACEHOLDER
|
|
self._token = secrets.token_urlsafe(16)
|
|
self._lock = threading.Lock()
|
|
self._httpd: Optional[ThreadingHTTPServer] = None
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._open_cb: Optional[Callable[[str], None]] = None
|
|
|
|
# ---- content / callbacks ----------------------------------------
|
|
def set_html(self, html: str) -> None:
|
|
"""Đặt nội dung HTML sẽ phục vụ; có khoá vì luồng nền ghi còn luồng HTTP đọc."""
|
|
with self._lock:
|
|
self._html = html
|
|
|
|
def set_open_callback(self, cb: Callable[[str], None]) -> None:
|
|
"""Called (from the server thread) with the node's storage path."""
|
|
self._open_cb = cb
|
|
|
|
# ---- lifecycle ----------------------------------------------------
|
|
@property
|
|
def running(self) -> bool:
|
|
"""Máy chủ có đang chạy không."""
|
|
return self._httpd is not None
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
"""URL đầy đủ kèm token; '' nếu chưa chạy."""
|
|
if self._httpd is None:
|
|
return ""
|
|
port = self._httpd.server_address[1]
|
|
return f"http://127.0.0.1:{port}/?t={self._token}"
|
|
|
|
def start(self) -> str:
|
|
"""Start (idempotent) and return the tokenised URL to open."""
|
|
if self._httpd is not None:
|
|
return self.url
|
|
server = self
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
"""Handler HTTP: chỉ phục vụ đúng trang đồ thị, và chỉ khi token khớp."""
|
|
def log_message(self, *_a) -> None: # keep the GUI console silent
|
|
"""Tắt log của thư viện chuẩn — nếu không, console GUI bị ngập request."""
|
|
pass
|
|
|
|
def _authorized(self, query: dict) -> bool:
|
|
"""Kiểm token trong query, so sánh theo kiểu chống dò thời gian.
|
|
|
|
Máy chủ này nghe trên localhost nhưng vẫn cần token: mọi tiến trình khác
|
|
trên cùng máy đều gọi được nó.
|
|
"""
|
|
supplied = (query.get("t") or [""])[0]
|
|
return secrets.compare_digest(supplied, server._token)
|
|
|
|
def do_GET(self) -> None: # noqa: N802 - stdlib naming
|
|
"""Trả trang đồ thị khi token đúng; sai token thì trả 403."""
|
|
parsed = urlparse(self.path)
|
|
query = parse_qs(parsed.query)
|
|
if not self._authorized(query):
|
|
self.send_error(403)
|
|
return
|
|
if parsed.path == "/":
|
|
with server._lock:
|
|
body = server._html.encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
# The page must never end up cached with a stale graph.
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
elif parsed.path == "/open":
|
|
path = (query.get("path") or [""])[0]
|
|
cb = server._open_cb
|
|
if path and cb is not None:
|
|
try:
|
|
cb(path)
|
|
except Exception: # noqa: BLE001 - never kill the server
|
|
pass
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
else:
|
|
self.send_error(404)
|
|
|
|
# Port 0 = let the OS pick a free port; loopback only.
|
|
self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
self._httpd.daemon_threads = True
|
|
self._thread = threading.Thread(target=self._httpd.serve_forever,
|
|
name="graph-server", daemon=True)
|
|
self._thread.start()
|
|
return self.url
|
|
|
|
def stop(self) -> None:
|
|
"""Dừng máy chủ và giải phóng cổng."""
|
|
httpd, self._httpd = self._httpd, None
|
|
if httpd is not None:
|
|
httpd.shutdown()
|
|
httpd.server_close()
|
|
self._thread = None
|