@wenbin_wb/dsh-bridge 2.2.8 → 2.3.0

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.
@@ -0,0 +1,351 @@
1
+ // dsh-bridge Feishu / Lark conversation node
2
+ // 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
3
+ // ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
4
+
5
+ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
6
+
7
+ function makePlatform(gateway) {
8
+ return {
9
+ id: 'feishu',
10
+ name: 'Feishu / Lark',
11
+ capabilities: { supportsGroup: true, group: true, media: true, approvals: true },
12
+ async sendText(peerId, text, opts = {}) {
13
+ return gateway?.sendMarkdownCard(peerId, text, opts)
14
+ },
15
+ async sendTyping() {},
16
+ dispose() {},
17
+ }
18
+ }
19
+
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms))
22
+ }
23
+
24
+ function splitIntoIncremental(content, maxChunk = 150) {
25
+ const chunks = []
26
+ let start = 0
27
+ while (start < content.length) {
28
+ if (content.length - start <= maxChunk) {
29
+ chunks.push(content.slice(start))
30
+ break
31
+ }
32
+ const windowStart = start + Math.floor(maxChunk * 0.6)
33
+ const windowEnd = start + maxChunk
34
+ let cut = content.lastIndexOf('\n', windowEnd)
35
+ if (cut <= windowStart || cut === -1) cut = windowEnd
36
+ chunks.push(content.slice(start, cut))
37
+ start = cut
38
+ }
39
+ if (chunks.length <= 1 && content.length > 30) {
40
+ const step = Math.max(20, Math.floor(content.length / 4))
41
+ chunks.length = 0
42
+ for (let i = 0; i < content.length; i += step) {
43
+ chunks.push(content.slice(i, Math.min(i + step, content.length)))
44
+ }
45
+ }
46
+ const slices = []
47
+ let acc = ''
48
+ for (const s of chunks) {
49
+ acc += s
50
+ slices.push(acc)
51
+ }
52
+ return slices
53
+ }
54
+
55
+ export class FeishuConversationNode extends ConversationBridge {
56
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
57
+ super({
58
+ ctx,
59
+ logger,
60
+ config,
61
+ platform: makePlatform(ctx.feishu),
62
+ onFirstSender,
63
+ onActiveSessionChange,
64
+ })
65
+ this.gateway = ctx.feishu
66
+ this._lastPeer = null // { peerId, senderId, isGroup }
67
+ this._streamCardId = null // 当前轮次的流式卡片 message_id
68
+ this._streamContent = '' // 当前轮次流式卡片的内容
69
+ this._patchTimer = null // 节流定时器
70
+ this._inTurn = false
71
+
72
+ // 订阅网关入站消息事件
73
+ this.ctx.on('feishu/message', (event) => {
74
+ void this._handleInbound(event)
75
+ })
76
+
77
+ // 订阅卡片交互事件(审批按钮点击)
78
+ this.ctx.on('feishu/action', (event) => {
79
+ void this._handleAction(event)
80
+ })
81
+
82
+ // 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
83
+ this.ctx.on('session/event', (session, event) => {
84
+ if (session.id !== this.activeSessionId) return
85
+ if (event.type === 'turn/start') {
86
+ this._inTurn = true
87
+ this._streamCardId = null
88
+ this._streamContent = ''
89
+ if (this._patchTimer) {
90
+ clearTimeout(this._patchTimer)
91
+ this._patchTimer = null
92
+ }
93
+ } else if (event.type === 'turn/end') {
94
+ this._inTurn = false
95
+ if (this._patchTimer) {
96
+ clearTimeout(this._patchTimer)
97
+ this._patchTimer = null
98
+ }
99
+ if (this._streamCardId && this._streamContent) {
100
+ void this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
101
+ }
102
+ this._streamCardId = null
103
+ this._streamContent = ''
104
+ }
105
+ })
106
+ }
107
+
108
+ async _handleInbound(event) {
109
+ const { peerId, senderId, isGroup, text } = event
110
+ this._lastPeer = { peerId, senderId, isGroup }
111
+
112
+ this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${text}`)
113
+
114
+ // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
115
+ if (!this.activeSessionId) {
116
+ await this._pickDefaultSession().catch(() => {})
117
+ }
118
+
119
+ // 转交通用 ConversationBridge 路由
120
+ return super.handleInbound({
121
+ senderId: isGroup ? peerId : senderId,
122
+ peerId,
123
+ isGroup,
124
+ text,
125
+ })
126
+ }
127
+
128
+ async _handleAction(event) {
129
+ const { operatorId, value } = event
130
+ const approvalId = Number(value.approvalId)
131
+ const action = value.action
132
+
133
+ if (approvalId && (action === 'approve' || action === 'reject')) {
134
+ const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
135
+ const pending = this.pending.get(approvalId)
136
+ if (pending) {
137
+ this.clearApproval(approvalId)
138
+ pending.resolve(outcome)
139
+ }
140
+ }
141
+ }
142
+
143
+ async sendText(text) {
144
+ const peerId = this._lastPeer?.peerId || this.peerId
145
+ if (!peerId) return
146
+ const isGroup = this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_'))
147
+ const content = String(text || '').trim()
148
+ if (!content) return
149
+
150
+ // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
151
+ if (this._inTurn) {
152
+ const maxChunk = 150
153
+ const slices = splitIntoIncremental(content, maxChunk)
154
+ const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
155
+
156
+ if (!this._streamCardId) {
157
+ // 首片:创建卡片并记录 message_id
158
+ const firstSlice = slices[0] || content
159
+ try {
160
+ const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
161
+ this._streamCardId = res?.message_id || null
162
+ this._streamContent = firstSlice
163
+ } catch (err) {
164
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
165
+ await this.gateway.sendText(peerId, content, { isGroup })
166
+ return
167
+ }
168
+
169
+ // 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
170
+ for (let i = 1; i < slices.length; i++) {
171
+ await sleep(delayMs)
172
+ this._streamContent = slices[i]
173
+ if (this._streamCardId) {
174
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
175
+ }
176
+ }
177
+ } else {
178
+ // 如果卡片已存在(同轮次后续输出):更新到最终内容
179
+ this._streamContent = content
180
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
181
+ }
182
+ return
183
+ }
184
+
185
+ // 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
186
+ try {
187
+ if (content.length <= 25000) {
188
+ await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
189
+ } else {
190
+ const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
191
+ for (const chunk of chunks) {
192
+ await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
193
+ }
194
+ }
195
+ } catch (err) {
196
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
197
+ await this.gateway.sendText(peerId, content, { isGroup })
198
+ }
199
+ }
200
+
201
+ // ---- 飞书专属卡片审批桥 ----
202
+
203
+ _attachApprovalBridge() {
204
+ const listener = async (req, next) => {
205
+ if (!this.ownsAgent(req.agent)) return next?.()
206
+ const peer = this._lastPeer
207
+ if (!peer?.peerId) return next?.()
208
+
209
+ const number = this.nextApprovalNumber()
210
+ const timeoutSec = this.config.approvalTimeoutSec || 600
211
+ const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
212
+
213
+ // 构建飞书原生交互卡片 (JSON 2.0)
214
+ const card = {
215
+ schema: '2.0',
216
+ header: {
217
+ title: {
218
+ tag: 'plain_text',
219
+ content: `⚠️ 操作权限确认 (#${number})`,
220
+ },
221
+ template: 'orange',
222
+ },
223
+ body: {
224
+ elements: [
225
+ {
226
+ tag: 'markdown',
227
+ content: [
228
+ `| 项目 | 详情 |`,
229
+ `| :--- | :--- |`,
230
+ `| **调用工具** | \`${req.toolName}\` |`,
231
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
232
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
233
+ ].join('\n'),
234
+ },
235
+ {
236
+ tag: 'column_set',
237
+ flex_mode: 'flow',
238
+ columns: [
239
+ {
240
+ tag: 'column',
241
+ width: 'auto',
242
+ elements: [
243
+ {
244
+ tag: 'button',
245
+ text: {
246
+ tag: 'plain_text',
247
+ content: '✓ 批准执行',
248
+ },
249
+ type: 'primary',
250
+ value: {
251
+ action: 'approve',
252
+ approvalId: number,
253
+ },
254
+ },
255
+ ],
256
+ },
257
+ {
258
+ tag: 'column',
259
+ width: 'auto',
260
+ elements: [
261
+ {
262
+ tag: 'button',
263
+ text: {
264
+ tag: 'plain_text',
265
+ content: '✕ 拒绝执行',
266
+ },
267
+ type: 'danger',
268
+ value: {
269
+ action: 'reject',
270
+ approvalId: number,
271
+ },
272
+ },
273
+ ],
274
+ },
275
+ ],
276
+ },
277
+ ],
278
+ },
279
+ }
280
+
281
+ // 发送飞书卡片
282
+ try {
283
+ await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
284
+ } catch (err) {
285
+ // 卡片发送失败降级发送 Markdown
286
+ const textFallback = [
287
+ `## ⚠️ 操作权限确认 (#${number})`,
288
+ '',
289
+ `| 项目 | 详情 |`,
290
+ `| :--- | :--- |`,
291
+ `| **调用工具** | \`${req.toolName}\` |`,
292
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
293
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
294
+ '',
295
+ `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
296
+ `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
297
+ ].join('\n')
298
+ void this.gateway.sendMarkdownCard(peer.peerId, textFallback, { isGroup: peer.isGroup })
299
+ }
300
+
301
+ // 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
302
+ let nextPromise = null
303
+ if (typeof next === 'function') {
304
+ try {
305
+ const res = next()
306
+ if (res && typeof res.then === 'function') {
307
+ nextPromise = res
308
+ }
309
+ } catch {}
310
+ }
311
+
312
+ let timeoutFired = false
313
+ let winner = 'feishu'
314
+ const feishuPromise = new Promise((resolve) => {
315
+ const timer = setTimeout(() => {
316
+ timeoutFired = true
317
+ this.clearApproval(number)
318
+ resolve('rejected')
319
+ }, timeoutSec * 1000)
320
+ if (typeof timer.unref === 'function') timer.unref()
321
+ this.registerApproval(number, { number, request: req, resolve, timer })
322
+ })
323
+
324
+ const outcome = await (nextPromise
325
+ ? Promise.race([
326
+ feishuPromise.then(res => { winner = 'feishu'; return res }),
327
+ nextPromise.then(res => { winner = 'web'; return res }),
328
+ ])
329
+ : feishuPromise)
330
+
331
+ // 如果 Web 端先决议,清除飞书端 pending 记录
332
+ if (winner === 'web') {
333
+ this.clearApproval(number)
334
+ }
335
+
336
+ if (!timeoutFired) {
337
+ const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
338
+ const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
339
+ void this.gateway.sendMarkdownCard(peer.peerId, `${label}(#${number})`, { isGroup: peer.isGroup })
340
+ }
341
+ return outcome
342
+ }
343
+
344
+ const disposer = this.ctx.on('approval/request', listener)
345
+ this.disposers.push(disposer)
346
+ }
347
+ }
348
+
349
+ export const feishuNodeHelpers = {
350
+ makePlatform,
351
+ }
package/lib/index.js CHANGED
@@ -20,6 +20,7 @@ import { CloudflaredManager } from './cloudflared-manager.mjs';
20
20
  import { PlatformManager } from './platform/manager.js';
21
21
  import { WechatService } from './wechat/index.js';
22
22
  import { QqService } from './qq/index.js';
23
+ import { FeishuService } from './feishu/index.js';
23
24
 
24
25
  const name = 'dsh-bridge';
25
26
  // 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
@@ -555,6 +556,19 @@ function apply(ctx, config = {}) {
555
556
  });
556
557
  platformManager.register(qq);
557
558
 
559
+ // 飞书 Bot(官方 OpenAPI / WebSocket 长连接)—— 作为 Platform 子类注册进平台管理器
560
+ const feishu = new FeishuService({
561
+ ctx,
562
+ logger,
563
+ config: config.feishu ?? {},
564
+ onPersist: async (patch) => {
565
+ const stored = await loadConfig();
566
+ stored.feishu = { ...(stored.feishu ?? {}), ...patch };
567
+ await saveConfig(stored);
568
+ },
569
+ });
570
+ platformManager.register(feishu);
571
+
558
572
  // 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
559
573
  loadConfig().then(async (stored) => {
560
574
  if (stored?.wechat) {
@@ -632,10 +646,46 @@ function apply(ctx, config = {}) {
632
646
  }
633
647
  }).catch(() => {});
634
648
 
649
+ // 启动时读取已保存的飞书 Bot 配置(凭证 + 白名单 + 活动会话)
650
+ loadConfig().then(async (stored) => {
651
+ if (stored?.feishu) {
652
+ const cfg = stored.feishu;
653
+ feishu.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
654
+
655
+ feishu.node._restoringConfig = (async () => {
656
+ try {
657
+ if (cfg.activeSessionId) {
658
+ feishu.node.activeSessionId = cfg.activeSessionId;
659
+ logger.info('dsh-bridge: restored feishu active session: %s', cfg.activeSessionId);
660
+ } else {
661
+ await feishu.node._pickDefaultSession().catch(() => {});
662
+ }
663
+ } finally {
664
+ feishu.node._configRestored = true;
665
+ }
666
+ })();
667
+
668
+ await feishu.node._restoringConfig;
669
+
670
+ if (cfg.appId && cfg.appSecret) {
671
+ feishu.gateway.updateConfig({
672
+ appId: cfg.appId,
673
+ appSecret: cfg.appSecret,
674
+ domain: cfg.domain || 'feishu',
675
+ });
676
+ logger.info('dsh-bridge: loaded saved feishu bot config, starting gateway');
677
+ await feishu.start().catch((err) => {
678
+ logger.error('dsh-bridge: feishu auto-start failed: %s', err?.message ?? err);
679
+ });
680
+ }
681
+ }
682
+ }).catch(() => {});
683
+
635
684
  const disposeRpc = installBridgeRpc(ctx, {
636
685
  service,
637
686
  wechat,
638
687
  qq,
688
+ feishu,
639
689
  platformManager,
640
690
  logger,
641
691
  saveCustomTunnelConfig: async (serverUrl, accessToken) => {
@@ -626,9 +626,9 @@ export class ConversationBridge {
626
626
 
627
627
  _attachApprovalBridge() {
628
628
  const listener = async (req, next) => {
629
- if (!this.ownsAgent(req.agent)) return next()
629
+ if (!this.ownsAgent(req.agent)) return next?.()
630
630
  const peer = this.peerId
631
- if (!peer) return next()
631
+ if (!peer) return next?.()
632
632
 
633
633
  const number = this.nextApprovalNumber()
634
634
  const timeoutSec = this.config.approvalTimeoutSec
@@ -636,9 +636,11 @@ export class ConversationBridge {
636
636
  const prompt = [
637
637
  `## ⚠️ 操作权限确认 (#${number})`,
638
638
  '',
639
- `- **调用工具**:\`${req.toolName}\``,
640
- ...(req.reason ? [`- **申请原因**:${req.reason}`] : []),
641
- `- **等待超时**:${timeoutMin} 分钟内未回复将自动拒绝`,
639
+ '| 项目 | 详情 |',
640
+ '| :--- | :--- |',
641
+ `| **调用工具** | \`${req.toolName}\` |`,
642
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
643
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
642
644
  '',
643
645
  `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
644
646
  `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
@@ -646,8 +648,20 @@ export class ConversationBridge {
646
648
 
647
649
  void this.sendText(prompt)
648
650
 
651
+ // 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
652
+ let nextPromise = null
653
+ if (typeof next === 'function') {
654
+ try {
655
+ const res = next()
656
+ if (res && typeof res.then === 'function') {
657
+ nextPromise = res
658
+ }
659
+ } catch {}
660
+ }
661
+
649
662
  let timeoutFired = false
650
- const outcome = await new Promise((resolve) => {
663
+ let winner = 'im'
664
+ const imPromise = new Promise((resolve) => {
651
665
  const timer = setTimeout(() => {
652
666
  timeoutFired = true
653
667
  this.clearApproval(number)
@@ -657,9 +671,22 @@ export class ConversationBridge {
657
671
  this.registerApproval(number, { number, request: req, resolve, timer })
658
672
  })
659
673
 
674
+ const outcome = await (nextPromise
675
+ ? Promise.race([
676
+ imPromise.then(res => { winner = 'im'; return res }),
677
+ nextPromise.then(res => { winner = 'web'; return res }),
678
+ ])
679
+ : imPromise)
680
+
681
+ // 如果 Web 端先决议,清除 IM 端 pending 记录
682
+ if (winner === 'web') {
683
+ this.clearApproval(number)
684
+ }
685
+
660
686
  // 仅在非超时路径发送确认消息(超时时 resolve 已经发生在 timer 回调)
661
687
  if (!timeoutFired) {
662
- const label = outcome === 'allowed-once' ? ' **已批准执行**' : outcome === 'rejected' ? '❌ **已拒绝执行**' : `**[${outcome}]**`
688
+ const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
689
+ const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
663
690
  void this.sendText(`${label}(#${number})`)
664
691
  }
665
692
  return outcome
@@ -827,11 +854,19 @@ async function routeCommand(node, text) {
827
854
  await node.sendText(`## 🗂️ 可用工作区\n\n> 当前没有已注册的工作区。可使用 \`/new <提示词> @<路径>\` 指定项目目录。`)
828
855
  return true
829
856
  }
830
- const lines = workspaces.map((w, i) => {
831
- const titleStr = w.title && w.title !== w.path ? `**${w.title}**` : `**${getWorkspaceBasename(w.path)}**`
832
- return `- **[${i + 1}]** ${titleStr}\n \`${w.path}\``
857
+ const rows = workspaces.map((w, i) => {
858
+ const titleStr = w.title && w.title !== w.path ? w.title : getWorkspaceBasename(w.path)
859
+ const safeTitle = titleStr.replace(/\|/g, '|')
860
+ return `| **@${i + 1}** | ${safeTitle} | \`${w.path}\` |`
833
861
  })
834
- await node.sendText(`## 🗂️ 可用工作区 (共 ${workspaces.length} 个)\n\n> 新建会话:发送 \`/new <提示词> @序号\`\n\n${lines.join('\n')}`)
862
+ await node.sendText([
863
+ `## 🗂️ 可用工作区 (共 ${workspaces.length} 个)`,
864
+ `> 新建会话:发送 \`/new <提示词> @序号\` 或 \`/new <提示词> @路径\``,
865
+ '',
866
+ '| 序号 | 工作区名称 | 目录路径 |',
867
+ '| :--- | :--- | :--- |',
868
+ ...rows,
869
+ ].join('\n'))
835
870
  return true
836
871
  }
837
872
  case 'new': {
@@ -901,10 +936,23 @@ async function routeCommand(node, text) {
901
936
  const lastTurn = [...(session.events ?? [])].reverse().find((e) => e.type === 'turn/end')
902
937
  const reason = lastTurn ? describeTurnEnd(lastTurn.data.reason) : '尚未运行'
903
938
  const title = session.title || (session.events ? sessionLabel(session) : '')
904
- const titleLine = title ? `\n- **会话标题**:${title}` : ''
905
- const cwdLine = session.header?.cwd ? `\n- **工作区**:\`${session.header.cwd}\`` : ''
939
+ const shortId = fmtSessionId(session.id)
940
+ const cwd = session.header?.cwd || node.config?.cwd || ''
906
941
 
907
- await node.sendText(`## 📊 Agent 状态看板\n\n- **会话 ID**:\`${session.id}\`${titleLine}${cwdLine}\n- **Agent 状态**:${status}\n- **累计事件**:${session.seq ?? 0} 条\n- **最近执行**:${reason}`)
942
+ const content = [
943
+ `## 📊 Agent 状态看板`,
944
+ '',
945
+ '| 属性 | 当前状态 / 参数 |',
946
+ '| :--- | :--- |',
947
+ `| **会话 ID** | \`${shortId}\` |`,
948
+ ...(title ? [`| **会话标题** | ${title.replace(/\|/g, '|')} |`] : []),
949
+ ...(cwd ? [`| **工作区** | \`${cwd}\` |`] : []),
950
+ `| **Agent 状态** | ${status} |`,
951
+ `| **累计事件** | ${session.seq ?? 0} 条 |`,
952
+ `| **最近执行** | ${reason} |`,
953
+ ].join('\n')
954
+
955
+ await node.sendText(content)
908
956
  return true
909
957
  }
910
958
  case 'start': // 别名:首次扫码自动开始一个会话
@@ -949,22 +997,21 @@ async function renderSessions(node) {
949
997
  let idx = 0
950
998
  for (const [cwd, sessions] of sortedGroups) {
951
999
  const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${getWorkspaceBasename(cwd)}**`
952
- const pathDetail = cwd === '(未指定)' ? '' : ` \`${cwd}\``
953
- parts.push(`${groupName}${pathDetail}`)
954
- parts.push('') // 空行:QQ 规定列表前必须空行
1000
+ parts.push(groupName)
1001
+ parts.push('')
1002
+ parts.push('| 序号 | 会话标题 / 摘要 | 时间 | 状态 |')
1003
+ parts.push('| :--- | :--- | :--- | :--- |')
955
1004
  for (const session of sessions.slice(0, 20)) {
956
1005
  idx += 1
957
1006
  const isActive = session.id === node.activeSessionId
958
- const activeTag = isActive ? ' `[当前]`' : ''
959
- const title = session.title || (session.events ? sessionLabel(session) : '')
960
- const label = title || fmtSessionId(session.id)
961
- const when = session.createdAt ? fmtTime(session.createdAt) : ''
962
- const timeStr = when ? ` · ${when}` : ''
963
- parts.push(`- **#${idx}** ${label}${activeTag}`)
964
- parts.push(` \`${fmtSessionId(session.id)}\`${timeStr}`)
1007
+ const statusTag = isActive ? '`[当前]`' : '-'
1008
+ const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
1009
+ const safeTitle = (rawTitle || '新会话 (待输入)').replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1010
+ const when = session.createdAt ? fmtTime(session.createdAt) : '-'
1011
+ parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
965
1012
  }
966
1013
  if (sessions.length > 20) {
967
- parts.push(` *…该工作区共 ${sessions.length} 个会话*`)
1014
+ parts.push(`*…该工作区共 ${sessions.length} 个会话,仅显示前 20 个*`)
968
1015
  }
969
1016
  parts.push('')
970
1017
  }
@@ -1009,26 +1056,28 @@ function helpText() {
1009
1056
  return [
1010
1057
  '## 🤖 常用指令帮助',
1011
1058
  '',
1012
- '**💬 会话控制**',
1013
- '',
1014
- '- `/sessions` (或 `/list`) 查看所有会话列表',
1015
- '- `/use <编号>` (或 `/resume <编号>`) 切换到指定编号会话',
1016
- '- `/new <提示词>` 在当前工作区创建新会话并开始',
1017
- '- `/new <提示词> @序号` 在指定工作区创建新会话(/workspaces)',
1018
- '- `/new <提示词> @路径` 在指定目录路径创建新会话',
1019
- '- `/end` 结束当前会话(回到无活动会话状态)',
1020
- '- `/stop` 中断停止当前正在执行的任务',
1021
- '',
1022
- '**📁 环境与状态**',
1023
- '',
1024
- '- `/workspaces` — 查看已注册的工作区列表',
1025
- '- `/status` — 查看 Agent 运行状态与事件摘要',
1026
- '- `/help` — 查看此帮助菜单',
1059
+ '### 💬 会话控制',
1060
+ '| 指令 | 说明 | 示例 |',
1061
+ '| :--- | :--- | :--- |',
1062
+ '| `/sessions` | 查看所有会话表格列表 | `/sessions` `/list` |',
1063
+ '| `/use <编号>` | 切换到指定编号会话 | `/use 1` 或 `/resume 1` |',
1064
+ '| `/new <提示词>` | 在当前工作区新建会话 | `/new 帮我写个脚本` |',
1065
+ '| `/new <词> @N` | 在指定工作区新建会话 | `/new 帮我写个脚本 @1` |',
1066
+ '| `/stop` | 中断停止当前正在执行的任务 | `/stop` |',
1067
+ '| `/end` | 结束当前会话(回到空闲) | `/end` |',
1027
1068
  '',
1028
- '**🔐 权限确认**',
1069
+ '### 📁 环境与状态',
1070
+ '| 指令 | 说明 |',
1071
+ '| :--- | :--- |',
1072
+ '| `/workspaces` | 查看可用工作区表格列表 |',
1073
+ '| `/status` | 查看 Agent 运行状态看板 |',
1074
+ '| `/help` | 查看此帮助菜单 |',
1029
1075
  '',
1030
- '- `/yes` (或 `1`) — 批准当前工具执行请求',
1031
- '- `/no` (或 `2`) 拒绝当前工具执行请求',
1076
+ '### 🔐 权限确认',
1077
+ '| 指令 | 快捷数字 | 说明 |',
1078
+ '| :--- | :--- | :--- |',
1079
+ '| `/yes` | `1` | 批准当前工具执行请求 |',
1080
+ '| `/no` | `2` | 拒绝当前工具执行请求 |',
1032
1081
  ].join('\n')
1033
1082
  }
1034
1083
 
package/lib/qq/gateway.js CHANGED
@@ -245,7 +245,7 @@ export class QqGateway extends Service {
245
245
  if (this.ws === ws) this.ws = null
246
246
  error ? reject(error) : resolve()
247
247
  }
248
- ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] gateway connected'))
248
+ ws.on('open', () => this.logger?.info?.('[dsh-bridge qq] WebSocket connected to QQ Open Platform'))
249
249
  ws.on('message', (raw) => {
250
250
  let payload
251
251
  try { payload = JSON.parse(String(raw)) } catch { return }