@dcrays/dcgchat 0.4.18 → 0.4.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat",
3
- "version": "0.4.18",
3
+ "version": "0.4.25",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/bot.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import path from 'node:path'
2
3
  import type { ReplyPayload } from 'openclaw/plugin-sdk'
3
4
  import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
@@ -10,14 +11,24 @@ import {
10
11
  getWorkspaceDir,
11
12
  setMsgStatus
12
13
  } from './utils/global.js'
13
- import { resolveAccount, sendDcgchatMedia } from './channel.js'
14
+ import { normalizeOutboundMediaPaths, resolveAccount, sendDcgchatMedia } from './channel.js'
14
15
  import { generateSignUrl } from './request/api.js'
15
16
  import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
16
17
  import { dcgLogger } from './utils/log.js'
17
- import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
18
- import { sendGatewayRpc } from './gateway/socket.js'
18
+ import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
19
19
  import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
20
- import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
20
+ import { waitUntilSubagentsIdle } from './tool.js'
21
+ import {
22
+ beginSupersedingUserTurn,
23
+ clearActiveRunIdForSession,
24
+ clearSessionStreamSuppression,
25
+ interruptLocalDispatchAndGateway,
26
+ isSessionStreamSuppressed,
27
+ preemptInboundQueueForStop,
28
+ releaseDispatchAbortIfCurrent,
29
+ runInboundTurnSequenced,
30
+ setActiveRunIdForSession
31
+ } from './sessionTermination.js'
21
32
 
22
33
  type MediaInfo = {
23
34
  path: string
@@ -30,14 +41,18 @@ type TFileInfo = { name: string; url: string }
30
41
 
31
42
  const mediaMaxBytes = 300 * 1024 * 1024
32
43
 
33
- /** 当前会话最近一次 agent run 的 runId(供观测;/stop 时对子会话与主会话使用不传 runId 的 chat.abort 以终止该键下全部活跃 run) */
34
- const activeRunIdBySessionKey = new Map<string, string>()
35
-
36
44
  /**
37
- * 用户在该 sessionKey 上触发打断后,旧 run 的流式/投递不再下发;与 sessionKey 一一对应,支持多会话。
38
- * 清除时机:① 下一条非打断用户消息开始处理时;② run 收尾到 mobook 段时若仍抑制则跳过并发后删除。
45
+ * 每条入站消息(含 /stop)递增。用于丢弃「已被后续入站抢占」的处理在 dispatch 结束后的尾部逻辑。
46
+ * /stop 时的 epoch 无法覆盖「新用户消息抢占上一轮」的并发 WS 场景,会导致旧 handler 仍执行 sendFinal(end)、
47
+ * 而新消息被误判为 stale;且上一轮若未对网关 chat.abort,服务端 run 会继续,表现为「新消息秒结束、旧回复复活」。
39
48
  */
40
- const sessionStreamSuppressed = new Set<string>()
49
+ const inboundGenerationBySessionKey = new Map<string, number>()
50
+
51
+ function bumpInboundGeneration(sessionKey: string): number {
52
+ const n = (inboundGenerationBySessionKey.get(sessionKey) ?? 0) + 1
53
+ inboundGenerationBySessionKey.set(sessionKey, n)
54
+ return n
55
+ }
41
56
 
42
57
  /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
43
58
  const streamChunkIdxBySessionKey = new Map<string, number>()
@@ -128,8 +143,11 @@ function buildMediaPayload(mediaList: MediaInfo[]): MediaPayload {
128
143
  }
129
144
 
130
145
  function resolveReplyMediaList(payload: ReplyPayload): string[] {
131
- if (payload.mediaUrls?.length) return payload.mediaUrls.filter(Boolean)
132
- return payload.mediaUrl ? [payload.mediaUrl] : []
146
+ const p = payload as { mediaUrls?: unknown[]; mediaUrl?: unknown }
147
+ if (p.mediaUrls != null && Array.isArray(p.mediaUrls) && p.mediaUrls.length > 0) {
148
+ return normalizeOutboundMediaPaths(p.mediaUrls)
149
+ }
150
+ return normalizeOutboundMediaPaths(p.mediaUrl ?? null)
133
151
  }
134
152
 
135
153
  const typingCallbacks = createTypingCallbacks({
@@ -140,7 +158,7 @@ const typingCallbacks = createTypingCallbacks({
140
158
  })
141
159
 
142
160
  /**
143
- * 处理一条用户消息,调用 Agent 并返回回复
161
+ * 处理一条用户消息,调用 Agent 并返回回复(同会话串行见 sessionTermination.runInboundTurnSequenced)。
144
162
  */
145
163
  export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
146
164
  const config = getOpenClawConfig()
@@ -149,6 +167,20 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
149
167
  return
150
168
  }
151
169
  const account = resolveAccount(config, accountId)
170
+ const queueSessionKey = getSessionKey(msg.content, account.accountId)
171
+ const queueText = (msg.content.text ?? '').trim()
172
+ if (stopCommand.includes(queueText)) {
173
+ preemptInboundQueueForStop(queueSessionKey)
174
+ }
175
+ await runInboundTurnSequenced(queueSessionKey, () => handleDcgchatMessageInboundTurn(msg, accountId))
176
+ }
177
+
178
+ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: string): Promise<void> {
179
+ const config = getOpenClawConfig()
180
+ if (!config) {
181
+ return
182
+ }
183
+ const account = resolveAccount(config, accountId)
152
184
  const userId = msg._userId.toString()
153
185
 
154
186
  const core = getDcgchatRuntime()
@@ -167,6 +199,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
167
199
 
168
200
  const effectiveAgentId = embeddedAgentId ?? route.agentId
169
201
  const dcgSessionKey = getSessionKey(msg.content, account.accountId)
202
+ const inboundGenAtStart = bumpInboundGeneration(dcgSessionKey)
170
203
 
171
204
  const mergedParams = {
172
205
  userId: msg._userId,
@@ -174,7 +207,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
174
207
  sessionId: conversationId,
175
208
  messageId: msg.content.message_id,
176
209
  domainId: msg.content.domain_id,
177
- appId: msg.content.app_id,
210
+ appId: config.channels?.["dcgchat"]?.appId || 100,
178
211
  botId: msg.content.bot_id ?? '',
179
212
  agentId: msg.content.agent_id ?? '',
180
213
  sessionKey: dcgSessionKey,
@@ -194,6 +227,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
194
227
  if (!text) {
195
228
  sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
196
229
  sendFinal(outboundCtx, 'not text')
230
+ setMsgStatus(dcgSessionKey, 'finished')
197
231
  return
198
232
  }
199
233
 
@@ -274,7 +308,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
274
308
  humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
275
309
  onReplyStart: async () => {},
276
310
  deliver: async (payload: ReplyPayload, info) => {
277
- if (sessionStreamSuppressed.has(dcgSessionKey)) return
311
+ if (isSessionStreamSuppressed(dcgSessionKey)) return
278
312
  const mediaList = resolveReplyMediaList(payload)
279
313
  for (const mediaUrl of mediaList) {
280
314
  const key = getMediaKey(mediaUrl)
@@ -287,9 +321,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
287
321
  onError: (err: unknown, info: { kind: string }) => {
288
322
  setMsgStatus(dcgSessionKey, 'finished')
289
323
  sendFinal(outboundCtx, 'error')
290
- activeRunIdBySessionKey.delete(dcgSessionKey)
324
+ clearActiveRunIdForSession(dcgSessionKey)
291
325
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
292
- const suppressed = sessionStreamSuppressed.has(dcgSessionKey)
326
+ const suppressed = isSessionStreamSuppressed(dcgSessionKey)
293
327
  dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
294
328
  },
295
329
  onIdle: () => {
@@ -297,10 +331,11 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
297
331
  }
298
332
  })
299
333
 
334
+ let dispatchAbort: AbortController | undefined
300
335
  try {
301
- if (!interruptCommand.includes(text?.trim())) {
302
- sessionStreamSuppressed.delete(dcgSessionKey)
336
+ if (!stopCommand.includes(text?.trim())) {
303
337
  streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
338
+ dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
304
339
  }
305
340
 
306
341
  if (systemCommand.includes(text?.trim())) {
@@ -315,40 +350,19 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
315
350
  dispatcher,
316
351
  replyOptions: {
317
352
  ...replyOptions,
353
+ abortSignal: dispatchAbort!.signal,
318
354
  onModelSelected: prefixContext.onModelSelected,
319
355
  onAgentRunStart: (runId) => {
320
- activeRunIdBySessionKey.set(dcgSessionKey, runId)
356
+ setActiveRunIdForSession(dcgSessionKey, runId)
321
357
  }
322
358
  }
323
359
  })
324
360
  })
325
- } else if (interruptCommand.includes(text?.trim())) {
326
- dcgLogger(`interrupt command: ${text}`)
361
+ } else if (stopCommand.includes(text?.trim())) {
327
362
  const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
328
- sendFinal(ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }, 'abort')
329
- sessionStreamSuppressed.add(dcgSessionKey)
330
- // 网关侧彻底中止:子 agent 使用独立 sessionKey,仅 abort 主会话不会停子 run;自深到浅 abort 后代,再 abort 主会话;不传 runId 以终止该键下全部活跃 chat run
331
- const descendantKeys = getDescendantSessionKeysForRequester(dcgSessionKey)
332
- const abortSubKeys = [...descendantKeys].reverse()
333
- if (abortSubKeys.length > 0) {
334
- dcgLogger(`interrupt: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
335
- }
336
- for (const subKey of abortSubKeys) {
337
- try {
338
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
339
- } catch (e) {
340
- dcgLogger(`chat.abort subagent ${subKey}: ${String(e)}`, 'error')
341
- }
342
- }
343
- try {
344
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: dcgSessionKey } })
345
- } catch (e) {
346
- dcgLogger(`chat.abort main ${dcgSessionKey}: ${String(e)}`, 'error')
347
- }
348
- activeRunIdBySessionKey.delete(dcgSessionKey)
363
+ await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
349
364
  streamChunkIdxBySessionKey.delete(dcgSessionKey)
350
365
  clearSentMediaKeys(msg.content.message_id)
351
- resetSubagentStateForRequesterSession(dcgSessionKey)
352
366
  setMsgStatus(dcgSessionKey, 'finished')
353
367
  clearParamsMessage(dcgSessionKey)
354
368
  sendText('会话已终止', outboundCtx)
@@ -381,12 +395,13 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
381
395
  dispatcher,
382
396
  replyOptions: {
383
397
  ...replyOptions,
398
+ abortSignal: dispatchAbort!.signal,
384
399
  onModelSelected: prefixContext.onModelSelected,
385
400
  onAgentRunStart: (runId) => {
386
- activeRunIdBySessionKey.set(dcgSessionKey, runId)
401
+ setActiveRunIdForSession(dcgSessionKey, runId)
387
402
  },
388
403
  onPartialReply: async (payload: ReplyPayload) => {
389
- if (sessionStreamSuppressed.has(dcgSessionKey)) return
404
+ if (isSessionStreamSuppressed(dcgSessionKey)) return
390
405
 
391
406
  // --- Streaming text chunks ---
392
407
  if (payload.text) {
@@ -424,17 +439,37 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
424
439
  })
425
440
  }
426
441
  } catch (err: unknown) {
427
- dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
442
+ dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
443
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
444
+ setMsgStatus(dcgSessionKey, 'finished')
445
+ }
446
+ } finally {
447
+ releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
448
+ }
449
+
450
+ const inboundGenNow = inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0
451
+ if (inboundGenNow !== inboundGenAtStart) {
452
+ dcgLogger(`skip post-reply tail: sessionKey=${dcgSessionKey} (stale handler: inbound gen ${inboundGenAtStart}→${inboundGenNow})`)
453
+ return
428
454
  }
429
455
 
430
- if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
431
- if (sessionStreamSuppressed.has(dcgSessionKey)) {
432
- sessionStreamSuppressed.delete(dcgSessionKey)
456
+ if (![...systemCommand, ...stopCommand].includes(text?.trim())) {
457
+ if (isSessionStreamSuppressed(dcgSessionKey)) {
458
+ clearSessionStreamSuppression(dcgSessionKey)
433
459
  }
434
460
  }
435
461
  clearSentMediaKeys(msg.content.message_id)
436
462
  const storePath = core.channel.session.resolveStorePath(config.session?.store)
437
463
  await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: 600_000 })
464
+ // 等待子 agent 期间可能已有新入站消息(generation 已变),不能再 end/finished,否则会误结束新轮或放行错挂的正文。
465
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) !== inboundGenAtStart) {
466
+ dcgLogger(
467
+ `skip post-wait tail: sessionKey=${dcgSessionKey} (stale handler after subagent wait: inbound gen ${inboundGenAtStart}→${inboundGenerationBySessionKey.get(dcgSessionKey)})`
468
+ )
469
+ return
470
+ }
471
+ clearActiveRunIdForSession(dcgSessionKey)
472
+ setMsgStatus(dcgSessionKey, 'finished')
438
473
  sendFinal(outboundCtx, 'end')
439
474
  dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
440
475
  core.channel.session
@@ -457,6 +492,9 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
457
492
  })
458
493
  } catch (err) {
459
494
  dcgLogger(` handle message failed: ${String(err)}`, 'error')
495
+ if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
496
+ setMsgStatus(dcgSessionKey, 'finished')
497
+ }
460
498
  sendError(err instanceof Error ? err.message : String(err), outboundCtx)
461
499
  }
462
500
  }
package/src/channel.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
1
3
  import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
2
4
  import { createPluginRuntimeStore, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk'
3
5
  import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
@@ -8,11 +10,14 @@ import {
8
10
  getDcgchatRuntime,
9
11
  getInfoBySessionKey,
10
12
  getOpenClawConfig,
11
- hasSentMediaKey
13
+ getWorkspaceDir,
14
+ hasSentMediaKey,
15
+ setCronMessageId
12
16
  } from './utils/global.js'
13
17
  import { isWsOpen, mergeDefaultParams, mergeSessionParams, sendFinal, wsSendRaw } from './transport.js'
14
18
  import { dcgLogger, setLogger } from './utils/log.js'
15
19
  import { getOutboundMsgParams, getParamsMessage } from './utils/params.js'
20
+ import { isSessionActiveForTool } from './tool.js'
16
21
  import { startDcgchatGatewaySocket } from './gateway/socket.js'
17
22
  import { getCronJobsPath, readCronJob } from './cron.js'
18
23
 
@@ -101,45 +106,151 @@ function outboundChatId(rawTo: string | undefined, normalizedTo: string): string
101
106
  return raw.indexOf('dcg-cron:') >= 0 ? raw : normalizedTo
102
107
  }
103
108
 
109
+ /**
110
+ * Core / message 工具可能传入:
111
+ * - 本地绝对路径字符串
112
+ * - 工作区虚拟路径(如 `/mobook/xxx`,需落到 `getWorkspaceDir()`)
113
+ * - `mediaUrls` 为 `{ file: string }[]` 或整段 JSON 字符串
114
+ */
115
+ function resolveWorkspaceMediaPath(p: string): string {
116
+ const s = p.trim()
117
+ if (!s) return ''
118
+ if (fs.existsSync(s)) return path.normalize(s)
119
+ const rel = s.replace(/^[\\/]+/, '')
120
+ return path.normalize(path.join(getWorkspaceDir(), rel))
121
+ }
122
+
123
+ function collectOutboundMediaPaths(item: unknown, out: string[]): void {
124
+ if (item == null) return
125
+ if (typeof item === 'string') {
126
+ const t = item.trim()
127
+ if (!t) return
128
+ if (t.startsWith('[')) {
129
+ try {
130
+ const parsed = JSON.parse(t) as unknown
131
+ collectOutboundMediaPaths(parsed, out)
132
+ return
133
+ } catch {
134
+ /* 非 JSON,按普通路径处理 */
135
+ }
136
+ }
137
+ out.push(resolveWorkspaceMediaPath(t))
138
+ return
139
+ }
140
+ if (Array.isArray(item)) {
141
+ for (const el of item) collectOutboundMediaPaths(el, out)
142
+ return
143
+ }
144
+ if (typeof item === 'object') {
145
+ const o = item as Record<string, unknown>
146
+ const raw = o.file ?? o.path ?? o.url
147
+ if (typeof raw === 'string' && raw.trim()) {
148
+ out.push(resolveWorkspaceMediaPath(raw))
149
+ }
150
+ }
151
+ }
152
+
153
+ /** 将出站 media 载荷统一为可 `fs` 访问的本地路径列表(去重保序) */
154
+ export function normalizeOutboundMediaPaths(raw: unknown): string[] {
155
+ const acc: string[] = []
156
+ collectOutboundMediaPaths(raw, acc)
157
+ const seen = new Set<string>()
158
+ const deduped: string[] = []
159
+ for (const p of acc) {
160
+ if (!p || seen.has(p)) continue
161
+ seen.add(p)
162
+ deduped.push(p)
163
+ }
164
+ return deduped
165
+ }
166
+
104
167
  export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<void> {
168
+ const rawOpt = (opts.sessionKey ?? '').trim()
169
+ const strippedForCron = rawOpt.replace(/^dcg-cron:/i, '').trim()
170
+ const fromIsolatedCron = extractCronJobIdFromIsolatedSessionKey(strippedForCron) !== null
171
+ const fromDcgCronWrapper = rawOpt.toLowerCase().startsWith('dcg-cron:')
172
+
105
173
  let sessionKey = normalizeSessionTarget(opts.sessionKey ?? '')
106
174
  sessionKey = resolveIsolatedCronSessionToJobSessionKey(sessionKey)
175
+
176
+ /** 定时自动执行未走 onRunCronJob,须与 finishedDcgchatCron 共用同一 messageId,否则附件与气泡错位 */
177
+ if (!opts.messageId?.trim() && (fromIsolatedCron || fromDcgCronWrapper) && !getCronMessageId(sessionKey)) {
178
+ setCronMessageId(sessionKey, `${Date.now()}`)
179
+ }
180
+
181
+ const cronMid = getCronMessageId(sessionKey)
107
182
  const baseCtx = getOutboundMsgParams(sessionKey)
108
- const msgCtx = opts.messageId?.trim() ? { ...baseCtx, messageId: opts.messageId.trim() } : baseCtx
183
+ const msgCtx = opts.messageId?.trim()
184
+ ? { ...baseCtx, messageId: opts.messageId.trim() }
185
+ : cronMid
186
+ ? { ...baseCtx, messageId: cronMid }
187
+ : baseCtx
109
188
  if (!isWsOpen()) {
110
- dcgLogger(`outbound media skipped -> ws not open: ${opts.mediaUrl ?? ''}`)
189
+ dcgLogger(`outbound media skipped -> ws not isWsOpen failed open: ${opts.mediaUrl ?? ''}`)
111
190
  return
112
191
  }
113
192
 
114
- const mediaUrl = opts.mediaUrl
115
- const dedupeId = msgCtx.messageId
116
- if (mediaUrl && dedupeId && hasSentMediaKey(dedupeId, mediaUrl)) {
117
- dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl}`)
193
+ const expanded = normalizeOutboundMediaPaths(opts.mediaUrl)
194
+ if (expanded.length === 0) {
195
+ dcgLogger(
196
+ `dcgchat: sendMedia skipped (no resolvable path): ${typeof opts.mediaUrl === 'string' ? opts.mediaUrl : JSON.stringify(opts.mediaUrl)} sessionKey=${sessionKey}`,
197
+ 'error'
198
+ )
199
+ return
200
+ }
201
+ if (expanded.length > 1) {
202
+ for (const single of expanded) {
203
+ await sendDcgchatMedia({ ...opts, mediaUrl: single })
204
+ }
118
205
  return
119
206
  }
120
- if (mediaUrl && dedupeId) {
121
- addSentMediaKey(dedupeId, mediaUrl)
207
+ const mediaUrl = expanded[0]
208
+ if (!mediaUrl || !msgCtx.sessionId) {
209
+ dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
210
+ return
211
+ }
212
+ // 判断文件存在
213
+ try {
214
+ if (!fs.existsSync(mediaUrl)) {
215
+ dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
216
+ return
217
+ }
218
+ } catch (err) {
219
+ dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
122
220
  }
123
221
 
124
- const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
222
+ if (mediaUrl && msgCtx.sessionId) {
223
+ if (hasSentMediaKey(msgCtx.sessionId, mediaUrl)) {
224
+ dcgLogger(`dcgchat: sendMedia skipped (hasSentMediaKey): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
225
+ return
226
+ }
227
+ addSentMediaKey(msgCtx.sessionId, mediaUrl)
228
+ }
125
229
 
230
+ const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
231
+ if (!msgCtx.sessionId) {
232
+ msgCtx.sessionId = sessionId
233
+ }
234
+ const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
235
+ const notMessageId = `${msgCtx?.messageId}`?.length === 13 || !msgCtx?.messageId
126
236
  try {
127
237
  const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat"]?.botToken ?? ''
128
238
  const url = opts.mediaUrl ? await ossUpload(opts.mediaUrl, botToken, 1) : ''
129
- if (!msgCtx.sessionId) {
130
- const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
131
- msgCtx.sessionId = sessionId
239
+ if (!msgCtx.agentId) {
132
240
  msgCtx.agentId = agentId
133
241
  }
134
242
  wsSendRaw(msgCtx, {
135
243
  response: opts.text ?? '',
244
+ is_finish: notMessageId ? -1 : 0,
245
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
136
246
  files: [{ url, name: fileName }]
137
247
  })
138
248
  dcgLogger(`dcgchat: sendMedia session=${sessionKey}, file=${fileName}`)
139
249
  } catch (error) {
140
250
  wsSendRaw(msgCtx, {
141
251
  response: opts.text ?? '',
142
- message_tags: { source: 'file' },
252
+ message_tags: notMessageId ? { mark: 'empty_text_file' } : {},
253
+ is_finish: notMessageId ? -1 : 0,
143
254
  files: [{ url: opts.mediaUrl ?? '', name: fileName }]
144
255
  })
145
256
  dcgLogger(`dcgchat: error sendMedia session=${sessionKey}: ${String(error)}`, 'error')
@@ -287,24 +398,20 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
287
398
  const outboundCtx = getOutboundMsgParams(to)
288
399
  const content: Record<string, unknown> = { response: ctx.text }
289
400
  if (!isCron) {
290
- // messageId = getCronMessageId(to) || `${Date.now()}`
291
- // const { sessionId, agentId } = getInfoBySessionKey(to)
292
- // content.is_finish = -1
293
- // content.message_tags = { source: 'cron' }
294
- // const merged = mergeDefaultParams({
295
- // agentId: agentId,
296
- // sessionId: `${sessionId}`,
297
- // messageId: messageId,
298
- // real_mobook: !sessionId ? 1 : ''
299
- // })
300
- // wsSendRaw(merged, content)
301
- // } else {
302
401
  if (outboundCtx?.sessionId) {
402
+ // 入站 handler 已将本轮标为 running;若已 sendFinal(end) 则应为 finished。
403
+ // 否则网关/Core 晚到的正文会仍用「当前 params map」里的 messageId,错挂到后一条用户消息上。
404
+ if (!isSessionActiveForTool(to)) {
405
+ dcgLogger(`channel sendText dropped (session not active): to=${to}`)
406
+ return {
407
+ channel: "dcgchat",
408
+ messageId: '',
409
+ chatId: outboundChatId(ctx.to, to)
410
+ }
411
+ }
303
412
  messageId = outboundCtx?.messageId || `${Date.now()}`
304
413
  const newCtx = { ...outboundCtx, messageId }
305
414
  wsSendRaw(newCtx, content)
306
- } else {
307
- dcgLogger(`channel sendText to ${ctx.to} -> sessionId not found`, 'error')
308
415
  }
309
416
  }
310
417
  }
@@ -320,11 +427,14 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
320
427
  const to = resolveIsolatedCronSessionToJobSessionKey(normalizedFromTo)
321
428
  const outboundCtx = getOutboundMsgParams(to)
322
429
  const msgCtx = getParamsMessage(to) ?? outboundCtx
430
+ if (isCron && !getCronMessageId(to)) {
431
+ setCronMessageId(to, `${Date.now()}`)
432
+ }
323
433
  const cronMsgId = getCronMessageId(to)
324
434
  const fallbackMessageId = `${Date.now()}`
325
435
  const messageId = cronMsgId || (isCron ? fallbackMessageId : msgCtx?.messageId || fallbackMessageId)
326
-
327
- if (!outboundCtx?.sessionId) {
436
+ const { sessionId } = getInfoBySessionKey(to)
437
+ if (!sessionId) {
328
438
  dcgLogger(`channel sendMedia to ${ctx.to} -> sessionId not found`, 'error')
329
439
  return {
330
440
  channel: "dcgchat",
@@ -334,11 +444,17 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
334
444
  }
335
445
 
336
446
  dcgLogger(`channel sendMedia to ${ctx.to}`)
337
- await sendDcgchatMedia({
338
- sessionKey: to || '',
339
- mediaUrl: ctx.mediaUrl || '',
340
- ...(isCron ? { messageId } : {})
341
- })
447
+
448
+ const ctxExt = ctx as { mediaUrls?: unknown; mediaUrl?: string }
449
+ const rawMedia = ctxExt.mediaUrls ?? ctxExt.mediaUrl
450
+ const paths = normalizeOutboundMediaPaths(rawMedia)
451
+ for (const mediaUrl of paths) {
452
+ await sendDcgchatMedia({
453
+ sessionKey: to || '',
454
+ mediaUrl,
455
+ ...(isCron ? { messageId } : {})
456
+ })
457
+ }
342
458
  return {
343
459
  channel: "dcgchat",
344
460
  messageId,
package/src/cron.ts CHANGED
@@ -135,7 +135,7 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
135
135
  )
136
136
  sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
137
137
  }
138
- export const finishedDcgchatCron = async (jobId: string, summary: string) => {
138
+ export const finishedDcgchatCron = async (jobId: string, summary: string, hasFileOutput?: boolean) => {
139
139
  const id = jobId?.trim()
140
140
  if (!id) {
141
141
  dcgLogger('finishedDcgchatCron: empty jobId', 'error')
@@ -159,7 +159,11 @@ export const finishedDcgchatCron = async (jobId: string, summary: string) => {
159
159
  messageId: messageId || `${Date.now()}`,
160
160
  real_mobook: !sessionId ? 1 : ''
161
161
  })
162
- wsSendRaw(merged, { response: summary, message_tags: { source: 'cron' }, is_finish: -1 })
162
+ const message_tags = { source: 'cron' } as Record<string, string | boolean>
163
+ if (hasFileOutput) {
164
+ message_tags.hasFile = true
165
+ }
166
+ wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
163
167
  setTimeout(() => {
164
168
  sendFinal(merged, 'cron send')
165
169
  }, 200)
@@ -117,33 +117,44 @@ function needsBestEffort(delivery: CronDelivery): boolean {
117
117
  }
118
118
 
119
119
  /**
120
- * 深拷贝 params 并注入 bestEffort: true
120
+ * 深拷贝 params,在 delivery 上写入 dcg 路由(to / accountId / sessionKey)。
121
+ * bestEffort + 默认 channel 仅在 needsBestEffort 为真时写入(announce 且无 channel)。
122
+ *
123
+ * 说明:原先仅在 needsBestEffort 为真时才改 delivery,若 jobs 里已有 channel 会整段跳过,
124
+ * 导致 `delivery.to` 永远不会被本钩子写入;运行时若缺 to 就会一直 not-delivered。
121
125
  */
122
- function injectBestEffort(params: Record<string, unknown>, sk: string): Record<string, unknown> {
126
+ function patchCronDeliveryInParams(
127
+ params: Record<string, unknown>,
128
+ sk: string,
129
+ deliverySnapshot: CronDelivery
130
+ ): Record<string, unknown> | null {
123
131
  const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
124
- const { agentId, sessionId } = formatterSessionKey(sk)
125
- // 顶层 delivery
132
+ const { agentId } = formatterSessionKey(sk)
133
+ const announceNoChannel = needsBestEffort(deliverySnapshot)
134
+
135
+ const apply = (d: CronDelivery) => {
136
+ d.to = `dcg-cron:${sk}`
137
+ if (agentId) d.accountId = agentId
138
+ if (announceNoChannel) {
139
+ d.bestEffort = true
140
+ d.channel = 'dcgchat-test'
141
+ }
142
+ }
143
+
126
144
  if (newParams.delivery && typeof newParams.delivery === 'object') {
127
- ;(newParams.delivery as CronDelivery).bestEffort = true
128
- ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
129
- ;(newParams.delivery as CronDelivery).accountId = agentId
130
- ;(newParams.delivery as CronDelivery).channel = "dcgchat"
145
+ apply(newParams.delivery as CronDelivery)
131
146
  newParams.sessionKey = sk
132
147
  return newParams
133
148
  }
134
149
 
135
- // job.delivery
136
150
  const job = newParams.job as Record<string, unknown> | undefined
137
151
  if (job?.delivery && typeof job.delivery === 'object') {
138
- ;(job.delivery as CronDelivery).bestEffort = true
139
- ;(newParams.delivery as CronDelivery).to = `dcg-cron:${sk}`
140
- ;(newParams.delivery as CronDelivery).accountId = agentId
141
- ;(newParams.delivery as CronDelivery).channel = "dcgchat"
152
+ apply(job.delivery as CronDelivery)
142
153
  newParams.sessionKey = sk
143
154
  return newParams
144
155
  }
145
156
 
146
- return newParams
157
+ return null
147
158
  }
148
159
 
149
160
  export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
@@ -154,34 +165,38 @@ export function cronToolCall(event: { toolName: any; params: any; toolCallId: an
154
165
  const delivery = extractDelivery(params)
155
166
  if (!delivery) {
156
167
  dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
157
- return params
168
+ return undefined
169
+ }
170
+ const newParams = patchCronDeliveryInParams(params, sk, delivery)
171
+ if (!newParams) {
172
+ dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) could not locate delivery on params clone, skip.`)
173
+ return undefined
158
174
  }
159
- if (!needsBestEffort(delivery)) {
175
+
176
+ const patched = newParams.delivery ?? (newParams.job as Record<string, unknown>)?.delivery
177
+ if (needsBestEffort(delivery)) {
160
178
  dcgLogger(
161
- `[${LOG_TAG}] cron call (${toolCallId}) delivery does not need bestEffort ` +
162
- `(mode=${String(delivery.mode)}, channel=${String(delivery.channel)}, bestEffort=${String(delivery.bestEffort)}), skip.`
179
+ `[${LOG_TAG}] cron call (${toolCallId}) patched delivery (announce, no channel): bestEffort=true, ` +
180
+ `delivery=${JSON.stringify(patched)}`
181
+ )
182
+ } else {
183
+ dcgLogger(
184
+ `[${LOG_TAG}] cron call (${toolCallId}) patched delivery.to / accountId (sessionKey=${sk}), ` +
185
+ `delivery=${JSON.stringify(patched)}`
163
186
  )
164
- return params
165
187
  }
166
188
 
167
- // ★ 核心:注入 bestEffort: true
168
- const newParams = injectBestEffort(params, sk)
169
- dcgLogger(
170
- `[${LOG_TAG}] cron call (${toolCallId}) injected bestEffort=true ` +
171
- `(mode=announce, no channel). delivery=${JSON.stringify(newParams.delivery || (newParams.job as Record<string, unknown>)?.delivery)}`
172
- )
173
-
174
189
  return { params: newParams }
175
190
  } else if (toolName === 'exec') {
176
191
  if (params.command.indexOf('cron create') > -1 || params.command.indexOf('cron add') > -1) {
177
192
  const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
178
193
  newParams.command =
179
- params.command.replace('--json', '') + ` --session-key ${sk} --channel ${"dcgchat"} --to dcg-cron:${sk} --json`
194
+ params.command.replace('--json', '') + ` --session-key ${sk} --channel ${'dcgchat-test'} --to dcg-cron:${sk} --json`
180
195
  return { params: newParams }
181
196
  } else {
182
- return params
197
+ return undefined
183
198
  }
184
199
  }
185
200
 
186
- return params
201
+ return undefined
187
202
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 会话终止 / 抢占 / 网关 abort 的集中实现,便于单独调整策略与观测。
3
+ * 与 bot 的 generation、流式分片序号、入站业务上下文分离,仅负责:本地 AbortController、流抑制标记、
4
+ * 入站串行队尾、activeRunId、chat.abort 子会话→主会话。
5
+ */
6
+ import { sendGatewayRpc } from './gateway/socket.js'
7
+ import { sendFinal } from './transport.js'
8
+ import type { IMsgParams } from './types.js'
9
+ import { dcgLogger } from './utils/log.js'
10
+ import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession } from './tool.js'
11
+
12
+ // --- 状态(仅本模块内修改,供 bot 通过下方 API 使用)---
13
+
14
+ /** 当前会话最近一次 agent run 的 runId(网关 chat.abort 主会话时携带) */
15
+ const activeRunIdBySessionKey = new Map<string, string>()
16
+
17
+ /** dispatchReplyFromConfig 使用的 AbortSignal,用于真正掐断工具与模型 */
18
+ const dispatchAbortBySessionKey = new Map<string, AbortController>()
19
+
20
+ /** 打断后抑制 deliver / onPartialReply 继续下发 */
21
+ const sessionStreamSuppressed = new Set<string>()
22
+
23
+ /**
24
+ * 同 sessionKey 入站串行队尾;与 Core session lane 对齐,避免并发 dispatch 过早返回。
25
+ */
26
+ const inboundTurnTailBySessionKey = new Map<string, Promise<void>>()
27
+
28
+ // --- activeRunId(供 bot 在 onAgentRunStart / 错误收尾时同步)---
29
+
30
+ export function setActiveRunIdForSession(sessionKey: string, runId: string): void {
31
+ activeRunIdBySessionKey.set(sessionKey, runId)
32
+ }
33
+
34
+ export function clearActiveRunIdForSession(sessionKey: string): void {
35
+ activeRunIdBySessionKey.delete(sessionKey)
36
+ }
37
+
38
+ // --- 流抑制 ---
39
+
40
+ export function isSessionStreamSuppressed(sessionKey: string): boolean {
41
+ return sessionStreamSuppressed.has(sessionKey)
42
+ }
43
+
44
+ export function clearSessionStreamSuppression(sessionKey: string): void {
45
+ sessionStreamSuppressed.delete(sessionKey)
46
+ }
47
+
48
+ export function markSessionStreamSuppressed(sessionKey: string): void {
49
+ sessionStreamSuppressed.add(sessionKey)
50
+ }
51
+
52
+ // --- 入站串行队列 ---
53
+
54
+ /** /stop 入队前:掐断当前 in-process,并重置队尾,使本条 stop 不必等待已被 abort 的长 turn */
55
+ export function preemptInboundQueueForStop(sessionKey: string): void {
56
+ const c = dispatchAbortBySessionKey.get(sessionKey)
57
+ if (c) {
58
+ c.abort()
59
+ dispatchAbortBySessionKey.delete(sessionKey)
60
+ }
61
+ inboundTurnTailBySessionKey.set(sessionKey, Promise.resolve())
62
+ dcgLogger(`inbound queue: reset tail for /stop sessionKey=${sessionKey}`)
63
+ }
64
+
65
+ /** 将本轮入站处理挂到 sessionKey 队尾,保证同会话顺序执行 */
66
+ export async function runInboundTurnSequenced(sessionKey: string, run: () => Promise<void>): Promise<void> {
67
+ const prev = inboundTurnTailBySessionKey.get(sessionKey) ?? Promise.resolve()
68
+ const next = prev.catch(() => {}).then(run)
69
+ inboundTurnTailBySessionKey.set(sessionKey, next.catch(() => {}))
70
+ await next
71
+ }
72
+
73
+ // --- 网关 abort ---
74
+
75
+ /**
76
+ * 终止网关上仍可能活跃的 run(子会话自深到浅,再主会话)。
77
+ * supersede:仅当有子会话或 mainRunId 时发 RPC;interrupt:主会话始终 chat.abort。
78
+ */
79
+ export async function abortGatewayRunsForSession(sessionKey: string, reason: 'interrupt' | 'supersede'): Promise<void> {
80
+ const prefix = reason === 'interrupt' ? 'interrupt' : 'supersede'
81
+ const descendantKeys = getDescendantSessionKeysForRequester(sessionKey)
82
+ const abortSubKeys = [...descendantKeys].reverse()
83
+ const mainRunId = activeRunIdBySessionKey.get(sessionKey)
84
+
85
+ if (reason === 'supersede' && abortSubKeys.length === 0 && !mainRunId) {
86
+ return
87
+ }
88
+
89
+ if (abortSubKeys.length > 0) {
90
+ dcgLogger(`${prefix}: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
91
+ }
92
+ for (const subKey of abortSubKeys) {
93
+ try {
94
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
95
+ } catch (e) {
96
+ dcgLogger(`${prefix}: chat.abort subagent ${subKey}: ${String(e)}`, 'error')
97
+ }
98
+ }
99
+
100
+ const shouldMainAbort = reason === 'interrupt' || Boolean(mainRunId)
101
+ if (shouldMainAbort) {
102
+ try {
103
+ await sendGatewayRpc({
104
+ method: 'chat.abort',
105
+ params: mainRunId ? { sessionKey, runId: mainRunId } : { sessionKey }
106
+ })
107
+ } catch (e) {
108
+ dcgLogger(`${prefix}: chat.abort main ${sessionKey}: ${String(e)}`, 'error')
109
+ }
110
+ }
111
+
112
+ activeRunIdBySessionKey.delete(sessionKey)
113
+ resetSubagentStateForRequesterSession(sessionKey)
114
+ }
115
+
116
+ // --- 本地 dispatch AbortController ---
117
+
118
+ /**
119
+ * 新一轮非 /stop 用户消息:清除流抑制、abort 上一轮 controller、网关 supersede,并安装新的 AbortController。
120
+ * 调用方须在之前或之后自行 `streamChunkIdxBySessionKey.set(sessionKey, 0)`。
121
+ */
122
+ export async function beginSupersedingUserTurn(sessionKey: string): Promise<AbortController> {
123
+ sessionStreamSuppressed.delete(sessionKey)
124
+ dispatchAbortBySessionKey.get(sessionKey)?.abort()
125
+ await abortGatewayRunsForSession(sessionKey, 'supersede')
126
+ const ac = new AbortController()
127
+ dispatchAbortBySessionKey.set(sessionKey, ac)
128
+ return ac
129
+ }
130
+
131
+ /** try/finally 中:仅当仍是当前 controller 时从 map 移除 */
132
+ export function releaseDispatchAbortIfCurrent(sessionKey: string, controller: AbortController | undefined): void {
133
+ if (controller && dispatchAbortBySessionKey.get(sessionKey) === controller) {
134
+ dispatchAbortBySessionKey.delete(sessionKey)
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 用户发送 /stop:本地 abort、对「上一轮对话」发 abort final、标记流抑制、网关 interrupt。
140
+ * 之后的 clearSentMedia、params、UI「已终止」等由 bot 继续处理。
141
+ */
142
+ export async function interruptLocalDispatchAndGateway(sessionKey: string, ctxForAbort: IMsgParams): Promise<void> {
143
+ dcgLogger(`interrupt command: sessionKey=${sessionKey}`)
144
+ const inFlight = dispatchAbortBySessionKey.get(sessionKey)
145
+ if (inFlight) {
146
+ dcgLogger(`interrupt: AbortController.abort() in-process run sessionKey=${sessionKey}`)
147
+ inFlight.abort()
148
+ dispatchAbortBySessionKey.delete(sessionKey)
149
+ }
150
+ const finalCtx = ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }
151
+ sendFinal(finalCtx, 'abort')
152
+ markSessionStreamSuppressed(sessionKey)
153
+ await abortGatewayRunsForSession(sessionKey, 'interrupt')
154
+ }
package/src/tool.ts CHANGED
@@ -65,7 +65,7 @@ function resolveOutboundParamsForSession(sk: string) {
65
65
  }
66
66
 
67
67
  /** 主会话已 running 时,子会话上的工具/事件也应下发(否则子 key 无 running 状态会整段丢消息) */
68
- function isSessionActiveForTool(sk: string): boolean {
68
+ export function isSessionActiveForTool(sk: string): boolean {
69
69
  const k = sk.trim()
70
70
  if (!k) return false
71
71
  if (getMsgStatus(k) === 'running') return true
@@ -325,6 +325,16 @@ function resolveHookSessionKey(
325
325
  return (args?.sessionKey || '').trim()
326
326
  }
327
327
 
328
+ /** 定时触发时会话往往非 running,但仍需跑 before_tool_call 以注入 sessionKey / delivery(见 cronToolCall) */
329
+ function shouldRunBeforeToolCallWithoutRunningSession(event: { toolName?: string; params?: { command?: string } }): boolean {
330
+ if (event?.toolName === 'cron') return true
331
+ const cmd = event?.params?.command
332
+ if (event?.toolName === 'exec' && typeof cmd === 'string') {
333
+ return cmd.includes('cron create') || cmd.includes('cron add')
334
+ }
335
+ return false
336
+ }
337
+
328
338
  function trackSubagentLifecycle(eventName: string, event: any, args: any): void {
329
339
  if (eventName === 'subagent_spawned') {
330
340
  const runId = typeof event?.runId === 'string' ? event.runId : ''
@@ -351,7 +361,9 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
351
361
  trackSubagentLifecycle(item.event, event, args)
352
362
  const sk = resolveHookSessionKey(item.event, args ?? {})
353
363
  if (sk) {
354
- if (isSessionActiveForTool(sk)) {
364
+ const toolHooksOk =
365
+ isSessionActiveForTool(sk) || (item.event === 'before_tool_call' && shouldRunBeforeToolCallWithoutRunningSession(event))
366
+ if (toolHooksOk) {
355
367
  if (['after_tool_call', 'before_tool_call'].includes(item.event)) {
356
368
  const { result: _result, ...rest } = event
357
369
  dcgLogger(`工具调用结果: ~ event:${item.event} ~ params:${JSON.stringify(rest)}`)
@@ -365,12 +377,7 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
365
377
  ...rest,
366
378
  status: 'running'
367
379
  })
368
- sendToolCallMessage(
369
- sk,
370
- text,
371
- event.toolCallId || event.runId || Date.now().toString(),
372
- 0
373
- )
380
+ sendToolCallMessage(sk, text, event.toolCallId || event.runId || Date.now().toString(), 0)
374
381
  return hookResult
375
382
  }
376
383
  const text = JSON.stringify({
package/src/transport.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getWsConnection } from './utils/global.js'
1
+ import { clearSentMediaKeys, getWsConnection } from './utils/global.js'
2
2
  import { dcgLogger } from './utils/log.js'
3
3
  import type { IMsgParams } from './types.js'
4
4
  import { getEffectiveMsgParams, getParamsDefaults } from './utils/params.js'
@@ -170,6 +170,7 @@ export function sendChunk(text: string, ctx: IMsgParams, chunkIdx: number): bool
170
170
 
171
171
  export function sendFinal(ctx: IMsgParams, tag: string): boolean {
172
172
  dcgLogger(` message handling complete state: to=${ctx.sessionId} final tag:${tag}`)
173
+ clearSentMediaKeys(ctx.sessionId)
173
174
  return wsSend(ctx, { response: '', state: 'final' })
174
175
  }
175
176
 
@@ -2,6 +2,6 @@ export const ENV: 'production' | 'test' | 'develop' = 'production'
2
2
 
3
3
 
4
4
  export const systemCommand = ['/new', '/status']
5
- export const interruptCommand = ['/stop']
5
+ export const stopCommand = ['/stop']
6
6
 
7
- export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...interruptCommand]
7
+ export const ignoreToolCommand = ['/search', '/abort', '/queue interrupt', ...systemCommand, ...stopCommand]
@@ -36,7 +36,12 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
36
36
  sendDcgchatCron(p?.jobId as string)
37
37
  }
38
38
  if (p?.action === 'finished' && p?.status === 'ok') {
39
- finishedDcgchatCron(p?.jobId as string, p?.summary as string)
39
+ const hasFileOutput = p.delivered === true && p.deliveryStatus === 'delivered'
40
+ let summary = p?.summary as string
41
+ if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
42
+ summary = summary.replace('HEARTBEAT_OK', '')
43
+ }
44
+ finishedDcgchatCron(p?.jobId as string, summary, hasFileOutput)
40
45
  }
41
46
  }
42
47
  } catch (error) {
@@ -82,11 +82,11 @@ const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
82
82
  /** 已发送媒体去重:外层 messageId → 内层该会话下已发送的媒体 key(文件名) */
83
83
  const sentMediaKeysBySession = new Map<string, Set<string>>()
84
84
 
85
- function getSessionMediaSet(messageId: string): Set<string> {
86
- let set = sentMediaKeysBySession.get(messageId)
85
+ function getSessionMediaSet(sessionId: string): Set<string> {
86
+ let set = sentMediaKeysBySession.get(sessionId)
87
87
  if (!set) {
88
88
  set = new Set<string>()
89
- sentMediaKeysBySession.set(messageId, set)
89
+ sentMediaKeysBySession.set(sessionId, set)
90
90
  }
91
91
  return set
92
92
  }
@@ -95,14 +95,14 @@ export function addSentMediaKey(messageId: string, url: string) {
95
95
  getSessionMediaSet(messageId).add(getMediaKey(url))
96
96
  }
97
97
 
98
- export function hasSentMediaKey(messageId: string, url: string): boolean {
99
- return sentMediaKeysBySession.get(messageId)?.has(getMediaKey(url)) ?? false
98
+ export function hasSentMediaKey(sessionId: string, url: string): boolean {
99
+ return sentMediaKeysBySession.get(sessionId)?.has(getMediaKey(url)) ?? false
100
100
  }
101
101
 
102
102
  /** 不传 messageId 时清空全部会话;传入则只清空该会话 */
103
- export function clearSentMediaKeys(messageId?: string) {
104
- if (messageId) {
105
- sentMediaKeysBySession.delete(messageId)
103
+ export function clearSentMediaKeys(sessionId?: string) {
104
+ if (sessionId) {
105
+ sentMediaKeysBySession.delete(sessionId)
106
106
  } else {
107
107
  sentMediaKeysBySession.clear()
108
108
  }