dsh-mini-remote 0.1.7 → 0.1.9

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/tree.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * 它的会话(要读日志折标题,贵)。
16
16
  */
17
17
  import { basename } from 'node:path'
18
+ import { readTailEvents } from './log-tail.js'
18
19
 
19
20
  /** 一个工作区默认最多列多少会话。手机上再多也划不动。 */
20
21
  export const SESSIONS_PER_WORKSPACE = 20
@@ -128,6 +129,8 @@ function rememberTitle(cache, id, title, persisted) {
128
129
  * 手机页面自己)抢。而且这些标题本来就没人等着,晚几百毫秒没有任何代价。
129
130
  */
130
131
  export const TITLE_CONCURRENCY = 2
132
+ const TITLE_READ_TIMEOUT_MS = 8_000
133
+ const TITLE_RETRY_MS = 60_000
131
134
 
132
135
  let titleRunning = 0
133
136
  const titleWaiting = []
@@ -192,6 +195,42 @@ function cachedTitle(cache, id, persisted) {
192
195
  * 同一批冷会话会被读两遍。这里挡住重复的在途请求,读完即释放(失败也释放)。
193
196
  */
194
197
  const titleInflight = new WeakMap()
198
+ const titleFailures = new WeakMap()
199
+
200
+ function legacyTitle(sessionId) {
201
+ if (typeof sessionId !== 'string' || !sessionId.startsWith('import-')) return null
202
+ try {
203
+ const result = readTailEvents(sessionId, { windowBytes: 1024 * 1024, maxEvents: 1000 })
204
+ for (const event of result?.events ?? []) {
205
+ if (event?.type !== 'session/title') continue
206
+ const title = event.data?.title ?? event.data?.text
207
+ if (typeof title === 'string') return title.trim()
208
+ }
209
+ return ''
210
+ } catch {
211
+ return ''
212
+ }
213
+ }
214
+
215
+ function markTitleFailure(query, ids) {
216
+ let cache = titleFailures.get(query)
217
+ if (!cache) {
218
+ cache = new Map()
219
+ titleFailures.set(query, cache)
220
+ }
221
+ const until = Date.now() + TITLE_RETRY_MS
222
+ for (const id of ids) cache.set(id, until)
223
+ }
224
+
225
+ function failedTitle(query, id) {
226
+ const until = titleFailures.get(query)?.get(id)
227
+ if (!until) return false
228
+ if (until <= Date.now()) {
229
+ titleFailures.get(query).delete(id)
230
+ return false
231
+ }
232
+ return true
233
+ }
195
234
 
196
235
  /**
197
236
  * 后台把缺的标题读出来,写进缓存。**调用方不 await 它**。
@@ -200,7 +239,7 @@ const titleInflight = new WeakMap()
200
239
  * 而读它要加载会话日志——实测一个工作区里几条冷会话就是十几秒。那十几秒是用户在
201
240
  * 盯着"正在读取…"干等,不该发生。
202
241
  */
203
- async function fillTitles(query, cache, ids, persisted) {
242
+ async function fillTitles(query, cache, ids, persisted, workspaceId, onTitle) {
204
243
  if (!query?.readTitleSnapshots || !ids.length) return
205
244
  let flying = titleInflight.get(query)
206
245
  if (!flying) {
@@ -212,33 +251,72 @@ async function fillTitles(query, cache, ids, persisted) {
212
251
  for (const id of todo) flying.add(id)
213
252
  try {
214
253
  // 读之前先过全局闸门(见 TITLE_CONCURRENCY):这一步是这次改动里唯一真正吃 CPU 的地方。
215
- const items = await withTitleSlot(() => query.readTitleSnapshots(todo))
254
+ let timer
255
+ const controller = new AbortController()
256
+ let items
257
+ try {
258
+ timer = setTimeout(() => controller.abort(), TITLE_READ_TIMEOUT_MS)
259
+ items = await withTitleSlot(() => Promise.race([
260
+ query.readTitleSnapshots(todo, controller.signal),
261
+ new Promise((_, reject) => controller.signal.addEventListener(
262
+ 'abort', () => reject(new Error('标题读取超时')), { once: true },
263
+ )),
264
+ ]))
265
+ } catch {
266
+ markTitleFailure(query, todo)
267
+ return
268
+ } finally {
269
+ if (timer) clearTimeout(timer)
270
+ }
216
271
  for (const item of items ?? []) {
217
- if (item?.status !== 'fulfilled') continue // 读失败不进缓存,下次还会重试
218
- rememberTitle(cache, item.sessionId, titleText(item.value?.title), persisted)
272
+ if (item?.status !== 'fulfilled') {
273
+ markTitleFailure(query, [item?.sessionId].filter(Boolean))
274
+ continue
275
+ }
276
+ const title = titleText(item.value?.title)
277
+ rememberTitle(cache, item.sessionId, title, persisted)
278
+ if (typeof onTitle === 'function') onTitle({ workspaceId, sessionId: item.sessionId, title })
219
279
  }
220
280
  } catch (err) {
221
- // 读不出来就没有标题,界面退回用日期,不影响切换。
281
+ markTitleFailure(query, todo)
222
282
  } finally {
223
283
  for (const id of todo) flying.delete(id)
224
284
  }
225
285
  }
226
286
 
227
- async function safeRecords(query, { force = false } = {}) {
287
+ async function safeRecords(query, { force = false, meta } = {}) {
228
288
  if (!query) return []
229
289
  let entry = caches.get(query)
230
290
  if (!entry) {
231
291
  entry = { at: 0, records: null, inflight: null }
232
292
  caches.set(query, entry)
233
293
  }
294
+ if (!force && !entry.records && meta?.all) {
295
+ const cached = meta.all()
296
+ if (cached.length) {
297
+ entry.records = cached
298
+ // 这份数据只负责冷启动首屏,后台立刻用 DSH 清单校正。
299
+ entry.at = 0
300
+ }
301
+ }
234
302
  if (!force && entry.records && Date.now() - entry.at < CACHE_TTL_MS) return entry.records
235
- // 同一时刻来两个请求就共用一趟,别让它们各扫一遍盘。
303
+ // 有旧记录时直接返回,同时只启动一趟后台校正;没有旧记录才等待首次读取。
304
+ if (entry.records && !force) {
305
+ if (!entry.inflight) entry.inflight = refreshRecords(query, entry, meta)
306
+ return entry.records
307
+ }
236
308
  if (entry.inflight) return entry.inflight
309
+ entry.inflight = refreshRecords(query, entry, meta)
310
+ return entry.inflight
311
+ }
312
+
313
+ async function refreshRecords(query, entry, meta) {
237
314
  entry.inflight = (async () => {
238
315
  try {
239
316
  const records = (await query.listSessions()) ?? []
240
317
  entry.at = Date.now()
241
318
  entry.records = records
319
+ meta?.replace(records)
242
320
  return records
243
321
  } catch (err) {
244
322
  // 读不到就当作没有:导航栏少几项,不该影响遥控本身。失败不进缓存,下次还会重试。
@@ -290,8 +368,8 @@ function titleText(value) {
290
368
  * (闸门脚本就是这么逮到的:接口回 created=true,列表里却找不到。)
291
369
  * 默认仍然是「滤掉」,桌面端那些老调用方的行为一点不变。
292
370
  */
293
- export async function listWorkspaces({ registry, query, runningIds, force, includeEmpty }) {
294
- const byId = toRecordMap(await safeRecords(query, { force }))
371
+ export async function listWorkspaces({ registry, query, runningIds, force, includeEmpty, meta }) {
372
+ const byId = toRecordMap(await safeRecords(query, { force, meta }))
295
373
  const out = []
296
374
  for (const ws of safeList(registry)) {
297
375
  const ids = (ws.sessionIds ?? []).filter((id) => visible(byId.get(id)))
@@ -328,7 +406,7 @@ export async function listWorkspaces({ registry, query, runningIds, force, inclu
328
406
  * - `no-workspace` 工作区不在了(桌面端刚把它删掉)
329
407
  * - `failed` DSH 拒绝了,原因原样带回
330
408
  */
331
- export async function createSessionIn({ controller, registry, workspaceId }) {
409
+ export async function createSessionIn({ controller, registry, workspaceId, agentPreset }) {
332
410
  if (!controller || typeof controller.create !== 'function') {
333
411
  return { ok: false, reason: 'no-controller' }
334
412
  }
@@ -337,7 +415,11 @@ export async function createSessionIn({ controller, registry, workspaceId }) {
337
415
 
338
416
  let created
339
417
  try {
340
- created = await controller.create({ workspaceId })
418
+ // agentPreset(2026-10-04,手机上也能选模式):**选了才带**——没选就让 DSH 用它
419
+ // 自己的默认,不猜、不塞一个假值。
420
+ created = await controller.create(agentPreset
421
+ ? { workspaceId, agentPreset }
422
+ : { workspaceId })
341
423
  } catch (err) {
342
424
  return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
343
425
  }
@@ -419,12 +501,12 @@ function shapeWorkspace(ws) {
419
501
  */
420
502
  export async function listSessionsOf({
421
503
  registry, query, agents, knownTitles, persisted, workspaceId,
422
- limit = SESSIONS_PER_WORKSPACE, force,
504
+ limit = SESSIONS_PER_WORKSPACE, force, onTitle, meta,
423
505
  }) {
424
506
  const ws = safeList(registry).find((w) => w.id === workspaceId)
425
507
  if (!ws) return null
426
508
 
427
- const byId = toRecordMap(await safeRecords(query, { force }))
509
+ const byId = toRecordMap(await safeRecords(query, { force, meta }))
428
510
  const rows = (ws.sessionIds ?? [])
429
511
  .map((id) => byId.get(id))
430
512
  .filter(visible)
@@ -443,6 +525,16 @@ export async function listSessionsOf({
443
525
  rememberTitle(cache, id, known, persisted)
444
526
  continue
445
527
  }
528
+ const legacy = legacyTitle(id)
529
+ if (legacy !== null) {
530
+ titles.set(id, legacy)
531
+ rememberTitle(cache, id, legacy, persisted)
532
+ continue
533
+ }
534
+ if (failedTitle(query, id)) {
535
+ titles.set(id, '')
536
+ continue
537
+ }
446
538
  const hit = cachedTitle(cache, id, persisted)
447
539
  if (hit === null) missing.push(id)
448
540
  else if (hit) titles.set(id, hit)
@@ -451,7 +543,7 @@ export async function listSessionsOf({
451
543
  // 那几条先显示日期。缓存里已有的照旧直接用,所以反复展开同一个工作区始终是快的。
452
544
  if (missing.length && query?.readTitleSnapshots) {
453
545
  await Promise.race([
454
- fillTitles(query, cache, missing, persisted),
546
+ fillTitles(query, cache, missing, persisted, workspaceId, onTitle),
455
547
  new Promise((resolve) => setTimeout(resolve, TITLE_WAIT_MS)),
456
548
  ])
457
549
  for (const id of missing) {
@@ -476,7 +568,7 @@ export async function listSessionsOf({
476
568
  if (typeof query?.readTitleSnapshots === 'function') {
477
569
  for (const r of rows) {
478
570
  const id = r.header.id
479
- if (!titles.has(id) && cachedTitle(cache, id, persisted) === null) pending += 1
571
+ if (!titles.has(id) && !failedTitle(query, id) && cachedTitle(cache, id, persisted) === null) pending += 1
480
572
  }
481
573
  }
482
574
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mini-remote",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -78,6 +78,7 @@
78
78
  }
79
79
  },
80
80
  "dependencies": {
81
+ "@larksuiteoapi/node-sdk": "^1.74.0",
81
82
  "qrcode": "^1.5.4"
82
83
  }
83
84
  }