@dcrays/dcgchat 0.3.35 → 0.4.4

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/index.ts CHANGED
@@ -4,7 +4,7 @@ import { dcgchatPlugin } from './src/channel.js'
4
4
  import { setDcgchatRuntime, setWorkspaceDir } from './src/utils/global.js'
5
5
  import { monitoringToolMessage } from './src/tool.js'
6
6
  import { setOpenClawConfig } from './src/utils/global.js'
7
- import { startDcgchatGatewaySocket } from './src/gateway/socket.js'
7
+ import { createDcgchatMessageTool } from './src/tools/messageTool.js'
8
8
 
9
9
  const plugin = {
10
10
  id: "dcgchat",
@@ -18,9 +18,8 @@ const plugin = {
18
18
  api.registerChannel({ plugin: dcgchatPlugin })
19
19
  setWorkspaceDir(api.config?.agents?.defaults?.workspace)
20
20
  api.registerTool((ctx) => {
21
- const workspaceDir = ctx.workspaceDir
22
- setWorkspaceDir(workspaceDir)
23
- return null
21
+ setWorkspaceDir(ctx.workspaceDir)
22
+ return createDcgchatMessageTool(ctx)
24
23
  })
25
24
  }
26
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/dcgchat",
3
- "version": "0.3.35",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
6
6
  "main": "index.ts",
package/src/agent.ts ADDED
@@ -0,0 +1,125 @@
1
+ import axios from 'axios'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { getWorkspaceDir } from './utils/global.js'
5
+ import { getWsConnection } from './utils/global.js'
6
+ import { dcgLogger } from './utils/log.js'
7
+ import { isWsOpen } from './transport.js'
8
+ import { sendMessageToGateway } from './gateway/socket.js'
9
+ import { extractZipBufferToDirectory } from './utils/zipExtract.js'
10
+
11
+ type IAgentParams = {
12
+ url: string
13
+ agent_code: string
14
+ agent_name: string
15
+ agent_description: string
16
+ }
17
+
18
+ function sendEvent(msgContent: Record<string, any>) {
19
+ const ws = getWsConnection()
20
+ if (isWsOpen()) {
21
+ ws?.send(
22
+ JSON.stringify({
23
+ messageType: 'openclaw_bot_event',
24
+ source: 'client',
25
+ content: msgContent
26
+ })
27
+ )
28
+ dcgLogger(`agent安装: ${JSON.stringify(msgContent)}`)
29
+ }
30
+ }
31
+
32
+ /** 若 workspace-${clone_code}/agent 存在,则复制到 agents/${clone_code}/agent */
33
+ function copyAgentsFiles(clone_code: string) {
34
+ const workspacePath = getWorkspaceDir()
35
+ if (!workspacePath) return
36
+ const workspaceDir = path.join(workspacePath, '../', `workspace-${clone_code}`)
37
+ const agentDir = path.join(workspacePath, '../', `agents/${clone_code}`)
38
+ const sourceAgent = path.join(workspaceDir, 'agent')
39
+ try {
40
+ if (!fs.existsSync(sourceAgent)) return
41
+ if (!fs.statSync(sourceAgent).isDirectory()) return
42
+ fs.mkdirSync(agentDir, { recursive: true })
43
+ const dest = path.join(agentDir, 'agent')
44
+ if (fs.existsSync(dest)) {
45
+ fs.rmSync(dest, { recursive: true, force: true })
46
+ }
47
+ fs.cpSync(sourceAgent, dest, { recursive: true })
48
+ } catch (err: unknown) {
49
+ dcgLogger(`copyAgentsFiles failed: ${String(err)}`, 'error')
50
+ }
51
+ }
52
+
53
+ export async function onCreateAgent(params: Record<string, any>) {
54
+ const { clone_code, name, description } = params
55
+ try {
56
+ await sendMessageToGateway(JSON.stringify({ method: 'agents.create', params: { name: clone_code, workspace: clone_code } }))
57
+ } catch (err: unknown) {
58
+ dcgLogger(`agents.create failed: ${String(err)}`, 'error')
59
+ }
60
+ // Update config.name to the user-supplied display name (may contain CJK, spaces, etc.)
61
+ try {
62
+ await sendMessageToGateway(JSON.stringify({ method: 'agents.update', params: { name: name, agentId: clone_code } }))
63
+ } catch (err: unknown) {
64
+ dcgLogger(`agents.update failed: ${String(err)}`, 'error')
65
+ }
66
+ if (description?.trim()) {
67
+ try {
68
+ await sendMessageToGateway(
69
+ JSON.stringify({
70
+ method: 'agents.files.set',
71
+ params: { agentId: clone_code, name: 'IDENTITY.md', content: description.trim() }
72
+ })
73
+ )
74
+ } catch {
75
+ // Non-fatal
76
+ }
77
+ }
78
+ if (name?.trim()) {
79
+ try {
80
+ await sendMessageToGateway(
81
+ JSON.stringify({
82
+ method: 'agents.files.set',
83
+ params: { agentId: clone_code, name: 'USER.md', content: name.trim() }
84
+ })
85
+ )
86
+ } catch {
87
+ // Non-fatal
88
+ }
89
+ }
90
+ copyAgentsFiles(clone_code)
91
+ sendEvent({ ...params, status: 'ok' })
92
+ }
93
+
94
+ export async function createAgent(msgContent: Record<string, any>) {
95
+ const { url, clone_code } = msgContent
96
+ if (!url || !clone_code) {
97
+ dcgLogger(`createAgent failed empty url&clone_code: ${JSON.stringify(msgContent)}`, 'error')
98
+ sendEvent({ ...msgContent, status: 'fail' })
99
+ return
100
+ }
101
+ const workspacePath = getWorkspaceDir()
102
+ const workspaceDir = path.join(workspacePath, '../', `workspace-${clone_code}`)
103
+
104
+ // 如果目标目录已存在,先删除
105
+ if (fs.existsSync(workspaceDir)) {
106
+ fs.rmSync(workspaceDir, { recursive: true, force: true })
107
+ }
108
+
109
+ try {
110
+ const response = await axios({
111
+ method: 'get',
112
+ url,
113
+ responseType: 'arraybuffer'
114
+ })
115
+ fs.mkdirSync(workspaceDir, { recursive: true })
116
+ await extractZipBufferToDirectory(Buffer.from(response.data), workspaceDir)
117
+ await onCreateAgent(msgContent)
118
+ } catch (error) {
119
+ // 如果安装失败,清理目录
120
+ if (fs.existsSync(workspaceDir)) {
121
+ fs.rmSync(workspaceDir, { recursive: true, force: true })
122
+ }
123
+ sendEvent({ ...msgContent, status: 'fail' })
124
+ }
125
+ }
package/src/bot.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import path from 'node:path'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
2
  import type { ReplyPayload } from 'openclaw/plugin-sdk'
5
- import { createReplyPrefixContext } from 'openclaw/plugin-sdk'
3
+ import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
6
4
  import type { InboundMessage } from './types.js'
7
5
  import {
8
6
  clearSentMediaKeys,
@@ -14,12 +12,12 @@ import {
14
12
  } from './utils/global.js'
15
13
  import { resolveAccount, sendDcgchatMedia } from './channel.js'
16
14
  import { generateSignUrl } from './request/api.js'
17
- import { extractMobookFiles } from './utils/searchFile.js'
18
15
  import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
19
16
  import { dcgLogger } from './utils/log.js'
20
17
  import { channelInfo, systemCommand, interruptCommand, ENV, ignoreToolCommand } from './utils/constant.js'
21
- import { sendMessageToGateway } from './gateway/socket.js'
22
- import { getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
18
+ import { sendGatewayRpc } from './gateway/socket.js'
19
+ import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
20
+ import { getChildSessionKeysTrackedForRequester, resetSubagentStateForRequesterSession, waitUntilSubagentsIdle } from './tool.js'
23
21
 
24
22
  type MediaInfo = {
25
23
  path: string
@@ -44,13 +42,6 @@ const sessionStreamSuppressed = new Set<string>()
44
42
  /** 各 sessionKey 当前轮回复的流式分片序号(仅统计实际下发的文本 chunk);每轮非打断消息开始时置 0 */
45
43
  const streamChunkIdxBySessionKey = new Map<string, number>()
46
44
 
47
- /** Active LLM generation abort controllers, keyed by conversationId */
48
- // const activeGenerations = new Map<string, AbortController>()
49
-
50
- /**
51
- * Extract agentId from conversation_id formatted as "agentId::suffix".
52
- * Returns null if the conversation_id does not contain the "::" separator.
53
- */
54
45
  export function extractAgentIdFromConversationId(conversationId: string): string | null {
55
46
  const idx = conversationId.indexOf('::')
56
47
  if (idx <= 0) return null
@@ -133,12 +124,17 @@ function resolveReplyMediaList(payload: ReplyPayload): string[] {
133
124
  return payload.mediaUrl ? [payload.mediaUrl] : []
134
125
  }
135
126
 
127
+ const typingCallbacks = createTypingCallbacks({
128
+ start: async () => {},
129
+ onStartError: (err) => {
130
+ dcgLogger(`typing start error: ${String(err)}`, 'error')
131
+ }
132
+ })
133
+
136
134
  /**
137
135
  * 处理一条用户消息,调用 Agent 并返回回复
138
136
  */
139
137
  export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
140
- let finalSent = false
141
-
142
138
  let completeText = ''
143
139
  const config = getOpenClawConfig()
144
140
  if (!config) {
@@ -151,7 +147,6 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
151
147
  const core = getDcgchatRuntime()
152
148
 
153
149
  const conversationId = msg.content.session_id?.trim()
154
- const agentId = msg.content.agent_id?.trim()
155
150
  const real_mobook = msg.content.real_mobook?.toString().trim()
156
151
 
157
152
  const route = core.channel.routing.resolveAgentRoute({
@@ -164,7 +159,7 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
164
159
  const embeddedAgentId = extractAgentIdFromConversationId(conversationId)
165
160
 
166
161
  const effectiveAgentId = embeddedAgentId ?? route.agentId
167
- const effectiveSessionKey = getSessionKey(msg.content, account.accountId)
162
+ const dcgSessionKey = getSessionKey(msg.content, account.accountId)
168
163
 
169
164
  const mergedParams = {
170
165
  userId: msg._userId,
@@ -175,44 +170,27 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
175
170
  appId: msg.content.app_id,
176
171
  botId: msg.content.bot_id ?? '',
177
172
  agentId: msg.content.agent_id ?? '',
178
- sessionKey: effectiveSessionKey,
173
+ sessionKey: dcgSessionKey,
179
174
  real_mobook
180
175
  }
181
- setParamsMessage(effectiveSessionKey, mergedParams)
182
- // OpenClaw 会话投递里仍可能出现的 ctx.to=SenderId(userId)对齐,便于 getOutboundMsgParams 命中
183
- dcgLogger(
184
- `target normalize: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, conversationId=${conversationId ?? ''}, messageId=${msg.content.message_id}`
185
- )
186
- setParamsMessage(userId, mergedParams)
187
- dcgLogger(`target alias bound: aliasTarget=${userId} -> sessionKey=${effectiveSessionKey}`)
188
- const outboundCtx = getEffectiveMsgParams(effectiveSessionKey)
176
+ /** 写入本条消息参数前快照:流式/abort 的 final 须对齐「上一轮」触发的对话 messageId,而非打断指令本身 */
177
+ const priorOutboundCtx = getEffectiveMsgParams(dcgSessionKey)
178
+ setParamsMessage(dcgSessionKey, mergedParams)
179
+ dcgLogger(`target alias bound: aliasTarget=${userId} -> sessionKey=${dcgSessionKey}`)
180
+ const outboundCtx = getEffectiveMsgParams(dcgSessionKey)
189
181
  const agentEntry =
190
182
  effectiveAgentId && effectiveAgentId !== 'main' ? config.agents?.list?.find((a) => a.id === effectiveAgentId) : undefined
191
183
  const agentDisplayName = agentEntry?.name ?? (effectiveAgentId && effectiveAgentId !== 'main' ? effectiveAgentId : undefined)
192
184
 
193
- const safeSendFinal = (tag: string) => {
194
- if (finalSent) return
195
- finalSent = true
196
- sendFinal(outboundCtx, tag)
197
- setMsgStatus(effectiveSessionKey, 'finished')
198
- }
199
-
200
- const text = msg.content.text?.trim()
185
+ let text = msg.content.text?.trim()
201
186
 
202
187
  if (!text) {
203
188
  sendTextMsg('你需要我帮你做什么呢?', outboundCtx)
204
- safeSendFinal('not text')
189
+ sendFinal(outboundCtx, 'not text')
205
190
  return
206
191
  }
207
192
 
208
193
  try {
209
- // Abort any existing generation for this conversation, then start a new one
210
- // const existingCtrl = activeGenerations.get(conversationId)
211
- // if (existingCtrl) existingCtrl.abort()
212
- // const genCtrl = new AbortController()
213
- // const genSignal = genCtrl.signal
214
- // activeGenerations.set(conversationId, genCtrl)
215
-
216
194
  // 处理用户上传的文件
217
195
  const files = msg.content.files ?? []
218
196
  let mediaPayload: Record<string, unknown> = {}
@@ -236,8 +214,8 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
236
214
  RawBody: text,
237
215
  CommandBody: text,
238
216
  From: userId,
239
- To: effectiveSessionKey,
240
- SessionKey: effectiveSessionKey,
217
+ To: dcgSessionKey,
218
+ SessionKey: dcgSessionKey,
241
219
  AccountId: route.accountId,
242
220
  ChatType: 'direct',
243
221
  SenderName: agentDisplayName,
@@ -249,17 +227,27 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
249
227
  WasMentioned: true,
250
228
  CommandAuthorized: true,
251
229
  OriginatingChannel: "dcgchat",
252
- OriginatingTo: effectiveSessionKey,
230
+ OriginatingTo: dcgSessionKey,
231
+ Target: dcgSessionKey,
232
+ SourceTarget: dcgSessionKey,
253
233
  ...mediaPayload
254
234
  })
255
- dcgLogger(
256
- `inbound context target: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, ctx.To=${String(ctxPayload.To ?? '')}, ctx.SessionKey=${String(ctxPayload.SessionKey ?? '')}, ctx.OriginatingTo=${String(ctxPayload.OriginatingTo ?? '')}`
257
- )
258
235
 
259
236
  const sentMediaKeys = new Set<string>()
260
237
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
261
238
  let streamedTextLen = 0
262
239
 
240
+ if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code) {
241
+ const workspaceDir = getWorkspaceDir()
242
+ const skillText = msg.content.skills_scope
243
+ .map((skill) => {
244
+ const skillDir = `${workspaceDir}/skills/${skill.skill_code}`
245
+ return `技能${skill.skill_code} 在目录${skillDir}下,在目录${skillDir}下读取技能 \n`
246
+ })
247
+ .join('\n')
248
+ text = skillText ? `${skillText} ${text}` : text
249
+ dcgLogger(`skill: text: ${text}`)
250
+ }
263
251
  const prefixContext = createReplyPrefixContext({
264
252
  cfg: config,
265
253
  agentId: effectiveAgentId ?? '',
@@ -267,78 +255,106 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
267
255
  accountId: account.accountId
268
256
  })
269
257
 
270
- const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({
258
+ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
271
259
  responsePrefix: prefixContext.responsePrefix,
272
260
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
273
261
  humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
274
262
  onReplyStart: async () => {},
275
263
  deliver: async (payload: ReplyPayload, info) => {
276
- if (sessionStreamSuppressed.has(effectiveSessionKey)) return
264
+ if (sessionStreamSuppressed.has(dcgSessionKey)) return
277
265
  const mediaList = resolveReplyMediaList(payload)
278
266
  for (const mediaUrl of mediaList) {
279
267
  const key = getMediaKey(mediaUrl)
280
268
  if (sentMediaKeys.has(key)) continue
281
269
  sentMediaKeys.add(key)
282
- await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl, text: '' })
270
+ await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
283
271
  }
284
272
  },
285
273
  onError: (err: unknown, info: { kind: string }) => {
286
- safeSendFinal('error')
287
- dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
288
- activeRunIdBySessionKey.delete(effectiveSessionKey)
289
- streamChunkIdxBySessionKey.delete(effectiveSessionKey)
290
- if (sessionStreamSuppressed.has(effectiveSessionKey)) {
291
- dcgLogger(`${info.kind} reply failed (stream suppressed): ${String(err)}`, 'error')
292
- return
293
- }
294
- dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
274
+ setMsgStatus(dcgSessionKey, 'finished')
275
+ sendFinal(outboundCtx, 'error')
276
+ activeRunIdBySessionKey.delete(dcgSessionKey)
277
+ streamChunkIdxBySessionKey.delete(dcgSessionKey)
278
+ const suppressed = sessionStreamSuppressed.has(dcgSessionKey)
279
+ dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
295
280
  },
296
- onIdle: () => {}
281
+ onIdle: () => {
282
+ typingCallbacks.onIdle?.()
283
+ }
297
284
  })
298
285
 
299
286
  try {
300
287
  if (!interruptCommand.includes(text?.trim())) {
301
- sessionStreamSuppressed.delete(effectiveSessionKey)
302
- streamChunkIdxBySessionKey.set(effectiveSessionKey, 0)
288
+ sessionStreamSuppressed.delete(dcgSessionKey)
289
+ streamChunkIdxBySessionKey.set(dcgSessionKey, 0)
303
290
  }
304
291
 
305
292
  if (systemCommand.includes(text?.trim())) {
306
- dcgLogger(`dispatching /new`)
307
- await core.channel.reply.dispatchReplyFromConfig({
308
- ctx: ctxPayload,
309
- cfg: config,
293
+ dcgLogger(`dispatching ${text?.trim()}`)
294
+ await core.channel.reply.withReplyDispatcher({
310
295
  dispatcher,
311
- replyOptions: {
312
- ...replyOptions,
313
- onModelSelected: prefixContext.onModelSelected,
314
- onAgentRunStart: (runId) => {
315
- activeRunIdBySessionKey.set(effectiveSessionKey, runId)
316
- }
317
- }
296
+ onSettled: () => markDispatchIdle(),
297
+ run: () =>
298
+ core.channel.reply.dispatchReplyFromConfig({
299
+ ctx: ctxPayload,
300
+ cfg: config,
301
+ dispatcher,
302
+ replyOptions: {
303
+ ...replyOptions,
304
+ onModelSelected: prefixContext.onModelSelected,
305
+ onAgentRunStart: (runId) => {
306
+ activeRunIdBySessionKey.set(dcgSessionKey, runId)
307
+ }
308
+ }
309
+ })
318
310
  })
319
311
  } else if (interruptCommand.includes(text?.trim())) {
320
312
  dcgLogger(`interrupt command: ${text}`)
321
- safeSendFinal('abort')
313
+ const ctxForAbort =
314
+ priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim()
315
+ ? priorOutboundCtx
316
+ : outboundCtx
317
+ sendFinal(
318
+ ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` },
319
+ 'abort'
320
+ )
322
321
  sendText('会话已终止', outboundCtx)
323
- sessionStreamSuppressed.add(effectiveSessionKey)
324
- const runId = activeRunIdBySessionKey.get(effectiveSessionKey)
325
- sendMessageToGateway(
326
- JSON.stringify({
327
- method: 'chat.abort',
328
- params: {
329
- sessionKey: effectiveSessionKey,
330
- ...(runId ? { runId } : {})
331
- }
322
+ sessionStreamSuppressed.add(dcgSessionKey)
323
+ const abortOneSession = async (sessionKey: string) => {
324
+ try {
325
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey } })
326
+ } catch (e) {
327
+ dcgLogger(`chat.abort ${sessionKey}: ${String(e)}`, 'error')
328
+ }
329
+ }
330
+ const keysToAbort = new Set<string>(getChildSessionKeysTrackedForRequester(dcgSessionKey))
331
+ try {
332
+ const listed = await sendGatewayRpc<{ sessions?: Array<{ key?: string }> }>({
333
+ method: 'sessions.list',
334
+ params: { spawnedBy: dcgSessionKey, limit: 256 }
332
335
  })
333
- )
334
- if (runId) activeRunIdBySessionKey.delete(effectiveSessionKey)
335
- safeSendFinal('stop')
336
+ for (const s of listed?.sessions ?? []) {
337
+ const k = typeof s?.key === 'string' ? s.key.trim() : ''
338
+ if (k) keysToAbort.add(k)
339
+ }
340
+ } catch (e) {
341
+ dcgLogger(`sessions.list spawnedBy: ${String(e)}`, 'error')
342
+ }
343
+ for (const sk of keysToAbort) {
344
+ await abortOneSession(sk)
345
+ }
346
+ await abortOneSession(dcgSessionKey)
347
+ streamChunkIdxBySessionKey.delete(dcgSessionKey)
348
+ resetSubagentStateForRequesterSession(dcgSessionKey)
349
+ setMsgStatus(dcgSessionKey, 'finished')
350
+ clearSentMediaKeys(msg.content.message_id)
351
+ clearParamsMessage(dcgSessionKey)
352
+ clearParamsMessage(userId)
353
+ sendFinal(outboundCtx, 'stop')
336
354
  return
337
355
  } else {
338
- dcgLogger(`dispatching to agent (session=${route.sessionKey})`)
339
- const params = getEffectiveMsgParams(effectiveSessionKey)
356
+ const params = getEffectiveMsgParams(dcgSessionKey)
340
357
  if (!ignoreToolCommand.includes(text?.trim())) {
341
- // message_received 没有 sessionKey 前置到bot中执行
342
358
  wsSendRaw(params, {
343
359
  is_finish: -1,
344
360
  tool_call_id: Date.now().toString(),
@@ -353,114 +369,79 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
353
369
  response: ''
354
370
  })
355
371
  }
356
- await core.channel.reply.dispatchReplyFromConfig({
357
- ctx: ctxPayload,
358
- cfg: config,
372
+ await core.channel.reply.withReplyDispatcher({
359
373
  dispatcher,
360
- replyOptions: {
361
- ...replyOptions,
362
- // abortSignal: genSignal,
363
- onModelSelected: prefixContext.onModelSelected,
364
- onAgentRunStart: (runId) => {
365
- activeRunIdBySessionKey.set(effectiveSessionKey, runId)
366
- },
367
- onPartialReply: async (payload: ReplyPayload) => {
368
- if (sessionStreamSuppressed.has(effectiveSessionKey)) return
369
-
370
- // Accumulate full text
371
- if (payload.text) {
372
- completeText = payload.text
373
- }
374
- // --- Streaming text chunks ---
375
- if (payload.text) {
376
- const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
377
- ? payload.text.slice(streamedTextLen)
378
- : payload.text
379
- if (delta.trim()) {
380
- const prev = streamChunkIdxBySessionKey.get(effectiveSessionKey) ?? 0
381
- streamChunkIdxBySessionKey.set(effectiveSessionKey, prev + 1)
382
- sendChunk(delta, outboundCtx, prev)
383
- dcgLogger(`[stream]: chunkIdx=${prev} len=${delta.length} user=${msg._userId} ${delta.slice(0, 100)}`)
374
+ onSettled: () => markDispatchIdle(),
375
+ run: () =>
376
+ core.channel.reply.dispatchReplyFromConfig({
377
+ ctx: ctxPayload,
378
+ cfg: config,
379
+ dispatcher,
380
+ replyOptions: {
381
+ ...replyOptions,
382
+ onModelSelected: prefixContext.onModelSelected,
383
+ onAgentRunStart: (runId) => {
384
+ activeRunIdBySessionKey.set(dcgSessionKey, runId)
385
+ },
386
+ onPartialReply: async (payload: ReplyPayload) => {
387
+ if (sessionStreamSuppressed.has(dcgSessionKey)) return
388
+
389
+ if (payload.text) {
390
+ completeText = payload.text
391
+ }
392
+ // --- Streaming text chunks ---
393
+ if (payload.text) {
394
+ const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
395
+ ? payload.text.slice(streamedTextLen)
396
+ : payload.text
397
+ if (delta.trim()) {
398
+ const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
399
+ streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
400
+ sendChunk(delta, outboundCtx, prev)
401
+ dcgLogger(
402
+ `[stream]: chunkIdx=${prev} len=${delta.length} sessionId=${outboundCtx.sessionId} ${delta.slice(0, 100)}`
403
+ )
404
+ }
405
+ streamedTextLen = payload.text.length
406
+ } else {
407
+ dcgLogger(`onPartialReply no text: ${JSON.stringify(payload)}`, 'error')
408
+ }
409
+ // --- Media from payload ---
410
+ const mediaList = resolveReplyMediaList(payload)
411
+ for (const mediaUrl of mediaList) {
412
+ const key = getMediaKey(mediaUrl)
413
+ if (sentMediaKeys.has(key)) continue
414
+ sentMediaKeys.add(key)
415
+ await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
416
+ }
384
417
  }
385
- streamedTextLen = payload.text.length
386
- }
387
- // --- Media from payload ---
388
- const mediaList = resolveReplyMediaList(payload)
389
- for (const mediaUrl of mediaList) {
390
- const key = getMediaKey(mediaUrl)
391
- if (sentMediaKeys.has(key)) continue
392
- sentMediaKeys.add(key)
393
- await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl, text: '' })
394
418
  }
395
- }
396
- }
419
+ })
397
420
  })
398
421
  }
399
422
  } catch (err: unknown) {
400
- // if (genSignal.aborted) {
401
- // wasAborted = true
402
- // dcgLogger(` generation aborted for conversationId=${conversationId}`)
403
- // } else if (err instanceof Error && err.name === 'AbortError') {
404
- // wasAborted = true
405
- // dcgLogger(` generation aborted for conversationId=${conversationId}`)
406
- // } else {
407
- // dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
408
- // }
409
- } finally {
410
- // if (activeGenerations.get(conversationId) === genCtrl) {
411
- // activeGenerations.delete(conversationId)
412
- // }
413
- }
414
- try {
415
- markRunComplete()
416
- markDispatchIdle()
417
- } catch (err) {
418
- dcgLogger(` markRunComplete||markRunComplete error: ${String(err)}`, 'error')
423
+ dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
419
424
  }
425
+
420
426
  if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
421
- if (sessionStreamSuppressed.has(effectiveSessionKey)) {
422
- sessionStreamSuppressed.delete(effectiveSessionKey)
423
- } else {
424
- for (const file of extractMobookFiles(completeText)) {
425
- const candidates: string[] = [file]
426
- candidates.push(path.join(getWorkspaceDir(), file))
427
- candidates.push(path.join(getWorkspaceDir(), file.replace(/^\//, '')))
428
- const underMobook = file.replace(/^\/mobook\//i, '').replace(/^mobook[\\/]/i, '')
429
- if (underMobook) {
430
- if (process.platform === 'win32') {
431
- candidates.push(path.join('C:\\', 'mobook', underMobook))
432
- } else if (process.platform === 'darwin') {
433
- candidates.push(path.join(os.homedir(), 'mobook', underMobook))
434
- }
435
- }
436
- const resolved = candidates.find((p) => fs.existsSync(p))
437
- if (!resolved) continue
438
- try {
439
- await sendDcgchatMedia({ sessionKey: effectiveSessionKey, mediaUrl: resolved, text: '' })
440
- } catch (err) {
441
- dcgLogger(` sendDcgchatMedia error: ${String(err)}`, 'error')
442
- }
443
- }
427
+ if (sessionStreamSuppressed.has(dcgSessionKey)) {
428
+ sessionStreamSuppressed.delete(dcgSessionKey)
444
429
  }
445
430
  }
446
- safeSendFinal('end')
447
431
  clearSentMediaKeys(msg.content.message_id)
448
-
449
- // Record session metadata
450
432
  const storePath = core.channel.session.resolveStorePath(config.session?.store)
451
- dcgLogger(
452
- `record session route: rawTarget=${userId}, normalizedTarget=${effectiveSessionKey}, updateLastRoute.to=${effectiveSessionKey}, accountId=${route.accountId}`
453
- )
433
+ await waitUntilSubagentsIdle(dcgSessionKey, { timeoutMs: 600_000 })
434
+ sendFinal(outboundCtx, 'end')
435
+ dcgLogger(`record session route: updateLastRoute.to=${dcgSessionKey}, accountId=${route.accountId}`)
454
436
  core.channel.session
455
437
  .recordInboundSession({
456
438
  storePath,
457
- sessionKey: effectiveSessionKey,
439
+ sessionKey: dcgSessionKey,
458
440
  ctx: ctxPayload,
459
- // 与 Telegram/Discord 等一致:写入 deliveryContext.to,否则投递可能回退为 From(userId),channel sendMedia 里 ctx.to 会变成数字 userId
460
441
  updateLastRoute: {
461
- sessionKey: effectiveSessionKey,
442
+ sessionKey: dcgSessionKey,
462
443
  channel: "dcgchat",
463
- to: effectiveSessionKey,
444
+ to: dcgSessionKey,
464
445
  accountId: route.accountId
465
446
  },
466
447
  onRecordError: (err) => {
@@ -473,7 +454,5 @@ export async function handleDcgchatMessage(msg: InboundMessage, accountId: strin
473
454
  } catch (err) {
474
455
  dcgLogger(` handle message failed: ${String(err)}`, 'error')
475
456
  sendError(err instanceof Error ? err.message : String(err), outboundCtx)
476
- } finally {
477
- safeSendFinal('finally')
478
457
  }
479
458
  }