@alfe.ai/openclaw-chat 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.cts +1 -94
- package/dist/index.d.ts +1 -94
- package/dist/plugin.d.cts +168 -2
- package/dist/plugin.d.ts +168 -2
- package/dist/plugin2.cjs +678 -17
- package/dist/plugin2.js +679 -18
- package/package.json +2 -2
package/dist/plugin2.js
CHANGED
|
@@ -1,10 +1,349 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
8
|
+
import { resolveConfig } from "@alfe.ai/config";
|
|
7
9
|
import { existsSync } from "node:fs";
|
|
10
|
+
//#region src/outbound-media.ts
|
|
11
|
+
/**
|
|
12
|
+
* Outbound media upload — agent → user.
|
|
13
|
+
*
|
|
14
|
+
* OpenClaw approves LOCAL file paths in outbound media (`MEDIA:` tokens →
|
|
15
|
+
* `payload.mediaUrl(s)`, and inline markdown images which the runtime never
|
|
16
|
+
* extracts for this channel). Forwarding those strings verbatim ships a VM
|
|
17
|
+
* path to the browser, which resolves it against the chat origin → broken
|
|
18
|
+
* image. This module uploads local refs to S3 through the EXISTING chat
|
|
19
|
+
* attachment machinery (`AgentApiClient.presignAttachments` →
|
|
20
|
+
* `POST /agent/chat/attachments/presign` → presigned PUT) and hands back
|
|
21
|
+
* hosted download URLs plus rich metadata.
|
|
22
|
+
*
|
|
23
|
+
* Key constraints (all load-bearing):
|
|
24
|
+
* - `deliver` fires PER STREAMING BLOCK plus a terminal frame, so the same
|
|
25
|
+
* media ref recurs within one turn. Every resolution goes through a
|
|
26
|
+
* per-turn `OutboundMediaContext` cache — one presign+PUT per distinct
|
|
27
|
+
* path per turn, failures cached too, failure annotations emitted once.
|
|
28
|
+
* - The upload runs inside the per-agent single-flight turn queue; an
|
|
29
|
+
* unbounded PUT would head-of-line block every later turn. Every PUT is
|
|
30
|
+
* bounded by an AbortController timeout.
|
|
31
|
+
* - The size cap mirrors the presign endpoint's 25 MB / 10-files limits —
|
|
32
|
+
* NOT the plugin's 50 MB inbound download cap — so oversize files fail
|
|
33
|
+
* fast locally instead of as an opaque presign 400.
|
|
34
|
+
* - The presigned PUT is signed with `ContentType`; the raw fetch MUST send
|
|
35
|
+
* a matching `Content-Type` header. (This raw fetch to a presigned S3 URL
|
|
36
|
+
* is the sanctioned exception to the no-raw-fetch rule.)
|
|
37
|
+
* - `resolveConfig()` throws on self-hosted agents without env/config.toml
|
|
38
|
+
* (and on unknown token prefixes). With no client, local refs resolve to
|
|
39
|
+
* FAILURES (dropped + fallback note) — never passthrough: a local path
|
|
40
|
+
* was never deliverable, and shipping it would leak filesystem structure.
|
|
41
|
+
* Remote http(s) refs are unaffected. The unavailability is memoized for
|
|
42
|
+
* the process lifetime (config appears via `alfe setup` + restart).
|
|
43
|
+
*/
|
|
44
|
+
const MAX_OUTBOUND_MEDIA_SIZE = 25 * 1024 * 1024;
|
|
45
|
+
const UPLOAD_TIMEOUT_MS = 3e4;
|
|
46
|
+
const EXTENSION_MIME = {
|
|
47
|
+
png: "image/png",
|
|
48
|
+
jpg: "image/jpeg",
|
|
49
|
+
jpeg: "image/jpeg",
|
|
50
|
+
gif: "image/gif",
|
|
51
|
+
webp: "image/webp",
|
|
52
|
+
svg: "image/svg+xml",
|
|
53
|
+
mp4: "video/mp4",
|
|
54
|
+
mov: "video/quicktime",
|
|
55
|
+
webm: "video/webm",
|
|
56
|
+
mp3: "audio/mpeg",
|
|
57
|
+
wav: "audio/wav",
|
|
58
|
+
ogg: "audio/ogg",
|
|
59
|
+
m4a: "audio/mp4",
|
|
60
|
+
pdf: "application/pdf",
|
|
61
|
+
txt: "text/plain",
|
|
62
|
+
md: "text/markdown",
|
|
63
|
+
csv: "text/csv",
|
|
64
|
+
json: "application/json"
|
|
65
|
+
};
|
|
66
|
+
/** Extensions the markdown-image rewrite will upload. Restricting to image
|
|
67
|
+
* types keeps ``-style refs from shipping arbitrary VM
|
|
68
|
+
* files to the attachment bucket. */
|
|
69
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
70
|
+
"png",
|
|
71
|
+
"jpg",
|
|
72
|
+
"jpeg",
|
|
73
|
+
"gif",
|
|
74
|
+
"webp",
|
|
75
|
+
"svg"
|
|
76
|
+
]);
|
|
77
|
+
function createOutboundMediaContext() {
|
|
78
|
+
return {
|
|
79
|
+
uploads: /* @__PURE__ */ new Map(),
|
|
80
|
+
annotatedFailures: /* @__PURE__ */ new Set()
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
let cachedClient;
|
|
84
|
+
function getUploadClient(log) {
|
|
85
|
+
if (cachedClient !== void 0) return cachedClient;
|
|
86
|
+
try {
|
|
87
|
+
const config = resolveConfig();
|
|
88
|
+
cachedClient = new AgentApiClient({
|
|
89
|
+
apiUrl: config.apiUrl,
|
|
90
|
+
apiKey: config.apiKey
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.warn(`Outbound media upload unavailable (no resolvable Alfe config): ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
cachedClient = null;
|
|
95
|
+
}
|
|
96
|
+
return cachedClient;
|
|
97
|
+
}
|
|
98
|
+
function mimeFromPath(p) {
|
|
99
|
+
const ext = extensionOf(p);
|
|
100
|
+
return (ext ? EXTENSION_MIME[ext] : void 0) ?? "application/octet-stream";
|
|
101
|
+
}
|
|
102
|
+
function extensionOf(p) {
|
|
103
|
+
const name = basename(p);
|
|
104
|
+
return name.includes(".") ? name.split(".").at(-1)?.toLowerCase() : void 0;
|
|
105
|
+
}
|
|
106
|
+
function isRemoteUrl(ref) {
|
|
107
|
+
return /^https?:\/\//i.test(ref);
|
|
108
|
+
}
|
|
109
|
+
/** Non-http(s) URI schemes (data:, blob:, media://inbound claim-checks…) —
|
|
110
|
+
* not local paths, nothing we can upload. Windows drive letters (C:\) are
|
|
111
|
+
* paths, not schemes. */
|
|
112
|
+
function hasNonPathScheme(ref) {
|
|
113
|
+
if (/^file:\/\//i.test(ref)) return false;
|
|
114
|
+
if (/^[a-zA-Z]:[\\/]/.test(ref)) return false;
|
|
115
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref);
|
|
116
|
+
}
|
|
117
|
+
/** file:// strip, ~ expansion, relative → cwd resolution. */
|
|
118
|
+
function resolveLocalMediaPath(ref) {
|
|
119
|
+
let p = ref.replace(/^file:\/\//i, "");
|
|
120
|
+
if (p === "~" || p.startsWith("~/")) p = join(homedir(), p.slice(1));
|
|
121
|
+
return isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
122
|
+
}
|
|
123
|
+
async function uploadLocalFile(localPath, log, deps) {
|
|
124
|
+
const client = deps.getClient ? deps.getClient() : getUploadClient(log);
|
|
125
|
+
if (!client) return null;
|
|
126
|
+
let size;
|
|
127
|
+
try {
|
|
128
|
+
const st = await stat(localPath);
|
|
129
|
+
if (!st.isFile()) {
|
|
130
|
+
log.warn(`Outbound media ref is not a file — skipping: ${localPath}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
size = st.size;
|
|
134
|
+
} catch {
|
|
135
|
+
log.warn(`Outbound media file not found — skipping: ${localPath}`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (size <= 0) {
|
|
139
|
+
log.warn(`Outbound media file is empty — skipping: ${localPath}`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (size > 26214400) {
|
|
143
|
+
log.warn(`Outbound media exceeds ${String(MAX_OUTBOUND_MEDIA_SIZE)} bytes (${String(size)}) — skipping: ${localPath}`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const filename = basename(localPath);
|
|
147
|
+
const mimeType = mimeFromPath(localPath);
|
|
148
|
+
try {
|
|
149
|
+
const body = await readFile(localPath);
|
|
150
|
+
const { attachments } = await client.presignAttachments([{
|
|
151
|
+
filename,
|
|
152
|
+
mimeType,
|
|
153
|
+
size
|
|
154
|
+
}]);
|
|
155
|
+
const presigned = attachments.at(0);
|
|
156
|
+
if (!presigned) {
|
|
157
|
+
log.warn(`Presign returned no attachment for ${filename}`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timeout = setTimeout(() => {
|
|
163
|
+
controller.abort();
|
|
164
|
+
}, UPLOAD_TIMEOUT_MS);
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetchFn(presigned.uploadUrl, {
|
|
167
|
+
method: "PUT",
|
|
168
|
+
headers: { "Content-Type": mimeType },
|
|
169
|
+
body,
|
|
170
|
+
signal: controller.signal
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
log.warn(`Outbound media PUT failed (${String(res.status)}) for ${filename}`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
}
|
|
179
|
+
log.info(`Uploaded outbound media: ${filename} (${String(size)} bytes)`);
|
|
180
|
+
return {
|
|
181
|
+
url: presigned.downloadUrl,
|
|
182
|
+
filename,
|
|
183
|
+
mimeType,
|
|
184
|
+
size
|
|
185
|
+
};
|
|
186
|
+
} catch (err) {
|
|
187
|
+
log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Resolve one outbound media ref. Remote http(s) URLs pass through; local
|
|
193
|
+
* paths upload (deduped per turn via `ctx`); non-path schemes and failures
|
|
194
|
+
* resolve to null.
|
|
195
|
+
*/
|
|
196
|
+
function resolveOutboundMediaRef(ref, ctx, log, deps = {}) {
|
|
197
|
+
const trimmed = ref.trim();
|
|
198
|
+
if (!trimmed) return Promise.resolve(null);
|
|
199
|
+
if (isRemoteUrl(trimmed)) return Promise.resolve({ url: trimmed });
|
|
200
|
+
if (hasNonPathScheme(trimmed)) {
|
|
201
|
+
log.warn(`Outbound media ref has unsupported scheme — skipping: ${trimmed.slice(0, 120)}`);
|
|
202
|
+
return Promise.resolve(null);
|
|
203
|
+
}
|
|
204
|
+
const localPath = resolveLocalMediaPath(trimmed);
|
|
205
|
+
const cached = ctx.uploads.get(localPath);
|
|
206
|
+
if (cached) return cached;
|
|
207
|
+
const promise = ctx.uploads.size >= 10 ? (() => {
|
|
208
|
+
log.warn(`Outbound media per-turn cap (${String(10)}) reached — skipping: ${localPath}`);
|
|
209
|
+
return Promise.resolve(null);
|
|
210
|
+
})() : uploadLocalFile(localPath, log, deps);
|
|
211
|
+
ctx.uploads.set(localPath, promise);
|
|
212
|
+
return promise;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a turn's `mediaUrl(s)` refs into hosted URLs + rich metadata.
|
|
216
|
+
* `newFailures` lists local refs that failed and haven't been annotated
|
|
217
|
+
* yet this turn — append fallback text for them so a media-only message
|
|
218
|
+
* doesn't silently vanish.
|
|
219
|
+
*/
|
|
220
|
+
async function resolveOutboundMedia(refs, ctx, log, deps = {}) {
|
|
221
|
+
const mediaUrls = [];
|
|
222
|
+
const attachments = [];
|
|
223
|
+
const newFailures = [];
|
|
224
|
+
for (const ref of refs) {
|
|
225
|
+
const trimmed = ref.trim();
|
|
226
|
+
if (!trimmed) continue;
|
|
227
|
+
const resolved = await resolveOutboundMediaRef(trimmed, ctx, log, deps);
|
|
228
|
+
if (resolved) {
|
|
229
|
+
mediaUrls.push(resolved.url);
|
|
230
|
+
attachments.push(resolved);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (isRemoteUrl(trimmed) || hasNonPathScheme(trimmed)) continue;
|
|
234
|
+
const localPath = resolveLocalMediaPath(trimmed);
|
|
235
|
+
if (!ctx.annotatedFailures.has(localPath)) {
|
|
236
|
+
ctx.annotatedFailures.add(localPath);
|
|
237
|
+
newFailures.push(basename(localPath));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
mediaUrls,
|
|
242
|
+
attachments,
|
|
243
|
+
newFailures
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** Formats the once-per-turn fallback note for failed uploads. */
|
|
247
|
+
function formatMediaFailureNote(failures) {
|
|
248
|
+
return failures.map((f) => `[attachment failed to upload: ${f}]`).join("\n");
|
|
249
|
+
}
|
|
250
|
+
/** Split into code (fenced blocks + inline spans) and plain segments so the
|
|
251
|
+
* rewrite never touches code. Fence detection is line-anchored ```/~~~,
|
|
252
|
+
* matching OpenClaw's own parser precedent. Offset-based slicing so
|
|
253
|
+
* rejoining segments with '' reproduces the input byte-for-byte. */
|
|
254
|
+
function splitCodeSegments(text) {
|
|
255
|
+
const fenceSpans = [];
|
|
256
|
+
let inFence = false;
|
|
257
|
+
let fenceStart = 0;
|
|
258
|
+
let offset = 0;
|
|
259
|
+
for (const line of text.split("\n")) {
|
|
260
|
+
if (/^[ \t]*(`{3,}|~{3,})/.test(line)) if (!inFence) {
|
|
261
|
+
inFence = true;
|
|
262
|
+
fenceStart = offset;
|
|
263
|
+
} else {
|
|
264
|
+
fenceSpans.push({
|
|
265
|
+
start: fenceStart,
|
|
266
|
+
end: offset + line.length
|
|
267
|
+
});
|
|
268
|
+
inFence = false;
|
|
269
|
+
}
|
|
270
|
+
offset += line.length + 1;
|
|
271
|
+
}
|
|
272
|
+
if (inFence) fenceSpans.push({
|
|
273
|
+
start: fenceStart,
|
|
274
|
+
end: text.length
|
|
275
|
+
});
|
|
276
|
+
const segments = [];
|
|
277
|
+
let cursor = 0;
|
|
278
|
+
for (const span of fenceSpans) {
|
|
279
|
+
if (span.start > cursor) segments.push({
|
|
280
|
+
code: false,
|
|
281
|
+
text: text.slice(cursor, span.start)
|
|
282
|
+
});
|
|
283
|
+
segments.push({
|
|
284
|
+
code: true,
|
|
285
|
+
text: text.slice(span.start, span.end)
|
|
286
|
+
});
|
|
287
|
+
cursor = span.end;
|
|
288
|
+
}
|
|
289
|
+
if (cursor < text.length) segments.push({
|
|
290
|
+
code: false,
|
|
291
|
+
text: text.slice(cursor)
|
|
292
|
+
});
|
|
293
|
+
const result = [];
|
|
294
|
+
for (const seg of segments) {
|
|
295
|
+
if (seg.code) {
|
|
296
|
+
result.push(seg);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
for (const part of seg.text.split(/(`[^`\n]+`)/)) {
|
|
300
|
+
if (!part) continue;
|
|
301
|
+
result.push({
|
|
302
|
+
code: part.startsWith("`") && part.endsWith("`") && part.length > 2,
|
|
303
|
+
text: part
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
|
|
310
|
+
/**
|
|
311
|
+
* Rewrite `` to hosted URLs. Per-image: a failed
|
|
312
|
+
* upload leaves that image's text untouched (a truncated mid-stream path
|
|
313
|
+
* fails `stat` and is left alone; the terminal frame carries the full text
|
|
314
|
+
* and rewrites correctly). Only image extensions are uploaded.
|
|
315
|
+
*/
|
|
316
|
+
async function rewriteLocalMarkdownImages(text, ctx, log, deps = {}) {
|
|
317
|
+
if (!text.includes("![")) return text;
|
|
318
|
+
const segments = splitCodeSegments(text);
|
|
319
|
+
return (await Promise.all(segments.map(async (seg) => {
|
|
320
|
+
if (seg.code || !seg.text.includes("![")) return seg.text;
|
|
321
|
+
const matches = [...seg.text.matchAll(MARKDOWN_IMAGE_RE)];
|
|
322
|
+
if (matches.length === 0) return seg.text;
|
|
323
|
+
let out = "";
|
|
324
|
+
let cursor = 0;
|
|
325
|
+
for (const match of matches) {
|
|
326
|
+
const [full, alt, src] = match;
|
|
327
|
+
const start = match.index;
|
|
328
|
+
out += seg.text.slice(cursor, start);
|
|
329
|
+
cursor = start + full.length;
|
|
330
|
+
if (isRemoteUrl(src) || hasNonPathScheme(src)) {
|
|
331
|
+
out += full;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const ext = extensionOf(src.split("?")[0]);
|
|
335
|
+
if (!ext || !IMAGE_EXTENSIONS.has(ext)) {
|
|
336
|
+
out += full;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const resolved = await resolveOutboundMediaRef(src, ctx, log, deps);
|
|
340
|
+
out += resolved ? `` : full;
|
|
341
|
+
}
|
|
342
|
+
out += seg.text.slice(cursor);
|
|
343
|
+
return out;
|
|
344
|
+
}))).join("");
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
8
347
|
//#region src/alfe-channel.ts
|
|
9
348
|
/**
|
|
10
349
|
* Alfe channel plugin definition — registers 'alfe' as an OpenClaw channel.
|
|
@@ -19,11 +358,161 @@ import { existsSync } from "node:fs";
|
|
|
19
358
|
*
|
|
20
359
|
* Config section: channels.alfe in openclaw.yaml
|
|
21
360
|
*/
|
|
361
|
+
const noopLog = {
|
|
362
|
+
info: () => void 0,
|
|
363
|
+
warn: () => void 0,
|
|
364
|
+
error: () => void 0
|
|
365
|
+
};
|
|
366
|
+
function isSafeComponentUrl(raw) {
|
|
367
|
+
let u;
|
|
368
|
+
try {
|
|
369
|
+
u = new URL(raw);
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
if (u.protocol !== "https:") return false;
|
|
374
|
+
const host = u.hostname.toLowerCase();
|
|
375
|
+
return host === "alfe.ai" || host.endsWith(".alfe.ai");
|
|
376
|
+
}
|
|
377
|
+
const MAX_COMPONENTS_PER_MESSAGE = 10;
|
|
378
|
+
const MAX_COMPONENT_LABEL_CHARS = 120;
|
|
379
|
+
const MAX_COMPONENT_VALUE_CHARS = 400;
|
|
380
|
+
const MAX_OPTIONS_PER_COMPONENT = 25;
|
|
381
|
+
/** Cap a maybe-string; returns '' for non-strings so callers can `if (!x)`. */
|
|
382
|
+
function capStr(v, max) {
|
|
383
|
+
return typeof v === "string" ? v.slice(0, max) : "";
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Validate + cap the {label,value} option list of a select / multi_select.
|
|
387
|
+
* Drops malformed / empty entries; caps the count. Empty result → the caller
|
|
388
|
+
* drops the whole component (a chooser with no choices is useless).
|
|
389
|
+
*/
|
|
390
|
+
function sanitizeOptions(raw) {
|
|
391
|
+
if (!Array.isArray(raw)) return [];
|
|
392
|
+
const out = [];
|
|
393
|
+
for (const item of raw) {
|
|
394
|
+
if (out.length >= MAX_OPTIONS_PER_COMPONENT) break;
|
|
395
|
+
if (!item || typeof item !== "object") continue;
|
|
396
|
+
const o = item;
|
|
397
|
+
const label = capStr(o.label, MAX_COMPONENT_LABEL_CHARS);
|
|
398
|
+
const value = capStr(o.value, MAX_COMPONENT_VALUE_CHARS);
|
|
399
|
+
if (!label || !value) continue;
|
|
400
|
+
out.push({
|
|
401
|
+
label,
|
|
402
|
+
value
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Validate + normalize agent-supplied components down to the persisted wire
|
|
409
|
+
* shape. Drops anything malformed or unsafe (unsafe URL, missing required
|
|
410
|
+
* fields, no valid options, over-cap). Returns at most
|
|
411
|
+
* MAX_COMPONENTS_PER_MESSAGE. Handles the full core set: `link_button`,
|
|
412
|
+
* `quick_reply`, `select`, `multi_select`, `confirm`, `copy_button`. Only
|
|
413
|
+
* `link_button` is URL-safety-gated — the rest carry no URL, so their values
|
|
414
|
+
* are plain text (capped, never navigated).
|
|
415
|
+
*/
|
|
416
|
+
function sanitizeComponents(raw) {
|
|
417
|
+
if (!Array.isArray(raw)) return [];
|
|
418
|
+
const out = [];
|
|
419
|
+
const usedIds = /* @__PURE__ */ new Set();
|
|
420
|
+
for (const item of raw) {
|
|
421
|
+
if (out.length >= MAX_COMPONENTS_PER_MESSAGE) break;
|
|
422
|
+
if (!item || typeof item !== "object") continue;
|
|
423
|
+
const c = item;
|
|
424
|
+
const label = capStr(c.label, MAX_COMPONENT_LABEL_CHARS);
|
|
425
|
+
let id = typeof c.id === "string" && c.id ? c.id : randomUUID();
|
|
426
|
+
if (usedIds.has(id)) id = randomUUID();
|
|
427
|
+
usedIds.add(id);
|
|
428
|
+
const style = c.style === "primary" || c.style === "secondary" || c.style === "danger" ? c.style : void 0;
|
|
429
|
+
if (c.type === "link_button") {
|
|
430
|
+
if (!label) continue;
|
|
431
|
+
if (typeof c.url !== "string" || !isSafeComponentUrl(c.url)) continue;
|
|
432
|
+
const target = c.target === "same-tab" || c.target === "new-tab" || c.target === "popup" ? c.target : void 0;
|
|
433
|
+
out.push({
|
|
434
|
+
type: "link_button",
|
|
435
|
+
id,
|
|
436
|
+
label,
|
|
437
|
+
url: c.url,
|
|
438
|
+
...target ? { target } : {},
|
|
439
|
+
...style ? { style } : {}
|
|
440
|
+
});
|
|
441
|
+
} else if (c.type === "quick_reply") {
|
|
442
|
+
if (!label) continue;
|
|
443
|
+
const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS);
|
|
444
|
+
if (!value) continue;
|
|
445
|
+
out.push({
|
|
446
|
+
type: "quick_reply",
|
|
447
|
+
id,
|
|
448
|
+
label,
|
|
449
|
+
value,
|
|
450
|
+
...style ? { style } : {}
|
|
451
|
+
});
|
|
452
|
+
} else if (c.type === "select") {
|
|
453
|
+
const options = sanitizeOptions(c.options);
|
|
454
|
+
if (options.length === 0) continue;
|
|
455
|
+
const placeholder = capStr(c.placeholder, MAX_COMPONENT_LABEL_CHARS);
|
|
456
|
+
out.push({
|
|
457
|
+
type: "select",
|
|
458
|
+
id,
|
|
459
|
+
...label ? { label } : {},
|
|
460
|
+
...placeholder ? { placeholder } : {},
|
|
461
|
+
options
|
|
462
|
+
});
|
|
463
|
+
} else if (c.type === "multi_select") {
|
|
464
|
+
const options = sanitizeOptions(c.options);
|
|
465
|
+
if (options.length === 0) continue;
|
|
466
|
+
const submitLabel = capStr(c.submitLabel, MAX_COMPONENT_LABEL_CHARS);
|
|
467
|
+
out.push({
|
|
468
|
+
type: "multi_select",
|
|
469
|
+
id,
|
|
470
|
+
...label ? { label } : {},
|
|
471
|
+
options,
|
|
472
|
+
...submitLabel ? { submitLabel } : {}
|
|
473
|
+
});
|
|
474
|
+
} else if (c.type === "confirm") {
|
|
475
|
+
const confirmLabel = capStr(c.confirmLabel, MAX_COMPONENT_LABEL_CHARS);
|
|
476
|
+
const confirmValue = capStr(c.confirmValue, MAX_COMPONENT_VALUE_CHARS);
|
|
477
|
+
if (!confirmLabel || !confirmValue) continue;
|
|
478
|
+
const cancelLabel = capStr(c.cancelLabel, MAX_COMPONENT_LABEL_CHARS);
|
|
479
|
+
const cancelValue = capStr(c.cancelValue, MAX_COMPONENT_VALUE_CHARS);
|
|
480
|
+
out.push({
|
|
481
|
+
type: "confirm",
|
|
482
|
+
id,
|
|
483
|
+
...label ? { label } : {},
|
|
484
|
+
confirmLabel,
|
|
485
|
+
confirmValue,
|
|
486
|
+
...cancelLabel ? { cancelLabel } : {},
|
|
487
|
+
...cancelValue ? { cancelValue } : {}
|
|
488
|
+
});
|
|
489
|
+
} else if (c.type === "copy_button") {
|
|
490
|
+
if (!label) continue;
|
|
491
|
+
const value = capStr(c.value, MAX_COMPONENT_VALUE_CHARS);
|
|
492
|
+
if (!value) continue;
|
|
493
|
+
out.push({
|
|
494
|
+
type: "copy_button",
|
|
495
|
+
id,
|
|
496
|
+
label,
|
|
497
|
+
value
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
22
503
|
const CHANNEL_ID = "alfe";
|
|
23
504
|
const DEFAULT_ACCOUNT_ID = "default";
|
|
24
|
-
async function sendViaChat(deps, ctx, mediaUrl) {
|
|
505
|
+
async function sendViaChat(deps, ctx, mediaUrl, components) {
|
|
25
506
|
const client = deps.getChatClient();
|
|
26
507
|
if (!client) throw new Error("Chat service not connected — cannot deliver");
|
|
508
|
+
const log = deps.log ?? noopLog;
|
|
509
|
+
const mediaCtx = createOutboundMediaContext();
|
|
510
|
+
let text = await rewriteLocalMarkdownImages(ctx.text, mediaCtx, log);
|
|
511
|
+
const media = await resolveOutboundMedia(mediaUrl ? [mediaUrl] : [], mediaCtx, log);
|
|
512
|
+
if (media.newFailures.length) {
|
|
513
|
+
if (!text.trim() && media.mediaUrls.length === 0 && !components?.length) throw new Error(`media_upload_failed: ${media.newFailures.join(", ")}`);
|
|
514
|
+
text = text ? `${text}\n\n${formatMediaFailureNote(media.newFailures)}` : formatMediaFailureNote(media.newFailures);
|
|
515
|
+
}
|
|
27
516
|
let conversationId;
|
|
28
517
|
if (ctx.to.startsWith("conv:")) {
|
|
29
518
|
conversationId = ctx.to.slice(5);
|
|
@@ -38,14 +527,19 @@ async function sendViaChat(deps, ctx, mediaUrl) {
|
|
|
38
527
|
await deps.createSession(conversationId, "", "alfe", void 0, userId);
|
|
39
528
|
}
|
|
40
529
|
}
|
|
41
|
-
await deps.addMessage(conversationId, "assistant",
|
|
530
|
+
if (components?.length) await deps.addMessage(conversationId, "assistant", text, void 0, void 0, components);
|
|
531
|
+
else await deps.addMessage(conversationId, "assistant", text);
|
|
42
532
|
const messageId = randomUUID();
|
|
43
533
|
client.notify("agent-message", {
|
|
44
534
|
conversationId,
|
|
45
|
-
text
|
|
535
|
+
text,
|
|
46
536
|
sessionKey: conversationId,
|
|
47
537
|
messageId,
|
|
48
|
-
...
|
|
538
|
+
...media.mediaUrls.length ? {
|
|
539
|
+
mediaUrls: media.mediaUrls,
|
|
540
|
+
attachments: media.attachments
|
|
541
|
+
} : {},
|
|
542
|
+
...components?.length ? { components } : {}
|
|
49
543
|
});
|
|
50
544
|
return {
|
|
51
545
|
channel: "alfe",
|
|
@@ -54,6 +548,22 @@ async function sendViaChat(deps, ctx, mediaUrl) {
|
|
|
54
548
|
timestamp: Date.now()
|
|
55
549
|
};
|
|
56
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* Present interactive components (link buttons, quick replies) into a
|
|
553
|
+
* conversation — the outbound path for the `chat_present_components` tool.
|
|
554
|
+
* `to` is a `conv:{id}` or `user:{id}` target (same vocabulary as the channel's
|
|
555
|
+
* `resolveTarget`). Persists + broadcasts through the same `sendViaChat` path
|
|
556
|
+
* as a normal reply, so the components ride the standard `message` frame and
|
|
557
|
+
* are replayed on backfill. Components are sanitized (unsafe URLs dropped)
|
|
558
|
+
* before delivery.
|
|
559
|
+
*/
|
|
560
|
+
async function presentComponentsViaChat(deps, args) {
|
|
561
|
+
if (args.components.length === 0) throw new Error("At least one valid component is required");
|
|
562
|
+
return await sendViaChat(deps, {
|
|
563
|
+
to: args.to,
|
|
564
|
+
text: args.text ?? ""
|
|
565
|
+
}, void 0, args.components);
|
|
566
|
+
}
|
|
57
567
|
function getChannelSection(cfg) {
|
|
58
568
|
return cfg.channels?.alfe ?? {};
|
|
59
569
|
}
|
|
@@ -330,7 +840,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
|
|
|
330
840
|
return session;
|
|
331
841
|
}
|
|
332
842
|
const writeLocks = /* @__PURE__ */ new Map();
|
|
333
|
-
async function addMessage(sessionId, role, content, senderId, senderName) {
|
|
843
|
+
async function addMessage(sessionId, role, content, senderId, senderName, components) {
|
|
334
844
|
const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
335
845
|
const session = await getSession(sessionId);
|
|
336
846
|
if (!session) return;
|
|
@@ -339,7 +849,8 @@ async function addMessage(sessionId, role, content, senderId, senderName) {
|
|
|
339
849
|
content,
|
|
340
850
|
timestamp: Date.now(),
|
|
341
851
|
...senderId ? { senderId } : {},
|
|
342
|
-
...senderName ? { senderName } : {}
|
|
852
|
+
...senderName ? { senderName } : {},
|
|
853
|
+
...components?.length ? { components } : {}
|
|
343
854
|
});
|
|
344
855
|
await saveSession(session);
|
|
345
856
|
}).finally(() => {
|
|
@@ -984,13 +1495,13 @@ function defineTool(def) {
|
|
|
984
1495
|
}
|
|
985
1496
|
};
|
|
986
1497
|
}
|
|
987
|
-
function buildA2ATools(getChatClient, log) {
|
|
1498
|
+
function buildA2ATools(getChatClient, log, present) {
|
|
988
1499
|
const requireClient = () => {
|
|
989
1500
|
const client = getChatClient();
|
|
990
1501
|
if (!client) throw new Error("chat service not connected yet — try again in a moment");
|
|
991
1502
|
return client;
|
|
992
1503
|
};
|
|
993
|
-
|
|
1504
|
+
const tools = [
|
|
994
1505
|
defineTool({
|
|
995
1506
|
name: "list_agents",
|
|
996
1507
|
description: "List other agents in your organization that you can communicate with.",
|
|
@@ -1066,6 +1577,134 @@ function buildA2ATools(getChatClient, log) {
|
|
|
1066
1577
|
}
|
|
1067
1578
|
})
|
|
1068
1579
|
];
|
|
1580
|
+
if (present) tools.push(defineTool({
|
|
1581
|
+
name: "chat_present_components",
|
|
1582
|
+
description: "Attach interactive UI components to a chat message so the user can act with a tap instead of typing. PREFER components whenever you offer the user a discrete set of choices — a quick_reply or select reads far better than \"reply 1, 2, or 3\". Use:\n • quick_reply — one-tap canned replies for a short set of options.\n • select — a single choice from a longer list (dropdown).\n • multi_select — let the user pick MANY options, then submit.\n • confirm — an approval / yes-no decision (primary + secondary button).\n • link_button — an actionable LINK (open a dashboard, a browser-takeover deep link). URL must be https on an Alfe-owned host.\n • copy_button — let the user copy a value (token, id, snippet) to their clipboard.\nChoosing/submitting an interactive component sends the chosen value back AS THE USER'S NEXT MESSAGE, so the conversation continues normally — you will see it as ordinary user input. Don't button-spam: components are for real interactions, not decoration, and each message allows at most 10. Components degrade to plain text on clients that don't render them, so keep the essentials in `text` too.\nExamples:\n Approval: { to:\"conv:abc\", text:\"Deploy to production?\", components:[ { type:\"confirm\", id:\"c1\", confirmLabel:\"Deploy\", confirmValue:\"yes, deploy\", cancelLabel:\"Cancel\", cancelValue:\"no, hold off\" } ] }\n Pick one: { to:\"user:u_123\", text:\"Which environment?\", components:[ { type:\"select\", id:\"s1\", placeholder:\"Choose an environment\", options:[ { label:\"Dev\", value:\"dev\" }, { label:\"Staging\", value:\"staging\" }, { label:\"Prod\", value:\"prod\" } ] } ] }\n Link: { to:\"conv:abc\", text:\"I need you to finish the login step.\", components:[ { type:\"link_button\", id:\"b1\", label:\"Open browser session\", url:\"https://app.alfe.ai/agents/x?tab=browser\", target:\"new-tab\", style:\"primary\" } ] }",
|
|
1583
|
+
parameters: {
|
|
1584
|
+
type: "object",
|
|
1585
|
+
properties: {
|
|
1586
|
+
to: {
|
|
1587
|
+
type: "string",
|
|
1588
|
+
description: "Target conversation: \"conv:{conversationId}\" or \"user:{userId}\"."
|
|
1589
|
+
},
|
|
1590
|
+
text: {
|
|
1591
|
+
type: "string",
|
|
1592
|
+
description: "Optional message text rendered above the components. Keep the key info here too — it is the fallback on clients that cannot render components."
|
|
1593
|
+
},
|
|
1594
|
+
components: {
|
|
1595
|
+
type: "array",
|
|
1596
|
+
description: "Interactive components to attach (at least one, at most 10).",
|
|
1597
|
+
items: {
|
|
1598
|
+
type: "object",
|
|
1599
|
+
properties: {
|
|
1600
|
+
type: {
|
|
1601
|
+
type: "string",
|
|
1602
|
+
enum: [
|
|
1603
|
+
"link_button",
|
|
1604
|
+
"quick_reply",
|
|
1605
|
+
"select",
|
|
1606
|
+
"multi_select",
|
|
1607
|
+
"confirm",
|
|
1608
|
+
"copy_button"
|
|
1609
|
+
],
|
|
1610
|
+
description: "Component kind — see the tool description for when to use each."
|
|
1611
|
+
},
|
|
1612
|
+
id: {
|
|
1613
|
+
type: "string",
|
|
1614
|
+
description: "Stable id (optional — auto-generated when omitted)."
|
|
1615
|
+
},
|
|
1616
|
+
label: {
|
|
1617
|
+
type: "string",
|
|
1618
|
+
description: "Button/control text. Required for link_button, quick_reply, copy_button; optional heading for select / multi_select."
|
|
1619
|
+
},
|
|
1620
|
+
url: {
|
|
1621
|
+
type: "string",
|
|
1622
|
+
description: "link_button only: https URL on an Alfe-owned host to open when clicked."
|
|
1623
|
+
},
|
|
1624
|
+
target: {
|
|
1625
|
+
type: "string",
|
|
1626
|
+
enum: [
|
|
1627
|
+
"same-tab",
|
|
1628
|
+
"new-tab",
|
|
1629
|
+
"popup"
|
|
1630
|
+
],
|
|
1631
|
+
description: "link_button only: how the URL opens (default same-tab)."
|
|
1632
|
+
},
|
|
1633
|
+
value: {
|
|
1634
|
+
type: "string",
|
|
1635
|
+
description: "quick_reply: the text submitted as the user's next message when tapped. copy_button: the value copied to the clipboard."
|
|
1636
|
+
},
|
|
1637
|
+
placeholder: {
|
|
1638
|
+
type: "string",
|
|
1639
|
+
description: "select only: the empty-state prompt shown before a choice is made."
|
|
1640
|
+
},
|
|
1641
|
+
options: {
|
|
1642
|
+
type: "array",
|
|
1643
|
+
description: "select / multi_select only: the choices (max 25). Each is { label, value }; `value` is what gets submitted (multi_select joins checked values with \", \").",
|
|
1644
|
+
items: {
|
|
1645
|
+
type: "object",
|
|
1646
|
+
properties: {
|
|
1647
|
+
label: {
|
|
1648
|
+
type: "string",
|
|
1649
|
+
description: "Option text shown to the user."
|
|
1650
|
+
},
|
|
1651
|
+
value: {
|
|
1652
|
+
type: "string",
|
|
1653
|
+
description: "Value submitted when chosen."
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
required: ["label", "value"]
|
|
1657
|
+
}
|
|
1658
|
+
},
|
|
1659
|
+
submitLabel: {
|
|
1660
|
+
type: "string",
|
|
1661
|
+
description: "multi_select only: submit button caption (default \"Submit\")."
|
|
1662
|
+
},
|
|
1663
|
+
confirmLabel: {
|
|
1664
|
+
type: "string",
|
|
1665
|
+
description: "confirm only: primary button text (e.g. \"Approve\")."
|
|
1666
|
+
},
|
|
1667
|
+
confirmValue: {
|
|
1668
|
+
type: "string",
|
|
1669
|
+
description: "confirm only: value submitted when the primary button is tapped."
|
|
1670
|
+
},
|
|
1671
|
+
cancelLabel: {
|
|
1672
|
+
type: "string",
|
|
1673
|
+
description: "confirm only: secondary button text (omit to show only the primary)."
|
|
1674
|
+
},
|
|
1675
|
+
cancelValue: {
|
|
1676
|
+
type: "string",
|
|
1677
|
+
description: "confirm only: value submitted when the secondary button is tapped."
|
|
1678
|
+
},
|
|
1679
|
+
style: {
|
|
1680
|
+
type: "string",
|
|
1681
|
+
enum: [
|
|
1682
|
+
"primary",
|
|
1683
|
+
"secondary",
|
|
1684
|
+
"danger"
|
|
1685
|
+
],
|
|
1686
|
+
description: "link_button / quick_reply only: visual style hint."
|
|
1687
|
+
}
|
|
1688
|
+
},
|
|
1689
|
+
required: ["type"]
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
},
|
|
1693
|
+
required: ["to", "components"]
|
|
1694
|
+
},
|
|
1695
|
+
handler: async (params) => {
|
|
1696
|
+
const to = params.to;
|
|
1697
|
+
if (!to || !to.startsWith("conv:") && !to.startsWith("user:")) throw new Error("`to` must be \"conv:{conversationId}\" or \"user:{userId}\"");
|
|
1698
|
+
const text = params.text;
|
|
1699
|
+
log.info("chat_present_components tool called");
|
|
1700
|
+
return await present({
|
|
1701
|
+
to,
|
|
1702
|
+
text,
|
|
1703
|
+
components: params.components
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
}));
|
|
1707
|
+
return tools;
|
|
1069
1708
|
}
|
|
1070
1709
|
//#endregion
|
|
1071
1710
|
//#region src/attachment-url.ts
|
|
@@ -1798,6 +2437,7 @@ async function handleAgentRequest(request, log) {
|
|
|
1798
2437
|
const userLabel = displayName ?? userId ?? senderId;
|
|
1799
2438
|
const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
|
|
1800
2439
|
let a2aResponseBuffer = "";
|
|
2440
|
+
const outboundMediaCtx = createOutboundMediaContext();
|
|
1801
2441
|
if (isA2A) resetA2AEndSignal();
|
|
1802
2442
|
const peer = conversationType === "group" ? {
|
|
1803
2443
|
kind: "group",
|
|
@@ -1863,8 +2503,13 @@ async function handleAgentRequest(request, log) {
|
|
|
1863
2503
|
} : {}
|
|
1864
2504
|
},
|
|
1865
2505
|
deliver: async (payload) => {
|
|
1866
|
-
|
|
1867
|
-
|
|
2506
|
+
let responseText = stripLeakedToolSummary(stripThinkSpans(payload.text ?? ""));
|
|
2507
|
+
responseText = await rewriteLocalMarkdownImages(responseText, outboundMediaCtx, log);
|
|
2508
|
+
const media = await resolveOutboundMedia([...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []], outboundMediaCtx, log);
|
|
2509
|
+
if (media.newFailures.length) {
|
|
2510
|
+
const note = formatMediaFailureNote(media.newFailures);
|
|
2511
|
+
responseText = responseText ? `${responseText}\n\n${note}` : note;
|
|
2512
|
+
}
|
|
1868
2513
|
await addMessage(sessionId, "assistant", responseText);
|
|
1869
2514
|
if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
|
|
1870
2515
|
chatClient?.notify("agent-message", {
|
|
@@ -1872,7 +2517,10 @@ async function handleAgentRequest(request, log) {
|
|
|
1872
2517
|
text: responseText,
|
|
1873
2518
|
sessionKey: runGate.sessionKey ?? legacySessionKey,
|
|
1874
2519
|
messageId: chatMessageId ?? request.id,
|
|
1875
|
-
...mediaUrls.length ? {
|
|
2520
|
+
...media.mediaUrls.length ? {
|
|
2521
|
+
mediaUrls: media.mediaUrls,
|
|
2522
|
+
attachments: media.attachments
|
|
2523
|
+
} : {}
|
|
1876
2524
|
});
|
|
1877
2525
|
},
|
|
1878
2526
|
onRecordError: (err) => {
|
|
@@ -1974,7 +2622,8 @@ async function handleSessionsGet(request, log) {
|
|
|
1974
2622
|
id: `msg-${String(m.timestamp)}`,
|
|
1975
2623
|
role: m.role,
|
|
1976
2624
|
content: m.content,
|
|
1977
|
-
timestamp: m.timestamp
|
|
2625
|
+
timestamp: m.timestamp,
|
|
2626
|
+
...m.components?.length ? { components: m.components } : {}
|
|
1978
2627
|
})),
|
|
1979
2628
|
activity: await resolveHistoryActivity(session)
|
|
1980
2629
|
});
|
|
@@ -1991,19 +2640,30 @@ const plugin = {
|
|
|
1991
2640
|
version: pkg.version,
|
|
1992
2641
|
activate(api) {
|
|
1993
2642
|
const log = api.logger;
|
|
1994
|
-
const
|
|
2643
|
+
const outboundDeps = {
|
|
1995
2644
|
getChatClient: () => chatClient,
|
|
2645
|
+
log,
|
|
1996
2646
|
listSessions,
|
|
1997
2647
|
getSession,
|
|
1998
2648
|
createSession,
|
|
1999
2649
|
addMessage
|
|
2000
|
-
}
|
|
2650
|
+
};
|
|
2651
|
+
const alfeChannel = createAlfeChannelPlugin(outboundDeps);
|
|
2001
2652
|
api.registerChannel(alfeChannel);
|
|
2002
2653
|
log.info(`Registered channel: ${alfeChannel.id}`);
|
|
2003
2654
|
if (typeof api.registerTool === "function") {
|
|
2004
|
-
const
|
|
2655
|
+
const present = async (args) => {
|
|
2656
|
+
const clean = sanitizeComponents(args.components);
|
|
2657
|
+
if (clean.length === 0) throw new Error("No valid components: link_button requires a `label` and an https `url` on an Alfe-owned host");
|
|
2658
|
+
return presentComponentsViaChat(outboundDeps, {
|
|
2659
|
+
to: args.to,
|
|
2660
|
+
text: args.text,
|
|
2661
|
+
components: clean
|
|
2662
|
+
});
|
|
2663
|
+
};
|
|
2664
|
+
const a2aTools = buildA2ATools(() => chatClient, log, present);
|
|
2005
2665
|
for (const tool of a2aTools) api.registerTool(tool);
|
|
2006
|
-
log.info(`Registered ${String(a2aTools.length)} agent
|
|
2666
|
+
log.info(`Registered ${String(a2aTools.length)} agent tools`);
|
|
2007
2667
|
}
|
|
2008
2668
|
const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
|
|
2009
2669
|
const startChatService = () => {
|
|
@@ -2114,7 +2774,8 @@ const plugin = {
|
|
|
2114
2774
|
id: `msg-${String(m.timestamp)}`,
|
|
2115
2775
|
role: m.role,
|
|
2116
2776
|
content: m.content,
|
|
2117
|
-
timestamp: m.timestamp
|
|
2777
|
+
timestamp: m.timestamp,
|
|
2778
|
+
...m.components?.length ? { components: m.components } : {}
|
|
2118
2779
|
})),
|
|
2119
2780
|
activity: await resolveHistoryActivity(session)
|
|
2120
2781
|
};
|