@dcrays/dcgchat 0.2.19 → 0.2.32

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/src/bot.ts CHANGED
@@ -1,87 +1,115 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import type { ClawdbotConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
4
- import { createReplyPrefixContext } from "openclaw/plugin-sdk";
5
- import type { InboundMessage, OutboundReply } from "./types.js";
6
- import { getDcgchatRuntime, getWorkspaceDir } from "./runtime.js";
7
- import { resolveAccount, sendDcgchatMedia } from "./channel.js";
8
- import { setMsgStatus } from "./tool.js";
9
- import { generateSignUrl } from "./api.js";
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { ReplyPayload } from 'openclaw/plugin-sdk'
4
+ import { createReplyPrefixContext } from 'openclaw/plugin-sdk'
5
+ import type { InboundMessage } from './types.js'
6
+ import {
7
+ clearSentMediaKeys,
8
+ getDcgchatRuntime,
9
+ getOpenClawConfig,
10
+ getWorkspaceDir,
11
+ getWsConnection,
12
+ setMsgStatus
13
+ } from './utils/global.js'
14
+ import { resolveAccount, sendDcgchatMedia } from './channel.js'
15
+ import { generateSignUrl } from './request/api.js'
16
+ import { extractMobookFiles } from './utils/searchFile.js'
17
+ import { createMsgContext, sendChunk, sendFinal, sendText as sendTextMsg, sendError, sendText } from './transport.js'
18
+ import { dcgLogger } from './utils/log.js'
19
+ import { channelInfo, systemCommand, interruptCommand, ENV } from './utils/constant.js'
10
20
 
11
21
  type MediaInfo = {
12
- path: string;
13
- fileName: string;
14
- contentType: string;
15
- placeholder: string;
16
- };
22
+ path: string
23
+ fileName: string
24
+ contentType: string
25
+ placeholder: string
26
+ }
27
+
28
+ type TFileInfo = { name: string; url: string }
29
+
30
+ const mediaMaxBytes = 300 * 1024 * 1024
31
+
32
+ /** Active LLM generation abort controllers, keyed by conversationId */
33
+ const activeGenerations = new Map<string, AbortController>()
17
34
 
18
- const mediaMaxBytes = 300 * 1024 * 1024;
19
- async function resolveMediaFromUrls(files: { name: string, url: string }[], botToken: string, log: (message: string) => void): Promise<MediaInfo[]> {
20
- const core = getDcgchatRuntime();
21
- const out: MediaInfo[] = [];
22
- log(`dcgchat media: starting resolve for ${files.length} file(s): ${JSON.stringify(files)}`);
35
+ /** Abort an in-progress LLM generation for a given conversationId */
36
+ export function abortMobookappGeneration(conversationId: string): void {
37
+ const ctrl = activeGenerations.get(conversationId)
38
+ if (ctrl) {
39
+ ctrl.abort()
40
+ activeGenerations.delete(conversationId)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Extract agentId from conversation_id formatted as "agentId::suffix".
46
+ * Returns null if the conversation_id does not contain the "::" separator.
47
+ */
48
+ export function extractAgentIdFromConversationId(conversationId: string): string | null {
49
+ const idx = conversationId.indexOf('::')
50
+ if (idx <= 0) return null
51
+ return conversationId.slice(0, idx)
52
+ }
53
+
54
+ async function resolveMediaFromUrls(files: TFileInfo[], botToken: string): Promise<MediaInfo[]> {
55
+ const core = getDcgchatRuntime()
56
+ const out: MediaInfo[] = []
57
+ dcgLogger(`media: user upload files: ${JSON.stringify(files)}`)
23
58
 
24
59
  for (let i = 0; i < files.length; i++) {
25
- const file = files[i];
60
+ const file = files[i]
26
61
  try {
27
62
  let data = ''
28
- if (/^https?:\/\//i.test(file.url)) {
29
- data = file.url
30
- } else {
31
- data = await generateSignUrl(file.url, botToken);
32
- }
33
- log(`dcgchat media: [${i + 1}/${files.length}] generateSignUrl: ${data}`);
34
- const response = await fetch(data);
63
+ if (/^https?:\/\//i.test(file.url)) {
64
+ data = file.url
65
+ } else {
66
+ data = await generateSignUrl(file.url, botToken)
67
+ }
68
+ dcgLogger(`media: generateSignUrl: ${data}`)
69
+ const response = await fetch(data)
35
70
  if (!response.ok) {
36
- log?.(`dcgchat media: [${i + 1}/${files.length}] fetch failed with HTTP ${response.status}, skipping`);
37
- continue;
71
+ dcgLogger?.(`media: ${file.url} fetch failed with HTTP ${response.status}`, 'error')
72
+ continue
38
73
  }
39
- const buffer = Buffer.from(await response.arrayBuffer());
74
+ const buffer = Buffer.from(await response.arrayBuffer())
40
75
 
41
- let contentType = response.headers.get("content-type") || "";
76
+ let contentType = response.headers.get('content-type') || ''
42
77
  if (!contentType) {
43
- contentType = await core.media.detectMime({ buffer }) || "";
78
+ contentType = (await core.media.detectMime({ buffer })) || ''
44
79
  }
45
- const fileName = file.name || path.basename(new URL(file.url).pathname) || "file";
46
- const saved = await core.channel.media.saveMediaBuffer(
47
- buffer,
48
- contentType,
49
- "inbound",
50
- mediaMaxBytes,
51
- fileName,
52
- );
53
- const isImage = contentType.startsWith("image/");
80
+ const fileName = file.name || path.basename(new URL(file.url).pathname) || 'file'
81
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, 'inbound', mediaMaxBytes, fileName)
82
+ const isImage = contentType.startsWith('image/')
54
83
  out.push({
55
84
  path: saved.path,
56
85
  fileName,
57
- contentType: saved.contentType || "",
58
- placeholder: isImage ? "<media:image>" : "<media:file>",
59
- });
60
-
86
+ contentType: saved.contentType || '',
87
+ placeholder: isImage ? '<media:image>' : '<media:file>'
88
+ })
61
89
  } catch (err) {
62
- log(`dcgchat media: [${i + 1}/${files.length}] FAILED to process ${file.url}: ${String(err)}`);
90
+ dcgLogger(`media: ${file.url} FAILED to process: ${String(err)}`, 'error')
63
91
  }
64
92
  }
65
- log(`dcgchat media: resolve complete, ${out.length}/${files.length} file(s) succeeded`);
66
-
67
- return out;
93
+ dcgLogger(`media: resolve complete, ${out.length}/${files.length} file(s) succeeded`)
94
+ return out
68
95
  }
69
96
 
70
- function buildMediaPayload(mediaList: MediaInfo[]): {
71
- MediaPath?: string;
72
- MediaFileName?: string;
73
- MediaType?: string;
74
- MediaUrl?: string;
75
- MediaFileNames?: string[];
76
- MediaPaths?: string[];
77
- MediaUrls?: string[];
78
- MediaTypes?: string[];
79
- } {
80
- if (mediaList.length === 0) return {};
81
- const first = mediaList[0];
82
- const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean);
83
- const mediaPaths = mediaList.map((m) => m.path);
84
- const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean);
97
+ type MediaPayload = {
98
+ MediaPath?: string
99
+ MediaFileName?: string
100
+ MediaType?: string
101
+ MediaUrl?: string
102
+ MediaFileNames?: string[]
103
+ MediaPaths?: string[]
104
+ MediaUrls?: string[]
105
+ MediaTypes?: string[]
106
+ }
107
+ function buildMediaPayload(mediaList: MediaInfo[]): MediaPayload {
108
+ if (mediaList.length === 0) return {}
109
+ const first = mediaList[0]
110
+ const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean)
111
+ const mediaPaths = mediaList.map((m) => m.path)
112
+ const mediaTypes = mediaList.map((m) => m.contentType).filter(Boolean)
85
113
  return {
86
114
  MediaPath: first?.path,
87
115
  MediaFileName: first?.fileName,
@@ -90,509 +118,247 @@ function buildMediaPayload(mediaList: MediaInfo[]): {
90
118
  MediaFileNames: mediaFileNames.length > 0 ? mediaFileNames : undefined,
91
119
  MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
92
120
  MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
93
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
94
- };
95
- }
96
-
97
- function resolveReplyMediaList(payload: ReplyPayload): string[] {
98
- if (payload.mediaUrls?.length) return payload.mediaUrls.filter(Boolean);
99
- return payload.mediaUrl ? [payload.mediaUrl] : [];
100
- }
101
-
102
- function createFileExtractor() {
103
- const globalSet = new Set()
104
-
105
- function getNewFiles(text: string) {
106
- if (!text) return []
107
-
108
- const currentSet = new Set()
109
- const lines = text.split(/\n+/)
110
-
111
- for (const line of lines) {
112
- const cleanLine = line.trim()
113
- if (!cleanLine) continue
114
-
115
- const matches = cleanLine.matchAll(/`([^`]+)`/g)
116
- for (const m of matches) {
117
- handlePath(m[1])
118
- }
119
-
120
- const rawMatches = cleanLine.match(/\/[^\s))]+/g) || []
121
- for (const p of rawMatches) {
122
- handlePath(p)
123
- }
124
- }
125
-
126
- function handlePath(p: string) {
127
- const filePath = p.trim()
128
- if (filePath.includes('\n')) return
129
- if (isValidFile(filePath)) {
130
- currentSet.add(filePath)
131
- }
132
- }
133
-
134
- const newFiles = []
135
- for (const file of currentSet) {
136
- if (!globalSet.has(file)) {
137
- globalSet.add(file)
138
- newFiles.push(file)
139
- }
140
- }
141
-
142
- return newFiles
143
- }
144
-
145
- function isValidFile(p: string) {
146
- return (
147
- /\/(upload|mobook)\//i.test(p) &&
148
- /\.[a-zA-Z0-9]+$/.test(p)
149
- )
150
- }
151
-
152
- return { getNewFiles }
153
- }
154
-
155
- /**
156
- * 从文本中提取 /mobook 目录下的文件
157
- * @param {string} text
158
- * @returns {string[]}
159
- */
160
- const EXT_LIST = [
161
- // 文档类
162
- 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt', 'rtf', 'odt',
163
-
164
- // 数据/开发
165
- 'json', 'xml', 'csv', 'yaml', 'yml',
166
-
167
- // 前端/文本
168
- 'html', 'htm', 'md', 'markdown', 'css', 'js', 'ts',
169
-
170
- // 图片
171
- 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff',
172
-
173
- // 音频
174
- 'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a',
175
-
176
- // 视频
177
- 'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm',
178
-
179
- // 压缩包
180
- 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
181
-
182
- // 可执行/程序
183
- 'exe', 'dmg', 'pkg', 'apk', 'ipa',
184
-
185
- // 其他常见
186
- 'log', 'dat', 'bin'
187
- ];
188
-
189
- function extractMobookFiles(text = '') {
190
- if (typeof text !== 'string' || !text.trim()) return [];
191
-
192
- const result = new Set();
193
-
194
- // ✅ 扩展名
195
- const EXT = `(${EXT_LIST.join('|')})`;
196
-
197
- // ✅ 文件名字符(增强:支持中文、符号)
198
- const FILE_NAME = `[\\w\\u4e00-\\u9fa5::《》()()\\-\\s]+?`;
199
-
200
- try {
201
- // 1️⃣ `xxx.xxx`
202
- const backtickReg = new RegExp(`\`([^\\\`]+?\\.${EXT})\``, 'gi');
203
- (text.match(backtickReg) || []).forEach(item => {
204
- const name = item.replace(/`/g, '').trim();
205
- if (isValidFileName(name)) {
206
- result.add(`/mobook/${name}`);
207
- }
208
- });
209
-
210
- // 2️⃣ /mobook/xxx.xxx
211
- const fullPathReg = new RegExp(`/mobook/${FILE_NAME}\\.${EXT}`, 'gi');
212
- (text.match(fullPathReg) || []).forEach(p => {
213
- result.add(normalizePath(p));
214
- });
215
-
216
- // 3️⃣ mobook下的 xxx.xxx
217
- const inlineReg = new RegExp(`mobook下的\\s*(${FILE_NAME}\\.${EXT})`, 'gi');
218
- (text.match(inlineReg) || []).forEach(item => {
219
- const match = item.match(new RegExp(`${FILE_NAME}\\.${EXT}`, 'i'));
220
- if (match && isValidFileName(match[0])) {
221
- result.add(`/mobook/${match[0].trim()}`);
222
- }
223
- });
224
-
225
- // 🆕 4️⃣ **xxx.xxx**
226
- const boldReg = new RegExp(`\\*\\*(${FILE_NAME}\\.${EXT})\\*\\*`, 'gi');
227
- (text.match(boldReg) || []).forEach(item => {
228
- const name = item.replace(/\*\*/g, '').trim();
229
- if (isValidFileName(name)) {
230
- result.add(`/mobook/${name}`);
231
- }
232
- });
233
-
234
- // 🆕 5️⃣ xxx.xxx (123字节)
235
- const looseReg = new RegExp(`(${FILE_NAME}\\.${EXT})\\s*\\(`, 'gi');
236
- (text.match(looseReg) || []).forEach(item => {
237
- const name = item.replace(/\s*\(.+$/, '').trim();
238
- if (isValidFileName(name)) {
239
- result.add(`/mobook/${name}`);
240
- }
241
- });
242
-
243
- } catch (e) {
244
- console.warn('extractMobookFiles error:', e);
121
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined
245
122
  }
246
-
247
- return [...result];
248
- }
249
-
250
- /**
251
- * 校验文件名是否合法(避免脏数据)
252
- */
253
- function isValidFileName(name: string) {
254
- if (!name) return false;
255
-
256
- // 过滤异常字符
257
- if (/[\/\\<>:"|?*]/.test(name)) return false;
258
-
259
- // 长度限制(防止异常长字符串)
260
- if (name.length > 200) return false;
261
-
262
- return true;
263
123
  }
264
124
 
265
- /**
266
- * 规范路径(去重用)
267
- */
268
- function normalizePath(path: string) {
269
- return path
270
- .replace(/\/+/g, '/') // 多斜杠 → 单斜杠
271
- .replace(/\/$/, ''); // 去掉结尾 /
125
+ function resolveReplyMediaList(payload: ReplyPayload): string[] {
126
+ if (payload.mediaUrls?.length) return payload.mediaUrls.filter(Boolean)
127
+ return payload.mediaUrl ? [payload.mediaUrl] : []
272
128
  }
273
129
 
274
130
  /**
275
131
  * 处理一条用户消息,调用 Agent 并返回回复
276
132
  */
277
- export async function handleDcgchatMessage(params: {
278
- cfg: ClawdbotConfig;
279
- msg: InboundMessage;
280
- accountId: string;
281
- runtime?: RuntimeEnv;
282
- onChunk: (reply: OutboundReply) => void;
283
- }): Promise<void> {
284
- const { cfg, msg, accountId, runtime } = params;
285
- const log = runtime?.log ?? console.log;
286
- const error = runtime?.error ?? console.error;
287
- // 完整的文本
288
- let completeText = ''
133
+ export async function handleDcgchatMessage(msg: InboundMessage, accountId: string): Promise<void> {
134
+ const msgCtx = createMsgContext(msg)
289
135
 
290
- const account = resolveAccount(cfg, accountId);
291
- const userId = msg._userId.toString();
292
- const text = msg.content.text?.trim();
136
+ let completeText = ''
137
+ const config = getOpenClawConfig()
138
+ if (!config) {
139
+ dcgLogger('no config available', 'error')
140
+ return
141
+ }
142
+ const account = resolveAccount(config, accountId)
143
+ const userId = msg._userId.toString()
144
+ const text = msg.content.text?.trim()
293
145
 
294
146
  if (!text) {
295
- params.onChunk({
296
- messageType: "openclaw_bot_chat",
297
- _userId: msg._userId,
298
- source: "client",
299
- // @ts-ignore
300
- content: {
301
- bot_token: msg.content.bot_token,
302
- domain_id: msg.content.domain_id,
303
- app_id: msg.content.app_id,
304
- bot_id: msg.content.bot_id,
305
- agent_id: msg.content.agent_id,
306
- session_id: msg.content.session_id,
307
- message_id: msg.content.message_id,
308
- response: "你需要我帮你做什么呢?",
309
- },
310
- });
311
- return;
147
+ sendTextMsg(msgCtx, '你需要我帮你做什么呢?')
148
+ sendFinal(msgCtx)
149
+ return
312
150
  }
313
151
 
314
152
  try {
315
- const core = getDcgchatRuntime();
153
+ const core = getDcgchatRuntime()
154
+
155
+ const conversationId = msg.content.session_id?.trim()
316
156
 
317
157
  const route = core.channel.routing.resolveAgentRoute({
318
- cfg,
158
+ cfg: config,
319
159
  channel: "dcgchat",
320
160
  accountId: account.accountId,
321
- peer: { kind: "direct", id: userId },
322
- });
161
+ peer: { kind: 'direct', id: conversationId }
162
+ })
163
+
164
+ // If conversation_id encodes an agentId prefix ("agentId::suffix"), override the route.
165
+ const embeddedAgentId = extractAgentIdFromConversationId(conversationId)
166
+ const effectiveAgentId = embeddedAgentId ?? route.agentId
167
+ const effectiveSessionKey = embeddedAgentId
168
+ ? `agent:${embeddedAgentId}:mobook:direct:${conversationId}`.toLowerCase()
169
+ : route.sessionKey
170
+
171
+ const agentEntry =
172
+ effectiveAgentId && effectiveAgentId !== 'main' ? config.agents?.list?.find((a) => a.id === effectiveAgentId) : undefined
173
+ const agentDisplayName = agentEntry?.name ?? (effectiveAgentId && effectiveAgentId !== 'main' ? effectiveAgentId : undefined)
174
+
175
+ // Abort any existing generation for this conversation, then start a new one
176
+ const existingCtrl = activeGenerations.get(conversationId)
177
+ if (existingCtrl) existingCtrl.abort()
178
+ const genCtrl = new AbortController()
179
+ const genSignal = genCtrl.signal
180
+ activeGenerations.set(conversationId, genCtrl)
323
181
 
324
182
  // 处理用户上传的文件
325
- const files = msg.content.files ?? [];
326
- let mediaPayload: Record<string, unknown> = {};
183
+ const files = msg.content.files ?? []
184
+ let mediaPayload: Record<string, unknown> = {}
327
185
  if (files.length > 0) {
328
- const mediaList = await resolveMediaFromUrls(files, msg.content.bot_token, log)
329
- mediaPayload = buildMediaPayload(mediaList);
330
- log(`dcgchat[${accountId}]: media resolved ${mediaList.length}/${files.length} file(s), payload=${JSON.stringify(mediaList)}`);
186
+ const mediaList = await resolveMediaFromUrls(files, msg.content.bot_token)
187
+ mediaPayload = buildMediaPayload(mediaList)
331
188
  }
332
189
 
333
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
334
- // const messageBody = `${userId}: ${text}`;
335
- // 补充消息
336
- const messageBody = text;
190
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config)
191
+ const messageBody = text
337
192
  const bodyFormatted = core.channel.reply.formatAgentEnvelope({
338
- channel: "书灵墨宝",
193
+ channel: '书灵墨宝',
339
194
  from: userId,
340
195
  timestamp: new Date(),
341
196
  envelope: envelopeOptions,
342
- body: messageBody,
343
- });
197
+ body: messageBody
198
+ })
344
199
 
345
200
  const ctxPayload = core.channel.reply.finalizeInboundContext({
346
201
  Body: bodyFormatted,
347
202
  RawBody: text,
348
203
  CommandBody: text,
349
204
  From: userId,
350
- To: userId,
351
- SessionKey: route.sessionKey,
352
- AccountId: msg.content.session_id,
353
- ChatType: "direct",
354
- SenderName: userId,
205
+ To: conversationId,
206
+ SessionKey: effectiveSessionKey,
207
+ AccountId: route.accountId,
208
+ ChatType: 'direct',
209
+ SenderName: agentDisplayName,
355
210
  SenderId: userId,
356
- Provider: "dcgchat" as const,
357
- Surface: "dcgchat" as const,
211
+ Provider: "dcgchat",
212
+ Surface: "dcgchat",
358
213
  MessageSid: msg.content.message_id,
359
214
  Timestamp: Date.now(),
360
215
  WasMentioned: true,
361
216
  CommandAuthorized: true,
362
- OriginatingChannel: "dcgchat" as const,
217
+ OriginatingChannel: "dcgchat",
363
218
  OriginatingTo: `user:${userId}`,
364
- ...mediaPayload,
365
- });
366
-
367
- log(`dcgchat[${accountId}]: ctxPayload=${JSON.stringify(ctxPayload)}`);
219
+ ...mediaPayload
220
+ })
368
221
 
369
222
  const sentMediaKeys = new Set<string>()
370
223
  const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
371
- let textChunk = ''
372
-
373
- const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
224
+ let streamedTextLen = 0
374
225
 
375
- const { dispatcher, replyOptions, markDispatchIdle } =
376
- core.channel.reply.createReplyDispatcherWithTyping({
377
- responsePrefix: prefixContext.responsePrefix,
378
- responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
379
- humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
380
- onReplyStart: async () => {},
381
- deliver: async (payload: ReplyPayload) => {
382
- log(`dcgchat[${accountId}][deliver]: received chunk, text length=${payload.text?.length || 0}`);
383
- },
384
- onError: (err: any, info: { kind: any; }) => {
385
- error(`dcgchat[${accountId}] ${info.kind} reply failed: ${String(err)}`);
386
- },
387
- onIdle: () => {},
388
- });
226
+ const prefixContext = createReplyPrefixContext({
227
+ cfg: config,
228
+ agentId: effectiveAgentId ?? '',
229
+ channel: "dcgchat",
230
+ accountId: account.accountId
231
+ })
232
+
233
+ const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({
234
+ responsePrefix: prefixContext.responsePrefix,
235
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
236
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(config, route.agentId),
237
+ onReplyStart: async () => {},
238
+ deliver: async (payload: ReplyPayload, info) => {
239
+ const mediaList = resolveReplyMediaList(payload)
240
+ for (const mediaUrl of mediaList) {
241
+ const key = getMediaKey(mediaUrl)
242
+ if (sentMediaKeys.has(key)) continue
243
+ sentMediaKeys.add(key)
244
+ await sendDcgchatMedia({ msgCtx, mediaUrl, text: '' })
245
+ }
246
+ },
247
+ onError: (err: unknown, info: { kind: string }) => {
248
+ dcgLogger(`${info.kind} reply failed: ${String(err)}`, 'error')
249
+ },
250
+ onIdle: () => {
251
+ sendFinal(msgCtx)
252
+ }
253
+ })
389
254
 
390
- if (text === '/new') {
391
- await core.channel.reply.dispatchReplyFromConfig({
392
- ctx: ctxPayload,
393
- cfg,
394
- dispatcher,
395
- replyOptions: {
396
- ...replyOptions,
397
- onModelSelected: prefixContext.onModelSelected
398
- },
399
- });
400
- log(`dcgchat[${accountId}]: skipping agent dispatch for /new`);
401
- params.onChunk({
402
- messageType: "openclaw_bot_chat",
403
- _userId: msg._userId,
404
- source: "client",
405
- content: {
406
- bot_token: msg.content.bot_token,
407
- domain_id: msg.content.domain_id,
408
- app_id: msg.content.app_id,
409
- bot_id: msg.content.bot_id,
410
- agent_id: msg.content.agent_id,
411
- session_id: msg.content.session_id,
412
- message_id: msg.content.message_id,
413
- response: '好呀~我不会忘记我们之前的聊天,只是暂时翻篇,让我们更轻松地开启新话题。',
414
- state: 'chunk',
415
- },
416
- });
417
- params.onChunk({
418
- messageType: "openclaw_bot_chat",
419
- _userId: msg._userId,
420
- source: "client",
421
- content: {
422
- bot_token: msg.content.bot_token,
423
- domain_id: msg.content.domain_id,
424
- app_id: msg.content.app_id,
425
- bot_id: msg.content.bot_id,
426
- agent_id: msg.content.agent_id,
427
- session_id: msg.content.session_id,
428
- message_id: msg.content.message_id,
429
- response: '',
430
- state: 'final',
431
- },
432
- });
433
- } else {
434
- log(`dcgchat[${accountId}]: dispatching to agent (session=${route.sessionKey})`);
435
- await core.channel.reply.dispatchReplyFromConfig({
436
- ctx: ctxPayload,
437
- cfg,
438
- dispatcher,
439
- replyOptions: {
440
- ...replyOptions,
441
- onModelSelected: prefixContext.onModelSelected,
442
- onPartialReply: async (payload: ReplyPayload) => {
443
- log(`dcgchat[${accountId}][deliver]: received chunk, text length=${payload.text?.length || 0}`);
444
- if (payload.text) {
445
- completeText = payload.text
446
- }
447
- const mediaList = resolveReplyMediaList(payload);
448
- if (mediaList.length > 0) {
449
- for (let i = 0; i < mediaList.length; i++) {
450
- const mediaUrl = mediaList[i];
451
- const key = getMediaKey(mediaUrl);
452
- if (sentMediaKeys.has(key)) continue;
453
- if (!/^https?:\/\//i.test(mediaUrl) && !fs.existsSync(mediaUrl)) {
454
- log(`dcgchat[${accountId}][deliver]: media file not found, skipping: ${mediaUrl}`);
455
- continue;
255
+ let wasAborted = false
256
+ try {
257
+ if (systemCommand.includes(text?.trim())) {
258
+ dcgLogger(`dispatching /new`)
259
+ await core.channel.reply.dispatchReplyFromConfig({
260
+ ctx: ctxPayload,
261
+ cfg: config,
262
+ dispatcher,
263
+ replyOptions: {
264
+ ...replyOptions,
265
+ onModelSelected: prefixContext.onModelSelected
266
+ }
267
+ })
268
+ } else if (interruptCommand.includes(text?.trim())) {
269
+ dcgLogger(`interrupt command: ${text}`)
270
+ abortMobookappGeneration(conversationId)
271
+ sendFinal(msgCtx)
272
+ return
273
+ } else {
274
+ dcgLogger(`dispatching to agent (session=${route.sessionKey})`)
275
+ await core.channel.reply.dispatchReplyFromConfig({
276
+ ctx: ctxPayload,
277
+ cfg: config,
278
+ dispatcher,
279
+ replyOptions: {
280
+ ...replyOptions,
281
+ abortSignal: genSignal,
282
+ onModelSelected: prefixContext.onModelSelected,
283
+ onPartialReply: async (payload: ReplyPayload) => {
284
+ // Accumulate full text
285
+ if (payload.text) {
286
+ completeText = payload.text
287
+ }
288
+ // --- Streaming text chunks ---
289
+ if (payload.text) {
290
+ const delta = payload.text.startsWith(completeText.slice(0, streamedTextLen))
291
+ ? payload.text.slice(streamedTextLen)
292
+ : payload.text
293
+ if (delta.trim()) {
294
+ sendChunk(msgCtx, delta)
295
+ dcgLogger(`[stream]: chunk ${delta.length} chars to user ${msg._userId} ${delta.slice(0, 100)}`)
456
296
  }
457
- sentMediaKeys.add(key);
458
- await sendDcgchatMedia({
459
- cfg,
460
- accountId,
461
- log,
462
- mediaUrl,
463
- text: "",
464
- });
297
+ streamedTextLen = payload.text.length
465
298
  }
466
- log(`dcgchat[${accountId}][deliver]: sent ${mediaList.length} media file(s) through channel adapter`);
467
- }
468
- if (payload.text) {
469
- const nextTextChunk = payload.text.replace(textChunk, '');
470
- if (nextTextChunk.trim()) {
471
- log(`dcgchat[${accountId}][deliver]: sending chunk to user ${msg._userId}, text="${nextTextChunk.slice(0, 50)}..."`);
472
- params.onChunk({
473
- messageType: "openclaw_bot_chat",
474
- _userId: msg._userId,
475
- source: "client",
476
- content: {
477
- bot_token: msg.content.bot_token,
478
- domain_id: msg.content.domain_id,
479
- app_id: msg.content.app_id,
480
- bot_id: msg.content.bot_id,
481
- agent_id: msg.content.agent_id,
482
- session_id: msg.content.session_id,
483
- message_id: msg.content.message_id,
484
- response: nextTextChunk,
485
- state: 'chunk',
486
- },
487
- });
488
- log(`dcgchat[${accountId}][deliver]: chunk sent successfully`);
299
+ // --- Media from payload ---
300
+ const mediaList = resolveReplyMediaList(payload)
301
+ for (const mediaUrl of mediaList) {
302
+ const key = getMediaKey(mediaUrl)
303
+ if (sentMediaKeys.has(key)) continue
304
+ sentMediaKeys.add(key)
305
+ await sendDcgchatMedia({ msgCtx, mediaUrl, text: '' })
489
306
  }
490
- textChunk = payload.text
491
- } else {
492
- log(`dcgchat[${accountId}][deliver]: skipping empty chunk`);
493
307
  }
494
- },
495
- },
496
- });
497
- }
498
-
499
- const extractor = createFileExtractor()
500
- const completeFiles = extractor.getNewFiles(completeText)
501
- if (completeFiles.length > 0) {
502
- for (let i = 0; i < completeFiles.length; i++) {
503
- let url = completeFiles[i] as string
504
- if (!path.isAbsolute(url)) {
505
- url = path.join(getWorkspaceDir(), url)
506
- }
507
- const key = getMediaKey(url);
508
- if (sentMediaKeys.has(key)) {
509
- log(`dcgchat[${accountId}]: completeFiles already sent, skipping: ${url}`);
510
- continue;
511
- }
512
- if (!fs.existsSync(url)) {
513
- log(`dcgchat[${accountId}]: completeFiles file not found, skipping: ${url}`);
514
- continue;
515
- }
516
- sentMediaKeys.add(key);
517
- await sendDcgchatMedia({
518
- cfg,
519
- accountId,
520
- log,
521
- mediaUrl: url,
522
- text: "",
523
- });
308
+ }
309
+ })
310
+ }
311
+ } catch (err: unknown) {
312
+ if (genSignal.aborted) {
313
+ wasAborted = true
314
+ dcgLogger(` generation aborted for conversationId=${conversationId}`)
315
+ } else if (err instanceof Error && err.name === 'AbortError') {
316
+ wasAborted = true
317
+ dcgLogger(` generation aborted for conversationId=${conversationId}`)
318
+ } else {
319
+ dcgLogger(` dispatchReplyFromConfig error: ${String(err)}`, 'error')
320
+ }
321
+ } finally {
322
+ if (activeGenerations.get(conversationId) === genCtrl) {
323
+ activeGenerations.delete(conversationId)
524
324
  }
525
- log(`dcgchat[${accountId}][deliver]: sent ${completeFiles.length} media file(s) through channel adapter`);
526
325
  }
527
- const mobookFiles = extractMobookFiles(completeText)
528
- if (mobookFiles.length > 0) {
529
- for (let i = 0; i < mobookFiles.length; i++) {
530
- let url = mobookFiles[i] as string
531
- const key = getMediaKey(url);
532
- if (sentMediaKeys.has(key)) {
533
- log(`dcgchat[${accountId}]: mobookFiles already sent, skipping: ${url}`);
534
- continue;
535
- }
536
- if (!fs.existsSync(url)) {
537
- url = path.join(getWorkspaceDir(), url)
538
- if (!fs.existsSync(url)) {
539
- log(`dcgchat[${accountId}]: mobookFiles file not found, skipping: ${url}`);
540
- continue;
541
- }
326
+ try {
327
+ markRunComplete()
328
+ } catch (err) {
329
+ dcgLogger(` markRunComplete error: ${String(err)}`, 'error')
330
+ }
331
+ markDispatchIdle()
332
+ if (![...systemCommand, ...interruptCommand].includes(text?.trim())) {
333
+ for (const file of extractMobookFiles(completeText)) {
334
+ let resolved = file
335
+ if (!fs.existsSync(resolved)) {
336
+ resolved = path.join(getWorkspaceDir(), file)
337
+ if (!fs.existsSync(resolved)) return
542
338
  }
543
- sentMediaKeys.add(key);
544
- await sendDcgchatMedia({
545
- cfg,
546
- accountId,
547
- log,
548
- mediaUrl: url,
549
- text: "",
550
- });
339
+ await sendDcgchatMedia({ msgCtx, mediaUrl: resolved, text: '' })
551
340
  }
552
- log(`dcgchat[${accountId}][deliver]: sent ${mobookFiles.length} media file(s) through channel adapter`);
553
341
  }
554
- log(`dcgchat[${accountId}]: dispatch complete, sending final state`);
555
- params.onChunk({
556
- messageType: "openclaw_bot_chat",
557
- _userId: msg._userId,
558
- source: "client",
559
- content: {
560
- bot_token: msg.content.bot_token,
561
- domain_id: msg.content.domain_id,
562
- app_id: msg.content.app_id,
563
- bot_id: msg.content.bot_id,
564
- agent_id: msg.content.agent_id,
565
- session_id: msg.content.session_id,
566
- message_id: msg.content.message_id,
567
- response: '',
568
- state: 'final',
569
- },
570
- });
571
-
572
- setMsgStatus('finished');
573
- textChunk = ''
574
- log(`dcgchat[${accountId}]: final state sent`);
575
-
576
- markDispatchIdle();
577
- log(`dcgchat[${accountId}]: message handling complete`);
578
-
342
+ sendFinal(msgCtx)
343
+ clearSentMediaKeys(msg.content.message_id)
344
+ setMsgStatus('finished')
345
+
346
+ // Record session metadata
347
+ const storePath = core.channel.session.resolveStorePath(config.session?.store)
348
+ core.channel.session
349
+ .recordInboundSession({
350
+ storePath,
351
+ sessionKey: effectiveSessionKey,
352
+ ctx: ctxPayload,
353
+ onRecordError: (err) => {
354
+ dcgLogger(` session record error: ${String(err)}`, 'error')
355
+ }
356
+ })
357
+ .catch((err: unknown) => {
358
+ dcgLogger(` recordInboundSession failed: ${String(err)}`, 'error')
359
+ })
579
360
  } catch (err) {
580
- error(`dcgchat[${accountId}]: handle message failed: ${String(err)}`);
581
- params.onChunk({
582
- messageType: "openclaw_bot_chat",
583
- _userId: msg._userId,
584
- source: "client",
585
- content: {
586
- bot_token: msg.content.bot_token,
587
- domain_id: msg.content.domain_id,
588
- app_id: msg.content.app_id,
589
- bot_id: msg.content.bot_id,
590
- agent_id: msg.content.agent_id,
591
- session_id: msg.content.session_id,
592
- message_id: msg.content.message_id,
593
- response: `[错误] ${err instanceof Error ? err.message : String(err)}`,
594
- state: 'final',
595
- },
596
- });
361
+ dcgLogger(` handle message failed: ${String(err)}`, 'error')
362
+ sendError(msgCtx, err instanceof Error ? err.message : String(err))
597
363
  }
598
364
  }