216 lines
6.7 KiB
Python
216 lines
6.7 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()
|