@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.
@@ -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 extractFilePathsFromText(text, cwd = process.cwd()) {
184
- if (typeof text !== 'string' || !text.trim()) return []
185
- const found = new Set()
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
- // 2. POSIX 绝对路径:/home/.../file.ext 或 /tmp/.../file.ext
196
- const posixAbsRegex = /\/(?:[^\s"'`<>|*?()\/]+\/)+[^\s"'`<>|*?()\/]+\.[A-Za-z0-9_.-]+/g
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
- // 3. Markdown 文件链接:[name](file:///path/to/file) 或 [name](path/to/file)
203
- const mdLinkRegex = /\[(?:[^\]]*)\]\((?:file:\/\/\/?)?([^)]+)\)/g
204
- while ((m = mdLinkRegex.exec(text)) !== null) {
205
- const r = resolveFilePath(m[1], cwd)
206
- 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
+ }
207
203
  }
208
204
 
209
- // 4. 关键词或 Emoji 引用的文件路径:📁 file.txt, 保存到:file.txt, 产物文件: ...
210
- const keywordRegex = /(?:📁|📄|📦|保存到[::\s]*|生成文件[::\s]*|文件路径[::\s]*|产物[::\s]*|输出文件[::\s]*|写入文件[::\s]*)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/g
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
- // 5. Shell 终端常用输出命令参数:-Path "...", > "...", Out-File "...", Set-Content "..."
217
- const cmdRegex = /(?:-Path\s+|>\s*|Out-File\s+|Set-Content\s+|TargetFile["':\s]+|targetFile["':\s]+)[`"']?([^\r\n`"'\t<>|*?]+?\.[A-Za-z0-9_.-]+)[`"']?/gi
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
- return Array.from(found)
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 cwd = cwdOverride || this.config.cwd || process.cwd()
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: prompt }],
407
+ content: [{ type: 'text', text: promptWithContext }],
402
408
  source: { kind: 'user' },
403
409
  }))
404
410
  }
405
- const wsDetail = cwdOverride ? `\n- **工作区**:\`${cwdOverride}\`` : ''
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
- if (this._restoringConfig) {
502
- await this._restoringConfig
503
- }
506
+ try {
507
+ // 等待配置恢复完成(防止启动时竞态)
508
+ if (this._restoringConfig) {
509
+ await this._restoringConfig
510
+ }
504
511
 
505
- const sender = String(senderId ?? '').trim()
506
- if (!sender) return 'ignored'
507
-
508
- if (!this.isAllowed(sender)) {
509
- // 自动授权:
510
- // - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
511
- // - 群聊:首次 @机器人 的群自动纳入(群维度授权,群内成员均可使用)
512
- // 这是"登录后第一条消息/首次被 @即完成授权"的一步到位体验。
513
- const shouldAutoApprove = Boolean(text?.trim()) && (
514
- this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
515
- isGroup // 群聊:始终自动授权群
516
- )
517
- if (shouldAutoApprove) {
518
- this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
519
- this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
520
- try {
521
- await this.onFirstSender?.(sender)
522
- } catch (err) {
523
- this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
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.isAllowed(sender)) {
531
- this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
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
- // 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
537
- if (isGroup && !this.platform?.capabilities?.supportsGroup) {
538
- this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
539
- return 'ignored'
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
- this.peerId = sender
555
+ this.peerId = sender
549
556
 
550
- if (await routeCommand(this, fullText)) return 'routed'
557
+ if (await routeCommand(this, fullText)) return 'routed'
551
558
 
552
- let agent = this.activeAgent()
553
- if (!agent && this.activeSessionId) {
554
- const sessionId = this.activeSessionId
555
- if (this._restoringSessionMap.has(sessionId)) {
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
- const agentOptions = {}
565
- if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
566
- if (this.config.agentModel) agentOptions.model = this.config.agentModel
567
- if (!agentOptions.provider || !agentOptions.model) {
568
- try {
569
- const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
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 headers = await this.ctx.sessionPersistence?.list?.()
582
- persisted = Array.isArray(headers) && headers.some((h) => h?.id === sessionId)
583
- } catch { /* 读取失败则按未持久化处理 */ }
584
-
585
- let handle
586
- if (persisted) {
587
- handle = await this.ctx.agents.resume({
588
- resumeSessionId: sessionId,
589
- agentOptions,
590
- })
591
- } else {
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
- handle = await this.ctx.agents.create({
599
- sessionId: sessionId,
600
- meta,
601
- agentOptions,
602
- })
603
- }
604
- const resumedAgent = handle?.agent
605
- this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
606
- return resumedAgent
607
- } catch (err) {
608
- const reason = err instanceof Error ? err.message : String(err)
609
- this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
610
- // 清除失效的 activeSessionId,避免用户误以为还在旧会话中
611
- if (this.activeSessionId === sessionId) {
612
- this.activeSessionId = null
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
- await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
615
- return null
616
- }
617
- })().finally(() => {
618
- this._restoringSessionMap.delete(sessionId)
619
- })
618
+ })().finally(() => {
619
+ this._restoringSessionMap.delete(sessionId)
620
+ })
620
621
 
621
- this._restoringSessionMap.set(sessionId, restorePromise)
622
- agent = await restorePromise
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
- // 针对微信/IM客户端用户,注入上下文提示,指导 Agent 生成文件后输出明确完整路径以触发自动上传直发
631
- const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户要求生成、导出或发送文件,请将文件保存在本地磁盘并在回复中明确写出文件的完整路径(如 📁 <路径>),网关会自动把该文件上传并直接发送到用户的 ${this.platform.name} 聊天窗口。 -->`
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
- const messageValue = createUserMessage({
634
- content: [{ type: 'text', text: promptWithContext }],
635
- source: { kind: 'user' },
636
- })
637
- agent.followup(messageValue)
638
- await this.sendTyping(1).catch(() => {})
639
- return 'routed'
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
- const cwd = getSessionCwd(session)
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 text = textOfAssistantMessage(event.data.message)
754
- if (text.trim()) {
742
+ const rawText = textOfAssistantMessage(event.data.message)
743
+ if (rawText.trim()) {
755
744
  const cwd = getSessionCwd(session)
756
- const fromText = extractFilePathsFromText(text, cwd)
757
- for (const f of fromText) state.createdFiles.add(f)
758
- void this.sendText(text)
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 = new Set()
775
+ const uniqueFilesToSend = []
785
776
  for (const f of rawFiles) {
786
777
  const resolved = resolveFilePath(f, cwd)
787
- if (resolved) uniqueFilesToSend.add(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 safeTitle = (rawTitle || '新会话').replace(/\|/g, '').replace(/\r?\n/g, ' ')
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) { 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