dsh-multi-chat 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # 💬 dsh-multi-chat —— 多对话,一屏驾驭
2
2
 
3
- > **在 DeepSeek Harness 里同时开 N 个对话,并排盯住每一个 Agent 的实时进度,还能用手机躺着看。** 一个浏览器,从「一次一个对话」升级成「全景多对话驾驶舱」。
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/dsh-multi-chat"><img src="https://img.shields.io/npm/v/dsh-multi-chat" alt="npm version"></a>
5
+ <a href="https://www.npmjs.com/package/dsh-multi-chat"><img src="https://img.shields.io/npm/dm/dsh-multi-chat" alt="npm downloads"></a>
6
+ <a href="https://github.com/daetz-coder/dsh-multi-chat/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="license"></a>
7
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/dsh-plugin-✓-brightgreen" alt="dsh-plugin"></a>
8
+ </p>
9
+
10
+ > **在 DeepSeek Harness 里同时开 N 个对话,并排盯住每一个 Agent 的实时进度,还能用手机/平板躺着看。** 一个浏览器,从「一次一个对话」升级成「全景多对话驾驶舱」。
4
11
 
5
12
  给 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 官方 Web 界面装上一面**多窗口墙**:在一张网格里同时显示 N 个正在运行的 DSH 对话实例(每个实例独立跑一个任务),所有 Agent 的实时进度、对话、输出**一眼尽收**,不用在无数标签页/窗口之间切来切去。
6
13
 
@@ -8,7 +15,7 @@
8
15
 
9
16
  | 能力 | 说明 |
10
17
  |------|------|
11
- | 📺 **多窗口墙** | 侧边栏一键进入,右侧对话区原位变成窗口网格,一个端口一格,并排看全部任务 |
18
+ | 📺 **多窗口** | 侧边栏一键进入,右侧对话区原位变成窗口网格,一个端口一格,并排看全部任务 |
12
19
  | 🔍 **自动发现** | 扫描端口区间自动发现正在运行的 DSH 实例,也可手动管理 |
13
20
  | ➕ **一键新建窗口** | 墙内直接启动全新 DSH 实例,凑成你的多对话矩阵 |
14
21
  | 📱 **手机访问** | 点「手机访问」自动起一个**内置带口令认证的局域网网关**,手机打开 URL、输入口令即可看进度 |
@@ -16,6 +23,20 @@
16
23
 
17
24
  > **多对话 = 多端口。** 启动 N 个 `dsh web --port <n>`,每个实例独立跑一个对话/任务;在任意一个实例里打开多窗口墙,即可并排看到全部。
18
25
 
26
+ ## 📸 运行效果
27
+
28
+ **🖥️ Windows · 双对话并排** —— 两个正在运行的 DSH 实例并排列出,每格都是完整的官方对话界面,带实时在线状态点与单窗控制(放大 / 刷新 / 新标签页 / 移除):
29
+
30
+ ![Windows 双对话:两个 DSH 实例并排显示](assets/01-windows-dual-chat.png)
31
+
32
+ **📱 iPad · 双对话移动端** —— 同一局域网内,iPad 打开带口令认证的网关地址,即可在平板上一屏并排盯住两个 Agent 的实时进度:
33
+
34
+ ![iPad 双对话:平板端并排显示两个 DSH 实例](assets/02-ipad-dual-chat.png)
35
+
36
+ **🖥️ Windows · 三对话全景** —— 3 列网格并排显示 3 个正在运行的实例,一屏尽收全部 Agent,把「一次一个对话」升级成「全景多对话驾驶舱」:
37
+
38
+ ![Windows 三对话:3 列网格并排显示 3 个 DSH 实例](assets/03-windows-triple-chat.png)
39
+
19
40
  ## 🚀 30 秒上手
20
41
 
21
42
  ```bash
@@ -25,7 +46,7 @@ npx dsh-multi-chat install
25
46
  # 2. 启动几个实例
26
47
  npx dsh-multi-chat start --ports 3080,3081,3082
27
48
 
28
- # 3. 打开任意实例,点侧边栏底部「多窗口墙」→ 完成 🎉
49
+ # 3. 打开任意实例,点侧边栏底部「多窗口」→ 完成 🎉
29
50
  ```
30
51
 
31
52
  ## 为什么这样做
@@ -60,7 +81,7 @@ harness-src/ # 官方 deepseek-harness 源码(开发/构
60
81
 
61
82
  # 2) 重启 dsh web,打开任意实例
62
83
  dsh web --port 3084
63
- # 浏览器打开 http://127.0.0.1:3084 ,侧边栏底部出现「多窗口墙」按钮
84
+ # 浏览器打开 http://127.0.0.1:3084 ,侧边栏底部出现「多窗口」按钮
64
85
  ```
65
86
 
66
87
  或手动:
@@ -74,7 +95,7 @@ dsh plugin --profile web add <tarball> # 装进 profile
74
95
  ## 使用
75
96
 
76
97
  1. 先启动若干实例:`.\scripts\start-multi.ps1 -Ports "3080,3081,3082,3084"`(或手动 `dsh web --port <n>`)。
77
- 2. 打开任意实例,点侧边栏底部的「多窗口墙」快捷入口(或点对话区头部的「多窗口墙」标签页)。
98
+ 2. 打开任意实例,点侧边栏底部的「多窗口」快捷入口(或点对话区头部的「多窗口」标签页)。
78
99
  3. 墙视图内:自动发现实例(自动排除自身端口)、列数切换(自动/1/2/3/4/6,默认横向铺满)、点标题放大、⟳ 单独刷新、↗ 新标签页打开、✕ 从视图移除、全部刷新、实时在线状态点。布局保存在 localStorage。
79
100
  4. 退出墙:点工具栏**右上角的「退出」按钮**,一键切回对话视图。
80
101
 
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-multi-chat",
3
- "version": "0.5.2",
4
- "description": "DSH 多对话:在官方 DeepSeek Harness Web 界面里并排运行和监控多个对话实例(多窗口墙),并内置带口令认证的手机访问网关。npx dsh-multi-chat install|start|stop",
3
+ "version": "0.6.1",
4
+ "description": "DSH 多对话:在官方 DeepSeek Harness Web 界面里以多窗口网格并排运行、监控多个对话实例,一屏盯住所有 Agent 的实时进度;内置带口令认证的局域网网关,手机/平板也能看。npx dsh-multi-chat install|start|stop|gateway",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -21,6 +21,8 @@
21
21
  "deepseek",
22
22
  "multi-chat",
23
23
  "multi-conversation",
24
+ "multi-window",
25
+ "multi-agent",
24
26
  "client-plugin",
25
27
  "gateway",
26
28
  "phone-access"
@@ -29,6 +31,7 @@
29
31
  "bin/",
30
32
  "scripts/",
31
33
  "patches/",
34
+ "assets/",
32
35
  "plugin/dsh-client-ui-multi-wall/package.json",
33
36
  "plugin/dsh-client-ui-multi-wall/README.md",
34
37
  "plugin/dsh-client-ui-multi-wall/lib/index.js",
@@ -242,27 +242,27 @@ window.__ModuleLoader__.load({
242
242
  document.head.appendChild(tag);
243
243
  }
244
244
  var WallView_module_css_default = {
245
- "danger": "ouhzTG_danger",
246
- "action": "ouhzTG_action",
247
- "confirm": "ouhzTG_confirm",
248
- "field": "ouhzTG_field",
245
+ "paneActions": "ouhzTG_paneActions",
246
+ "linkBar": "ouhzTG_linkBar",
249
247
  "wall": "ouhzTG_wall",
250
- "grid": "ouhzTG_grid",
248
+ "action": "ouhzTG_action",
249
+ "danger": "ouhzTG_danger",
250
+ "paneBody": "ouhzTG_paneBody",
251
+ "title": "ouhzTG_title",
251
252
  "hint": "ouhzTG_hint",
253
+ "empty": "ouhzTG_empty",
254
+ "zoomed": "ouhzTG_zoomed",
255
+ "field": "ouhzTG_field",
256
+ "paneHead": "ouhzTG_paneHead",
252
257
  "status": "ouhzTG_status",
253
258
  "paneTitle": "ouhzTG_paneTitle",
254
- "controls": "ouhzTG_controls",
255
- "linkText": "ouhzTG_linkText",
256
- "paneHead": "ouhzTG_paneHead",
257
- "paneBody": "ouhzTG_paneBody",
258
- "empty": "ouhzTG_empty",
259
+ "grid": "ouhzTG_grid",
259
260
  "dot": "ouhzTG_dot",
260
- "toolbar": "ouhzTG_toolbar",
261
- "linkBar": "ouhzTG_linkBar",
262
- "title": "ouhzTG_title",
263
261
  "pane": "ouhzTG_pane",
264
- "paneActions": "ouhzTG_paneActions",
265
- "zoomed": "ouhzTG_zoomed"
262
+ "confirm": "ouhzTG_confirm",
263
+ "toolbar": "ouhzTG_toolbar",
264
+ "controls": "ouhzTG_controls",
265
+ "linkText": "ouhzTG_linkText"
266
266
  };
267
267
  //#endregion
268
268
  //#region src/client/WallView.tsx
@@ -294,6 +294,27 @@ window.__ModuleLoader__.load({
294
294
  /** Embed flag appended to every pane URL; such pages register no wall UI. */
295
295
  const EMBED_FLAG = "multi-wall=embed";
296
296
  /**
297
+ * Whether this wall is being viewed through the phone gateway (a non-loopback
298
+ * host) rather than on the machine running DSH. When remote, pane iframes must
299
+ * load through the gateway's `/gw/<port>` route — a phone's `127.0.0.1` points
300
+ * at the phone itself, not the host.
301
+ */
302
+ function isRemoteViewer() {
303
+ const host = typeof window !== "undefined" ? window.location.hostname : "";
304
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
305
+ }
306
+ /**
307
+ * The iframe URL for a pane. Local viewers embed the loopback instance
308
+ * directly; remote (phone) viewers route through the gateway that is already
309
+ * serving this page, appending the port so the gateway proxies to it.
310
+ * @param port - target DSH instance port.
311
+ * @returns the pane URL.
312
+ */
313
+ function paneUrl(port) {
314
+ if (isRemoteViewer()) return `${window.location.origin}/gw/${port}/?${EMBED_FLAG}`;
315
+ return `http://127.0.0.1:${port}/?${EMBED_FLAG}`;
316
+ }
317
+ /**
297
318
  * One pane: header (port, liveness dot, zoom/refresh/open/stop/remove) plus
298
319
  * the embedded original DSH UI.
299
320
  */
@@ -338,7 +359,7 @@ window.__ModuleLoader__.load({
338
359
  className: WallView_module_css_default.action,
339
360
  title: t("openTab"),
340
361
  onClick: () => {
341
- window.open(`http://127.0.0.1:${port}/`, "_blank");
362
+ window.open(paneUrl(port), "_blank");
342
363
  },
343
364
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRightUpOutline16, { size: 14 })
344
365
  }),
@@ -363,7 +384,7 @@ window.__ModuleLoader__.load({
363
384
  className: WallView_module_css_default.paneBody,
364
385
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
365
386
  title: `DSH :${port}`,
366
- src: `http://127.0.0.1:${port}/?${EMBED_FLAG}`,
387
+ src: paneUrl(port),
367
388
  loading: "lazy"
368
389
  })
369
390
  })]
@@ -660,8 +681,8 @@ window.__ModuleLoader__.load({
660
681
  document.head.appendChild(tag);
661
682
  }
662
683
  var WallToggle_module_css_default = {
663
- "label": "bmjS6q_label",
664
- "row": "bmjS6q_row"
684
+ "row": "bmjS6q_row",
685
+ "label": "bmjS6q_label"
665
686
  };
666
687
  //#endregion
667
688
  //#region src/client/WallToggle.tsx
@@ -170,11 +170,13 @@ function injectPolyfill(body) {
170
170
  * `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
171
171
  * request. Non-upgrade requests get `connection: close` (fresh connection per
172
172
  * request keeps keep-alive from smuggling unrewritten Host headers); upgrades
173
- * (WebSocket) keep `connection: Upgrade`.
173
+ * (WebSocket) keep `connection: Upgrade`. When `pathOverride` is set (a
174
+ * `/gw/<port>` route), the request line's path becomes the de-prefixed path.
174
175
  */
175
- function rewriteHead(parsed, targetPort) {
176
+ function rewriteHead(parsed, targetPort, pathOverride) {
176
177
  const upgrade = parsed.map.upgrade !== void 0;
177
- const lines = [`${parsed.method} ${parsed.target} ${parsed.version}`];
178
+ const requestTarget = pathOverride !== void 0 ? pathOverride : parsed.target;
179
+ const lines = [`${parsed.method} ${requestTarget} ${parsed.version}`];
178
180
  for (const [name, value] of parsed.headers) if (name === "host") lines.push(`host: 127.0.0.1:${targetPort}`);
179
181
  else if (name === "origin") lines.push(`origin: http://127.0.0.1:${targetPort}`);
180
182
  else if (name === "connection" || name === "proxy-connection") {} else lines.push(`${name}: ${value}`);
@@ -187,7 +189,8 @@ function rewriteHead(parsed, targetPort) {
187
189
  * @returns a handle with the assigned port and token, and a close().
188
190
  */
189
191
  function startGateway(options) {
190
- const { targetPort, port: requestedPort, token, name, maxAgeHours = 12, log = () => {} } = options;
192
+ const { targetPort, port: requestedPort, token, name, maxAgeHours = 12, routedPorts, log = () => {} } = options;
193
+ const allowedRouted = new Set(routedPorts ?? []);
191
194
  const hmac = (data) => createHmac("sha256", token).update(data).digest("hex");
192
195
  const signSession = (exp) => {
193
196
  const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
@@ -412,6 +415,19 @@ connection: close\r
412
415
  handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
413
416
  return;
414
417
  }
418
+ let routePort = null;
419
+ let routePath;
420
+ const routeMatch = /^\/gw\/(\d+)(\/.*)?$/.exec(path);
421
+ if (routeMatch !== null) {
422
+ const candidate = Number(routeMatch[1]);
423
+ if (!Number.isInteger(candidate) || candidate <= 0 || !allowedRouted.has(candidate)) {
424
+ respondPlain(socket, "403", "port not routable");
425
+ return;
426
+ }
427
+ routePort = candidate;
428
+ routePath = routeMatch[2] && routeMatch[2] !== "" ? routeMatch[2] : "/";
429
+ }
430
+ const effectivePort = routePort ?? targetPort;
415
431
  const kind = authKind(parsed);
416
432
  if (kind === "none") {
417
433
  respondLogin(socket, name, path === "/" ? "/" : path);
@@ -421,19 +437,19 @@ connection: close\r
421
437
  respondSessionRedirect(socket, parsed, `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`);
422
438
  return;
423
439
  }
424
- const target = connect(targetPort, "127.0.0.1");
440
+ const target = connect(effectivePort, "127.0.0.1");
425
441
  const rest = buffer.subarray(parsed.headBytes);
426
442
  let connected = false;
427
443
  target.on("connect", () => {
428
444
  connected = true;
429
- target.write(rewriteHead(parsed, targetPort));
445
+ target.write(rewriteHead(parsed, effectivePort, routePath));
430
446
  if (rest.length > 0) target.write(rest);
431
447
  proxying = true;
432
448
  socket.pipe(target);
433
449
  relayTarget(socket, target);
434
450
  });
435
451
  target.on("error", (error) => {
436
- if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${targetPort}: ${error.message}`);
452
+ if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${effectivePort}: ${error.message}`);
437
453
  socket.destroy();
438
454
  });
439
455
  socket.on("error", () => target.destroy());
@@ -817,7 +833,8 @@ function lanAddresses() {
817
833
  if (virtual) continue;
818
834
  candidates.push({
819
835
  address: iface.address,
820
- physical
836
+ physical,
837
+ virtual: false
821
838
  });
822
839
  }
823
840
  }
@@ -960,11 +977,15 @@ function apply(ctx, config = {}) {
960
977
  const token = config.gatewayToken && config.gatewayToken !== "" ? config.gatewayToken : randomBytes(6).toString("hex");
961
978
  const gatewayPort = config.gatewayPort && config.gatewayPort !== 0 ? config.gatewayPort : port + 5e3;
962
979
  gatewayTargetPort = port;
980
+ const routed = [];
981
+ for (let p = scanFrom; p <= scanTo; p++) routed.push(p);
982
+ for (const p of fixedPorts) if (!routed.includes(p)) routed.push(p);
963
983
  return startGateway({
964
984
  targetPort: port,
965
985
  port: gatewayPort,
966
986
  token,
967
987
  name: "DSH",
988
+ routedPorts: routed,
968
989
  log: (msg) => ctx.logger.info(`multi-wall gateway: ${msg}`)
969
990
  }).then((handle) => {
970
991
  gateway = handle;