@wenbin_wb/dsh-bridge 2.8.4 → 2.8.6
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 +28 -0
- package/client/client.js +302 -32
- package/client/index.js +319 -39
- package/docs/fix-tunnel-sse-and-session-list.md +134 -0
- package/lib/cloudflared-manager.mjs +75 -12
- package/lib/feishu/gateway.js +1 -0
- package/lib/feishu/node.js +1 -0
- package/lib/index.js +65 -9
- package/lib/platform/conversation-bridge.js +201 -197
- 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/tunnel-client.mjs +402 -289
- package/lib/wechat/gateway.js +2 -0
- package/lib/wechat/node.js +1 -0
- package/package.json +2 -2
|
@@ -21,7 +21,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
|
21
21
|
import { randomUUID } from 'node:crypto'
|
|
22
22
|
import { statSync, existsSync, readFileSync } from 'node:fs'
|
|
23
23
|
import { stat } from 'node:fs/promises'
|
|
24
|
-
import { join, resolve, normalize, basename, isAbsolute } from 'node:path'
|
|
24
|
+
import { join, resolve, normalize, basename, extname, isAbsolute } from 'node:path'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
26
|
import { isSafeWorkspacePath } from '../security/path-validator.js'
|
|
27
27
|
|
|
@@ -178,49 +178,41 @@ export function resolveFilePath(rawPath, cwd = process.cwd()) {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
|
-
*
|
|
181
|
+
* 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
|
|
182
|
+
* 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
|
|
183
|
+
* @param {string} text - 原始助手回复文本
|
|
184
|
+
* @param {string} cwd - 会话当前工作目录
|
|
185
|
+
* @returns {{ cleanText: string, files: string[] }}
|
|
182
186
|
*/
|
|
183
|
-
export function
|
|
184
|
-
if (typeof text !== 'string' || !text.trim())
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// 1. Windows 绝对路径:C:\Users\...\file.ext 或 C:/Users/.../file.ext(支持中文、空格、特殊符号)
|
|
188
|
-
const winAbsRegex = /[A-Za-z]:[\\/][^\s"'`<>|*?()]+?\.[A-Za-z0-9_.-]+/g
|
|
189
|
-
let m
|
|
190
|
-
while ((m = winAbsRegex.exec(text)) !== null) {
|
|
191
|
-
const r = resolveFilePath(m[0], cwd)
|
|
192
|
-
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: [] }
|
|
193
190
|
}
|
|
194
191
|
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
while ((m = posixAbsRegex.exec(text)) !== null) {
|
|
198
|
-
const r = resolveFilePath(m[0], cwd)
|
|
199
|
-
if (r) found.add(r)
|
|
200
|
-
}
|
|
192
|
+
const files = []
|
|
193
|
+
const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
|
|
201
194
|
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
while ((m =
|
|
205
|
-
const
|
|
206
|
-
|
|
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
|
+
}
|
|
207
203
|
}
|
|
208
204
|
|
|
209
|
-
//
|
|
210
|
-
const
|
|
211
|
-
while ((m = keywordRegex.exec(text)) !== null) {
|
|
212
|
-
const r = resolveFilePath(m[1], cwd)
|
|
213
|
-
if (r) found.add(r)
|
|
214
|
-
}
|
|
205
|
+
// 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
|
|
206
|
+
const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
|
|
215
207
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
while ((m = cmdRegex.exec(text)) !== null) {
|
|
219
|
-
const r = resolveFilePath(m[1], cwd)
|
|
220
|
-
if (r) found.add(r)
|
|
221
|
-
}
|
|
208
|
+
return { cleanText, files }
|
|
209
|
+
}
|
|
222
210
|
|
|
223
|
-
|
|
211
|
+
/**
|
|
212
|
+
* 提取文本中的产物文件路径(基于显式指令)
|
|
213
|
+
*/
|
|
214
|
+
export function extractFilePathsFromText(text, cwd = process.cwd()) {
|
|
215
|
+
return extractAndStripSendFileDirectives(text, cwd).files
|
|
224
216
|
}
|
|
225
217
|
|
|
226
218
|
// ---------------------------------------------------------------------------
|
|
@@ -368,7 +360,9 @@ export class ConversationBridge {
|
|
|
368
360
|
// 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
|
|
369
361
|
const sessionId = `session-${randomUUID()}`
|
|
370
362
|
try {
|
|
371
|
-
const
|
|
363
|
+
const workspaces = await listWorkspaces(this)
|
|
364
|
+
const defaultWsPath = workspaces.length > 0 ? workspaces[0].path : process.cwd()
|
|
365
|
+
const cwd = cwdOverride || this.config.cwd || defaultWsPath
|
|
372
366
|
// 校验指定目录存在且是目录,防止路径遍历
|
|
373
367
|
if (cwdOverride) {
|
|
374
368
|
const validation = await validateWorkspacePath(this, cwdOverride)
|
|
@@ -396,13 +390,25 @@ export class ConversationBridge {
|
|
|
396
390
|
// 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
|
|
397
391
|
const handle = await this.ctx.agents.create({ sessionId, meta, agentOptions })
|
|
398
392
|
this.setActiveSession(handle.agent.session)
|
|
393
|
+
|
|
394
|
+
// 同步挂载进 DSH 工作区账本 (workspaceRegistry),使新会话在 Web 侧边栏和 /list 中精准归组
|
|
395
|
+
try {
|
|
396
|
+
const reg = this.ctx.workspaceRegistry
|
|
397
|
+
const entities = reg?.list ? (await reg.list()) : []
|
|
398
|
+
const normCwd = normalize(cwd).toLowerCase()
|
|
399
|
+
const match = (entities || []).find((ws) => ws?.path && normalize(ws.path).toLowerCase() === normCwd)
|
|
400
|
+
if (match?.attachSession) await match.attachSession(sessionId)
|
|
401
|
+
} catch { /* 账本写入失败不影响会话创建 */ }
|
|
402
|
+
|
|
399
403
|
if (prompt) {
|
|
404
|
+
const platformName = this.platform?.name || 'IM客户端'
|
|
405
|
+
const promptWithContext = `${prompt}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${platformName}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
400
406
|
handle.agent.followup(createUserMessage({
|
|
401
|
-
content: [{ type: 'text', text:
|
|
407
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
402
408
|
source: { kind: 'user' },
|
|
403
409
|
}))
|
|
404
410
|
}
|
|
405
|
-
const wsDetail =
|
|
411
|
+
const wsDetail = cwd ? `\n- **工作区**:\`${cwd}\`` : ''
|
|
406
412
|
const hint = prompt ? '' : '\n\n> 💡 发送任意消息即可直接与 Agent 对话。'
|
|
407
413
|
await this.sendText(`✓ **已创建新会话**\n- **会话 ID**:\`${fmtSessionId(handle.agent.session.id)}\`${wsDetail}${hint}`)
|
|
408
414
|
} catch (error) {
|
|
@@ -497,152 +503,154 @@ export class ConversationBridge {
|
|
|
497
503
|
// 'ignored' 消息被忽略(未授权/群消息/空消息)
|
|
498
504
|
// 'routed' 消息已路由到 agent
|
|
499
505
|
async handleInbound({ senderId, text, isGroup = false }) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
506
|
+
try {
|
|
507
|
+
// 等待配置恢复完成(防止启动时竞态)
|
|
508
|
+
if (this._restoringConfig) {
|
|
509
|
+
await this._restoringConfig
|
|
510
|
+
}
|
|
504
511
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
512
|
+
const sender = String(senderId ?? '').trim()
|
|
513
|
+
if (!sender) return 'ignored'
|
|
514
|
+
|
|
515
|
+
if (!this.isAllowed(sender)) {
|
|
516
|
+
// 自动授权:
|
|
517
|
+
// - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
|
|
518
|
+
// - 群聊:首次 @机器人 的群自动纳入(群维度授权,群内成员均可使用)
|
|
519
|
+
// 这是"登录后第一条消息/首次被 @即完成授权"的一步到位体验。
|
|
520
|
+
const shouldAutoApprove = Boolean(text?.trim()) && (
|
|
521
|
+
this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
|
|
522
|
+
isGroup // 群聊:始终自动授权群
|
|
523
|
+
)
|
|
524
|
+
if (shouldAutoApprove) {
|
|
525
|
+
this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
|
|
526
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
|
|
527
|
+
try {
|
|
528
|
+
await this.onFirstSender?.(sender)
|
|
529
|
+
} catch (err) {
|
|
530
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 如果仍未通过白名单,拒绝处理(防止绕过白名单)
|
|
537
|
+
if (!this.isAllowed(sender)) {
|
|
538
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
|
|
539
|
+
return 'ignored'
|
|
524
540
|
}
|
|
525
|
-
} else {
|
|
526
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
|
|
527
541
|
}
|
|
528
542
|
|
|
529
|
-
//
|
|
530
|
-
if (!this.
|
|
531
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from
|
|
543
|
+
// 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
|
|
544
|
+
if (isGroup && !this.platform?.capabilities?.supportsGroup) {
|
|
545
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
|
|
532
546
|
return 'ignored'
|
|
533
547
|
}
|
|
534
|
-
}
|
|
535
548
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const fullText = text?.trim() ?? ''
|
|
543
|
-
if (!fullText) {
|
|
544
|
-
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
|
|
545
|
-
return 'ignored'
|
|
546
|
-
}
|
|
549
|
+
const fullText = text?.trim() ?? ''
|
|
550
|
+
if (!fullText) {
|
|
551
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
|
|
552
|
+
return 'ignored'
|
|
553
|
+
}
|
|
547
554
|
|
|
548
|
-
|
|
555
|
+
this.peerId = sender
|
|
549
556
|
|
|
550
|
-
|
|
557
|
+
if (await routeCommand(this, fullText)) return 'routed'
|
|
551
558
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
try {
|
|
557
|
-
await this._restoringSessionMap.get(sessionId)
|
|
558
|
-
} catch { /* 错误已在原始 Promise 中捕获 */ }
|
|
559
|
-
agent = this.activeAgent()
|
|
560
|
-
} else {
|
|
561
|
-
const restorePromise = (async () => {
|
|
562
|
-
// agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
|
|
559
|
+
let agent = this.activeAgent()
|
|
560
|
+
if (!agent && this.activeSessionId) {
|
|
561
|
+
const sessionId = this.activeSessionId
|
|
562
|
+
if (this._restoringSessionMap.has(sessionId)) {
|
|
563
563
|
try {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if (def) {
|
|
571
|
-
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
572
|
-
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
573
|
-
}
|
|
574
|
-
} catch { /* ignore */ }
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
|
|
578
|
-
// 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
|
|
579
|
-
let persisted = false
|
|
564
|
+
await this._restoringSessionMap.get(sessionId)
|
|
565
|
+
} catch { /* 错误已在原始 Promise 中捕获 */ }
|
|
566
|
+
agent = this.activeAgent()
|
|
567
|
+
} else {
|
|
568
|
+
const restorePromise = (async () => {
|
|
569
|
+
// agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
|
|
580
570
|
try {
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// 读取持久化会话的 cwd 做 fallback(新建会话时用)
|
|
593
|
-
let sessionCwd = this.config.cwd || process.cwd()
|
|
594
|
-
const meta = {
|
|
595
|
-
cwd: sessionCwd,
|
|
596
|
-
agentPreset: this.config.agentPreset || 'routing-suite',
|
|
571
|
+
const agentOptions = {}
|
|
572
|
+
if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
|
|
573
|
+
if (this.config.agentModel) agentOptions.model = this.config.agentModel
|
|
574
|
+
if (!agentOptions.provider || !agentOptions.model) {
|
|
575
|
+
try {
|
|
576
|
+
const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
|
|
577
|
+
if (def) {
|
|
578
|
+
if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
|
|
579
|
+
if (!agentOptions.model && def.model) agentOptions.model = def.model
|
|
580
|
+
}
|
|
581
|
+
} catch { /* ignore */ }
|
|
597
582
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
583
|
+
|
|
584
|
+
// 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
|
|
585
|
+
// 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
|
|
586
|
+
let persisted = false
|
|
587
|
+
try {
|
|
588
|
+
const headers = await this.ctx.sessionPersistence?.list?.()
|
|
589
|
+
persisted = Array.isArray(headers) && headers.some((h) => h?.id === sessionId)
|
|
590
|
+
} catch { /* 读取失败则按未持久化处理 */ }
|
|
591
|
+
|
|
592
|
+
let handle
|
|
593
|
+
if (persisted) {
|
|
594
|
+
handle = await this.ctx.agents.resume({
|
|
595
|
+
resumeSessionId: sessionId,
|
|
596
|
+
agentOptions,
|
|
597
|
+
})
|
|
598
|
+
} else {
|
|
599
|
+
handle = await this.ctx.agents.create({
|
|
600
|
+
sessionId,
|
|
601
|
+
meta: { cwd: this.config.cwd || process.cwd() },
|
|
602
|
+
agentOptions,
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
const resumedAgent = handle?.agent
|
|
606
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
|
|
607
|
+
return resumedAgent
|
|
608
|
+
} catch (err) {
|
|
609
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
610
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
|
|
611
|
+
// 清除失效的 activeSessionId,避免用户误以为还在旧会话中
|
|
612
|
+
if (this.activeSessionId === sessionId) {
|
|
613
|
+
this.activeSessionId = null
|
|
614
|
+
}
|
|
615
|
+
await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
|
|
616
|
+
return null
|
|
613
617
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
})().finally(() => {
|
|
618
|
-
this._restoringSessionMap.delete(sessionId)
|
|
619
|
-
})
|
|
618
|
+
})().finally(() => {
|
|
619
|
+
this._restoringSessionMap.delete(sessionId)
|
|
620
|
+
})
|
|
620
621
|
|
|
621
|
-
|
|
622
|
-
|
|
622
|
+
this._restoringSessionMap.set(sessionId, restorePromise)
|
|
623
|
+
agent = await restorePromise
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (!agent) {
|
|
627
|
+
await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
|
|
628
|
+
return 'routed'
|
|
623
629
|
}
|
|
624
|
-
}
|
|
625
|
-
if (!agent) {
|
|
626
|
-
await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
|
|
627
|
-
return 'routed'
|
|
628
|
-
}
|
|
629
630
|
|
|
630
|
-
|
|
631
|
-
|
|
631
|
+
// 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
|
|
632
|
+
const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
|
|
632
633
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
634
|
+
const messageValue = createUserMessage({
|
|
635
|
+
content: [{ type: 'text', text: promptWithContext }],
|
|
636
|
+
source: { kind: 'user' },
|
|
637
|
+
})
|
|
638
|
+
agent.followup(messageValue)
|
|
639
|
+
await this.sendTyping(1).catch(() => {})
|
|
640
|
+
return 'routed'
|
|
641
|
+
} catch (err) {
|
|
642
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
643
|
+
this.logger?.error?.(`[dsh-bridge ${this.platform.id}] unhandled error in handleInbound: ${errMsg}`)
|
|
644
|
+
await this.sendText(`❌ **执行出错**:${errMsg}`).catch(() => {})
|
|
645
|
+
return 'ignored'
|
|
646
|
+
}
|
|
640
647
|
}
|
|
641
648
|
|
|
642
649
|
// ---- 发送(子类必须实现)----
|
|
643
650
|
|
|
644
651
|
/** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
|
|
645
652
|
async sendText(text) {
|
|
653
|
+
if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
|
|
646
654
|
const peer = this.peerId || this.config.allowFrom?.[0]
|
|
647
655
|
if (!peer) return
|
|
648
656
|
const chunks = splitForIM(text, this.config.maxMessageChars)
|
|
@@ -711,6 +719,7 @@ export class ConversationBridge {
|
|
|
711
719
|
stopHeartbeat(state)
|
|
712
720
|
}
|
|
713
721
|
if (session.id !== this.activeSessionId) return
|
|
722
|
+
if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
|
|
714
723
|
|
|
715
724
|
if (event.type === 'turn/start') {
|
|
716
725
|
const turn = event.data?.turn
|
|
@@ -726,36 +735,21 @@ export class ConversationBridge {
|
|
|
726
735
|
const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
|
|
727
736
|
|
|
728
737
|
if (event.type === 'tool/call') {
|
|
729
|
-
|
|
730
|
-
let args = event.data?.parameters || event.data?.args || event.data?.arguments || {}
|
|
731
|
-
if (typeof args === 'string') {
|
|
732
|
-
try { args = JSON.parse(args) } catch {}
|
|
733
|
-
}
|
|
734
|
-
if (typeof args === 'object' && args !== null) {
|
|
735
|
-
const possibleKeys = [
|
|
736
|
-
'TargetFile', 'targetFile', 'target_file', 'path', 'filePath', 'file',
|
|
737
|
-
'destination', 'out_file', 'output', 'ImageName', 'fileName', 'filename'
|
|
738
|
-
]
|
|
739
|
-
for (const k of possibleKeys) {
|
|
740
|
-
const val = args[k]
|
|
741
|
-
if (val && typeof val === 'string') {
|
|
742
|
-
const clean = val.trim().replace(/^["'`]|["'`]$/g, '').replace(/^file:\/\/\/?/, '')
|
|
743
|
-
if (clean) state.createdFiles.add(clean)
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
const rawStr = JSON.stringify(event.data || {})
|
|
748
|
-
const fromRaw = extractFilePathsFromText(rawStr, cwd)
|
|
749
|
-
for (const f of fromRaw) state.createdFiles.add(f)
|
|
738
|
+
// 工具执行仅在终端/状态中展示,文件直发由 AI 回复中的 [SEND_FILE: ...] 指令显式驱动,杜绝误判
|
|
750
739
|
return
|
|
751
740
|
}
|
|
752
741
|
if (event.type === 'assistant/message') {
|
|
753
|
-
const
|
|
754
|
-
if (
|
|
742
|
+
const rawText = textOfAssistantMessage(event.data.message)
|
|
743
|
+
if (rawText.trim()) {
|
|
755
744
|
const cwd = getSessionCwd(session)
|
|
756
|
-
const
|
|
757
|
-
for (const f of
|
|
758
|
-
|
|
745
|
+
const { cleanText, files } = extractAndStripSendFileDirectives(rawText, cwd)
|
|
746
|
+
for (const f of files) {
|
|
747
|
+
state.createdFiles.add(f)
|
|
748
|
+
}
|
|
749
|
+
// 仅向聊天窗口发送过滤掉 [SEND_FILE: ...] 控制指令后的纯净正文
|
|
750
|
+
if (cleanText) {
|
|
751
|
+
void this.sendText(cleanText)
|
|
752
|
+
}
|
|
759
753
|
}
|
|
760
754
|
return
|
|
761
755
|
}
|
|
@@ -771,20 +765,19 @@ export class ConversationBridge {
|
|
|
771
765
|
void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
|
|
772
766
|
}
|
|
773
767
|
|
|
774
|
-
//
|
|
768
|
+
// 如果本轮 AI 显式指定了 [SEND_FILE: ...] 文件发送指令,直接上传并发送给用户
|
|
775
769
|
if (state.createdFiles && state.createdFiles.size > 0) {
|
|
776
770
|
const rawFiles = Array.from(state.createdFiles)
|
|
777
771
|
const cwd = getSessionCwd(session)
|
|
778
|
-
const fileLines = rawFiles.map((f) => `- \`${f}\``).join('\n')
|
|
779
|
-
void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
|
|
780
|
-
|
|
781
|
-
// 如果平台支持 sendMediaFile,自动尝试直接发送真实存在的文件/图片到聊天窗口(严格按绝对路径去重)
|
|
782
772
|
const targetPeer = this.peerId || this.config.allowFrom?.[0]
|
|
773
|
+
|
|
783
774
|
if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
|
|
784
|
-
const uniqueFilesToSend =
|
|
775
|
+
const uniqueFilesToSend = []
|
|
785
776
|
for (const f of rawFiles) {
|
|
786
777
|
const resolved = resolveFilePath(f, cwd)
|
|
787
|
-
if (resolved
|
|
778
|
+
if (resolved && !uniqueFilesToSend.includes(resolved)) {
|
|
779
|
+
uniqueFilesToSend.push(resolved)
|
|
780
|
+
}
|
|
788
781
|
}
|
|
789
782
|
for (const resolved of uniqueFilesToSend) {
|
|
790
783
|
try {
|
|
@@ -1049,6 +1042,15 @@ function isSubagentSession(cacheRow, liveSession) {
|
|
|
1049
1042
|
return false
|
|
1050
1043
|
}
|
|
1051
1044
|
|
|
1045
|
+
function formatGoalTitle(raw) {
|
|
1046
|
+
if (typeof raw === 'string' && raw) return raw
|
|
1047
|
+
if (raw && typeof raw === 'object') {
|
|
1048
|
+
const obj = raw.objective ?? raw.goal?.objective ?? raw.title
|
|
1049
|
+
if (typeof obj === 'string' && obj) return obj
|
|
1050
|
+
}
|
|
1051
|
+
return ''
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1052
1054
|
// 列出会话:严格对齐 DSH Web 端侧边栏会话树逻辑。
|
|
1053
1055
|
// 1. 过滤已归档会话 (archivedSessionIds)
|
|
1054
1056
|
// 2. 过滤未发起提问的空白会话 (blank: true)
|
|
@@ -1082,7 +1084,7 @@ async function listSessions(node) {
|
|
|
1082
1084
|
let title = s.title || (s.events ? foldTitle(s.events) : '')
|
|
1083
1085
|
if (!title) {
|
|
1084
1086
|
const cache = projCache[s.id]
|
|
1085
|
-
title = cache?.rows?.title?.val || cache?.rows?.goal?.val
|
|
1087
|
+
title = cache?.rows?.title?.val || formatGoalTitle(cache?.rows?.goal?.val)
|
|
1086
1088
|
}
|
|
1087
1089
|
result.push({
|
|
1088
1090
|
id: s.id,
|
|
@@ -1112,7 +1114,7 @@ async function listSessions(node) {
|
|
|
1112
1114
|
continue
|
|
1113
1115
|
}
|
|
1114
1116
|
|
|
1115
|
-
let title = cache?.rows?.title?.val || cache?.rows?.goal?.val
|
|
1117
|
+
let title = cache?.rows?.title?.val || formatGoalTitle(cache?.rows?.goal?.val)
|
|
1116
1118
|
let createdAt = cache?.identity?.createdAt || 0
|
|
1117
1119
|
let cwd = ws.path
|
|
1118
1120
|
|
|
@@ -1475,7 +1477,8 @@ async function renderSessions(node) {
|
|
|
1475
1477
|
const isActive = session.id === node.activeSessionId
|
|
1476
1478
|
const statusTag = isActive ? '`[当前]`' : '-'
|
|
1477
1479
|
const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
|
|
1478
|
-
const
|
|
1480
|
+
const titleText = formatGoalTitle(rawTitle) || (typeof rawTitle === 'string' ? rawTitle : '') || '新会话'
|
|
1481
|
+
const safeTitle = String(titleText).replace(/\|/g, '|').replace(/\r?\n/g, ' ')
|
|
1479
1482
|
const when = session.createdAt ? fmtTime(session.createdAt) : '-'
|
|
1480
1483
|
parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
|
|
1481
1484
|
}
|
|
@@ -1558,6 +1561,7 @@ export const conversationBridgeHelpers = {
|
|
|
1558
1561
|
textOfAssistantMessage,
|
|
1559
1562
|
resolveFilePath,
|
|
1560
1563
|
extractFilePathsFromText,
|
|
1564
|
+
extractAndStripSendFileDirectives,
|
|
1561
1565
|
sessionsInDisplayOrder,
|
|
1562
1566
|
listSessions,
|
|
1563
1567
|
renderSessions,
|
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
|