关键词:DeepSeek Harness(DSH)、WinForms、WebView2、C#、npx、本地服务探测

一、背景

DeepSeek Harness(简称 DSH)提供了一个开箱即用的 Web GUI,只需要在终端执行一条命令:

npx @deepseek-ai/dsh web

终端会输出:

dsh web: http://127.0.0.1:3080

浏览器打开 http://127.0.0.1:3080 就能使用完整的图形界面。

我手头有一个自己维护的 WinForms 客户端(基于 WebView2 的浏览器外壳,顶部有一个网址下拉框 + 跳转按钮)。每次要用 DSH 都得:开终端 → 敲命令 → 复制地址 → 粘贴到客户端 → 回车,步骤繁琐。

目标:把 http://127.0.0.1:3080 直接放进客户端的网址下拉里,选中它时自动完成"启动服务 → 等待就绪 → 打开页面"全过程,一键直达。

这类网址和普通收藏网址不同:它需要先执行一条命令把本地服务拉起来,页面才打得开。我把它称为**“需要命令启动的特殊网址”**。

二、整体方案

用户在下拉框选中 http://127.0.0.1:3080
        │
        ▼
┌─────────────────────┐
│ 是"特殊网址"吗?      │ ── 否 ──► 直接导航(原有逻辑)
└─────────────────────┘
        │ 是
        ▼
┌─────────────────────┐
│ 端口 3080 已在监听?  │ ── 是 ──► 直接导航(不重复拉起服务)
└─────────────────────┘
        │ 否
        ▼
┌─────────────────────┐
│ cmd.exe /c npx ...   │  后台启动,不等待进程退出
└─────────────────────┘
        │
        ▼
┌─────────────────────┐
│ 轮询端口,就绪即导航   │  每 0.5s 探测一次,最长 60s
└─────────────────────┘

设计上有三个关键决策:

  1. 注册表模式:用一个 Dictionary<网址, 启动命令> 登记所有特殊网址。以后再加类似条目(比如别的本地 AI 工具),只需加一行,主流程零改动。
  2. 端口探测代替输出解析:不解析命令输出里的 URL,而是直接探测目标端口是否可连接。服务是否就绪,端口说了算——这比文本解析可靠得多,还能天然处理"服务已经在运行"的场景。
  3. 不写数据库:普通收藏网址存在 SQLite 里,特殊网址是代码内置的,只在加载时下拉去重追加,避免污染用户的收藏数据。

三、核心代码

1. 特殊网址注册表

// 需要命令启动的特殊网址:网址 -> 启动命令
// 选中这些网址时会先在终端执行对应命令,待服务就绪后再导航
private readonly Dictionary<string, string> SpecialUrls =
    new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        { "http://127.0.0.1:3080", "npx @deepseek-ai/dsh web" },
    };

2. 加载时注入下拉框(去重、不落库)

private void LoadData()
{
    // …原有逻辑:从 SQLite 读取用户收藏的网址加入 txtUrl…

    // 把需要命令启动的特殊网址加入下拉(不写数据库)
    foreach (var kv in SpecialUrls)
    {
        if (!txtUrl.Items.Contains(kv.Key))
            txtUrl.Items.Add(kv.Key);
    }
}

3. 跳转入口识别特殊网址

private async void btnGoTo_Click(object sender, EventArgs e)
{
    string uri = txtUrl.Text.Trim();
    if (webView21 == null || uri.Length == 0) return;
    if (!_webViewReady || webView21.CoreWebView2 == null)
    {
        MessageBox.Show("浏览器尚未就绪,请稍候...", "提示",
            MessageBoxButtons.OK, MessageBoxIcon.Information);
        return;
    }

    if (!uri.StartsWith("http"))
        uri = "https://" + uri;

    // 需要命令启动的特殊网址:先执行启动命令,待服务就绪后再导航
    string specialUrl = GetSpecialUrl(uri);
    if (specialUrl != null)
    {
        uri = specialUrl;
        await EnsureSpecialUrlStartedAsync(specialUrl, SpecialUrls[specialUrl]);
    }

    webView21.CoreWebView2.Navigate(uri);
}

/// <summary>
/// 判断是否为需要命令启动的特殊网址,返回登记表中的标准网址,否则返回 null
/// </summary>
private string GetSpecialUrl(string uri)
{
    foreach (var key in SpecialUrls.Keys)
    {
        if (string.Equals(uri.TrimEnd('/'), key.TrimEnd('/'),
                StringComparison.OrdinalIgnoreCase))
            return key;
    }
    return null;
}

匹配时忽略大小写和末尾斜杠,保证下拉框选中、手动输入两种路径都能命中。

4. 启动服务并等待就绪(核心)

/// <summary>
/// 确保特殊网址对应的服务已启动:已在运行则直接返回,
/// 否则执行启动命令并等待端口就绪
/// </summary>
private async Task EnsureSpecialUrlStartedAsync(string url, string command)
{
    Uri u = new Uri(url);
    int port = u.Port > 0 ? u.Port : 80;

    // 服务已在运行,无需重复启动
    if (await IsPortOpenAsync(u.Host, port, 500)) return;

    // 在终端执行启动命令
    // (如:npx @deepseek-ai/dsh web,输出 "dsh web: http://127.0.0.1:3080")
    try
    {
        ProcessStartInfo psi = new ProcessStartInfo
        {
            FileName = "cmd.exe",
            Arguments = "/c " + command,
            UseShellExecute = false,
            CreateNoWindow = CreateNoWindow   // 跟随客户端的"调试窗口"开关
        };
        Process.Start(psi); // 注意:不等待退出,服务需持续运行
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"启动命令执行失败: {ex.Message}");
    }

    // 等待服务就绪(最多 60 秒)
    for (int i = 0; i < 120; i++)
    {
        if (await IsPortOpenAsync(u.Host, port, 500)) return;
        await Task.Delay(500);
    }
    MessageBox.Show($"服务启动超时,请确认命令可正常执行:{command}",
        "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}

/// <summary>
/// 检测指定主机端口是否可连接(服务是否已监听)
/// </summary>
private async Task<bool> IsPortOpenAsync(string host, int port, int timeoutMs)
{
    try
    {
        using (var client = new System.Net.Sockets.TcpClient())
        {
            Task connectTask = client.ConnectAsync(host, port);
            Task finished = await Task.WhenAny(connectTask, Task.Delay(timeoutMs));
            return finished == connectTask && client.Connected;
        }
    }
    catch (Exception)
    {
        return false;
    }
}

四、几个值得注意的细节

① 为什么用 cmd.exe /c 包一层?

在 Windows 上,npx 不是一个真正的 .exe,而是 Node.js 安装目录下的 npx.cmd 批处理 shimProcess.Start 直接填 "npx" 会找不到文件。通过 cmd.exe /c 启动,让命令行解析走标准 PATH 查找,npx、npm、pnpm 这类 shim 都能正常工作。

② 为什么不能 WaitForExit()

dsh web 启动的是一个常驻 Web 服务,进程不会退出。如果等待进程退出,UI 线程会被永久卡死。正确姿势是 Process.Start(psi) 之后直接放手,让服务在后台跑。

③ 为什么用端口探测而不是解析控制台输出?

最初也想过重定向 StandardOutput,匹配 dsh web: (https?://\S+) 拿到真实地址。但端口探测方案明显更优:

维度 解析输出 端口探测
可靠性 依赖输出格式,版本变化可能失配 TCP 连接是成功监听的事实标准
服务已运行 会重复拉起进程 一次探测直接短路,幂等
隐藏窗口运行 重定向输出才能读 与窗口显隐无关
代码量 异步读取 + 正则 + 防死锁 一个 TcpClient 方法

④ 全程异步,UI 不冻结

btnGoTo_Click 标记为 async void(WinForms 事件处理的标准做法),等待服务就绪的 0.5 秒轮询全部走 await Task.Delay,主窗口在等待期间依然响应。首次运行 npx 可能要下载包,耗时较长,60 秒超时兜底并给出可读提示。

⑤ 超时兜底

如果 60 秒内端口始终不通(比如本机没装 Node.js),弹出提示告知用户检查命令,而不是无声失败。

五、最终效果

  • 打开客户端,网址下拉里直接多了 http://127.0.0.1:3080
  • 选中它 → 后台自动执行 npx @deepseek-ai/dsh web → 服务端口一就绪,WebView2 立即打开 DSH Web GUI;
  • 如果 DSH 服务已经在跑,选中后毫秒级直接打开,不会重复启动;
  • 普通收藏网址的跳转逻辑完全不受影响。

六、扩展性

这套"特殊网址注册表"机制是通用的。任何需要先拉起本地服务才能访问的 Web 应用,都可以一行注册接入:

private readonly Dictionary<string, string> SpecialUrls = new(StringComparer.OrdinalIgnoreCase)
{
    { "http://127.0.0.1:3080", "npx @deepseek-ai/dsh web" },
    // 未来示例:
    // { "http://127.0.0.1:7860", "python -m my_local_tool" },
    // { "http://127.0.0.1:9000", "docker start my-panel" },
};

匹配、启动、等待、导航的流程全自动复用。

七、总结

把"命令行启动的本地 Web 服务"无缝嵌入桌面客户端,核心就三件事:

  1. 注册表把"网址 → 启动命令"的映射显性化,主流程与具体工具解耦;
  2. cmd.exe /c 解决 npx shim 的启动问题,进程不等待退出;
  3. 端口探测作为服务就绪的唯一判据,天然幂等、与输出格式无关。

整套改动不足百行,却把"开终端、敲命令、等启动、复制地址"四步压缩成了下拉框里的一次点击。对于任何集成了多个本地 AI 工具的桌面客户端,这都是一个值得借鉴的小模式。


环境:Windows 11 / .NET Framework 4.8 / WebView2 / Node.js(npx)

2520bea022194d3b889bed0b5ab116ce