Files
kefu/wechat_rpa/dsh_web_server.py
2026-08-18 17:25:22 +08:00

216 lines
6.4 KiB
Python

# -*- coding: utf-8 -*-
"""Launch the official DeepSeek Harness Web UI (`npx @deepseek-ai/dsh web`)."""
from __future__ import annotations
import os
import re
import shutil
import socket
import subprocess
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from runtime_paths import application_data_dir
READY_RE = re.compile(r"dsh web:\s*(https?://127\.0\.0\.1:\d+)", re.I)
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 18765
START_TIMEOUT_SECONDS = 180.0
PACKAGE = "@deepseek-ai/dsh"
def parse_ready_url(text: str) -> str:
match = READY_RE.search(str(text or ""))
return match.group(1).rstrip("/") if match else ""
def workspace_dir() -> Path:
path = application_data_dir() / "dsh_desk" / "workspace"
path.mkdir(parents=True, exist_ok=True)
return path
def _which(name: str) -> str:
found = shutil.which(name)
if found:
return found
if os.name == "nt":
return shutil.which(f"{name}.cmd") or shutil.which(f"{name}.exe") or ""
return ""
def launch_command(port: int = DEFAULT_PORT) -> list[str]:
npx = _which("npx")
if not npx:
raise FileNotFoundError(
"未找到 Node.js / npx。请安装 Node 22.19+ 或 24+ 后再启动官方 DeepSeek Harness。"
)
args = [
npx,
"--yes",
PACKAGE,
"web",
"--host",
DEFAULT_HOST,
"--port",
str(int(port)),
]
if os.name == "nt" and npx.lower().endswith((".cmd", ".bat")):
comspec = os.environ.get("COMSPEC") or "cmd.exe"
args = [comspec, "/d", "/s", "/c"] + args
return args
def port_open(port: int, host: str = DEFAULT_HOST) -> bool:
try:
with socket.create_connection((host, int(port)), timeout=0.4):
return True
except OSError:
return False
def http_ready(url: str, timeout: float = 1.5) -> bool:
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
return int(getattr(response, "status", 200) or 200) < 500
except (urllib.error.URLError, TimeoutError, OSError):
return False
class DshWebServer:
"""One official `dsh web` process, reused for the embedded AI 客服 page."""
def __init__(self, port: int = DEFAULT_PORT) -> None:
self.port = int(port)
self.url = f"http://{DEFAULT_HOST}:{self.port}"
self._proc: subprocess.Popen[str] | None = None
self._owned = False
self._lock = threading.Lock()
self._log: list[str] = []
def start(self, timeout: float = START_TIMEOUT_SECONDS) -> str:
with self._lock:
if self._proc is not None and self._proc.poll() is None:
return self.url
if http_ready(self.url):
self._owned = False
return self.url
args = launch_command(self.port)
env = os.environ.copy()
try:
import ai_config
key = str(getattr(ai_config, "AI_API_KEY", "") or "").strip()
base = str(getattr(ai_config, "AI_API_BASE", "") or "").strip()
if key:
env["DEEPSEEK_API_KEY"] = key
if base:
env["DEEPSEEK_BASE_URL"] = base
except Exception:
pass
cwd = str(workspace_dir())
creationflags = 0
startupinfo = None
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
self._proc = subprocess.Popen(
args,
cwd=cwd,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
creationflags=creationflags,
startupinfo=startupinfo,
)
self._owned = True
threading.Thread(
target=self._pump_output,
name="dsh-web-stdout",
daemon=True,
).start()
deadline = time.monotonic() + max(15.0, float(timeout))
while time.monotonic() < deadline:
proc = self._proc
if proc is not None and proc.poll() is not None:
raise RuntimeError(self._fail_message(f"进程已退出,代码 {proc.returncode}"))
snapshot = "\n".join(self._log[-40:])
parsed = parse_ready_url(snapshot)
if parsed:
self.url = parsed
if http_ready(self.url):
return self.url
time.sleep(0.4)
raise RuntimeError(self._fail_message("启动超时"))
def stop(self) -> None:
with self._lock:
proc = self._proc
self._proc = None
owned = self._owned
self._owned = False
if not owned or proc is None:
return
if proc.poll() is not None:
return
if os.name == "nt":
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
def _pump_output(self) -> None:
proc = self._proc
if proc is None or proc.stdout is None:
return
for line in proc.stdout:
text = line.rstrip()
if text:
self._log.append(text)
if len(self._log) > 400:
del self._log[:200]
def _fail_message(self, reason: str) -> str:
tail = "\n".join(self._log[-12:])
detail = f"{reason}{tail}" if tail else reason
return f"官方 DeepSeek Harness Web UI 未能启动:{detail}"
_server: DshWebServer | None = None
_server_lock = threading.Lock()
def get_server() -> DshWebServer:
global _server
with _server_lock:
if _server is None:
_server = DshWebServer()
return _server
def stop_server() -> None:
global _server
with _server_lock:
server = _server
_server = None
if server is not None:
server.stop()