@wenbin_wb/dsh-bridge 2.8.3 → 2.8.5

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,22 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.8.5] - 2026-08-27
8
+
9
+ ### 🎯 产物文件推送改为 AI 指令驱动 & 修复 IM 平台断开连接问题
10
+ - **🎯 显式文件发送协议**:由 AI 通过 `[SEND_FILE: <路径>]` 显式驱动文件直传,杜绝修改代码时的文件误发风暴;
11
+ - **🧹 聊天正文自动净化**:自动剔除聊天窗口中的文件发送指令,保持聊天气泡清爽;
12
+ - **🔌 修复 IM 断开连接失效**:修复 QQ 等网关断开连接时 WebSocket 事件悬挂问题,断开后彻底阻断入站消息与出站回复。
13
+
14
+ ---
15
+
16
+ ## [v2.8.4] - 2026-08-26
17
+
18
+ ### 🗂️ 会话列表 Web 端 1:1 深度对齐与归档过滤修复
19
+ - **🗂️ 严格按工作区账本对齐会话列表**:全面采用 DSH 官方工作区账本排序规则,自动过滤子代理派生会话、空白草稿与已归档会话,彻底解决磁盘历史孤立会话冗余列出的问题,保持 IM 端与 Web 端侧边栏 1:1 结构一致与编号精准对应。
20
+
21
+ ---
22
+
7
23
  ## [v2.8.3] - 2026-08-26
8
24
 
9
25
  ### 📱 微信与全平台 IM 文件收发链路全面修复与深度兼容
@@ -205,6 +205,7 @@ export class FeishuGateway extends Service {
205
205
  // ---- 消息收发 ----
206
206
 
207
207
  async _handleMessageReceive(data) {
208
+ if (this._closing || this.status === 'offline') return
208
209
  if (!data?.message) return
209
210
  const { message, sender } = data
210
211
  const messageId = message.message_id
@@ -107,6 +107,7 @@ export class FeishuConversationNode extends ConversationBridge {
107
107
  }
108
108
 
109
109
  async _handleInbound(event) {
110
+ if (this.gateway?._closing || this.gateway?.status === 'offline') return
110
111
  const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
111
112
  this._lastPeer = { peerId, senderId, isGroup }
112
113
 
@@ -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, extname, isAbsolute } from 'node:path'
25
+ import { homedir } from 'node:os'
25
26
  import { isSafeWorkspacePath } from '../security/path-validator.js'
26
27
 
27
28
  // 纯文本标记(用户偏好不用 emoji)
@@ -177,49 +178,41 @@ export function resolveFilePath(rawPath, cwd = process.cwd()) {
177
178
  }
178
179
 
179
180
  /**
180
- * 从模型助手回复正文或工具命令中提取所有真实存在于本地磁盘的文件绝对路径
181
+ * 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
182
+ * 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
183
+ * @param {string} text - 原始助手回复文本
184
+ * @param {string} cwd - 会话当前工作目录
185
+ * @returns {{ cleanText: string, files: string[] }}
181
186
  */
182
- export function extractFilePathsFromText(text, cwd = process.cwd()) {
183
- if (typeof text !== 'string' || !text.trim()) return []
184
- const found = new Set()
185
-
186
- // 1. Windows 绝对路径:C:\Users\...\file.ext 或 C:/Users/.../file.ext(支持中文、空格、特殊符号)
187
- const winAbsRegex = /[A-Za-z]:[\\/][^\s"'`<>|*?()]+?\.[A-Za-z0-9_.-]+/g
188
- let m
189
- while ((m = winAbsRegex.exec(text)) !== null) {
190
- const r = resolveFilePath(m[0], cwd)
191
- if (r) found.add(r)
187
+ export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
188
+ if (typeof text !== 'string' || !text.trim()) {
189
+ return { cleanText: text || '', files: [] }
192
190
  }
193
191
 
194
- // 2. POSIX 绝对路径:/home/.../file.ext 或 /tmp/.../file.ext
195
- const posixAbsRegex = /\/(?:[^\s"'`<>|*?()\/]+\/)+[^\s"'`<>|*?()\/]+\.[A-Za-z0-9_.-]+/g
196
- while ((m = posixAbsRegex.exec(text)) !== null) {
197
- const r = resolveFilePath(m[0], cwd)
198
- if (r) found.add(r)
199
- }
192
+ const files = []
193
+ const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
200
194
 
201
- // 3. Markdown 文件链接:[name](file:///path/to/file) 或 [name](path/to/file)
202
- const mdLinkRegex = /\[(?:[^\]]*)\]\((?:file:\/\/\/?)?([^)]+)\)/g
203
- while ((m = mdLinkRegex.exec(text)) !== null) {
204
- const r = resolveFilePath(m[1], cwd)
205
- if (r) found.add(r)
195
+ let m
196
+ const re = new RegExp(directiveRegex)
197
+ while ((m = re.exec(text)) !== null) {
198
+ const rawPath = m[1].trim()
199
+ const resolved = resolveFilePath(rawPath, cwd)
200
+ if (resolved && !files.includes(resolved)) {
201
+ files.push(resolved)
202
+ }
206
203
  }
207
204
 
208
- // 4. 关键词或 Emoji 引用的文件路径:📁 file.txt, 保存到:file.txt, 产物文件: ...
209
- const keywordRegex = /(?:📁|📄|📦|保存到[::\s]*|生成文件[::\s]*|文件路径[::\s]*|产物[::\s]*|输出文件[::\s]*|写入文件[::\s]*)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/g
210
- while ((m = keywordRegex.exec(text)) !== null) {
211
- const r = resolveFilePath(m[1], cwd)
212
- if (r) found.add(r)
213
- }
205
+ // 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
206
+ const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
214
207
 
215
- // 5. Shell 终端常用输出命令参数:-Path "...", > "...", Out-File "...", Set-Content "..."
216
- const cmdRegex = /(?:-Path\s+|>\s*|Out-File\s+|Set-Content\s+|TargetFile["':\s]+|targetFile["':\s]+)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/gi
217
- while ((m = cmdRegex.exec(text)) !== null) {
218
- const r = resolveFilePath(m[1], cwd)
219
- if (r) found.add(r)
220
- }
208
+ return { cleanText, files }
209
+ }
221
210
 
222
- return Array.from(found)
211
+ /**
212
+ * 提取文本中的产物文件路径(基于显式指令)
213
+ */
214
+ export function extractFilePathsFromText(text, cwd = process.cwd()) {
215
+ return extractAndStripSendFileDirectives(text, cwd).files
223
216
  }
224
217
 
225
218
  // ---------------------------------------------------------------------------
@@ -626,8 +619,8 @@ export class ConversationBridge {
626
619
  return 'routed'
627
620
  }
628
621
 
629
- // 针对微信/IM客户端用户,注入上下文提示,指导 Agent 生成文件后输出明确完整路径以触发自动上传直发
630
- const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户要求生成、导出或发送文件,请将文件保存在本地磁盘并在回复中明确写出文件的完整路径(如 📁 <路径>),网关会自动把该文件上传并直接发送到用户的 ${this.platform.name} 聊天窗口。 -->`
622
+ // 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
623
+ const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
631
624
 
632
625
  const messageValue = createUserMessage({
633
626
  content: [{ type: 'text', text: promptWithContext }],
@@ -642,6 +635,7 @@ export class ConversationBridge {
642
635
 
643
636
  /** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
644
637
  async sendText(text) {
638
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
645
639
  const peer = this.peerId || this.config.allowFrom?.[0]
646
640
  if (!peer) return
647
641
  const chunks = splitForIM(text, this.config.maxMessageChars)
@@ -710,6 +704,7 @@ export class ConversationBridge {
710
704
  stopHeartbeat(state)
711
705
  }
712
706
  if (session.id !== this.activeSessionId) return
707
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
713
708
 
714
709
  if (event.type === 'turn/start') {
715
710
  const turn = event.data?.turn
@@ -725,36 +720,21 @@ export class ConversationBridge {
725
720
  const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
726
721
 
727
722
  if (event.type === 'tool/call') {
728
- const cwd = getSessionCwd(session)
729
- let args = event.data?.parameters || event.data?.args || event.data?.arguments || {}
730
- if (typeof args === 'string') {
731
- try { args = JSON.parse(args) } catch {}
732
- }
733
- if (typeof args === 'object' && args !== null) {
734
- const possibleKeys = [
735
- 'TargetFile', 'targetFile', 'target_file', 'path', 'filePath', 'file',
736
- 'destination', 'out_file', 'output', 'ImageName', 'fileName', 'filename'
737
- ]
738
- for (const k of possibleKeys) {
739
- const val = args[k]
740
- if (val && typeof val === 'string') {
741
- const clean = val.trim().replace(/^["'`]|["'`]$/g, '').replace(/^file:\/\/\/?/, '')
742
- if (clean) state.createdFiles.add(clean)
743
- }
744
- }
745
- }
746
- const rawStr = JSON.stringify(event.data || {})
747
- const fromRaw = extractFilePathsFromText(rawStr, cwd)
748
- for (const f of fromRaw) state.createdFiles.add(f)
723
+ // 工具执行仅在终端/状态中展示,文件直发由 AI 回复中的 [SEND_FILE: ...] 指令显式驱动,杜绝误判
749
724
  return
750
725
  }
751
726
  if (event.type === 'assistant/message') {
752
- const text = textOfAssistantMessage(event.data.message)
753
- if (text.trim()) {
727
+ const rawText = textOfAssistantMessage(event.data.message)
728
+ if (rawText.trim()) {
754
729
  const cwd = getSessionCwd(session)
755
- const fromText = extractFilePathsFromText(text, cwd)
756
- for (const f of fromText) state.createdFiles.add(f)
757
- void this.sendText(text)
730
+ const { cleanText, files } = extractAndStripSendFileDirectives(rawText, cwd)
731
+ for (const f of files) {
732
+ state.createdFiles.add(f)
733
+ }
734
+ // 仅向聊天窗口发送过滤掉 [SEND_FILE: ...] 控制指令后的纯净正文
735
+ if (cleanText) {
736
+ void this.sendText(cleanText)
737
+ }
758
738
  }
759
739
  return
760
740
  }
@@ -770,20 +750,19 @@ export class ConversationBridge {
770
750
  void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
771
751
  }
772
752
 
773
- // 如果本轮生成/记录了产物文件,下发清单通知并尝试直接上传文件至聊天窗口
753
+ // 如果本轮 AI 显式指定了 [SEND_FILE: ...] 文件发送指令,直接上传并发送给用户
774
754
  if (state.createdFiles && state.createdFiles.size > 0) {
775
755
  const rawFiles = Array.from(state.createdFiles)
776
756
  const cwd = getSessionCwd(session)
777
- const fileLines = rawFiles.map((f) => `- \`${f}\``).join('\n')
778
- void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
779
-
780
- // 如果平台支持 sendMediaFile,自动尝试直接发送真实存在的文件/图片到聊天窗口(严格按绝对路径去重)
781
757
  const targetPeer = this.peerId || this.config.allowFrom?.[0]
758
+
782
759
  if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
783
- const uniqueFilesToSend = new Set()
760
+ const uniqueFilesToSend = []
784
761
  for (const f of rawFiles) {
785
762
  const resolved = resolveFilePath(f, cwd)
786
- if (resolved) uniqueFilesToSend.add(resolved)
763
+ if (resolved && !uniqueFilesToSend.includes(resolved)) {
764
+ uniqueFilesToSend.push(resolved)
765
+ }
787
766
  }
788
767
  for (const resolved of uniqueFilesToSend) {
789
768
  try {
@@ -942,45 +921,261 @@ function foldTitle(events) {
942
921
  return null
943
922
  }
944
923
 
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) => {
924
+ /** 获取所有已归档会话 ID 集合(支持 ctx.workspaceRegistry 内存服务 + workspace.json 文件存储双重兜底) */
925
+ function getArchivedSessionIds(ctx) {
926
+ const archived = new Set()
927
+ // 1. 尝试从 ctx.workspaceRegistry 内存服务读取
928
+ try {
929
+ const list = ctx?.workspaceRegistry?.archivedSessionIds
930
+ if (Array.isArray(list)) {
931
+ for (const id of list) {
932
+ if (id) archived.add(String(id))
933
+ }
934
+ return archived
935
+ }
936
+ } catch { /* ignore */ }
937
+
938
+ // 2. 尝试从 DSH workspace 存储文件($DSH_HOME/storages/workspace.json)读取兜底
939
+ if (!ctx?._mock) {
955
940
  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 }
941
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
942
+ const wsFile = join(home, 'storages', 'workspace.json')
943
+ if (existsSync(wsFile)) {
944
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
945
+ const fileArchived = data?.global?.archivedSessionIds
946
+ if (Array.isArray(fileArchived)) {
947
+ for (const id of fileArchived) {
948
+ if (id) archived.add(String(id))
949
+ }
950
+ }
951
+ }
958
952
  } 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 = []
953
+ }
954
+
955
+ return archived
956
+ }
957
+
958
+ /** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
959
+ function getSessionProjCache(ctx) {
960
+ if (ctx?._mock) return {}
963
961
  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
962
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
963
+ const cacheFile = join(home, 'storages', 'session_projcache.json')
964
+ if (existsSync(cacheFile)) {
965
+ const data = JSON.parse(readFileSync(cacheFile, 'utf8'))
966
+ return data?.tables?.sessions || {}
967
+ }
968
+ } catch { /* ignore */ }
969
+ return {}
970
+ }
971
+
972
+ /** 读取 DSH 官方注册的工作区列表及各自绑定的 sessionIds 列表 */
973
+ async function getRegisteredWorkspaces(ctx) {
974
+ const workspaces = []
975
+
976
+ // 优先从内存服务获取
977
+ if (ctx?.workspaceRegistry) {
978
+ try {
979
+ const list = await ctx.workspaceRegistry.list?.()
980
+ if (Array.isArray(list)) {
981
+ for (const w of list) {
982
+ if (w && w.path) {
983
+ workspaces.push({
984
+ id: w.id || w.path,
985
+ path: w.path,
986
+ title: w.title || basename(w.path),
987
+ sessionIds: Array.isArray(w.sessionIds) ? [...w.sessionIds] : [],
988
+ })
989
+ }
990
+ }
991
+ return workspaces
992
+ }
993
+ } catch { /* ignore */ }
994
+ }
995
+
996
+ // 兜底从 workspace.json 存储文件读取
997
+ if (!ctx?._mock) {
998
+ try {
999
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1000
+ const wsFile = join(home, 'storages', 'workspace.json')
1001
+ if (existsSync(wsFile)) {
1002
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
1003
+ const wsIds = data?.global?.workspaceIds || Object.keys(data?.tables?.workspaces || {})
1004
+ const table = data?.tables?.workspaces || {}
1005
+ for (const wId of wsIds) {
1006
+ const ws = table[wId]
1007
+ if (ws && ws.path) {
1008
+ workspaces.push({
1009
+ id: wId,
1010
+ path: ws.path,
1011
+ title: ws.title || basename(ws.path),
1012
+ sessionIds: Array.isArray(ws.sessionIds) ? [...ws.sessionIds] : [],
1013
+ })
1014
+ }
1015
+ }
1016
+ }
1017
+ } catch { /* ignore */ }
1018
+ }
1019
+
1020
+ return workspaces
1021
+ }
1022
+
1023
+ function isSubagentSession(cacheRow, liveSession) {
1024
+ if (liveSession?.origin === 'subagent' || liveSession?.header?.origin === 'subagent') return true
1025
+ const subVal = cacheRow?.rows?.subagent?.val
1026
+ if (subVal && typeof subVal === 'object' && Object.keys(subVal).length > 0) return true
1027
+ return false
1028
+ }
1029
+
1030
+ // 列出会话:严格对齐 DSH Web 端侧边栏会话树逻辑。
1031
+ // 1. 过滤已归档会话 (archivedSessionIds)
1032
+ // 2. 过滤未发起提问的空白会话 (blank: true)
1033
+ // 3. 过滤子代理内部会话 (subagent origin)
1034
+ // 4. 严格按工作区账本 (workspace.sessionIds) 组织
1035
+ async function listSessions(node) {
1036
+ const archived = getArchivedSessionIds(node.ctx)
1037
+ const projCache = getSessionProjCache(node.ctx)
1038
+ const workspaces = await getRegisteredWorkspaces(node.ctx)
1039
+
1040
+ // 内存活跃会话(按 id 索引)
1041
+ const liveList = [...(node.ctx.sessions?.list?.() ?? [])].filter(
1042
+ (s) => s && s.id && !archived.has(s.id) && !s.archived && !s.header?.archived
1043
+ )
1044
+ const liveById = new Map(liveList.map((s) => [s.id, s]))
1045
+
1046
+ const accounted = new Set()
1047
+ const result = []
1048
+
1049
+ // 1. 如果存在已注册的工作区,严格按工作区及其 sessionIds 账本组织(与 Web 端完全一致)
1050
+ if (workspaces.length > 0) {
1051
+ for (const ws of workspaces) {
1052
+ const normWsPath = ws.path ? normalize(ws.path).toLowerCase() : ''
1053
+
1054
+ // 优先将当前工作区下新创建但在内存里的 live 会话追加到头部
1055
+ for (const s of liveList) {
1056
+ const sCwd = s.header?.cwd || s.cwd
1057
+ if (sCwd && normalize(sCwd).toLowerCase() === normWsPath && !accounted.has(s.id)) {
1058
+ if (isSubagentSession(projCache[s.id], s)) continue
1059
+ accounted.add(s.id)
1060
+ let title = s.title || (s.events ? foldTitle(s.events) : '')
1061
+ if (!title) {
1062
+ const cache = projCache[s.id]
1063
+ title = cache?.rows?.title?.val || cache?.rows?.goal?.val
1064
+ }
1065
+ result.push({
1066
+ id: s.id,
1067
+ createdAt: s.header?.createdAt || Date.now(),
1068
+ cwd: ws.path,
1069
+ workspaceTitle: ws.title,
1070
+ title: title || '新会话',
1071
+ events: s.events,
1072
+ seq: s.seq ?? 0,
1073
+ })
1074
+ }
1075
+ }
1076
+
1077
+ // 按工作区账本存储的 sessionIds 顺序追加已记录会话
1078
+ for (const sId of ws.sessionIds) {
1079
+ if (archived.has(sId) || accounted.has(sId)) continue
1080
+ accounted.add(sId)
1081
+
1082
+ const cache = projCache[sId]
1083
+ const live = liveById.get(sId)
1084
+ // 过滤空白草稿会话(非当前活动会话)
1085
+ if (cache?.rows?.sessionListMetadata?.val?.blank === true && sId !== node.activeSessionId) {
1086
+ continue
1087
+ }
1088
+ // 过滤子代理内部会话
1089
+ if (isSubagentSession(cache, live)) {
1090
+ continue
1091
+ }
1092
+
1093
+ let title = cache?.rows?.title?.val || cache?.rows?.goal?.val
1094
+ let createdAt = cache?.identity?.createdAt || 0
1095
+ let cwd = ws.path
1096
+
1097
+ // 如果内存有该会话,提取最新数据
1098
+ if (live) {
1099
+ title = live.title || (live.events ? foldTitle(live.events) : '') || title
1100
+ createdAt = live.header?.createdAt || createdAt
1101
+ } else if (!title && node.ctx.sessionPersistence?.load) {
973
1102
  try {
974
- const insp = await node.ctx.sessionPersistence.load(h.id)
1103
+ const insp = await node.ctx.sessionPersistence.load(sId)
975
1104
  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)
1105
+ } catch {}
1106
+ }
1107
+
1108
+ result.push({
1109
+ id: sId,
1110
+ createdAt,
1111
+ cwd,
1112
+ workspaceTitle: ws.title,
1113
+ title: title || '新会话',
1114
+ events: live?.events,
1115
+ seq: live?.seq ?? 0,
1116
+ })
980
1117
  }
981
1118
  }
982
- } catch { /* 持久化服务不可用时仅返回内存会话 */ }
983
- return [...liveMapped, ...cold].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq)
1119
+
1120
+ // 处理当前内存中处于活动状态但未绑定任何工作区的 live 会话
1121
+ for (const s of liveList) {
1122
+ if (accounted.has(s.id)) continue
1123
+ if (isSubagentSession(projCache[s.id], s)) continue
1124
+ accounted.add(s.id)
1125
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '未分组会话'
1126
+ result.push({
1127
+ id: s.id,
1128
+ createdAt: s.header?.createdAt || Date.now(),
1129
+ cwd: s.header?.cwd || '(未指定)',
1130
+ workspaceTitle: '未指定工作区',
1131
+ title,
1132
+ events: s.events,
1133
+ seq: s.seq ?? 0,
1134
+ })
1135
+ }
1136
+ } else {
1137
+ // 2. 如果系统未注册任何工作区(如单目录/无工作区模式),降级读取内存及持久化会话
1138
+ for (const s of liveList) {
1139
+ accounted.add(s.id)
1140
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '活跃会话'
1141
+ result.push({
1142
+ id: s.id,
1143
+ createdAt: s.header?.createdAt || Date.now(),
1144
+ cwd: s.header?.cwd || '(未指定)',
1145
+ workspaceTitle: '未指定工作区',
1146
+ title,
1147
+ events: s.events,
1148
+ seq: s.seq ?? 0,
1149
+ })
1150
+ }
1151
+ if (node.ctx.sessionPersistence?.list) {
1152
+ try {
1153
+ const headers = await node.ctx.sessionPersistence.list()
1154
+ if (Array.isArray(headers)) {
1155
+ const coldHeaders = headers.filter((h) => h && h.id && !accounted.has(h.id) && !archived.has(h.id) && !h.archived)
1156
+ for (const h of coldHeaders) {
1157
+ accounted.add(h.id)
1158
+ let title
1159
+ try {
1160
+ const insp = await node.ctx.sessionPersistence.load(h.id)
1161
+ title = foldTitle(insp.events ?? []) ?? undefined
1162
+ } catch {}
1163
+ result.push({
1164
+ id: h.id,
1165
+ createdAt: h.createdAt ?? 0,
1166
+ events: undefined,
1167
+ seq: 0,
1168
+ cwd: h.cwd || '(未指定)',
1169
+ workspaceTitle: '未指定工作区',
1170
+ title: title || '新会话',
1171
+ })
1172
+ }
1173
+ }
1174
+ } catch {}
1175
+ }
1176
+ }
1177
+
1178
+ return result
984
1179
  }
985
1180
 
986
1181
  // 列出可用工作区:使用 DSH 官方 workspaceRegistry。返回 [{title, path}]。
@@ -1232,37 +1427,38 @@ async function renderSessions(node) {
1232
1427
  if (all.length === 0) {
1233
1428
  return `## 📋 会话列表\n\n> 暂无历史会话。发送 \`/new <提示词>\` 开始新会话。`
1234
1429
  }
1235
- // 按工作区(真实 cwd)分组;无 cwd 的归入 '(未指定)'
1430
+ // 按工作区分组(保持 listSessions 中的工作区账本顺序)
1236
1431
  const groups = new Map()
1237
1432
  for (const s of all) {
1238
1433
  const key = s.cwd || '(未指定)'
1239
- if (!groups.has(key)) groups.set(key, [])
1240
- groups.get(key).push(s)
1434
+ if (!groups.has(key)) {
1435
+ groups.set(key, { title: s.workspaceTitle || getWorkspaceBasename(key), sessions: [] })
1436
+ }
1437
+ groups.get(key).sessions.push(s)
1241
1438
  }
1242
- const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
1243
1439
  const parts = [
1244
1440
  `## 📋 会话列表 (共 ${all.length} 个)`,
1245
1441
  `> 切换会话:发送 \`/use 编号\` 或 \`/resume 编号\``,
1246
1442
  '',
1247
1443
  ]
1248
1444
  let idx = 0
1249
- for (const [cwd, sessions] of sortedGroups) {
1250
- const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${getWorkspaceBasename(cwd)}**`
1445
+ for (const [cwd, group] of groups) {
1446
+ const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${group.title || getWorkspaceBasename(cwd)}**`
1251
1447
  parts.push(groupName)
1252
1448
  parts.push('')
1253
1449
  parts.push('| 序号 | 会话标题 / 摘要 | 时间 | 状态 |')
1254
1450
  parts.push('| :--- | :--- | :--- | :--- |')
1255
- for (const session of sessions.slice(0, 20)) {
1451
+ for (const session of group.sessions.slice(0, 20)) {
1256
1452
  idx += 1
1257
1453
  const isActive = session.id === node.activeSessionId
1258
1454
  const statusTag = isActive ? '`[当前]`' : '-'
1259
1455
  const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
1260
- const safeTitle = (rawTitle || '新会话 (待输入)').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1456
+ const safeTitle = (rawTitle || '新会话').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1261
1457
  const when = session.createdAt ? fmtTime(session.createdAt) : '-'
1262
1458
  parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
1263
1459
  }
1264
- if (sessions.length > 20) {
1265
- parts.push(`*…该工作区共 ${sessions.length} 个会话,仅显示前 20 个*`)
1460
+ if (group.sessions.length > 20) {
1461
+ parts.push(`*…该工作区共 ${group.sessions.length} 个会话,仅显示前 20 个*`)
1266
1462
  }
1267
1463
  parts.push('')
1268
1464
  }
@@ -1270,7 +1466,7 @@ async function renderSessions(node) {
1270
1466
  return parts.join('\n').trim()
1271
1467
  }
1272
1468
 
1273
- // 与 renderSessions 完全一致的显示顺序:按工作区字母序分组、组内保持 listSessions 顺序。
1469
+ // 与 renderSessions 完全一致的显示顺序:保持 listSessions 中的分组和顺序。
1274
1470
  // /use N 用这个数组索引,保证显示的编号 N 与切换的会话一一对应。
1275
1471
  function sessionsInDisplayOrder(all) {
1276
1472
  const groups = new Map()
@@ -1279,8 +1475,7 @@ function sessionsInDisplayOrder(all) {
1279
1475
  if (!groups.has(key)) groups.set(key, [])
1280
1476
  groups.get(key).push(s)
1281
1477
  }
1282
- const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
1283
- return sortedGroups.flatMap(([, sessions]) => sessions)
1478
+ return [...groups.values()].flatMap((sessions) => sessions)
1284
1479
  }
1285
1480
 
1286
1481
  // 时间戳 → 简洁可读时间 (MM-DD HH:mm 或 YYYY-MM-DD HH:mm)
@@ -1341,8 +1536,10 @@ export const conversationBridgeHelpers = {
1341
1536
  textOfAssistantMessage,
1342
1537
  resolveFilePath,
1343
1538
  extractFilePathsFromText,
1539
+ extractAndStripSendFileDirectives,
1344
1540
  sessionsInDisplayOrder,
1345
1541
  listSessions,
1542
+ renderSessions,
1346
1543
  listWorkspaces,
1347
1544
  BRIDGE_MARK,
1348
1545
  }
package/lib/qq/gateway.js CHANGED
@@ -164,9 +164,16 @@ export class QqGateway extends Service {
164
164
  async stop() {
165
165
  this.stopRequested = true
166
166
  this.clearHeartbeat()
167
+ const finish = this._finishConnect
168
+ this._finishConnect = null
169
+ if (finish) {
170
+ try { finish(new Error('Stopped by user')) } catch {}
171
+ }
167
172
  const ws = this.ws
168
173
  this.ws = null
169
- if (ws) { try { ws.removeAllListeners(); ws.close(); ws.terminate() } catch {} }
174
+ if (ws) {
175
+ try { ws.close(); ws.terminate() } catch {}
176
+ }
170
177
  const task = this.loopTask
171
178
  this.loopTask = null
172
179
  if (task) await task.catch(() => {})
@@ -227,11 +234,13 @@ export class QqGateway extends Service {
227
234
  try {
228
235
  this.setStatus('starting')
229
236
  const token = await this.refreshAccessToken()
237
+ if (this.stopRequested) break
230
238
  // 官方「获取带分片 WSS 接入点」接口,返回网关地址与建议分片数
231
239
  // 参考:https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/event-emit/websocket.html
232
240
  const gateway = this.config.gatewayUrl
233
241
  || ((await requestJson(`${API_BASE}/gateway/bot`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
234
242
  || DEFAULT_GATEWAY
243
+ if (this.stopRequested) break
235
244
  await this.connect(gateway, token)
236
245
  backoffMs = this.config.reconnectDelayMs
237
246
  } catch (error) {
@@ -247,18 +256,32 @@ export class QqGateway extends Service {
247
256
 
248
257
  connect(url, token) {
249
258
  return new Promise((resolve, reject) => {
259
+ if (this.stopRequested) {
260
+ resolve()
261
+ return
262
+ }
250
263
  const ws = new WebSocket(url)
251
264
  this.ws = ws
252
265
  let settled = false
253
266
  const finish = (error) => {
254
267
  if (settled) return
255
268
  settled = true
269
+ this._finishConnect = null
256
270
  this.clearHeartbeat()
257
271
  if (this.ws === ws) this.ws = null
258
272
  error ? reject(error) : resolve()
259
273
  }
260
- ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] WebSocket connected to QQ Open Platform'))
274
+ this._finishConnect = finish
275
+
276
+ ws.on('open', () => {
277
+ if (this.stopRequested) {
278
+ finish()
279
+ return
280
+ }
281
+ this.logger?.info?.('[dsh-bridge qq] WebSocket connected to QQ Open Platform')
282
+ })
261
283
  ws.on('message', (raw) => {
284
+ if (this.stopRequested) return
262
285
  let payload
263
286
  try { payload = JSON.parse(String(raw)) } catch { return }
264
287
  void this.handlePayload(payload, token, ws).catch(finish)
@@ -269,6 +292,7 @@ export class QqGateway extends Service {
269
292
  }
270
293
 
271
294
  async handlePayload(payload, token, ws) {
295
+ if (this.stopRequested) return
272
296
  const op = Number(payload?.op)
273
297
  if (payload?.s != null) this.sequence = payload.s
274
298
  if (op === 10) {
package/lib/qq/node.js CHANGED
@@ -360,6 +360,7 @@ export class QqConversationNode extends ConversationBridge {
360
360
  // ---- 入站 ----
361
361
 
362
362
  async _handleInbound(event) {
363
+ if (this.gateway?.stopRequested) return
363
364
  const sender = String(event.senderId ?? '').trim()
364
365
  if (!sender) return
365
366
 
@@ -449,6 +450,7 @@ export class QqConversationNode extends ConversationBridge {
449
450
  // ---- 互动事件 ----
450
451
 
451
452
  async _handleInteraction(event) {
453
+ if (this.gateway?.stopRequested) return
452
454
  const sender = String(event.senderId ?? '').trim()
453
455
  if (!sender) return
454
456
 
@@ -365,8 +365,11 @@ export class TelegramGateway extends Service {
365
365
  consecutiveErrors = 0
366
366
  if (this.status !== 'online') this.setStatus('online')
367
367
 
368
+ if (this._stopPolling) break
369
+
368
370
  if (Array.isArray(updates) && updates.length > 0) {
369
371
  for (const update of updates) {
372
+ if (this._stopPolling) break
370
373
  if (this._seenUpdates.has(update.update_id)) continue
371
374
  this._seenUpdates.add(update.update_id)
372
375
  if (this._seenUpdates.size > 2000) {
@@ -95,6 +95,7 @@ export class TelegramConversationNode extends ConversationBridge {
95
95
  }
96
96
 
97
97
  async _handleInbound(event) {
98
+ if (this.gateway?._stopPolling) return
98
99
  const { chatId, senderId, senderUsername, isGroup, text, messageId, raw } = event
99
100
  this._lastPeer = { chatId, senderId, senderUsername, isGroup }
100
101
  const authId = isGroup ? chatId : senderId
@@ -845,8 +845,10 @@ export class WechatGateway extends Service {
845
845
  if (this.statusValue !== 'connected') {
846
846
  this.logger?.info?.('[dsh-bridge wechat] connected to iLink platform')
847
847
  }
848
+ if (this.stopPollingLocal) break
848
849
  this.setStatus('connected')
849
850
  for (const message of batch.messages) {
851
+ if (this.stopPollingLocal) break
850
852
  this.dispatchInbound(message)
851
853
  }
852
854
  if (this.c.pollIdleDelayMs > 0) await sleep(this.c.pollIdleDelayMs)
@@ -67,6 +67,7 @@ export class WechatConversationNode extends ConversationBridge {
67
67
  // ---- 入站 ----
68
68
 
69
69
  async _handleInbound(message) {
70
+ if (this.ctx?.wechat?.stopPollingLocal) return
70
71
  const sender = String(message.from_user_id ?? '').trim()
71
72
  if (!sender) return
72
73
 
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.5",
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.5 产物文件推送改为 AI 指令驱动 & 修复 IM 平台断开连接问题】\n• 🎯 显式文件发送协议:由 AI 通过 [SEND_FILE: <路径>] 显式驱动文件直传,杜绝修改代码时的文件误发风暴\n• 🧹 聊天正文自动净化:自动剔除聊天窗口中的文件发送指令,保持聊天气泡清爽\n• 🔌 修复 IM 断开连接失效:修复 QQ 等网关断开连接时 WebSocket 事件悬挂问题,断开后彻底阻断入站消息与出站回复",
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": {