@efengx/openclaw-channel-dragon 0.5.36 → 0.5.37

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.
@@ -1,5 +1,6 @@
1
1
  import { IComponent } from "../../core/IComponent.js";
2
2
  import { TelemetryComponent } from "../telemetry/TelemetryComponent.js";
3
+ import { MediaRegistryComponent } from "../media/MediaRegistryComponent.js";
3
4
  export interface ChannelOptions {
4
5
  accountId: string;
5
6
  agentId: string;
@@ -10,9 +11,10 @@ export interface ChannelOptions {
10
11
  export declare class ChannelComponent implements IComponent {
11
12
  private options;
12
13
  private telemetry;
14
+ private mediaRegistry;
13
15
  private processedMessageIds;
14
16
  private sessionQueues;
15
- constructor(options: ChannelOptions, telemetry: TelemetryComponent);
17
+ constructor(options: ChannelOptions, telemetry: TelemetryComponent, mediaRegistry: MediaRegistryComponent);
16
18
  start(): Promise<void>;
17
19
  stop(): Promise<void>;
18
20
  private normalizeSessionSegment;
@@ -23,16 +25,17 @@ export declare class ChannelComponent implements IComponent {
23
25
  private resolveWorkbenchSessionIdFromOpenClawSessionKey;
24
26
  private stringifyProgressDetail;
25
27
  private findTaskCompletionEvent;
26
- private extractGeneratedMediaLines;
27
28
  private formatTaskCompletionReply;
28
29
  deliverToOpenClaw: (content: string, sessionId?: string, modelId?: string, attachments?: any[], messageId?: string | number) => Promise<void>;
29
30
  handleOutboundText: (ctx: any) => Promise<{
30
31
  ok: boolean;
31
32
  messageId: string;
32
33
  error: any;
34
+ attachments?: undefined;
33
35
  } | {
34
36
  ok: boolean;
35
37
  messageId: string;
38
+ attachments: import("../media/MediaRegistryComponent.js").DragonMediaAttachment[];
36
39
  error?: undefined;
37
40
  }>;
38
41
  reportTargetProtocolError: (message: string, rawTarget: unknown) => Promise<void>;
@@ -4,11 +4,13 @@ const workbenchTargetPrefix = "dragon-workbench:";
4
4
  export class ChannelComponent {
5
5
  options;
6
6
  telemetry;
7
+ mediaRegistry;
7
8
  processedMessageIds = new Set();
8
9
  sessionQueues = new Map();
9
- constructor(options, telemetry) {
10
+ constructor(options, telemetry, mediaRegistry) {
10
11
  this.options = options;
11
12
  this.telemetry = telemetry;
13
+ this.mediaRegistry = mediaRegistry;
12
14
  }
13
15
  async start() { }
14
16
  async stop() { }
@@ -96,29 +98,8 @@ export class ChannelComponent {
96
98
  }
97
99
  return undefined;
98
100
  }
99
- extractGeneratedMediaLines(event) {
100
- const lines = new Set();
101
- for (const url of Array.isArray(event?.mediaUrls) ? event.mediaUrls : []) {
102
- if (typeof url === 'string' && url.trim()) {
103
- lines.add(`MEDIA:${url.trim()}`);
104
- }
105
- }
106
- for (const attachment of Array.isArray(event?.attachments) ? event.attachments : []) {
107
- const value = attachment?.url || attachment?.path || attachment?.filePath || attachment?.mediaUrl;
108
- if (typeof value === 'string' && value.trim()) {
109
- lines.add(`MEDIA:${value.trim()}`);
110
- }
111
- }
112
- return Array.from(lines);
113
- }
114
101
  formatTaskCompletionReply(event) {
115
- const result = typeof event?.result === 'string' ? event.result.trim() : '';
116
- const mediaLines = this.extractGeneratedMediaLines(event);
117
- const lines = [
118
- result,
119
- ...mediaLines.filter((line) => !result.includes(line)),
120
- ].filter((line) => line.trim());
121
- return lines.join('\n');
102
+ return typeof event?.result === 'string' ? event.result.trim() : '';
122
103
  }
123
104
  deliverToOpenClaw = async (content, sessionId = 'default', modelId, attachments, messageId) => {
124
105
  const replyMsgId = messageId ? `dragon_msg_${messageId}` : `dragon_msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -166,13 +147,15 @@ export class ChannelComponent {
166
147
  dispatcherOptions: {
167
148
  deliver: async (payload) => {
168
149
  const text = payload?.text || "";
169
- if (!text && !payload?.tool_calls?.length)
150
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(payload);
151
+ if (!text && !payload?.tool_calls?.length && archivedAttachments.length === 0)
170
152
  return;
171
153
  await this.telemetry.reportReply({
172
154
  content: text,
173
155
  sessionId,
174
156
  tool_calls: payload?.tool_calls,
175
157
  reasoning_content: payload?.reasoning_content,
158
+ attachments: archivedAttachments.length > 0 ? archivedAttachments : payload?.attachments,
176
159
  source: "telemetry_deliver",
177
160
  msgId: replyMsgId,
178
161
  });
@@ -218,12 +201,16 @@ export class ChannelComponent {
218
201
  });
219
202
  },
220
203
  onToolResult: async (payload) => {
204
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(payload);
221
205
  await progress({
222
- kind: 'tool',
206
+ kind: archivedAttachments.length > 0 ? 'media' : 'tool',
223
207
  phase: 'end',
224
208
  status: 'completed',
225
- title: '工具执行完成',
226
- detail: this.stringifyProgressDetail(payload?.text || payload),
209
+ title: archivedAttachments.length > 0 ? '媒体文件已生成' : '工具执行完成',
210
+ detail: archivedAttachments.length > 0
211
+ ? archivedAttachments.map((item) => item.name).join(', ')
212
+ : this.stringifyProgressDetail(payload?.text || payload),
213
+ data: archivedAttachments.length > 0 ? { ...payload, attachments: archivedAttachments } : payload,
227
214
  });
228
215
  },
229
216
  onItemEvent: async (payload) => {
@@ -371,6 +358,7 @@ export class ChannelComponent {
371
358
  handleOutboundText = async (ctx) => {
372
359
  const text = ctx?.text || "";
373
360
  const { logger } = this.options;
361
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(ctx);
374
362
  let sessionId;
375
363
  try {
376
364
  sessionId = this.decodeWorkbenchTarget(ctx?.peer?.id);
@@ -382,8 +370,13 @@ export class ChannelComponent {
382
370
  return { ok: false, messageId: Date.now().toString(), error: message };
383
371
  }
384
372
  logger?.info?.(`[dragon channels][Dragon Plugin] Outbound Text (Action): "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}" [Session: ${sessionId}] [RawPeer: ${ctx?.peer?.id || 'none'}]`);
385
- await this.telemetry.reportReply({ content: text, sessionId, source: "channel_outbound" });
386
- return { ok: true, messageId: Date.now().toString() };
373
+ await this.telemetry.reportReply({
374
+ content: text,
375
+ sessionId,
376
+ attachments: archivedAttachments.length > 0 ? archivedAttachments : ctx?.attachments,
377
+ source: "channel_outbound",
378
+ });
379
+ return { ok: true, messageId: Date.now().toString(), attachments: archivedAttachments };
387
380
  };
388
381
  reportTargetProtocolError = async (message, rawTarget) => {
389
382
  const content = [
@@ -407,7 +400,8 @@ export class ChannelComponent {
407
400
  if (!taskCompletion)
408
401
  return;
409
402
  const content = this.formatTaskCompletionReply(taskCompletion);
410
- if (!content)
403
+ const archivedAttachments = await this.mediaRegistry.archiveStructuredMedia(taskCompletion);
404
+ if (!content && archivedAttachments.length === 0)
411
405
  return;
412
406
  await this.telemetry.reportProgress({
413
407
  sessionId,
@@ -417,11 +411,12 @@ export class ChannelComponent {
417
411
  status: taskCompletion.status === 'ok' ? 'completed' : taskCompletion.status,
418
412
  title: taskCompletion.status === 'ok' ? '后台任务完成' : '后台任务更新',
419
413
  detail: this.stringifyProgressDetail(taskCompletion.taskLabel || taskCompletion.statusLabel),
420
- data: taskCompletion,
414
+ data: archivedAttachments.length > 0 ? { ...taskCompletion, attachments: archivedAttachments } : taskCompletion,
421
415
  });
422
416
  await this.telemetry.reportReply({
423
417
  content,
424
418
  sessionId,
419
+ attachments: archivedAttachments.length > 0 ? archivedAttachments : taskCompletion?.attachments,
425
420
  source: 'agent_event_task_completion',
426
421
  msgId: taskCompletion.childSessionId ? `dragon_task_reply_${taskCompletion.childSessionId}` : undefined,
427
422
  });
@@ -0,0 +1,35 @@
1
+ import { IComponent } from "../../core/IComponent.js";
2
+ type LoggerLike = {
3
+ info?: (...args: any[]) => void;
4
+ warn?: (...args: any[]) => void;
5
+ error?: (...args: any[]) => void;
6
+ };
7
+ export type DragonMediaAttachment = {
8
+ type: string;
9
+ kind: string;
10
+ name: string;
11
+ mimeType?: string;
12
+ sourcePath?: string;
13
+ workspacePath?: string;
14
+ path?: string;
15
+ url?: string;
16
+ size?: number;
17
+ };
18
+ export declare class MediaRegistryComponent implements IComponent {
19
+ private options;
20
+ constructor(options: {
21
+ cfg: any;
22
+ logger?: LoggerLike;
23
+ });
24
+ start(): Promise<void>;
25
+ stop(): Promise<void>;
26
+ private resolveWorkspaceRoot;
27
+ private normalizeLocalPath;
28
+ private isPathInside;
29
+ private inferMime;
30
+ private inferKind;
31
+ private buildWorkspaceRelativePath;
32
+ private collectStructuredMedia;
33
+ archiveStructuredMedia(value: any): Promise<DragonMediaAttachment[]>;
34
+ }
35
+ export {};
@@ -0,0 +1,141 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ export class MediaRegistryComponent {
4
+ options;
5
+ constructor(options) {
6
+ this.options = options;
7
+ }
8
+ async start() { }
9
+ async stop() { }
10
+ resolveWorkspaceRoot() {
11
+ const candidates = [
12
+ this.options.cfg?.agents?.defaults?.workspace,
13
+ this.options.cfg?.agents?.default?.workspace,
14
+ ...(Array.isArray(this.options.cfg?.agents?.list)
15
+ ? this.options.cfg.agents.list.map((agent) => agent?.workspace)
16
+ : []),
17
+ process.env.OPENCLAW_WORKSPACE_ROOT,
18
+ process.env.DRAGON_OPENCLAW_WORKSPACE_ROOT,
19
+ ];
20
+ const found = candidates.find((value) => typeof value === "string" && value.trim());
21
+ return found ? path.resolve(String(found).trim()) : undefined;
22
+ }
23
+ normalizeLocalPath(value) {
24
+ if (typeof value !== "string")
25
+ return undefined;
26
+ const raw = value.trim();
27
+ if (!raw)
28
+ return undefined;
29
+ if (raw.startsWith("file://")) {
30
+ try {
31
+ return path.resolve(decodeURIComponent(new URL(raw).pathname));
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ if (!path.isAbsolute(raw))
38
+ return undefined;
39
+ return path.resolve(raw);
40
+ }
41
+ isPathInside(parent, child) {
42
+ const rel = path.relative(parent, child);
43
+ return rel === "" || (!!rel && !rel.startsWith("..") && !path.isAbsolute(rel));
44
+ }
45
+ inferMime(filePath, fallback) {
46
+ if (fallback)
47
+ return fallback;
48
+ const ext = path.extname(filePath).toLowerCase();
49
+ if (ext === ".png")
50
+ return "image/png";
51
+ if (ext === ".jpg" || ext === ".jpeg")
52
+ return "image/jpeg";
53
+ if (ext === ".webp")
54
+ return "image/webp";
55
+ if (ext === ".gif")
56
+ return "image/gif";
57
+ if (ext === ".svg")
58
+ return "image/svg+xml";
59
+ return undefined;
60
+ }
61
+ inferKind(mimeType) {
62
+ if (mimeType?.startsWith("image/"))
63
+ return "image";
64
+ if (mimeType?.startsWith("video/"))
65
+ return "video";
66
+ if (mimeType?.startsWith("audio/"))
67
+ return "audio";
68
+ return "file";
69
+ }
70
+ buildWorkspaceRelativePath(sourcePath, workspaceRoot) {
71
+ const normalized = path.resolve(sourcePath);
72
+ if (this.isPathInside(workspaceRoot, normalized)) {
73
+ return path.relative(workspaceRoot, normalized).split(path.sep).join("/");
74
+ }
75
+ const marker = `${path.sep}media${path.sep}`;
76
+ const mediaIndex = normalized.lastIndexOf(marker);
77
+ if (mediaIndex >= 0) {
78
+ return path
79
+ .join("media", normalized.slice(mediaIndex + marker.length))
80
+ .split(path.sep)
81
+ .join("/");
82
+ }
83
+ return path.join("media", path.basename(normalized)).split(path.sep).join("/");
84
+ }
85
+ collectStructuredMedia(value) {
86
+ const items = [];
87
+ if (!value || typeof value !== "object")
88
+ return items;
89
+ if (Array.isArray(value.attachments))
90
+ items.push(...value.attachments);
91
+ if (Array.isArray(value.media))
92
+ items.push(...value.media);
93
+ if (Array.isArray(value.mediaUrls)) {
94
+ items.push(...value.mediaUrls.map((url) => ({ url })));
95
+ }
96
+ return items.filter(Boolean);
97
+ }
98
+ async archiveStructuredMedia(value) {
99
+ const workspaceRoot = this.resolveWorkspaceRoot();
100
+ if (!workspaceRoot)
101
+ return [];
102
+ const media = this.collectStructuredMedia(value);
103
+ const seen = new Set();
104
+ const attachments = [];
105
+ for (const item of media) {
106
+ const sourcePath = this.normalizeLocalPath(item?.path ?? item?.filePath ?? item?.mediaPath ?? item?.url);
107
+ if (!sourcePath || seen.has(sourcePath))
108
+ continue;
109
+ seen.add(sourcePath);
110
+ try {
111
+ const stat = await fs.stat(sourcePath);
112
+ if (!stat.isFile())
113
+ continue;
114
+ const workspacePath = this.buildWorkspaceRelativePath(sourcePath, workspaceRoot);
115
+ const targetPath = path.resolve(workspaceRoot, workspacePath);
116
+ if (!this.isPathInside(workspaceRoot, targetPath))
117
+ continue;
118
+ if (path.resolve(sourcePath) !== targetPath) {
119
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
120
+ await fs.copyFile(sourcePath, targetPath);
121
+ }
122
+ const mimeType = this.inferMime(targetPath, item?.mimeType || item?.mime);
123
+ const kind = item?.kind || item?.type || this.inferKind(mimeType);
124
+ attachments.push({
125
+ type: kind,
126
+ kind,
127
+ name: item?.name || path.basename(targetPath),
128
+ mimeType,
129
+ sourcePath,
130
+ workspacePath,
131
+ path: workspacePath,
132
+ size: stat.size,
133
+ });
134
+ }
135
+ catch (error) {
136
+ this.options.logger?.warn?.(`[dragon channels][media] failed to archive media: source=${sourcePath}, error=${error?.message || error}`);
137
+ }
138
+ }
139
+ return attachments;
140
+ }
141
+ }
@@ -13,6 +13,7 @@ export declare class TelemetryComponent implements IComponent {
13
13
  sessionId: string;
14
14
  tool_calls?: any[];
15
15
  reasoning_content?: string;
16
+ attachments?: any[];
16
17
  source?: string;
17
18
  msgId?: string;
18
19
  }): Promise<void>;
@@ -15,7 +15,7 @@ export class TelemetryComponent {
15
15
  const sessionId = payload.sessionId || "default";
16
16
  const content = payload.content || "";
17
17
  const normalized = content.replace(/\s+/g, "");
18
- if (normalized) {
18
+ if (normalized && !payload.attachments?.length) {
19
19
  const key = `${source}_${sessionId}`;
20
20
  const last = this.lastReplies.get(key);
21
21
  const now = Date.now();
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { TelemetryComponent } from "./components/telemetry/TelemetryComponent.js
6
6
  import { HttpComponent } from "./components/http/HttpComponent.js";
7
7
  import { ChannelComponent } from "./components/channel/ChannelComponent.js";
8
8
  import { SseComponent } from "./components/sync/SseComponent.js";
9
+ import { MediaRegistryComponent } from "./components/media/MediaRegistryComponent.js";
9
10
  import { dragonChannelPluginVersion } from "./version.js";
10
11
  const channelId = "dragon";
11
12
  const workbenchTargetPrefix = "dragon-workbench:";
@@ -62,6 +63,10 @@ async function getOrCreateContainer(account, ctx) {
62
63
  logger
63
64
  }));
64
65
  const telemetry = container.register('telemetry', new TelemetryComponent(http, account.agentId));
66
+ const mediaRegistry = container.register('mediaRegistry', new MediaRegistryComponent({
67
+ cfg: ctx.cfg,
68
+ logger,
69
+ }));
65
70
  // 2. Channel Logic (Centralized)
66
71
  const channel = container.register('channel', new ChannelComponent({
67
72
  accountId: account.accountId,
@@ -69,7 +74,7 @@ async function getOrCreateContainer(account, ctx) {
69
74
  channelRuntime: ctx.channelRuntime,
70
75
  cfg: ctx.cfg,
71
76
  logger
72
- }, telemetry));
77
+ }, telemetry, mediaRegistry));
73
78
  // 3. Sync Infrastructure
74
79
  container.register('sse', new SseComponent(http, channel, {
75
80
  agentId: account.agentId,
@@ -248,54 +253,21 @@ const plugin = createChatChannelPlugin({
248
253
  };
249
254
  }
250
255
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction target resolved: sessionId=${sessionId}, target=${target}, rawTarget=${rawTarget}`);
251
- // Collect ALL media URLs from the params (mirrors what OpenClaw tracks internally
252
- // via collectMessagingMediaUrlsFromRecord when the tool call starts, so that
253
- // hasGatewayAgentDeliveredExpectedMedia can match them against expectedMediaUrls).
254
- const sentMediaUrls = [];
255
- const pushMedia = (v, source) => {
256
- if (typeof v === 'string' && v.trim()) {
257
- const url = v.trim();
258
- sentMediaUrls.push(url);
259
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction extracted media URL from ${source}: ${url}`);
260
- }
261
- };
262
- pushMedia(params.media, "params.media");
263
- pushMedia(params.mediaUrl, "params.mediaUrl");
264
- pushMedia(params.path, "params.path");
265
- pushMedia(params.filePath, "params.filePath");
266
- pushMedia(params.fileUrl, "params.fileUrl");
267
- if (Array.isArray(params.mediaUrls)) {
268
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction found params.mediaUrls array, size=${params.mediaUrls.length}`);
269
- for (const u of params.mediaUrls)
270
- pushMedia(u, "params.mediaUrls");
271
- }
272
- if (Array.isArray(params.attachments)) {
273
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction found params.attachments array, size=${params.attachments.length}`);
274
- for (const a of params.attachments) {
275
- if (a && typeof a === 'object') {
276
- pushMedia(a.media, "attachment.media");
277
- pushMedia(a.mediaUrl, "attachment.mediaUrl");
278
- pushMedia(a.path, "attachment.path");
279
- pushMedia(a.filePath, "attachment.filePath");
280
- pushMedia(a.fileUrl, "attachment.fileUrl");
281
- }
282
- }
283
- }
284
- logger?.info?.(`[dragon channels][Dragon Plugin] handleAction total media URLs extracted: count=${sentMediaUrls.length}, urls=${JSON.stringify(sentMediaUrls)}`);
285
256
  let text = params.message || params.text || "";
286
257
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction base message text length: ${text.length}`);
287
- // Append media path to text so the Workbench can render the image
288
- for (const url of sentMediaUrls) {
289
- const mediaLine = `MEDIA: ${url}`;
290
- if (!text.includes(mediaLine)) {
291
- text = text ? `${text}\n${mediaLine}` : mediaLine;
292
- }
293
- }
294
258
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction sending text over channel: text_len=${text.length}, targetSession=${sessionId}`);
295
259
  const result = await channel.handleOutboundText({
296
260
  text,
297
- peer: { id: target }
261
+ peer: { id: target },
262
+ attachments: params.attachments,
263
+ media: params.media,
264
+ mediaUrls: params.mediaUrls,
298
265
  });
266
+ const deliveredMediaUrls = Array.isArray(result.attachments)
267
+ ? result.attachments
268
+ .map((attachment) => attachment?.sourcePath || attachment?.path || attachment?.url)
269
+ .filter((value) => typeof value === 'string' && value.trim())
270
+ : (Array.isArray(params.mediaUrls) ? params.mediaUrls : []);
299
271
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction channel send result: ok=${result.ok}, messageId=${result.messageId || "none"}`);
300
272
  const evidence = {
301
273
  ok: result.ok,
@@ -304,8 +276,8 @@ const plugin = createChatChannelPlugin({
304
276
  // and hasGatewayAgentDeliveredExpectedMedia
305
277
  didSendViaMessagingTool: true,
306
278
  messagingToolSentTexts: text ? [text] : [],
307
- messagingToolSentMediaUrls: sentMediaUrls,
308
- messagingToolSentTargets: [{ to: target, ...(sentMediaUrls.length > 0 ? { mediaUrls: sentMediaUrls } : {}) }],
279
+ messagingToolSentMediaUrls: deliveredMediaUrls,
280
+ messagingToolSentTargets: [{ to: target, ...(deliveredMediaUrls.length > 0 ? { mediaUrls: deliveredMediaUrls } : {}) }],
309
281
  };
310
282
  logger?.info?.(`[dragon channels][Dragon Plugin] handleAction returning evidence to OpenClaw: didSendViaMessagingTool=${evidence.didSendViaMessagingTool}, sentMediaUrlsCount=${evidence.messagingToolSentMediaUrls.length}`);
311
283
  return evidence;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const dragonChannelPluginVersion = "0.5.36";
1
+ export declare const dragonChannelPluginVersion = "0.5.37";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const dragonChannelPluginVersion = "0.5.36";
1
+ export const dragonChannelPluginVersion = "0.5.37";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efengx/openclaw-channel-dragon",
3
- "version": "0.5.36",
3
+ "version": "0.5.37",
4
4
  "description": "Dragon workbench channel for OpenClaw",
5
5
  "author": "feng xiang <ofengx@gmail.com>",
6
6
  "type": "module",