@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 +16 -0
- package/lib/feishu/gateway.js +1 -0
- package/lib/feishu/node.js +1 -0
- package/lib/platform/conversation-bridge.js +314 -117
- package/lib/qq/gateway.js +26 -2
- package/lib/qq/node.js +2 -0
- package/lib/telegram/gateway.js +3 -0
- package/lib/telegram/node.js +1 -0
- package/lib/wechat/gateway.js +2 -0
- package/lib/wechat/node.js +1 -0
- package/package.json +3 -2
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 文件收发链路全面修复与深度兼容
|
package/lib/feishu/gateway.js
CHANGED
|
@@ -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
|
package/lib/feishu/node.js
CHANGED
|
@@ -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
|
|
183
|
-
if (typeof text !== 'string' || !text.trim())
|
|
184
|
-
|
|
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
|
-
|
|
195
|
-
const
|
|
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
|
-
|
|
202
|
-
const
|
|
203
|
-
while ((m =
|
|
204
|
-
const
|
|
205
|
-
|
|
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
|
-
//
|
|
209
|
-
const
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
630
|
-
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${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
|
-
|
|
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
|
|
753
|
-
if (
|
|
727
|
+
const rawText = textOfAssistantMessage(event.data.message)
|
|
728
|
+
if (rawText.trim()) {
|
|
754
729
|
const cwd = getSessionCwd(session)
|
|
755
|
-
const
|
|
756
|
-
for (const f of
|
|
757
|
-
|
|
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 =
|
|
760
|
+
const uniqueFilesToSend = []
|
|
784
761
|
for (const f of rawFiles) {
|
|
785
762
|
const resolved = resolveFilePath(f, cwd)
|
|
786
|
-
if (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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
|
957
|
-
|
|
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
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return archived
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
|
|
959
|
+
function getSessionProjCache(ctx) {
|
|
960
|
+
if (ctx?._mock) return {}
|
|
963
961
|
try {
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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(
|
|
1103
|
+
const insp = await node.ctx.sessionPersistence.load(sId)
|
|
975
1104
|
title = foldTitle(insp.events ?? []) ?? undefined
|
|
976
|
-
} catch {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
983
|
-
|
|
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
|
-
//
|
|
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))
|
|
1240
|
-
|
|
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,
|
|
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 || '新会话
|
|
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
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
|
package/lib/telegram/gateway.js
CHANGED
|
@@ -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) {
|
package/lib/telegram/node.js
CHANGED
|
@@ -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
|
package/lib/wechat/gateway.js
CHANGED
|
@@ -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)
|
package/lib/wechat/node.js
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.5",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.8.
|
|
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": {
|