dsh-session-conductor 1.3.4 → 1.3.6

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
@@ -137,10 +137,29 @@ allowBuilds:
137
137
 
138
138
  ### 真实后端预览页
139
139
 
140
- 改 UI 后免重启查看效果:
140
+ 改 UI 后免重启查看效果 —— **预览能力已移出本插件**,统一用独立插件 `dsh-useful-tools`(**0.1.11**)的 `preview` / `minihost` 工具。
141
141
 
142
- - **快照模式(推荐,免重启)**:`bash tools/preview-snapshot.sh` → 生成 `assets/preview-*.html`(内嵌生成时真实后端响应),file:// 打开即可查看,无 CORS、无假数据
143
- - **DSH 同源路由**:重启后访问 `/api/session-conductor/preview/settings`(或 `/panel`),同源实时 fetch
142
+ **最短三行(实测可用;本仓库没有 `assets/preview.spec.mjs` 也能出真实界面)**:
143
+
144
+ ```
145
+ minihost { action: "start", plugin: "<插件根>", port: 31177, services: "<插件根>/test/api/helpers/fake-services.mjs" }
146
+ preview { action: "gen", plugin: "<插件根>" }
147
+ preview { action: "serve", file: "<插件根>/assets/preview.html", backend: "http://127.0.0.1:31177" }
148
+ ```
149
+
150
+ 停止:`minihost { action:"stop", port:31177 }` + `preview { action:"stop", port:31188 }`;状态:两者都用 `action:"status"`。
151
+
152
+ **要点**:
153
+
154
+ - **`services` 必须传**(0.1.11 新增):本插件 `apply()` 依赖 `storageDomain` 等宿主服务,不注入时 `/templates` 会 500、页面显示「storageDomain 不可用」;注入 `test/api/helpers/fake-services.mjs` 后 `/templates` 返回 200 真实数据(已实测)。
155
+ - **autoMount**(0.1.7 起):无 spec 时 mock 宿主 `__ModuleLoader__` / `require` / `ctx`(slots / locale / effect),调插件 `apply(ctx)`,把 `slots.register(def, component)` 捕获到的组件 `createRoot().render()` 到 `#root`。**限制**:组件若依赖 `useSettings` / `useSession` 这类宿主 hooks 会渲染失败(页面会显示错误与已捕获插槽清单,不白屏);要 100% 还原仍需 spec 提供 mock。locale 取插件自己 `ctx.locale.register` 的字典,键名不一致会回退成 key。
156
+ - `gen` 默认产物是 `<插件根>/assets/preview.html`(会写进仓库)⇒ 不想入库就传 `out`(如 `out: "/tmp/sc-preview.html"`),并让 `serve` 的 `file` 指向同一路径。本仓库已把该产物加进 `.gitignore`。
157
+ - `serve` 默认端口 **31188**,显式传 `port` 才用你给的;`backend` 可指向 minihost 的端口(`/api/*` 反代到模拟后端),也可指向**真实 DSH 后端**。
158
+ - 显式传 `spec` 但文件不存在会**直接报错**(不静默降级)—— 故意的,避免指错文件还生成一个看不懂的页面。
159
+ - **截图**(README 配图)走 **browser 插件的 `browser_screenshot`**;「自包含快照」形态不做(理由见 `dsh-useful-tools` 的 `docs/NOT-DONE.md`)。
160
+ - **隔离实例自测**(0.1.8/0.1.9):`test_env { action:"create"|"start"|"stop"|"status", plugin, pluginName }` 起独立 `DSH_HOME` + 自动端口的实例;`sync_plugin { write:true, register:true, dshHome:"<测试环境 DSH_HOME>" }` 把插件装进去(写 profile 的 `dependencies` + `dsh.profile.bundles`,并对齐 `node_modules` 副本)。
161
+ - 停止:`minihost { action: "stop", port: 31177 }` + `preview { action: "stop", port: 31200 }`;状态:两者都用 `action: "status"`。
162
+ - **截图**(README 配图)改走 **browser 插件的 `browser_screenshot`**;「自包含快照」形态不做(理由见 `dsh-useful-tools` 的 `docs/NOT-DONE.md`)。
144
163
 
145
164
  ### 聊天框指令:`/model-switch`
146
165
 
@@ -221,7 +240,6 @@ allowBuilds:
221
240
  | `/templates/dir` | GET | 目录浏览 |
222
241
  | `/auto-rename-model` | GET | 自动重命名模型(透传官方 modelCatalog) |
223
242
  | `/i18n` | GET | 语言包(zh/en 外置 JSON) |
224
- | `/preview/settings` `/preview/panel` | GET | 真实后端预览页(同源服务) |
225
243
 
226
244
  示例:
227
245
 
package/lib/apply.js CHANGED
@@ -234,35 +234,11 @@ export async function apply(ctx, config = {}) {
234
234
  },
235
235
  });
236
236
 
237
- // ---------- GET /api/session-conductor/preview/<settings|panel> ----------
238
- // 真实后端预览页:单文件 html(assets/preview-*.html)经本路由同源服务,浏览器从 DSH 反代地址
239
- // 打开即同源 fetch /api/*(file:// 双击会被 CORS 拦——DSH API 不带 Access-Control-Allow-Origin)。
240
- // 用 kind:"exact" 枚举两个预览(DSH 的 kind:"prefix" 实测 401 不生效)
241
- const PREVIEW_FILES = {
242
- settings: new URL("../../assets/preview-settings.html", import.meta.url),
243
- panel: new URL("../../assets/preview-panel.html", import.meta.url),
244
- };
245
- const servePreview = async (req, res, name) => {
246
- try {
247
- if ((req.method ?? "GET") !== "GET") {
248
- return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
249
- }
250
- const file = PREVIEW_FILES[name];
251
- if (!file) return send(res, 404, { ok: false, error: { code: "NOT_FOUND", message: "未知预览: " + name } });
252
- const html = readFileSync(file, "utf8");
253
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
254
- return res.end(html);
255
- } catch (error) {
256
- return send(res, 500, { ok: false, error: { code: "INTERNAL", message: String(error?.message ?? error) } });
257
- }
258
- };
259
- for (const name of ["settings", "panel"]) {
260
- webServer.register({
261
- kind: "exact",
262
- path: "/api/session-conductor/preview/" + name,
263
- handler: (req, res) => servePreview(req, res, name),
264
- });
265
- }
237
+ // ---------- 预览页已移出本插件 ----------
238
+ // 原先这里用 /api/session-conductor/preview/<settings|panel> 同源服务 assets/preview-*.html
239
+ // (那两份 html 由已移除的 tools/preview-snapshot.sh 生成)。现在预览能力统一由独立插件
240
+ // dsh-useful-tools 的 `preview` 工具提供:gen 生成 + serve 带 backend 反代真实后端,
241
+ // 效果等价且本插件不必再托管静态页,故路由与产物一并移除。
266
242
 
267
243
  // ---------- GET /api/session-conductor/list ----------
268
244
  webServer.register({
@@ -17,10 +17,24 @@
17
17
  locale: NS,
18
18
  inject: () => ({
19
19
  onOpenSession: (sessionId) => {
20
+ // 打开会话的真实入口随宿主版本不同,按优先级尝试(**不要只认一个**):
21
+ // · workspaceNavigation.openSession(id) —— dsh-client-ui-conversation 的用法
22
+ // · ctx.uiWorkspace.openSession(id) —— dsh-client-ui-chat 的用法
23
+ // · sessions.open(id) —— 早期路径,保留兜底
24
+ // 以前只写了第三个,而宿主客户端并没有 `sessions` 这个服务 ⇒ 可选链把失败吞成
25
+ // 「点了没反应」,且不报错、难查。所以这里:多个入口都试;全失败要留下痕迹。
20
26
  try {
21
- ctx.get("sessions")?.open?.(sessionId);
22
- } catch {
23
- // 打开失败不阻塞面板
27
+ const nav = ctx.get?.("workspaceNavigation");
28
+ if (typeof nav?.openSession === "function") return nav.openSession(sessionId);
29
+ if (typeof ctx.uiWorkspace?.openSession === "function") return ctx.uiWorkspace.openSession(sessionId);
30
+ const legacy = ctx.get?.("sessions");
31
+ if (typeof legacy?.open === "function") return legacy.open(sessionId);
32
+ // eslint-disable-next-line no-console
33
+ console.warn("[session-conductor] 打不开会话:宿主客户端未暴露 workspaceNavigation/uiWorkspace/sessions 任一入口", sessionId);
34
+ } catch (error) {
35
+ // 打开失败不阻塞面板,但不再静默
36
+ // eslint-disable-next-line no-console
37
+ console.warn("[session-conductor] 打开会话失败:", error);
24
38
  }
25
39
  },
26
40
  }),
@@ -38,6 +52,6 @@
38
52
  * 本卡只保留:
39
53
  * · 分组列表(GET /api/session-conductor/group/list,只读展示)
40
54
  * · 每行「在分组下新建会话」(POST /api/session-conductor/group/new-session {workspaceId})
41
- * · 「新建会话(上次会话工作区)」(new-session 不带 workspaceId = 最近活跃会话 cwd 归属分组)
55
+ * · 「新建会话(上一次使用的工作区)」(new-session 不带 workspaceId = 最近活跃会话 cwd 归属分组)
42
56
  */
43
57
 
@@ -768,7 +768,33 @@
768
768
  }),
769
769
  (0, react_jsx_runtime.jsx)("div", {
770
770
  style: S.rowSid,
771
- title: "session id",
771
+ title: "点击复制会话 id",
772
+ // 单击复制:优先 Clipboard API,退回 execCommand(旧版宿主/非安全上下文);
773
+ // 成功后把这段文字临时换成「✅ 已复制」再复原 —— 不依赖宿主 toast(旧版未必有)。
774
+ onClick: (event) => {
775
+ event.stopPropagation();
776
+ const el = event.currentTarget;
777
+ const flash = () => {
778
+ try {
779
+ const prev = el.textContent;
780
+ el.textContent = "✅ 已复制";
781
+ setTimeout(() => { el.textContent = prev; }, 1200);
782
+ } catch { /* 提示失败不影响复制 */ }
783
+ };
784
+ try {
785
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
786
+ navigator.clipboard.writeText(session.id).then(flash, () => {});
787
+ } else {
788
+ const ta = document.createElement("textarea");
789
+ ta.value = session.id;
790
+ document.body.appendChild(ta);
791
+ ta.select();
792
+ document.execCommand("copy");
793
+ ta.remove();
794
+ flash();
795
+ }
796
+ } catch { /* 复制失败不阻塞面板 */ }
797
+ },
772
798
  children: session.id,
773
799
  }),
774
800
  ],
package/lib/client.js CHANGED
@@ -1177,7 +1177,33 @@ window.__ModuleLoader__.load({
1177
1177
  }),
1178
1178
  (0, react_jsx_runtime.jsx)("div", {
1179
1179
  style: S.rowSid,
1180
- title: "session id",
1180
+ title: "点击复制会话 id",
1181
+ // 单击复制:优先 Clipboard API,退回 execCommand(旧版宿主/非安全上下文);
1182
+ // 成功后把这段文字临时换成「✅ 已复制」再复原 —— 不依赖宿主 toast(旧版未必有)。
1183
+ onClick: (event) => {
1184
+ event.stopPropagation();
1185
+ const el = event.currentTarget;
1186
+ const flash = () => {
1187
+ try {
1188
+ const prev = el.textContent;
1189
+ el.textContent = "✅ 已复制";
1190
+ setTimeout(() => { el.textContent = prev; }, 1200);
1191
+ } catch { /* 提示失败不影响复制 */ }
1192
+ };
1193
+ try {
1194
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
1195
+ navigator.clipboard.writeText(session.id).then(flash, () => {});
1196
+ } else {
1197
+ const ta = document.createElement("textarea");
1198
+ ta.value = session.id;
1199
+ document.body.appendChild(ta);
1200
+ ta.select();
1201
+ document.execCommand("copy");
1202
+ ta.remove();
1203
+ flash();
1204
+ }
1205
+ } catch { /* 复制失败不阻塞面板 */ }
1206
+ },
1181
1207
  children: session.id,
1182
1208
  }),
1183
1209
  ],
@@ -2106,10 +2132,24 @@ window.__ModuleLoader__.load({
2106
2132
  locale: NS,
2107
2133
  inject: () => ({
2108
2134
  onOpenSession: (sessionId) => {
2135
+ // 打开会话的真实入口随宿主版本不同,按优先级尝试(**不要只认一个**):
2136
+ // · workspaceNavigation.openSession(id) —— dsh-client-ui-conversation 的用法
2137
+ // · ctx.uiWorkspace.openSession(id) —— dsh-client-ui-chat 的用法
2138
+ // · sessions.open(id) —— 早期路径,保留兜底
2139
+ // 以前只写了第三个,而宿主客户端并没有 `sessions` 这个服务 ⇒ 可选链把失败吞成
2140
+ // 「点了没反应」,且不报错、难查。所以这里:多个入口都试;全失败要留下痕迹。
2109
2141
  try {
2110
- ctx.get("sessions")?.open?.(sessionId);
2111
- } catch {
2112
- // 打开失败不阻塞面板
2142
+ const nav = ctx.get?.("workspaceNavigation");
2143
+ if (typeof nav?.openSession === "function") return nav.openSession(sessionId);
2144
+ if (typeof ctx.uiWorkspace?.openSession === "function") return ctx.uiWorkspace.openSession(sessionId);
2145
+ const legacy = ctx.get?.("sessions");
2146
+ if (typeof legacy?.open === "function") return legacy.open(sessionId);
2147
+ // eslint-disable-next-line no-console
2148
+ console.warn("[session-conductor] 打不开会话:宿主客户端未暴露 workspaceNavigation/uiWorkspace/sessions 任一入口", sessionId);
2149
+ } catch (error) {
2150
+ // 打开失败不阻塞面板,但不再静默
2151
+ // eslint-disable-next-line no-console
2152
+ console.warn("[session-conductor] 打开会话失败:", error);
2113
2153
  }
2114
2154
  },
2115
2155
  }),
@@ -2127,7 +2167,7 @@ window.__ModuleLoader__.load({
2127
2167
  * 本卡只保留:
2128
2168
  * · 分组列表(GET /api/session-conductor/group/list,只读展示)
2129
2169
  * · 每行「在分组下新建会话」(POST /api/session-conductor/group/new-session {workspaceId})
2130
- * · 「新建会话(上次会话工作区)」(new-session 不带 workspaceId = 最近活跃会话 cwd 归属分组)
2170
+ * · 「新建会话(上一次使用的工作区)」(new-session 不带 workspaceId = 最近活跃会话 cwd 归属分组)
2131
2171
  */
2132
2172
 
2133
2173
  return module.exports;
@@ -180,7 +180,15 @@ export function formatRecallBlock(turns, opts = {}) {
180
180
  kept.unshift(block);
181
181
  used += block.length;
182
182
  }
183
- return head + kept.join("");
183
+ // 有轮次因总长上限被丢掉时**如实说明**:静默丢轮次是「要 30 轮只回 5 轮」那个 bug 的根因之一,
184
+ // 调用方需要知道自己拿到的是残缺结果、以及怎么取到更早的内容。
185
+ const dropped = list.length - kept.length;
186
+ const tail = dropped > 0
187
+ ? `\n\n(⚠️ 受总长上限 ${maxTotal} 字符限制,最早的 ${dropped} 轮未包含;需要更早的内容请调小 rounds 分次取)`
188
+ : "";
189
+ // 输出末尾附上实际字符数:调用方据此判断这次取回的代价(也便于对照 maxTotalChars 的截断阈值)。
190
+ const body = head + kept.join("") + tail;
191
+ return `${body}\n\n(本次输出正文 ${body.length} 字符)`;
184
192
  }
185
193
 
186
194
  /**
@@ -291,7 +299,9 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
291
299
  });
292
300
  if (!turns || turns.length === 0) return "没取到可用的记忆(日志缺失,或该会话还没有用户消息)。";
293
301
  const text = format(turns, {
294
- maxTotalChars: config?.retrieveMemoryMaxChars,
302
+ // 手动路径的总额**按请求规模算**(rounds × 单条上限),不套自动注入那条 4000 字符的紧上限:
303
+ // 调用方明确要 N 轮,就该给 N 轮;套紧上限会把最早的轮次静默丢掉(实测 rounds:30 只剩 5 轮)。
304
+ maxTotalChars: rounds * (config?.retrieveMemoryMaxCharsPerItem ?? 800),
295
305
  manual: true,
296
306
  // 跨会话时标注来源,并写明只作背景——别把别处的文字当成当前这轮的要求
297
307
  ...(fromOther ? { sourceNote: `来自另一个会话 ${targetId}` } : {}),
@@ -262,11 +262,18 @@ export function contentOf(text) {
262
262
  return [{ type: "text", text: String(text) }];
263
263
  }
264
264
 
265
- /** 投递一条消息到目标会话(走本体官方 prompt;目标空闲时会被 resume 唤醒)。 */
266
- export function deliverPrompt(sc, { sessionId, text, mode, requestId }) {
265
+ /**
266
+ * 投递一条消息到目标会话(走本体官方 prompt;目标空闲时会被 resume 唤醒)。
267
+ *
268
+ * 【为什么自动加发送者标记】本体 prompt 只带正文,接收方看不到「这条是谁发的」——实测出现过
269
+ * 「A 会话投递的消息,B 会话以为是当前这一方发的」。这里统一在正文头部加一行
270
+ * `【来自会话 <sessionId>】`,id 由调用方从 `currentSessionId(exec.agent)` 取,**不需要 AI 自己写**。
271
+ */
272
+ export function deliverPrompt(sc, { sessionId, text, mode, requestId, from }) {
273
+ const body = from ? `【来自会话 ${from}】\n${String(text ?? "")}` : String(text ?? "");
267
274
  // 本体签名是 prompt(request, signal):signal 必传(内部会 throwIfAborted)。
268
275
  return sc.prompt(
269
- { requestId, sessionId, mode, content: contentOf(text) },
276
+ { requestId, sessionId, mode, content: contentOf(body) },
270
277
  AbortSignal.timeout(PROMPT_TIMEOUT_MS),
271
278
  );
272
279
  }
@@ -236,7 +236,7 @@ function registerSendToSession(ctx, defineTool, tools) {
236
236
  if (isInFlight(requestId)) return `投递失败:同一 requestId 正在投递中(${requestId})`;
237
237
  markInFlight(requestId);
238
238
  try {
239
- await deliverPrompt(sc, { sessionId: to, text, mode, requestId });
239
+ await deliverPrompt(sc, { sessionId: to, text, mode, requestId, from });
240
240
  logMessaging(ctx, `跨会话投递 ${from ?? "?"} → ${to}(mode=${mode} requestId=${requestId} hops=${hops})`);
241
241
  return `已投递到 ${to}(mode=${mode} | requestId=${requestId})。`
242
242
  + (mode === "steer"
@@ -341,7 +341,7 @@ function registerCreateSession(ctx, defineTool, tools) {
341
341
  }
342
342
  if (text.trim()) {
343
343
  try {
344
- await deliverPrompt(sc, { sessionId, text, mode: "queue", requestId: newRequestId() });
344
+ await deliverPrompt(sc, { sessionId, text, mode: "queue", requestId: newRequestId(), from: currentSessionId(exec?.agent) });
345
345
  } catch (error) {
346
346
  notes.push(`⚠️ 会话已建,但初始消息未投递(${mapPromptError(error)}:${shortError(error)})`);
347
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",