dsh-mini-remote 0.1.7 → 0.1.8

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/lib/page.html CHANGED
@@ -125,6 +125,79 @@
125
125
  }
126
126
  .icon-btn:active { transform: scale(.94); }
127
127
 
128
+ /* ---------- 子智能体(2026-10-03) ----------
129
+ 整块**只新增两条外观**:列表里的一行、聊天记录里那张完工卡片。其余全部复用:
130
+ 抽屉外壳用设置那一套(#subagents 的骨架和 .sheet 完全一样),状态点用既有 .dot,
131
+ 主按钮用既有 .btn.primary,气泡用既有 .bubble / .said。
132
+
133
+ **一个颜色令牌都没新加**:全用主屏已有的那几个,浅色主题下跟着变就行——
134
+ 令牌是两套主题各写一份的,新造一个就等于在浅色下多挖一个坑。
135
+
136
+ 也没有新的关键帧:卡片能不能展开交给原生 <details>,不写一行脚本去演它。 */
137
+ #subagents {
138
+ position: fixed; inset: 0; background: var(--scrim);
139
+ display: none; align-items: flex-end; z-index: 50;
140
+ }
141
+ #subagents.open { display: flex; }
142
+ .sa-head { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }
143
+ .sa-head h2 { margin: 0; font-size: 16px; flex: 1 1 auto; }
144
+ .sa-back {
145
+ flex: 0 0 auto; border: none; background: transparent; color: var(--muted);
146
+ font: inherit; font-size: 14px; cursor: pointer; padding: 0;
147
+ }
148
+ .sa-none { color: var(--muted); font-size: 14px; padding: 6px 0 2px; }
149
+ /* 行负着左右两边的 16px:让分隔线横贯整个抽屉,而不是在里面缩一截。 */
150
+ .sa-list { margin: 0 -16px; }
151
+ .sa-row {
152
+ display: flex; align-items: center; gap: 10px; width: 100%;
153
+ padding: 12px 16px; border: none; border-bottom: 1px solid var(--line);
154
+ background: transparent; color: var(--fg); font: inherit; text-align: left; cursor: pointer;
155
+ }
156
+ .sa-row:last-child { border-bottom: none; }
157
+ /* 清单还在读时那一行也在 .sa-list 里,会被那 -16px 拽到贴屏边,
158
+ 和行的文字差着一档。补回 .sa-row 同一个 16px 才对齐。 */
159
+ .sa-none.sa-none-indent { padding-left: 16px; }
160
+ /* 读不动的那一支只有说明、点不动,所以没有手指光标。 */
161
+ .sa-plain { cursor: default; }
162
+ .sa-main { flex: 1 1 auto; min-width: 0; }
163
+ .sa-name { display: block; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
164
+ .sa-sub { display: block; font-size: 12px; color: var(--muted); margin-top: 3px; }
165
+ /* 数字用等宽数位:几行并排时小数点对齐,扫一眼就能比大小。 */
166
+ .sa-num {
167
+ flex: 0 0 auto; text-align: right; font-size: 12px; color: var(--muted);
168
+ font-variant-numeric: tabular-nums; line-height: 1.5;
169
+ }
170
+ .sa-acts { display: flex; gap: 10px; margin-top: 16px; }
171
+ .sa-acts .btn { flex: 1 1 0; text-align: center; }
172
+ .sa-ask { display: flex; gap: 10px; margin-top: 16px; align-items: flex-end; }
173
+ .sa-ask input {
174
+ flex: 1 1 auto; min-width: 0; padding: 10px 12px; border-radius: var(--r);
175
+ border: 1px solid var(--line); background: var(--panel-2); color: var(--fg);
176
+ font: inherit; font-size: 16px; line-height: 1.5; outline: none;
177
+ }
178
+ .sa-ask input:focus { border-color: var(--ice); }
179
+ .sa-ask .btn { flex: 0 0 auto; }
180
+ .sa-doc { max-height: 52vh; overflow-y: auto; margin: 0 -16px; padding: 0 16px; }
181
+ .sa-doc .bubble, .sa-doc .said { max-width: 100%; }
182
+
183
+ /* 完工卡片。展开/收起用原生 <details>:不写脚本、不加关键帧,键盘和读屏也都认。
184
+ 样子和主会话里那张卡片同一个思路——单薄一层边框,不抢回答的气泡感。 */
185
+ .sa-card {
186
+ margin: 10px 0; border: 1px solid var(--line); border-radius: var(--r);
187
+ background: var(--panel-2); overflow: hidden;
188
+ }
189
+ .sa-card > summary {
190
+ display: flex; align-items: center; gap: 8px; list-style: none;
191
+ padding: 10px 12px; font-size: 13px; color: var(--muted); cursor: pointer;
192
+ }
193
+ .sa-card > summary::-webkit-details-marker { display: none; }
194
+ .sa-card > summary b { color: var(--fg); font-weight: 600; }
195
+ .sa-card[open] > summary { border-bottom: 1px solid var(--line); }
196
+ .sa-card .sa-body {
197
+ padding: 10px 12px; font-size: 14px; white-space: pre-wrap;
198
+ word-break: break-word; max-height: 240px; overflow: auto;
199
+ }
200
+
128
201
  /* ---------- 显示区 ---------- */
129
202
  main { flex: 1 1 auto; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 16px 14px; }
130
203
  main.center { display: grid; place-items: center; }
@@ -261,13 +334,25 @@
261
334
  border-left: 2px solid var(--line); border-radius: 0 var(--r) var(--r) 0;
262
335
  background: var(--panel-2); font-size: 13px; line-height: 1.5;
263
336
  }
264
- /* 名字加参数排一行,太长就省略——它是「做了哪件事」的标签,不是正文。 */
337
+ /* 名字加参数排一行,太长就省略——它是「做了哪件事」的标签,不是正文。
338
+ 收缩权重给到 10:位置要让给下面那条结果。挪不开的时候它先让到 0
339
+ (2026-09-30 之前就是这样:长结果把标签挤没了),这个观感这次不动它。 */
265
340
  .cmd .cmd-line {
341
+ flex: 0 10 auto;
266
342
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
267
343
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
268
344
  color: var(--fg);
269
345
  }
270
- .cmd .cmd-res { flex: 0 0 auto; color: var(--muted); }
346
+ /* 结果是**正文**,得能整段读全。原来是 `flex: 0 0 auto`——那等于「这一格不许缩,
347
+ 一行放完」,于是长结果(2026-09-30 用户报的 /doctor 那条)直接顶出 .cmd 的框,
348
+ 顶出去的半截连底色都没有;.cmd 不裁剪,溢出再顶到 main,而 main 的 overflow-y:auto
349
+ 会把横向一起算成滚动区,整页于是能左右拉、右边那截得滑一下才看得见。
350
+ 改成可以缩(min-width:0 拿掉「不许窄于内容」的下限)+ 允许从任意处断开,
351
+ 长结果就在框里正常折行。不做横向滚动、也不做省略号截断:用户要的是读全。 */
352
+ .cmd .cmd-res {
353
+ flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere;
354
+ color: var(--muted);
355
+ }
271
356
  .cmd.success { border-left-color: var(--ok); }
272
357
  .cmd.success .cmd-res { color: var(--ok); }
273
358
  /* 出错的那一种必须是红的:整串历史里只有它需要用户抬眼看一下。 */
@@ -920,6 +1005,7 @@
920
1005
  <span class="dot" id="dot"></span>
921
1006
  <span class="brand">极简遥控器</span>
922
1007
  <span class="status" id="status">连接中…</span>
1008
+ <button class="icon-btn" id="btnSubagents" aria-label="子智能体"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.2 12H11M11 6v12M11 6h4.8M11 18h4.8"/><circle cx="6" cy="12" r="2.2"/><circle cx="18" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/></svg></button>
923
1009
  <button class="icon-btn" id="btnSettings" aria-label="设置"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h8M16 7h4M4 17h4M12 17h8"/><circle cx="14" cy="7" r="2.2"/><circle cx="10" cy="17" r="2.2"/></svg></button>
924
1010
  </header>
925
1011
 
@@ -1102,6 +1188,28 @@
1102
1188
  </div>
1103
1189
  </div>
1104
1190
 
1191
+ <!-- 子智能体(2026-10-03 用户要求:把电脑端「子智能体」搬到手机)。
1192
+
1193
+ 入口在顶栏,紧挨着设置齿轮的左边。面板外壳和设置抽屉**是同一套**(#subagents
1194
+ 的骨架和 .sheet 一个样),不新建样式体系;这一块独有的外观只有两条——列表里的
1195
+ 一行,和聊天记录里那张完工卡片(见 CSS 那段注释)。
1196
+
1197
+ **列表取的是手机当前绑定的那个会话**名下的子智能体,和 send / stop 同一套口径:
1198
+ 手机上遥控的是你正看着的那个会话,不该牵动你在电脑上另开的那个。
1199
+
1200
+ 一个面板两种视图:清单;点某一行换成它**自己的运行记录**(返回键换回来)。 -->
1201
+ <div id="subagents">
1202
+ <div class="sheet">
1203
+ <div class="sa-head">
1204
+ <button class="sa-back" id="subagentsBack" hidden>‹ 返回</button>
1205
+ <h2 id="subagentsTitle">子智能体</h2>
1206
+ <button class="btn" id="subagentsClose">关闭</button>
1207
+ </div>
1208
+ <div class="sa-list" id="subagentsList"></div>
1209
+ <div class="sa-doc" id="subagentsDoc" hidden></div>
1210
+ </div>
1211
+ </div>
1212
+
1105
1213
  <div id="gate">
1106
1214
  <h1>需要访问令牌</h1>
1107
1215
  <p>在电脑上打开 DSH 的「设置 → 手机遥控」,用手机重新扫一次二维码就能进来。
@@ -2043,6 +2151,11 @@
2043
2151
  // 「正在执行」那块照旧:它是常驻的 #work,跟这里没关系。
2044
2152
  var tail = commandTail();
2045
2153
  if (tail) parts.push(commandBlock(tail));
2154
+ // 子智能体刚完工的那条通知同理(判据见 noticeTail)。它出现在这里的时机很窄——
2155
+ // 通知一到,父级通常会被唤醒作答,那条回答很快就会把它顶掉——但那一段空窗里
2156
+ // 屏幕上什么都不变的话,用户会以为子智能体那边没消息。
2157
+ var note = noticeTail();
2158
+ if (note) parts.push(noticeBlock(note));
2046
2159
  if (live) parts.push(liveBlock(live));
2047
2160
  if (parts.length) {
2048
2161
  var stick = atBottom();
@@ -2150,6 +2263,61 @@
2150
2263
  return last;
2151
2264
  }
2152
2265
 
2266
+ /**
2267
+ * 一个会话 id 的「能区分人的那一段」。
2268
+ *
2269
+ * **不能直接取 id 的开头几位**:本机的会话 id 长这样 `session-80fd8f3c-…`,
2270
+ * 开头八位永远是 `session-`,一排子智能体就会全叫同一个名字。导航栏那边踩过
2271
+ * 同一个坑,page.test.mjs 里有一条守卫专门钉着它。所以先把 `session-` 这段
2272
+ * 人人相同的前缀去掉,再取一段开头——留下的才是真能区分它们的字符。
2273
+ */
2274
+ function shortSessionId(id) {
2275
+ var s = String(id || '');
2276
+ if (s.slice(0, 8) === 'session-') s = s.slice(8);
2277
+ if (!s) s = String(id || '');
2278
+ return s.slice(0, 8);
2279
+ }
2280
+
2281
+ /**
2282
+ * 子智能体完工的那张卡片(聊天记录里的一种条目)。
2283
+ *
2284
+ * 它是**第三样东西**:不是用户说的话,也不是模型的回答,而是「这个会话派出去的
2285
+ * 一件事有结果了」。所以既不套用户气泡、也不画成回答正文——套上气泡就变成
2286
+ * 「我说过这句话」,混进正文又会被读成模型的话,两种都是假的。
2287
+ *
2288
+ * 展开/收起交给原生 `<details>`:不写一行脚本、不加一个关键帧,键盘和读屏也都认。
2289
+ *
2290
+ * 正文**原样贴出来**(`white-space: pre-wrap`,见 .sa-card .sa-body),不再解析:
2291
+ * 这段话是 DSH 生成的——它先说清「谁、为什么结束」,再把子智能体最后的原话接上。
2292
+ * 那是**转述**,不该由我们这边的渲染器再加工一遍。
2293
+ */
2294
+ function noticeBlock(m) {
2295
+ var who = m.senderSessionId ? shortSessionId(m.senderSessionId) : '';
2296
+ var when = m.timestamp ? timeLabel(m.timestamp) : '';
2297
+ return '<details class="sa-card"><summary><b>子智能体完工</b>'
2298
+ + (who ? '<span>' + escapeHtml(who) + '</span>' : '')
2299
+ + (when ? '<span>' + escapeHtml(when) + '</span>' : '')
2300
+ + '</summary><div class="sa-body">' + escapeHtml(m.text || '') + '</div></details>';
2301
+ }
2302
+
2303
+ /**
2304
+ * 单帧模式要跟着显示的那条通知:**历史里最后一条**,而且得真的是一条通知。
2305
+ *
2306
+ * 判据和 commandTail 完全一样,连那道时间判断都照抄,理由也一样:
2307
+ * - 用「最后一条」而不是「最近一条」:中间要是夹了别的消息(父级被唤醒后又答了
2308
+ * 一句),那条通知已经不是最新的事了,再贴在最下面就成了过期通知;
2309
+ * - 单帧模式的回答走 SSE 的 reply 那条路,**不往 history 里塞**,所以 history
2310
+ * 的尾巴会一直停在那条通知上——不挡一下,新回答到了之后底下还挂着它。
2311
+ */
2312
+ function noticeTail() {
2313
+ var h = state.history || [];
2314
+ var last = h.length ? h[h.length - 1] : null;
2315
+ if (!last || last.role !== 'notice') return null;
2316
+ var lt = (state.latest && state.latest.timestamp) || 0;
2317
+ if (lt && last.timestamp && last.timestamp < lt) return null;
2318
+ return last;
2319
+ }
2320
+
2153
2321
  /**
2154
2322
  * 该不该自动滚到底。
2155
2323
  *
@@ -2285,6 +2453,9 @@
2285
2453
  // 指令行:这是会话做的一个动作,不是谁说的话,单独一种长相——
2286
2454
  // 不套气泡、不给复制按钮(见 commandBlock 的说明)。
2287
2455
  if (m.role === 'command') return commandBlock(m);
2456
+ // 子智能体完工的通知:同一类「会话做的事」,画成一张可展开的卡片
2457
+ // (见 noticeBlock 的说明)。它没有复制按钮,也没有气泡。
2458
+ if (m.role === 'notice') return noticeBlock(m);
2288
2459
  // 被按停的那一轮:标一句,别让人以为模型答到一半断了。
2289
2460
  var stopped = m.interrupted ? '<span class="stopped">已停止</span>' : '';
2290
2461
  // 两种消息都给复制按钮。自己敲的指令也常有「再发一遍」「拿去别处用」的时候,
@@ -2483,6 +2654,7 @@
2483
2654
  // 本机实测 35 个工作区、最多的一个有 452 个会话,一次全拉会卡住手机。
2484
2655
  var navList = null; // 工作区列表,开一次取一次
2485
2656
  var navSessions = {}; // workspaceId -> { sessions, total, truncated }
2657
+ var navFollowTimers = {}; // workspaceId -> 当前标题补齐轮询定时器
2486
2658
  var navExpanded = {}; // workspaceId -> 是否展开
2487
2659
 
2488
2660
  function openNav() {
@@ -2493,6 +2665,17 @@
2493
2665
 
2494
2666
  function loadWorkspaces(force) {
2495
2667
  if (navList && !force) { renderNav(); return; }
2668
+ if (!force && navPrefetchPromise) {
2669
+ $('navBody').innerHTML = '<div class="placeholder">正在读取…</div>';
2670
+ navPrefetchPromise.then(function () {
2671
+ if ($('nav').classList.contains('open')) renderNav();
2672
+ }).catch(function (err) {
2673
+ navList = null;
2674
+ $('navBody').innerHTML = '<div class="placeholder">读不到会话列表:'
2675
+ + escapeHtml(err.message) + '</div>';
2676
+ });
2677
+ return;
2678
+ }
2496
2679
  $('navBody').innerHTML = '<div class="placeholder">正在读取…</div>';
2497
2680
  // 带 refresh=1 是给 ⟳ 用的:绕过服务端的短时缓存,真去读一遍。
2498
2681
  // 不带的话服务端会把最近一次的结果直接给你,导航栏就不再「每次都要重新读取」。
@@ -2507,6 +2690,28 @@
2507
2690
  });
2508
2691
  }
2509
2692
 
2693
+ // 导航栏不是首屏内容,但用户通常会在连上手机后很快点左上角。
2694
+ // 趁页面空闲先取一次;真正打开菜单时若已完成就直接画,不再把等待放在点击之后。
2695
+ var navPrefetchPromise = null;
2696
+ function prefetchWorkspaces() {
2697
+ if (navList || navPrefetchPromise || navView !== 'tree' || !state.token) return;
2698
+ var run = function () {
2699
+ navPrefetchPromise = api('/mini/api/workspaces').then(function (body) {
2700
+ navList = body.workspaces || [];
2701
+ navSessions = {};
2702
+ if ($('nav').classList.contains('open')) renderNav();
2703
+ }).finally(function () { navPrefetchPromise = null; });
2704
+ navPrefetchPromise.catch(function () {
2705
+ // 预取失败不改变导航栏原来的行为,用户打开时仍会再试一次。
2706
+ });
2707
+ };
2708
+ if (typeof window.requestIdleCallback === 'function') {
2709
+ window.requestIdleCallback(run, { timeout: 1200 });
2710
+ } else {
2711
+ setTimeout(run, 0);
2712
+ }
2713
+ }
2714
+
2510
2715
  function dayLabel(ts) {
2511
2716
  if (!ts) return '';
2512
2717
  var d = new Date(ts);
@@ -2802,7 +3007,8 @@
2802
3007
  */
2803
3008
  function followTitles(cur, deadline) {
2804
3009
  if (!(pendingCount(cur) > 0) || Date.now() >= deadline) return;
2805
- setTimeout(function () {
3010
+ navFollowTimers[wsId] = setTimeout(function () {
3011
+ delete navFollowTimers[wsId];
2806
3012
  // 用户收起了、或者这份数据已经被刷新换掉,就放手:别跟人抢。
2807
3013
  if (navSessions[wsId] !== cur || !navExpanded[wsId]) return;
2808
3014
  api('/mini/api/workspaces/' + encodeURIComponent(wsId) + '/sessions')
@@ -2947,11 +3153,29 @@
2947
3153
  state.connected = true;
2948
3154
  updateStatusUI();
2949
3155
  $('connHint').textContent = '已连接';
3156
+ prefetchWorkspaces();
2950
3157
  });
2951
3158
  es.addEventListener('state', function (e) {
2952
3159
  state.connected = true;
2953
3160
  updateStatusUI();
2954
3161
  try { applySnapshot(JSON.parse(e.data)); } catch (err) {}
3162
+ prefetchWorkspaces();
3163
+ });
3164
+ es.addEventListener('navigation-title', function (e) {
3165
+ var payload;
3166
+ try { payload = JSON.parse(e.data); } catch (err) { return; }
3167
+ if (!payload || typeof payload.workspaceId !== 'string' || typeof payload.sessionId !== 'string') return;
3168
+ var got = navSessions[payload.workspaceId];
3169
+ if (!got || !Array.isArray(got.sessions)) return;
3170
+ var row = got.sessions.find(function (item) { return item.id === payload.sessionId; });
3171
+ if (!row) return;
3172
+ row.title = typeof payload.title === 'string' ? payload.title : '';
3173
+ if (typeof got.pending === 'number' && got.pending > 0) got.pending -= 1;
3174
+ if (got.pending <= 0 && navFollowTimers[payload.workspaceId]) {
3175
+ clearTimeout(navFollowTimers[payload.workspaceId]);
3176
+ delete navFollowTimers[payload.workspaceId];
3177
+ }
3178
+ if (navExpanded[payload.workspaceId] && navView === 'tree') renderNav();
2955
3179
  });
2956
3180
  es.addEventListener('reply', function (e) {
2957
3181
  var payload = JSON.parse(e.data);
@@ -3133,6 +3357,31 @@
3133
3357
  ask = { id: payload.id, questions: payload.questions, i: 0, picked: {}, custom: false }
3134
3358
  askRender()
3135
3359
  });
3360
+ // 这道题在**别处**被答掉了(飞书那边回的),插件主动推一条状态过来。
3361
+ //
3362
+ // 没有这一条,卡片就干巴巴挂在屏幕上:用户在飞书里答完了,切回手机还看得见这道题,
3363
+ // 只能以为没生效、再答一次——那时候拿到的是「已经过期了」。所以这里就地收起来,
3364
+ // 并说一句它是怎么没的。**只在自己正显示着同一道题时动手**,别的题(编号对不上)
3365
+ // 一个字都不碰。
3366
+ es.addEventListener('question-done', function (e) {
3367
+ let payload = null
3368
+ try { payload = JSON.parse(e.data) } catch (err) { return }
3369
+ if (!payload || !payload.id) return
3370
+ if (!ask || ask.id !== payload.id) return
3371
+ ask = null
3372
+ $('askCard').classList.remove('on')
3373
+ // 谁答的由插件如实说('feishu')——猜不得,也不把「超时/断线」说成「已在别处答过」。
3374
+ if (payload.source === 'feishu') toast('这道题已经在飞书里答过了')
3375
+ });
3376
+ es.addEventListener('approval-done', function (e) {
3377
+ let payload = null
3378
+ try { payload = JSON.parse(e.data) } catch (err) { return }
3379
+ if (!payload || !payload.id) return
3380
+ if (!approve || approve.id !== payload.id) return
3381
+ approve = null
3382
+ $('approveCard').classList.remove('on')
3383
+ if (payload.source === 'feishu') toast('这条已经在飞书里处理过了')
3384
+ });
3136
3385
  es.addEventListener('status', function (e) {
3137
3386
  try { setRunning(JSON.parse(e.data).running); } catch (err) {}
3138
3387
  });
@@ -3536,6 +3785,231 @@
3536
3785
  $('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
3537
3786
  sheet.addEventListener('click', function (e) { if (e.target === sheet) sheet.classList.remove('open'); });
3538
3787
 
3788
+ // ---------------- 子智能体 ----------------
3789
+ //
3790
+ // 数据全从 /mini/api/subagents 那三条来(见 lib/server.js)。三条都在电脑上的
3791
+ // **同一个进程里直接问 DSH**,不经过 WebSocket——靠进程里这层关系拿到的东西
3792
+ // (清单、某一道自己的运行记录)反而比桌面前端更直接:桌面那边只能隔着一条
3793
+ // 只开了两个口子的线路,清单和记录得靠投影另推一份过来。
3794
+ //
3795
+ // 两条纪律,和主会话一模一样:**读不到就说读不到,不编**;**数字算不出来就空着**。
3796
+ var saPanel = $('subagents');
3797
+ var saRows = []; // 最近一次读到的清单,点行时按 id 找回来
3798
+ var saCurrent = null; // 正在看运行记录的那一道;null = 现在在看清单
3799
+
3800
+ function saToList() {
3801
+ saCurrent = null;
3802
+ $('subagentsDoc').hidden = true;
3803
+ $('subagentsList').hidden = false;
3804
+ $('subagentsBack').hidden = true;
3805
+ $('subagentsTitle').textContent = '子智能体';
3806
+ }
3807
+ function saToDoc(row) {
3808
+ saCurrent = row;
3809
+ $('subagentsList').hidden = true;
3810
+ $('subagentsDoc').hidden = false;
3811
+ $('subagentsBack').hidden = false;
3812
+ $('subagentsTitle').textContent = row.label || shortSessionId(row.id);
3813
+ }
3814
+
3815
+ /** 用量(token 数)。读不到就是 null,页面上写「—」——写 0 会被读成「一次都没跑过」。 */
3816
+ function saTokens(n) {
3817
+ if (typeof n !== 'number' || !isFinite(n) || n < 0) return '—';
3818
+ if (n < 1000) return String(n);
3819
+ if (n < 1000000) return (n / 1000).toFixed(1) + 'k';
3820
+ return (n / 1000000).toFixed(1) + 'M';
3821
+ }
3822
+ /** 耗时。同样:读不到写「—」。 */
3823
+ function saDuration(ms) {
3824
+ if (typeof ms !== 'number' || !isFinite(ms) || ms < 0) return '—';
3825
+ var s = Math.round(ms / 1000);
3826
+ if (s < 60) return s + ' 秒';
3827
+ var m = Math.floor(s / 60);
3828
+ if (m < 60) return m + ' 分 ' + (s % 60) + ' 秒';
3829
+ return Math.floor(m / 60) + ' 时 ' + (m % 60) + ' 分';
3830
+ }
3831
+ /** 目录读不动的那三种原因,翻成人话。认不出来就照原样说,不编一个像样的理由。 */
3832
+ function saReason(r) {
3833
+ if (r === 'corrupt') return '那一支的目录坏了';
3834
+ if (r === 'unsupported') return '那条记录是更新的格式,这个版本读不了';
3835
+ return '那一支暂时读不到(' + r + ')';
3836
+ }
3837
+
3838
+ function saRowHtml(s) {
3839
+ // 有一支目录读不动:DSH 会以一行「诊断」如实说原因。这里也如实转达,
3840
+ // 不假装那一支里没有人——吞掉的表现是「明明有子代理却一个都不显示」。
3841
+ if (s.diagnostic) {
3842
+ return '<div class="sa-row sa-plain"><span class="dot err"></span>'
3843
+ + '<span class="sa-main"><span class="sa-name">有一支读不出来</span>'
3844
+ + '<span class="sa-sub">' + escapeHtml(saReason(s.diagnostic)) + '</span></span></div>';
3845
+ }
3846
+ // 状态点用既有 .dot,和顶栏那颗同一套:在跑 = run;没在跑但最近跑完过 = ok;
3847
+ // 其余 = 灰。**没有动画**——那圈呼吸闪动在规格里已经被砍掉了(见 .dot 的注释)。
3848
+ var dot = s.running ? 'dot run' : (s.lastTurnCompleted ? 'dot ok' : 'dot');
3849
+ var kind = s.mode === 'continuable' ? '可续接' : '一次性';
3850
+ return '<button class="sa-row" type="button" data-sa="' + escapeHtml(s.id) + '">'
3851
+ + '<span class="' + dot + '"></span>'
3852
+ + '<span class="sa-main"><span class="sa-name">' + escapeHtml(s.label || shortSessionId(s.id)) + '</span>'
3853
+ + '<span class="sa-sub">' + kind + ' · ' + (s.running ? '在跑' : '已结束') + '</span></span>'
3854
+ + '<span class="sa-num">' + saTokens(s.tokens) + '<br>' + saDuration(s.durationMs) + '</span>'
3855
+ + '</button>';
3856
+ }
3857
+
3858
+ function saRenderList(body) {
3859
+ var listEl = $('subagentsList');
3860
+ if (!body || !body.ok) {
3861
+ listEl.innerHTML = '<div class="sa-none">'
3862
+ + escapeHtml((body && body.error) || '读不到子智能体。') + '</div>';
3863
+ return;
3864
+ }
3865
+ saRows = body.subagents || [];
3866
+ if (!saRows.length) {
3867
+ listEl.innerHTML = '<div class="sa-none">这个会话还没有派出过子智能体。</div>';
3868
+ return;
3869
+ }
3870
+ listEl.innerHTML = saRows.map(saRowHtml).join('');
3871
+ var btns = listEl.querySelectorAll('[data-sa]');
3872
+ for (var i = 0; i < btns.length; i++) {
3873
+ (function (btn) {
3874
+ btn.addEventListener('click', function () { saOpenDoc(btn.getAttribute('data-sa')); });
3875
+ })(btns[i]);
3876
+ }
3877
+ }
3878
+
3879
+ function saLoad() {
3880
+ $('subagentsList').innerHTML = '<div class="sa-none sa-none-indent">正在读取…</div>';
3881
+ api('/mini/api/subagents').then(saRenderList).catch(function (err) {
3882
+ $('subagentsList').innerHTML = '<div class="sa-none">'
3883
+ + escapeHtml(err && err.message || '没读出来。') + '</div>';
3884
+ });
3885
+ }
3886
+
3887
+ /** 点开某一道:换成它**自己的运行记录**(和主会话同一套重放,见 lib/history.js)。 */
3888
+ function saOpenDoc(childId) {
3889
+ var row = null;
3890
+ for (var i = 0; i < saRows.length; i++) if (saRows[i].id === childId) row = saRows[i];
3891
+ if (!row) return;
3892
+ saToDoc(row);
3893
+ $('subagentsDoc').innerHTML = '<div class="sa-none">正在读取它的记录…</div>';
3894
+ api('/mini/api/subagent?id=' + encodeURIComponent(childId)).then(function (body) {
3895
+ // 读的过程中用户可能已经返回、或点开了另一道——那这一份就别再往上贴了。
3896
+ if (saCurrent !== row) return;
3897
+ saRenderDoc(body);
3898
+ }).catch(function (err) {
3899
+ if (saCurrent !== row) return;
3900
+ $('subagentsDoc').innerHTML = '<div class="sa-none">'
3901
+ + escapeHtml(err && err.message || '没读出来。') + '</div>';
3902
+ });
3903
+ }
3904
+
3905
+ /** 运行记录里的一条。长相和主会话**完全一样**:气泡/正文/指令行/完工卡片共用同一批样式。 */
3906
+ function saEntryHtml(e) {
3907
+ if (e.role === 'notice') return noticeBlock(e);
3908
+ if (e.role === 'command') return commandBlock(e);
3909
+ if (e.role === 'user') {
3910
+ return '<div class="bubble user">' + escapeHtml(e.text || '')
3911
+ + '<span class="meta">' + timeLabel(e.timestamp) + '</span></div>';
3912
+ }
3913
+ return '<div class="said"><div class="md">' + mdToHtml(e.text || '') + '</div>'
3914
+ + '<div class="reply-foot"><span class="time">' + timeLabel(e.timestamp) + '</span></div></div>';
3915
+ }
3916
+
3917
+ function saRenderDoc(body) {
3918
+ var docEl = $('subagentsDoc');
3919
+ if (!body || !body.ok) {
3920
+ docEl.innerHTML = '<div class="sa-none">'
3921
+ + escapeHtml((body && body.error) || '没读出来。') + '</div>';
3922
+ return;
3923
+ }
3924
+ var html = '';
3925
+ if (body.reclaimed) {
3926
+ // 一次性子智能体跑完、记录已经被回收:**如实说没有记录**,不拿它冒充
3927
+ // 「它没干过活」——那两件事对用户含义不同。
3928
+ html += '<div class="sa-none">它的运行记录已经不在电脑上了,'
3929
+ + '只能看到它当时发回来的那条完工通知。</div>';
3930
+ } else if (!body.entries || !body.entries.length) {
3931
+ html += '<div class="sa-none">它还没有任何记录。</div>';
3932
+ } else {
3933
+ if (body.truncated) {
3934
+ html += '<div class="history-note">'
3935
+ + escapeHtml(body.note || '只显示最近一段。') + '</div>';
3936
+ }
3937
+ html += body.entries.map(saEntryHtml).join('');
3938
+ }
3939
+ // 底部:能做什么**由类型和状态决定**,不写死。三条失败理由都由服务端翻成人话
3940
+ // (见 lib/index.js 的 SUBAGENT_FAILURES),页面只负责把它们显示出来。
3941
+ html += body.mode === 'continuable' ? saActsHtml(body)
3942
+ : '<div class="sa-none">这是一次性子智能体:跑完就结束了,接不了话,'
3943
+ + '只能看它跑过什么。</div>';
3944
+ docEl.innerHTML = html;
3945
+ saBindActs(body);
3946
+ }
3947
+
3948
+ function saActsHtml(body) {
3949
+ var html = '';
3950
+ // 停:只对「正在跑的、可续接的」那一轮有意义。已经停稳的子智能体没有可停的东西。
3951
+ if (body.running) {
3952
+ html += '<div class="sa-acts"><button class="btn" id="saStop">停止这一轮</button></div>';
3953
+ }
3954
+ // 继续说话:只有「可续接」才有这一栏。父会话不在线、并发满了,DSH 都会明确拒绝,
3955
+ // 那时候服务端把理由翻成人话给回来,这里照原样弹出来。
3956
+ html += '<div class="sa-ask">'
3957
+ + '<input id="saAskText" type="text" placeholder="跟它说一句…" autocomplete="off"'
3958
+ + ' autocapitalize="off" autocorrect="off" spellcheck="false">'
3959
+ + '<button class="btn primary" id="saSend">发送</button></div>';
3960
+ return html;
3961
+ }
3962
+
3963
+ function saBindActs(body) {
3964
+ var stop = $('saStop');
3965
+ if (stop) stop.addEventListener('click', function () { saStop(body, stop); });
3966
+ var send = $('saSend');
3967
+ if (send) send.addEventListener('click', function () { saAsk(body, send); });
3968
+ }
3969
+
3970
+ function saStop(body, btn) {
3971
+ btn.disabled = true;
3972
+ api('/mini/api/subagent/stop', {
3973
+ method: 'POST',
3974
+ body: JSON.stringify({ id: body.id, mode: body.mode }),
3975
+ }).then(function () {
3976
+ // 递进去不等于停稳(和主会话那条一样):重读一次,等它的状态自己翻过来。
3977
+ toast('已经让它停下这一轮。');
3978
+ saOpenDoc(body.id);
3979
+ }).catch(function (err) {
3980
+ toast(err && err.message || '停不下来。');
3981
+ btn.disabled = false;
3982
+ });
3983
+ }
3984
+
3985
+ function saAsk(body, btn) {
3986
+ var input = $('saAskText');
3987
+ var text = input ? input.value.trim() : '';
3988
+ if (!text) { toast('先说点什么。'); return; }
3989
+ btn.disabled = true;
3990
+ api('/mini/api/subagent/ask', {
3991
+ method: 'POST',
3992
+ body: JSON.stringify({ id: body.id, mode: body.mode, text: text }),
3993
+ }).then(function () {
3994
+ toast('已经递过去了。');
3995
+ saOpenDoc(body.id);
3996
+ }).catch(function (err) {
3997
+ toast(err && err.message || '没送出去。');
3998
+ btn.disabled = false;
3999
+ });
4000
+ }
4001
+
4002
+ $('btnSubagents').addEventListener('click', function () {
4003
+ saToList();
4004
+ saPanel.classList.add('open');
4005
+ saLoad();
4006
+ });
4007
+ $('subagentsClose').addEventListener('click', function () { saPanel.classList.remove('open'); });
4008
+ $('subagentsBack').addEventListener('click', saToList);
4009
+ saPanel.addEventListener('click', function (e) {
4010
+ if (e.target === saPanel) saPanel.classList.remove('open');
4011
+ });
4012
+
3539
4013
  // ---------------- 权限档位 ----------------
3540
4014
  //
3541
4015
  // 这是把 PC 端那道授权闸门搬到了手机上:**手机从此能指挥 Agent 把电脑提到
package/lib/page.js CHANGED
@@ -67,14 +67,26 @@ const IMAGE_MAX = 20 * 1024 * 1024
67
67
  *
68
68
  * 2026-09-28 之前这里只认"把图拷进 lib/art 并写裸名字"一种写法,正文里写全路径就成了
69
69
  * 破图标——用户第三次为此来问,这是那时的放宽。
70
+ *
71
+ * `extraRoots`(2026-09-28 追加)是「额外候选根」,由调用方给——服务那边传的是**当前已
72
+ * 注册的工作区目录**。理由:正文里的相对路径相对的是**那段对话的工作目录**,而它可能
73
+ * 是别的项目(实测出图那段对话在 I:\DSH\dsh-jev-ultrafast,正文写 `scratch/x.png`),
74
+ * 插件只按自己的两个根找就会 404,手机上正是那张破图。这里只放宽"在哪找":
75
+ * 能发什么、发多大、要不要令牌,全都没动。相对写法里带 `..` 一律拒绝——要跨目录,
76
+ * 正文里写绝对路径,那本来就是支持的写法。
70
77
  */
71
- export function readImage(raw) {
78
+ export function readImage(raw, extraRoots = []) {
72
79
  if (typeof raw !== 'string') return null
73
80
  const p = raw.trim()
74
81
  if (!p || p.length > 4096 || p.includes('\0')) return null
75
82
  const type = IMAGE_TYPES[extname(p).toLowerCase()]
76
83
  if (!type) return null
77
- const candidates = isAbsolute(p) ? [p] : [p, join(PROJECT_ROOT, p), join(ART_DIR, p)]
84
+ const abs = isAbsolute(p)
85
+ const roots = Array.isArray(extraRoots) ? extraRoots : []
86
+ if (!abs && p.split(/[\\/]/).includes('..')) return null
87
+ const candidates = abs
88
+ ? [p]
89
+ : [p, join(PROJECT_ROOT, p), join(ART_DIR, p), ...roots.filter((r) => typeof r === 'string' && r).map((r) => join(r, p))]
78
90
  for (const file of candidates) {
79
91
  try {
80
92
  if (statSync(file).size > IMAGE_MAX) continue