关键词: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
└─────────────────────┘
设计上有三个关键决策:
- 注册表模式:用一个
Dictionary<网址, 启动命令>登记所有特殊网址。以后再加类似条目(比如别的本地 AI 工具),只需加一行,主流程零改动。 - 端口探测代替输出解析:不解析命令输出里的 URL,而是直接探测目标端口是否可连接。服务是否就绪,端口说了算——这比文本解析可靠得多,还能天然处理"服务已经在运行"的场景。
- 不写数据库:普通收藏网址存在 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 批处理 shim。Process.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 服务"无缝嵌入桌面客户端,核心就三件事:
- 注册表把"网址 → 启动命令"的映射显性化,主流程与具体工具解耦;
cmd.exe /c解决 npx shim 的启动问题,进程不等待退出;- 端口探测作为服务就绪的唯一判据,天然幂等、与输出格式无关。
整套改动不足百行,却把"开终端、敲命令、等启动、复制地址"四步压缩成了下拉框里的一次点击。对于任何集成了多个本地 AI 工具的桌面客户端,这都是一个值得借鉴的小模式。
环境:Windows 11 / .NET Framework 4.8 / WebView2 / Node.js(npx)
