@wenbin_wb/dsh-bridge 2.8.3 → 2.8.4

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.8.4] - 2026-08-26
8
+
9
+ ### 🗂️ 会话列表 Web 端 1:1 深度对齐与归档过滤修复
10
+ - **🗂️ 严格按工作区账本对齐会话列表**:全面采用 DSH 官方工作区账本排序规则,自动过滤子代理派生会话、空白草稿与已归档会话,彻底解决磁盘历史孤立会话冗余列出的问题,保持 IM 端与 Web 端侧边栏 1:1 结构一致与编号精准对应。
11
+
12
+ ---
13
+
7
14
  ## [v2.8.3] - 2026-08-26
8
15
 
9
16
  ### 📱 微信与全平台 IM 文件收发链路全面修复与深度兼容
@@ -19,9 +19,10 @@
19
19
 
20
20
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
21
  import { randomUUID } from 'node:crypto'
22
- import { statSync } from 'node:fs'
22
+ import { statSync, existsSync, readFileSync } from 'node:fs'
23
23
  import { stat } from 'node:fs/promises'
24
- import { resolve, normalize, basename, isAbsolute } from 'node:path'
24
+ import { join, resolve, normalize, basename, isAbsolute } from 'node:path'
25
+ import { homedir } from 'node:os'
25
26
  import { isSafeWorkspacePath } from '../security/path-validator.js'
26
27
 
27
28
  // 纯文本标记(用户偏好不用 emoji)
@@ -942,45 +943,261 @@ function foldTitle(events) {
942
943
  return null
943
944
  }
944
945
 
945
- // 列出会话:使用 DSH 官方 API(ctx.sessions + sessionPersistence),与 web 端一致。
946
- // 屏蔽已归档会话。返回 [{ id, createdAt, events?, seq?, cwd?, title? }],按时间倒序。
947
- async function listSessions(node) {
948
- // 归档会话 ID 集合
949
- let archived = new Set()
950
- try { archived = new Set(node.ctx.workspaceRegistry?.archivedSessionIds ?? []) } catch { /* ignore */ }
951
- const live = [...(node.ctx.sessions?.list() ?? [])].filter((s) => !archived.has(s.id))
952
- const liveIds = new Set(live.map((s) => s.id))
953
- // 内存活跃会话(带完整 events/title)
954
- const liveMapped = live.map((s) => {
946
+ /** 获取所有已归档会话 ID 集合(支持 ctx.workspaceRegistry 内存服务 + workspace.json 文件存储双重兜底) */
947
+ function getArchivedSessionIds(ctx) {
948
+ const archived = new Set()
949
+ // 1. 尝试从 ctx.workspaceRegistry 内存服务读取
950
+ try {
951
+ const list = ctx?.workspaceRegistry?.archivedSessionIds
952
+ if (Array.isArray(list)) {
953
+ for (const id of list) {
954
+ if (id) archived.add(String(id))
955
+ }
956
+ return archived
957
+ }
958
+ } catch { /* ignore */ }
959
+
960
+ // 2. 尝试从 DSH workspace 存储文件($DSH_HOME/storages/workspace.json)读取兜底
961
+ if (!ctx?._mock) {
955
962
  try {
956
- const title = foldTitle(s.events ?? [])
957
- if (title) return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd, title }
963
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
964
+ const wsFile = join(home, 'storages', 'workspace.json')
965
+ if (existsSync(wsFile)) {
966
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
967
+ const fileArchived = data?.global?.archivedSessionIds
968
+ if (Array.isArray(fileArchived)) {
969
+ for (const id of fileArchived) {
970
+ if (id) archived.add(String(id))
971
+ }
972
+ }
973
+ }
958
974
  } catch { /* ignore */ }
959
- return { id: s.id, createdAt: s.header?.createdAt ?? 0, events: s.events, seq: s.seq, cwd: s.header?.cwd }
960
- })
961
- // 持久化会话(含 cwd,与 web 端过滤一致)
962
- let cold = []
975
+ }
976
+
977
+ return archived
978
+ }
979
+
980
+ /** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
981
+ function getSessionProjCache(ctx) {
982
+ if (ctx?._mock) return {}
963
983
  try {
964
- const headers = await node.ctx.sessionPersistence?.list?.()
965
- if (Array.isArray(headers)) {
966
- const coldHeaders = headers.filter((h) => h && h.id && !liveIds.has(h.id) && h.cwd !== undefined && !archived.has(h.id))
967
- // 分批加载以避免并发过载(每批 10 个)
968
- const BATCH_SIZE = 10
969
- for (let i = 0; i < coldHeaders.length; i += BATCH_SIZE) {
970
- const batch = coldHeaders.slice(i, i + BATCH_SIZE)
971
- const batchResults = await Promise.all(batch.map(async (h) => {
972
- let title
984
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
985
+ const cacheFile = join(home, 'storages', 'session_projcache.json')
986
+ if (existsSync(cacheFile)) {
987
+ const data = JSON.parse(readFileSync(cacheFile, 'utf8'))
988
+ return data?.tables?.sessions || {}
989
+ }
990
+ } catch { /* ignore */ }
991
+ return {}
992
+ }
993
+
994
+ /** 读取 DSH 官方注册的工作区列表及各自绑定的 sessionIds 列表 */
995
+ async function getRegisteredWorkspaces(ctx) {
996
+ const workspaces = []
997
+
998
+ // 优先从内存服务获取
999
+ if (ctx?.workspaceRegistry) {
1000
+ try {
1001
+ const list = await ctx.workspaceRegistry.list?.()
1002
+ if (Array.isArray(list)) {
1003
+ for (const w of list) {
1004
+ if (w && w.path) {
1005
+ workspaces.push({
1006
+ id: w.id || w.path,
1007
+ path: w.path,
1008
+ title: w.title || basename(w.path),
1009
+ sessionIds: Array.isArray(w.sessionIds) ? [...w.sessionIds] : [],
1010
+ })
1011
+ }
1012
+ }
1013
+ return workspaces
1014
+ }
1015
+ } catch { /* ignore */ }
1016
+ }
1017
+
1018
+ // 兜底从 workspace.json 存储文件读取
1019
+ if (!ctx?._mock) {
1020
+ try {
1021
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1022
+ const wsFile = join(home, 'storages', 'workspace.json')
1023
+ if (existsSync(wsFile)) {
1024
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
1025
+ const wsIds = data?.global?.workspaceIds || Object.keys(data?.tables?.workspaces || {})
1026
+ const table = data?.tables?.workspaces || {}
1027
+ for (const wId of wsIds) {
1028
+ const ws = table[wId]
1029
+ if (ws && ws.path) {
1030
+ workspaces.push({
1031
+ id: wId,
1032
+ path: ws.path,
1033
+ title: ws.title || basename(ws.path),
1034
+ sessionIds: Array.isArray(ws.sessionIds) ? [...ws.sessionIds] : [],
1035
+ })
1036
+ }
1037
+ }
1038
+ }
1039
+ } catch { /* ignore */ }
1040
+ }
1041
+
1042
+ return workspaces
1043
+ }
1044
+
1045
+ function isSubagentSession(cacheRow, liveSession) {
1046
+ if (liveSession?.origin === 'subagent' || liveSession?.header?.origin === 'subagent') return true
1047
+ const subVal = cacheRow?.rows?.subagent?.val
1048
+ if (subVal && typeof subVal === 'object' && Object.keys(subVal).length > 0) return true
1049
+ return false
1050
+ }
1051
+
1052
+ // 列出会话:严格对齐 DSH Web 端侧边栏会话树逻辑。
1053
+ // 1. 过滤已归档会话 (archivedSessionIds)
1054
+ // 2. 过滤未发起提问的空白会话 (blank: true)
1055
+ // 3. 过滤子代理内部会话 (subagent origin)
1056
+ // 4. 严格按工作区账本 (workspace.sessionIds) 组织
1057
+ async function listSessions(node) {
1058
+ const archived = getArchivedSessionIds(node.ctx)
1059
+ const projCache = getSessionProjCache(node.ctx)
1060
+ const workspaces = await getRegisteredWorkspaces(node.ctx)
1061
+
1062
+ // 内存活跃会话(按 id 索引)
1063
+ const liveList = [...(node.ctx.sessions?.list?.() ?? [])].filter(
1064
+ (s) => s && s.id && !archived.has(s.id) && !s.archived && !s.header?.archived
1065
+ )
1066
+ const liveById = new Map(liveList.map((s) => [s.id, s]))
1067
+
1068
+ const accounted = new Set()
1069
+ const result = []
1070
+
1071
+ // 1. 如果存在已注册的工作区,严格按工作区及其 sessionIds 账本组织(与 Web 端完全一致)
1072
+ if (workspaces.length > 0) {
1073
+ for (const ws of workspaces) {
1074
+ const normWsPath = ws.path ? normalize(ws.path).toLowerCase() : ''
1075
+
1076
+ // 优先将当前工作区下新创建但在内存里的 live 会话追加到头部
1077
+ for (const s of liveList) {
1078
+ const sCwd = s.header?.cwd || s.cwd
1079
+ if (sCwd && normalize(sCwd).toLowerCase() === normWsPath && !accounted.has(s.id)) {
1080
+ if (isSubagentSession(projCache[s.id], s)) continue
1081
+ accounted.add(s.id)
1082
+ let title = s.title || (s.events ? foldTitle(s.events) : '')
1083
+ if (!title) {
1084
+ const cache = projCache[s.id]
1085
+ title = cache?.rows?.title?.val || cache?.rows?.goal?.val
1086
+ }
1087
+ result.push({
1088
+ id: s.id,
1089
+ createdAt: s.header?.createdAt || Date.now(),
1090
+ cwd: ws.path,
1091
+ workspaceTitle: ws.title,
1092
+ title: title || '新会话',
1093
+ events: s.events,
1094
+ seq: s.seq ?? 0,
1095
+ })
1096
+ }
1097
+ }
1098
+
1099
+ // 按工作区账本存储的 sessionIds 顺序追加已记录会话
1100
+ for (const sId of ws.sessionIds) {
1101
+ if (archived.has(sId) || accounted.has(sId)) continue
1102
+ accounted.add(sId)
1103
+
1104
+ const cache = projCache[sId]
1105
+ const live = liveById.get(sId)
1106
+ // 过滤空白草稿会话(非当前活动会话)
1107
+ if (cache?.rows?.sessionListMetadata?.val?.blank === true && sId !== node.activeSessionId) {
1108
+ continue
1109
+ }
1110
+ // 过滤子代理内部会话
1111
+ if (isSubagentSession(cache, live)) {
1112
+ continue
1113
+ }
1114
+
1115
+ let title = cache?.rows?.title?.val || cache?.rows?.goal?.val
1116
+ let createdAt = cache?.identity?.createdAt || 0
1117
+ let cwd = ws.path
1118
+
1119
+ // 如果内存有该会话,提取最新数据
1120
+ if (live) {
1121
+ title = live.title || (live.events ? foldTitle(live.events) : '') || title
1122
+ createdAt = live.header?.createdAt || createdAt
1123
+ } else if (!title && node.ctx.sessionPersistence?.load) {
973
1124
  try {
974
- const insp = await node.ctx.sessionPersistence.load(h.id)
1125
+ const insp = await node.ctx.sessionPersistence.load(sId)
975
1126
  title = foldTitle(insp.events ?? []) ?? undefined
976
- } catch { /* 标题提取失败则只用 id */ }
977
- return { id: h.id, createdAt: h.createdAt ?? 0, events: undefined, seq: 0, cwd: h.cwd, title }
978
- }))
979
- cold.push(...batchResults)
1127
+ } catch {}
1128
+ }
1129
+
1130
+ result.push({
1131
+ id: sId,
1132
+ createdAt,
1133
+ cwd,
1134
+ workspaceTitle: ws.title,
1135
+ title: title || '新会话',
1136
+ events: live?.events,
1137
+ seq: live?.seq ?? 0,
1138
+ })
980
1139
  }
981
1140
  }
982
- } catch { /* 持久化服务不可用时仅返回内存会话 */ }
983
- return [...liveMapped, ...cold].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq)
1141
+
1142
+ // 处理当前内存中处于活动状态但未绑定任何工作区的 live 会话
1143
+ for (const s of liveList) {
1144
+ if (accounted.has(s.id)) continue
1145
+ if (isSubagentSession(projCache[s.id], s)) continue
1146
+ accounted.add(s.id)
1147
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '未分组会话'
1148
+ result.push({
1149
+ id: s.id,
1150
+ createdAt: s.header?.createdAt || Date.now(),
1151
+ cwd: s.header?.cwd || '(未指定)',
1152
+ workspaceTitle: '未指定工作区',
1153
+ title,
1154
+ events: s.events,
1155
+ seq: s.seq ?? 0,
1156
+ })
1157
+ }
1158
+ } else {
1159
+ // 2. 如果系统未注册任何工作区(如单目录/无工作区模式),降级读取内存及持久化会话
1160
+ for (const s of liveList) {
1161
+ accounted.add(s.id)
1162
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '活跃会话'
1163
+ result.push({
1164
+ id: s.id,
1165
+ createdAt: s.header?.createdAt || Date.now(),
1166
+ cwd: s.header?.cwd || '(未指定)',
1167
+ workspaceTitle: '未指定工作区',
1168
+ title,
1169
+ events: s.events,
1170
+ seq: s.seq ?? 0,
1171
+ })
1172
+ }
1173
+ if (node.ctx.sessionPersistence?.list) {
1174
+ try {
1175
+ const headers = await node.ctx.sessionPersistence.list()
1176
+ if (Array.isArray(headers)) {
1177
+ const coldHeaders = headers.filter((h) => h && h.id && !accounted.has(h.id) && !archived.has(h.id) && !h.archived)
1178
+ for (const h of coldHeaders) {
1179
+ accounted.add(h.id)
1180
+ let title
1181
+ try {
1182
+ const insp = await node.ctx.sessionPersistence.load(h.id)
1183
+ title = foldTitle(insp.events ?? []) ?? undefined
1184
+ } catch {}
1185
+ result.push({
1186
+ id: h.id,
1187
+ createdAt: h.createdAt ?? 0,
1188
+ events: undefined,
1189
+ seq: 0,
1190
+ cwd: h.cwd || '(未指定)',
1191
+ workspaceTitle: '未指定工作区',
1192
+ title: title || '新会话',
1193
+ })
1194
+ }
1195
+ }
1196
+ } catch {}
1197
+ }
1198
+ }
1199
+
1200
+ return result
984
1201
  }
985
1202
 
986
1203
  // 列出可用工作区:使用 DSH 官方 workspaceRegistry。返回 [{title, path}]。
@@ -1232,37 +1449,38 @@ async function renderSessions(node) {
1232
1449
  if (all.length === 0) {
1233
1450
  return `## 📋 会话列表\n\n> 暂无历史会话。发送 \`/new <提示词>\` 开始新会话。`
1234
1451
  }
1235
- // 按工作区(真实 cwd)分组;无 cwd 的归入 '(未指定)'
1452
+ // 按工作区分组(保持 listSessions 中的工作区账本顺序)
1236
1453
  const groups = new Map()
1237
1454
  for (const s of all) {
1238
1455
  const key = s.cwd || '(未指定)'
1239
- if (!groups.has(key)) groups.set(key, [])
1240
- groups.get(key).push(s)
1456
+ if (!groups.has(key)) {
1457
+ groups.set(key, { title: s.workspaceTitle || getWorkspaceBasename(key), sessions: [] })
1458
+ }
1459
+ groups.get(key).sessions.push(s)
1241
1460
  }
1242
- const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
1243
1461
  const parts = [
1244
1462
  `## 📋 会话列表 (共 ${all.length} 个)`,
1245
1463
  `> 切换会话:发送 \`/use 编号\` 或 \`/resume 编号\``,
1246
1464
  '',
1247
1465
  ]
1248
1466
  let idx = 0
1249
- for (const [cwd, sessions] of sortedGroups) {
1250
- const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${getWorkspaceBasename(cwd)}**`
1467
+ for (const [cwd, group] of groups) {
1468
+ const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${group.title || getWorkspaceBasename(cwd)}**`
1251
1469
  parts.push(groupName)
1252
1470
  parts.push('')
1253
1471
  parts.push('| 序号 | 会话标题 / 摘要 | 时间 | 状态 |')
1254
1472
  parts.push('| :--- | :--- | :--- | :--- |')
1255
- for (const session of sessions.slice(0, 20)) {
1473
+ for (const session of group.sessions.slice(0, 20)) {
1256
1474
  idx += 1
1257
1475
  const isActive = session.id === node.activeSessionId
1258
1476
  const statusTag = isActive ? '`[当前]`' : '-'
1259
1477
  const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
1260
- const safeTitle = (rawTitle || '新会话 (待输入)').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1478
+ const safeTitle = (rawTitle || '新会话').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1261
1479
  const when = session.createdAt ? fmtTime(session.createdAt) : '-'
1262
1480
  parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
1263
1481
  }
1264
- if (sessions.length > 20) {
1265
- parts.push(`*…该工作区共 ${sessions.length} 个会话,仅显示前 20 个*`)
1482
+ if (group.sessions.length > 20) {
1483
+ parts.push(`*…该工作区共 ${group.sessions.length} 个会话,仅显示前 20 个*`)
1266
1484
  }
1267
1485
  parts.push('')
1268
1486
  }
@@ -1270,7 +1488,7 @@ async function renderSessions(node) {
1270
1488
  return parts.join('\n').trim()
1271
1489
  }
1272
1490
 
1273
- // 与 renderSessions 完全一致的显示顺序:按工作区字母序分组、组内保持 listSessions 顺序。
1491
+ // 与 renderSessions 完全一致的显示顺序:保持 listSessions 中的分组和顺序。
1274
1492
  // /use N 用这个数组索引,保证显示的编号 N 与切换的会话一一对应。
1275
1493
  function sessionsInDisplayOrder(all) {
1276
1494
  const groups = new Map()
@@ -1279,8 +1497,7 @@ function sessionsInDisplayOrder(all) {
1279
1497
  if (!groups.has(key)) groups.set(key, [])
1280
1498
  groups.get(key).push(s)
1281
1499
  }
1282
- const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
1283
- return sortedGroups.flatMap(([, sessions]) => sessions)
1500
+ return [...groups.values()].flatMap((sessions) => sessions)
1284
1501
  }
1285
1502
 
1286
1503
  // 时间戳 → 简洁可读时间 (MM-DD HH:mm 或 YYYY-MM-DD HH:mm)
@@ -1343,6 +1560,7 @@ export const conversationBridgeHelpers = {
1343
1560
  extractFilePathsFromText,
1344
1561
  sessionsInDisplayOrder,
1345
1562
  listSessions,
1563
+ renderSessions,
1346
1564
  listWorkspaces,
1347
1565
  BRIDGE_MARK,
1348
1566
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.8.3",
3
+ "version": "2.8.4",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.8.3 微信与全平台 IM 文件收发链路全面修复与深度兼容】\n• 📱 微信 iLink 官方媒体传输协议完全对齐:修复 getuploadurl 文件媒体类型枚举(media_type=3)与 AES 密钥规范编码(base64(hex)),彻底解决手机端「文件已过期/下载失败/0B」问题\n• 🤖 QQ 机器人富媒体文件直传修复:支持 file_type=4 通用文件传输并显式传递 file_name,彻底消除「未命名」卡片显示\n• 🌐 微信会话凭证(context_token)持久化机制:自动捕获并持久化落盘实时安全凭证,重启服务永不丢失会话上下文\n• 📢 多平台多端共享会话广播:同一会话在任一端(飞书/QQ/TG/Web)生成产物文件时,自动同步广播推送到所有已绑定的 IM 客户端",
5
+ "releaseNotes": "【v2.8.4 会话列表 Web 1:1 深度对齐与归档过滤修复】\n• 🗂️ 严格按工作区账本对齐会话列表:修复会话列表展示冗余,严格以 DSH 官方工作区账本为准,自动过滤子代理派生会话、空白草稿与已归档会话,与 Web 端侧边栏 1:1 保持一致",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {
@@ -27,6 +27,7 @@
27
27
  "build:lark": "node scripts/bundle-lark.mjs",
28
28
  "build:banner": "node scripts/generate-banner.mjs",
29
29
  "prepack": "npm run build:lark && npm run build:client",
30
+ "release:github": "node scripts/create-github-release.mjs",
30
31
  "test": "node --test test/*.test.mjs"
31
32
  },
32
33
  "dependencies": {